diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index cb88c5b89fc9..730b3aa460f4 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -582,5 +582,3 @@ enabled: - x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/serverless.endpoint.config.ts - x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/integrations.config.ts - x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/serverless.integrations.config.ts - - x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/serverless.integrations_feature_flag.config.ts - - x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/integrations_feature_flag.config.ts diff --git a/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml b/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml index 497968f39ad9..3240cf5e0177 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml @@ -20,7 +20,7 @@ spec: spec: env: SLACK_NOTIFICATIONS_CHANNEL: '#kibana-operations-alerts' - GITHUB_BUILD_COMMIT_STATUS_ENABLED: 'true' + ELASTIC_GITHUB_BUILD_COMMIT_STATUS_ENABLED: 'true' GITHUB_COMMIT_STATUS_CONTEXT: buildkite/on-merge REPORT_FAILED_TESTS_TO_GITHUB: 'true' ELASTIC_SLACK_NOTIFICATIONS_ENABLED: 'true' diff --git a/.buildkite/pipeline-resource-definitions/kibana-pr.yml b/.buildkite/pipeline-resource-definitions/kibana-pr.yml index 8d2a6c8bf9e9..4d6275843327 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-pr.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-pr.yml @@ -19,10 +19,10 @@ spec: description: Runs manually for pull requests spec: env: - PR_COMMENTS_ENABLED: 'true' - GITHUB_BUILD_COMMIT_STATUS_ENABLED: 'true' + ELASTIC_PR_COMMENTS_ENABLED: 'true' + ELASTIC_GITHUB_BUILD_COMMIT_STATUS_ENABLED: 'true' + ELASTIC_GITHUB_STEP_COMMIT_STATUS_ENABLED: 'true' GITHUB_BUILD_COMMIT_STATUS_CONTEXT: kibana-ci - GITHUB_STEP_COMMIT_STATUS_ENABLED: 'true' allow_rebuilds: true branch_configuration: '' cancel_intermediate_builds: true diff --git a/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml b/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml new file mode 100644 index 000000000000..ba053d7c44da --- /dev/null +++ b/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml @@ -0,0 +1,33 @@ +# yaml-language-server: $schema=https://gist.githubusercontent.com/elasticmachine/988b80dae436cafea07d9a4a460a011d/raw/rre.schema.json +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: kibana-tests-emergency-pipeline + description: Definition of the kibana pipeline + links: + - title: Pipeline + url: https://buildkite.com/elastic/kibana-tests-emergency +spec: + type: buildkite-pipeline + owner: group:kibana-tech-leads + system: buildkite + implementation: + apiVersion: buildkite.elastic.dev/v1 + kind: Pipeline + metadata: + name: kibana-tests-emergency + description: Pipeline that tests the service integration in various environments + spec: + repository: elastic/kibana + pipeline_file: ./.buildkite/pipelines/quality-gates/emergency/pipeline.emergency.kibana-tests.yaml + provider_settings: + trigger_mode: none + teams: + kibana-operations: + access_level: MANAGE_BUILD_AND_READ + kibana-release-operators: + access_level: BUILD_AND_READ + cloud-tooling: + access_level: BUILD_AND_READ + everyone: + access_level: READ_ONLY diff --git a/.buildkite/pipeline-resource-definitions/locations.yml b/.buildkite/pipeline-resource-definitions/locations.yml index 55af40868bd4..ccbc41c60ece 100644 --- a/.buildkite/pipeline-resource-definitions/locations.yml +++ b/.buildkite/pipeline-resource-definitions/locations.yml @@ -27,6 +27,7 @@ spec: - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-performance-data-set-extraction-daily.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-pr.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-purge-cloud-deployments.yml + - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-serverless-release-testing.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-serverless-release.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/scalability_testing-daily.yml diff --git a/.buildkite/pipeline-utils/test-failures/annotate.ts b/.buildkite/pipeline-utils/test-failures/annotate.ts index 39aa2d36b9dd..89f651f6d985 100644 --- a/.buildkite/pipeline-utils/test-failures/annotate.ts +++ b/.buildkite/pipeline-utils/test-failures/annotate.ts @@ -170,7 +170,7 @@ export const annotateTestFailures = async () => { buildkite.setAnnotation('test_failures', 'error', getAnnotation(failures, failureHtmlArtifacts)); - if (process.env.PR_COMMENTS_ENABLED === 'true') { + if (process.env.ELASTIC_PR_COMMENTS_ENABLED === 'true') { buildkite.setMetadata( 'pr_comment:test_failures:body', getPrComment(failures, failureHtmlArtifacts) diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index 3f8a671a2d88..8a4f407c9f22 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -3,7 +3,7 @@ steps: label: Build Kibana Artifacts agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: c2-standard-16 timeout_in_minutes: 75 @@ -18,7 +18,7 @@ steps: label: Artifact Testing agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true localSsds: 1 @@ -34,7 +34,7 @@ steps: label: Artifact Testing agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true localSsds: 1 @@ -50,7 +50,7 @@ steps: label: Artifact Testing agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true localSsds: 1 @@ -66,7 +66,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -81,7 +81,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -96,7 +96,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: n2-standard-2 timeout_in_minutes: 30 @@ -109,7 +109,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -127,7 +127,7 @@ steps: - exit_status: -1 agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -154,7 +154,7 @@ steps: label: 'Publish Kibana Artifacts' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme diff --git a/.buildkite/pipelines/artifacts_container_image.yml b/.buildkite/pipelines/artifacts_container_image.yml index 8f4436fb7db9..4788625c142d 100644 --- a/.buildkite/pipelines/artifacts_container_image.yml +++ b/.buildkite/pipelines/artifacts_container_image.yml @@ -3,7 +3,7 @@ steps: label: Build serverless container images agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: n2-standard-16 timeout_in_minutes: 60 diff --git a/.buildkite/pipelines/artifacts_trigger.yml b/.buildkite/pipelines/artifacts_trigger.yml index 98851ddea31a..760281dd4e58 100644 --- a/.buildkite/pipelines/artifacts_trigger.yml +++ b/.buildkite/pipelines/artifacts_trigger.yml @@ -3,7 +3,7 @@ steps: label: Trigger artifacts build agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: n2-standard-2 timeout_in_minutes: 10 diff --git a/.buildkite/pipelines/quality-gates/emergency/pipeline.emergency.kibana-tests.yaml b/.buildkite/pipelines/quality-gates/emergency/pipeline.emergency.kibana-tests.yaml new file mode 100644 index 000000000000..60ede2aae2b9 --- /dev/null +++ b/.buildkite/pipelines/quality-gates/emergency/pipeline.emergency.kibana-tests.yaml @@ -0,0 +1,33 @@ +# This pipeline serves as the entry point for your service's quality gates definitions. When +# properly configured, it will be invoked automatically as part of the automated +# promotion process once a new version was rolled out in one of the various cloud stages. +# +# The updated environment is provided via ENVIRONMENT variable. The seedling +# step will branch and execute pipeline snippets at the following location: +# pipeline.tests-qa.yaml +# pipeline.tests-staging.yaml +# pipeline.tests-production.yaml +# +# Docs: https://docs.elastic.dev/serverless/qualitygates + +agents: + cpu: 2 + ephemeralStorage: "20G" + memory: "8G" + +env: + SKIP_NODE_SETUP: true + TEAM_CHANNEL: "#kibana-mission-control" + ENVIRONMENT: ${ENVIRONMENT?} + +steps: + - label: ":pipeline::grey_question::seedling: Trigger Kibana Tests for ${ENVIRONMENT}" + env: + QG_PIPELINE_LOCATION: ".buildkite/pipelines/quality-gates/emergency" + command: "make -C /agent run-environment-tests" # will trigger https://buildkite.com/elastic/kibana-tests-emergency + agents: + image: "docker.elastic.co/ci-agent-images/quality-gate-seedling:0.0.4" + +notify: + - slack: "${TEAM_CHANNEL?}" + if: build.branch == "main" && build.state == "failed" diff --git a/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-production.yaml b/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-production.yaml new file mode 100644 index 000000000000..a1de7f41a210 --- /dev/null +++ b/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-production.yaml @@ -0,0 +1,37 @@ +# These pipeline steps constitute the quality gate for your service within the production environment. +# Incorporate any necessary additional logic to validate the service's integrity. +# A failure in this pipeline build will prevent further progression to the subsequent stage. + +steps: + - label: ":kibana: SLO check" + trigger: "serverless-quality-gates" # https://buildkite.com/elastic/serverless-quality-gates + build: + message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-production.yaml)" + env: + TARGET_ENV: production + CHECK_SLO: true + CHECK_SLO_TAG: kibana + CHECK_SLO_WAITING_PERIOD: 15m + CHECK_SLO_BURN_RATE_THRESHOLD: 0.1 + soft_fail: true + + - label: ":rocket: control-plane e2e tests" + if: build.env("ENVIRONMENT") == "production-canary" + trigger: "ess-k8s-production-e2e-tests" # https://buildkite.com/elastic/ess-k8s-production-e2e-tests + build: + env: + REGION_ID: aws-us-east-1 + NAME_PREFIX: ci_test_kibana-promotion_ + message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-production.yaml)" + + - label: ":cookie: 24h bake time before continuing promotion" + if: build.env("ENVIRONMENT") == "production-canary" + command: "sleep 86400" + soft_fail: + # A manual cancel of that step produces return code 255. + # We're treating this case as a soft fail to allow manual bake time skipping. + # To stop the promotion entirely, instead click the "Cancel" button at the top of the page + - exit_status: 255 + agents: + # How long can this agent live for in minutes - 25 hours + instanceMaxAge: 1500 diff --git a/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-qa.yaml b/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-qa.yaml new file mode 100644 index 000000000000..1c0e69ef7a7b --- /dev/null +++ b/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-qa.yaml @@ -0,0 +1,12 @@ +# These pipeline steps constitute the quality gate for your service within the QA environment. +# Incorporate any necessary additional logic to validate the service's integrity. +# A failure in this pipeline build will prevent further progression to the subsequent stage. + +steps: + - label: ":rocket: control-plane e2e tests" + trigger: "ess-k8s-qa-e2e-tests-daily" # https://buildkite.com/elastic/ess-k8s-qa-e2e-tests-daily + build: + env: + REGION_ID: aws-eu-west-1 + NAME_PREFIX: ci_test_kibana-promotion_ + message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-qa.yaml)" diff --git a/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-staging.yaml b/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-staging.yaml new file mode 100644 index 000000000000..febb61c12c5f --- /dev/null +++ b/.buildkite/pipelines/quality-gates/emergency/pipeline.tests-staging.yaml @@ -0,0 +1,45 @@ +# These pipeline steps constitute the quality gate for your service within the staging environment. +# Incorporate any necessary additional logic to validate the service's integrity. +# A failure in this pipeline build will prevent further progression to the subsequent stage. + +steps: + - label: ":rocket: control-plane e2e tests" + trigger: "ess-k8s-staging-e2e-tests" # https://buildkite.com/elastic/ess-k8s-staging-e2e-tests + build: + env: + REGION_ID: aws-us-east-1 + NAME_PREFIX: ci_test_kibana-promotion_ + message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-staging.yaml)" + + - label: ":kibana: Kibana Serverless Tests for ${ENVIRONMENT}" + trigger: appex-qa-serverless-kibana-ftr-tests # https://buildkite.com/elastic/appex-qa-serverless-kibana-ftr-tests + soft_fail: true # Remove when tests stabilize + build: + env: + ENVIRONMENT: ${ENVIRONMENT} + EC_ENV: staging + EC_REGION: aws-us-east-1 + RETRY_TESTS_ON_FAIL: "true" + message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-staging.yaml)" + + - label: ":rocket: Fleet synthetic monitor to check the long standing project" + trigger: "serverless-quality-gates" + build: + message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-staging.yaml)" + env: + TARGET_ENV: staging + CHECK_SYNTHETICS: true + CHECK_SYNTHETICS_TAG: "fleet" + CHECK_SYNTHETICS_MINIMUM_RUNS: 3 + MAX_FAILURES: 2 + CHECK_SYNTHETIC_MAX_POLL: 50 + soft_fail: true + + - wait: ~ + + - group: "Kibana Release Manager" + steps: + - label: ":judge::seedling: Trigger Manual Tests Phase" + command: "make -C /agent trigger-manual-verification-phase" + agents: + image: "docker.elastic.co/ci-agent-images/manual-verification-agent:0.0.6" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml index 837234fc5144..febb61c12c5f 100644 --- a/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml @@ -21,7 +21,7 @@ steps: EC_REGION: aws-us-east-1 RETRY_TESTS_ON_FAIL: "true" message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-staging.yaml)" - + - label: ":rocket: Fleet synthetic monitor to check the long standing project" trigger: "serverless-quality-gates" build: diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml index a20d3c709223..b880b0a5f2f0 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml @@ -1,6 +1,6 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/edr_workflows/mki_security_solution_defend_workflows.sh cypress:dw:qa:serverless:run - label: "Serverless MKI QA Defend Workflows Cypress Tests on Serverless" + label: "Cypress MKI - Defend Workflows " key: test_defend_workflows agents: image: family/kibana-ubuntu-2004 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml index aee2f92b712b..da5aa911a6c2 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml @@ -1,9 +1,9 @@ steps: - - group: "Serverless MKI QA Detection Engine - Cypress Tests" + - group: "Cypress MKI - Detection Engine" key: cypress_test_detections_engine steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine - label: "Serverless MKI QA Detection Engine - Security Solution Cypress Tests" + label: "Cypress MKI - Detection Engine" key: test_detection_engine env: BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" @@ -22,7 +22,7 @@ steps: limit: 1 - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine:exceptions - label: "Serverless MKI QA Detection Engine - Exceptions - Security Solution Cypress Tests" + label: "Cypress MKI - Detection Engine - Exceptions" key: test_detection_engine_exceptions env: BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" @@ -40,7 +40,7 @@ steps: - exit_status: "-1" limit: 1 - - group: "Serverless MKI QA Detection Engine - API Integration" + - group: "API MKI - Detection Engine - " key: api_test_detections_engine steps: - label: Running exception_lists_items:qa:serverless diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_entity_analytics.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_entity_analytics.yml index 238da924ffd2..f993986aefbb 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_entity_analytics.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_entity_analytics.yml @@ -1,6 +1,6 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:entity_analytics - label: 'Serverless MKI QA Entity Analytics - Security Solution Cypress Tests' + label: 'Cypress MKI - Entity Analytics' key: test_entity_analytics env: BK_TEST_SUITE_KEY: "serverless-cypress-entity-analytics" @@ -18,7 +18,7 @@ steps: - exit_status: '-1' limit: 1 - - group: "Serverless MKI QA Entity Analytics - API Integration" + - group: "API MKI - Entity Analytics" key: api_test_entity_analytics steps: - label: Running entity_analytics:qa:serverless diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_explore.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_explore.yml index e35f6004ad3e..7697da4b3eda 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_explore.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_explore.yml @@ -1,7 +1,7 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:explore key: test_explore - label: 'Serverless MKI QA Explore - Security Solution Cypress Tests' + label: 'Cypress MKI - Explore' env: BK_TEST_SUITE_KEY: "serverless-cypress-explore" agents: @@ -17,3 +17,66 @@ steps: automatic: - exit_status: '-1' limit: 1 + + - group: "API MKI - Explore" + key: api_test_explore + steps: + - label: Running explore:hosts:runner:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:hosts:runner:qa:serverless + key: explore:hosts:runner:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running explore:network:runner:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:network:runner:qa:serverless + key: explore:network:runner:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running explore:overview:runner:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:overview:runner:qa:serverless + key: explore:overview:runner:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running explore:users:runner:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:users:runner:qa:serverless + key: explore:users:runner:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_gen_ai.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_gen_ai.yml index d6ce8b4a80eb..2d84e7d4e031 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_gen_ai.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_gen_ai.yml @@ -1,6 +1,6 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:ai_assistant - label: "Serverless MKI QA AI Assistant - Security Solution Cypress Tests" + label: "Cypress MKI - GenAI key: test_ai_assistant env: BK_TEST_SUITE_KEY: "serverless-cypress-gen-ai" @@ -18,7 +18,7 @@ steps: - exit_status: "-1" limit: 1 - - group: "Serverless MKI QA AI Assistant - API Integration" + - group: "API MKI - GenAI" key: api_test_ai_assistant steps: - label: Running genai:qa:serverless diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_investigations.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_investigations.yml index caa788853c11..0988bf6ecf6b 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_investigations.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_investigations.yml @@ -1,7 +1,7 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:investigations key: test_investigations - label: 'Serverless MKI QA Investigations - Security Solution Cypress Tests' + label: 'Cypress MKI - Investigations' env: BK_TEST_SUITE_KEY: "serverless-cypress-investigations" agents: @@ -17,3 +17,36 @@ steps: automatic: - exit_status: '-1' limit: 1 + + - group: "API MKI - Investigations" + key: api_test_investigations + steps: + - label: Running investigations:timeline:runner:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh investigations:timeline:runner:qa:serverless + key: investigations:timeline:runner:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running investigations:saved-objects:runner:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh investigations:saved-objects:runner:qa:serverless + key: investigations:saved-objects:runner:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_rule_management.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_rule_management.yml index 428325ec0a1d..7f08247a91b8 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_rule_management.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_rule_management.yml @@ -3,7 +3,7 @@ steps: key: cypress_test_rule_management steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:rule_management - label: "Serverless MKI QA Rule Management - Security Solution Cypress Tests" + label: "Cypress MKI - Rule Management" key: test_rule_management env: BK_TEST_SUITE_KEY: "serverless-cypress-rule-management" @@ -22,7 +22,7 @@ steps: limit: 1 - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:rule_management:prebuilt_rules - label: "Serverless MKI QA Rule Management - Prebuilt Rules - Security Solution Cypress Tests" + label: "Cypress MKI - Rule Management - Prebuilt Rules" key: test_rule_management_prebuilt_rules env: BK_TEST_SUITE_KEY: "serverless-cypress-rule-management" @@ -40,7 +40,7 @@ steps: - exit_status: "-1" limit: 1 - - group: "Serverless MKI QA Rule Management - API Integration" + - group: "API MKI - Rule Management" key: api_test_rule_management steps: - label: Running rule_creation:qa:serverless diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml index 96761bb5e9d7..e59ca507e400 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml @@ -1,6 +1,6 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/edr_workflows/mki_security_solution_defend_workflows.sh cypress:dw:qa:serverless:run - label: 'Serverless MKI QA Defend Workflows Cypress Tests on Serverless' + label: 'Cypress MKI - Defend Workflows' key: test_defend_workflows agents: image: family/kibana-ubuntu-2004 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml index a44847c52b05..f73ecc6225dc 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml @@ -1,9 +1,9 @@ steps: - - group: "Serverless MKI QA Detection Engine - Cypress Tests" + - group: "Cypress MKI - Detection Engine" key: cypress_test_detections_engine steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine - label: "Serverless MKI QA Detection Engine - Security Solution Cypress Tests" + label: "Cypress MKI - Detection Engine" key: test_detection_engine env: BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" @@ -22,7 +22,7 @@ steps: limit: 1 - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine:exceptions - label: "Serverless MKI QA Detection Engine - Exceptions - Security Solution Cypress Tests" + label: "Cypress MKI - Detection Engine - Exceptions" key: test_detection_engine_exceptions env: BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" @@ -40,7 +40,7 @@ steps: - exit_status: "-1" limit: 1 - - group: "Serverless MKI QA Detection Engine - API Integration" + - group: "API MKI - Detection Engine" key: api_test_detections_engine steps: - label: Running exception_lists_items:qa:serverless:release diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_entity_analytics.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_entity_analytics.yml index a3552645ac53..16f2ec688bde 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_entity_analytics.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_entity_analytics.yml @@ -1,6 +1,6 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:entity_analytics - label: 'Serverless MKI QA Entity Analytics - Security Solution Cypress Tests' + label: 'Cypress MKI - Entity Analytics' key: test_entity_analytics env: BK_TEST_SUITE_KEY: "serverless-cypress-entity-analytics" @@ -18,7 +18,7 @@ steps: - exit_status: '-1' limit: 1 - - group: "Serverless MKI QA Entity Analytics - API Integration" + - group: "API MKI - Entity Analytics" key: api_test_entity_analytics steps: - label: Running entity_analytics:qa:serverless:release diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_explore.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_explore.yml index e51e06a8a054..2c518fa24efa 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_explore.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_explore.yml @@ -1,7 +1,7 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:explore key: test_explore - label: 'Serverless MKI QA Explore - Security Solution Cypress Tests' + label: 'Cypress MKI - Explore' env: BK_TEST_SUITE_KEY: "serverless-cypress-explore" agents: @@ -17,3 +17,66 @@ steps: automatic: - exit_status: '-1' limit: 1 + + - group: "API MKI - Explore" + key: api_test_explore + steps: + - label: Running explore:hosts:runner:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:hosts:runner:qa:serverless:release + key: explore:hosts:runner:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running explore:network:runner:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:network:runner:qa:serverless:release + key: explore:network:runner:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running explore:overview:runner:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:overview:runner:qa:serverless:release + key: explore:overview:runner:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running explore:users:runner:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh explore:users:runner:qa:serverless:release + key: explore:users:runner:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_gen_ai.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_gen_ai.yml index 60677728a048..9ea5755438ef 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_gen_ai.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_gen_ai.yml @@ -1,6 +1,6 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:ai_assistant - label: "Serverless MKI QA AI Assistant - Security Solution Cypress Tests" + label: "Cypress MKI - GenAI" key: test_ai_assistant env: BK_TEST_SUITE_KEY: "serverless-cypress-gen-ai" @@ -18,7 +18,7 @@ steps: - exit_status: "-1" limit: 1 - - group: "Serverless MKI QA AI Assistant - API Integration" + - group: "API MKI - GenAI" key: api_test_ai_assistant steps: - label: Running genai:qa:serverless:release diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_investigations.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_investigations.yml index 5e5707ad2ea8..d3f57e40ec2c 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_investigations.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_investigations.yml @@ -1,7 +1,7 @@ steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:investigations key: test_investigations - label: 'Serverless MKI QA Investigations - Security Solution Cypress Tests' + label: 'Cypress MKI - Investigations' env: BK_TEST_SUITE_KEY: "serverless-cypress-investigations" agents: @@ -17,3 +17,36 @@ steps: automatic: - exit_status: '-1' limit: 1 + + - group: "API MKI - Investigations" + key: api_test_investigations + steps: + - label: Running investigations:timeline:runner:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh investigations:timeline:runner:qa:serverless:release + key: investigations:timeline:runner:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running investigations:saved-objects:runner:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh investigations:saved-objects:runner:qa:serverless:release + key: investigations:saved-objects:runner:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_rule_management.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_rule_management.yml index ca13baa0bd2a..5134d96f043c 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_rule_management.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_rule_management.yml @@ -1,9 +1,9 @@ steps: - - group: "Serverless MKI QA Rule Management - Cypress Test" + - group: "Cypress MKI - Rule Management" key: cypress_test_rule_management steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:rule_management - label: "Serverless MKI QA Rule Management - Security Solution Cypress Tests" + label: "Cypress MKI - Rule Management" key: test_rule_management env: BK_TEST_SUITE_KEY: "serverless-cypress-rule-management" @@ -22,7 +22,7 @@ steps: limit: 1 - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:rule_management:prebuilt_rules - label: "Serverless MKI QA Rule Management - Prebuilt Rules - Security Solution Cypress Tests" + label: "Cypress MKI - Rule Management - Prebuilt Rules key: test_rule_management_prebuilt_rules env: BK_TEST_SUITE_KEY: "serverless-cypress-rule-management" @@ -40,7 +40,7 @@ steps: - exit_status: "-1" limit: 1 - - group: "Serverless MKI QA Rule Management - API Integration" + - group: "API MKI - Rule Management" key: api_test_rule_management steps: - label: Running rule_creation:qa:serverless:release diff --git a/.buildkite/scripts/common/setup_job_env.sh b/.buildkite/scripts/common/setup_job_env.sh index 6066e24708f7..bc1d902af781 100644 --- a/.buildkite/scripts/common/setup_job_env.sh +++ b/.buildkite/scripts/common/setup_job_env.sh @@ -16,9 +16,6 @@ fi GITHUB_TOKEN=$(vault_get kibanamachine github_token) export GITHUB_TOKEN - KIBANA_CI_GITHUB_TOKEN=$(vault_get kibana-ci-github github_token) - export KIBANA_CI_GITHUB_TOKEN - KIBANA_DOCKER_USERNAME="$(vault_get container-registry username)" KIBANA_DOCKER_PASSWORD="$(vault_get container-registry password)" if (command -v docker && docker version) &> /dev/null; then diff --git a/.buildkite/scripts/lifecycle/post_build.sh b/.buildkite/scripts/lifecycle/post_build.sh index 3ca36e9d04b7..c0fb7edde1b0 100755 --- a/.buildkite/scripts/lifecycle/post_build.sh +++ b/.buildkite/scripts/lifecycle/post_build.sh @@ -5,7 +5,7 @@ set -euo pipefail BUILD_SUCCESSFUL=$(ts-node "$(dirname "${0}")/build_status.ts") export BUILD_SUCCESSFUL -if [[ "${GITHUB_BUILD_COMMIT_STATUS_ENABLED:-}" != "true" ]]; then +if [[ "${ELASTIC_GITHUB_BUILD_COMMIT_STATUS_ENABLED:-}" != "true" ]]; then "$(dirname "${0}")/commit_status_complete.sh" fi diff --git a/.buildkite/scripts/lifecycle/pre_build.sh b/.buildkite/scripts/lifecycle/pre_build.sh index b8ccaf04f9bb..91d8adda85eb 100755 --- a/.buildkite/scripts/lifecycle/pre_build.sh +++ b/.buildkite/scripts/lifecycle/pre_build.sh @@ -4,7 +4,7 @@ set -euo pipefail source .buildkite/scripts/common/util.sh -if [[ "${GITHUB_BUILD_COMMIT_STATUS_ENABLED:-}" != "true" ]]; then +if [[ "${ELASTIC_GITHUB_BUILD_COMMIT_STATUS_ENABLED:-}" != "true" ]]; then "$(dirname "${0}")/commit_status_start.sh" fi diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.ts b/.buildkite/scripts/pipelines/pull_request/pipeline.ts index c6b28bc20c6f..cd5d9aa470b3 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.ts +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.ts @@ -307,7 +307,11 @@ const getPipeline = (filename: string, removeSteps = true) => { } if ( - ((await doAnyChangesMatch([/^x-pack\/plugins\/osquery/, /^x-pack\/test\/osquery_cypress/])) || + ((await doAnyChangesMatch([ + /^x-pack\/plugins\/osquery/, + /^x-pack\/test\/osquery_cypress/, + /^x-pack\/plugins\/security_solution/, + ])) || GITHUB_PR_LABELS.includes('ci:all-cypress-suites')) && !GITHUB_PR_LABELS.includes('ci:skip-cypress-osquery') ) { diff --git a/.buildkite/scripts/steps/api_docs/publish_api_docs.sh b/.buildkite/scripts/steps/api_docs/publish_api_docs.sh index 77f6fb0888fb..7ab4ae6b3497 100755 --- a/.buildkite/scripts/steps/api_docs/publish_api_docs.sh +++ b/.buildkite/scripts/steps/api_docs/publish_api_docs.sh @@ -29,5 +29,3 @@ git push origin "$branch" prUrl=$(gh pr create --repo elastic/kibana --base main --head "$branch" --title "[api-docs] $(date +%F) Daily api_docs build" --body "Generated by $BUILDKITE_BUILD_URL" --label "release_note:skip" --label "docs") echo "Opened PR: $prUrl" gh pr merge --repo elastic/kibana --auto --squash "$prUrl" - -GH_TOKEN="$KIBANA_CI_GITHUB_TOKEN" gh pr review --repo elastic/kibana --approve -b "Automated review from $BUILDKITE_BUILD_URL" "$prUrl" diff --git a/.github/workflows/auto-approve-api-docs.yml b/.github/workflows/auto-approve-api-docs.yml new file mode 100644 index 000000000000..503ea9634d00 --- /dev/null +++ b/.github/workflows/auto-approve-api-docs.yml @@ -0,0 +1,18 @@ +on: + pull_request: + branches: + - main + types: + - opened + +jobs: + approve: + name: Auto-approve API docs + runs-on: ubuntu-latest + if: | + startsWith(github.event.pull_request.head.ref, 'api_docs') && + github.event.pull_request.user.login == 'kibanamachine' + permissions: + pull-requests: write + steps: + - uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 #4.0.0 diff --git a/.github/workflows/auto-approve-backports.yml b/.github/workflows/auto-approve-backports.yml index 4b205f697179..2f73696406d9 100644 --- a/.github/workflows/auto-approve-backports.yml +++ b/.github/workflows/auto-approve-backports.yml @@ -1,7 +1,9 @@ on: - pull_request_target: + pull_request: branches-ignore: - main + types: + - opened jobs: approve: @@ -13,4 +15,4 @@ jobs: permissions: pull-requests: write steps: - - uses: hmarr/auto-approve-action@v3 + - uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 #4.0.0 diff --git a/api_docs/actions.mdx b/api_docs/actions.mdx index a8ce305d0f13..4747cbb25d84 100644 --- a/api_docs/actions.mdx +++ b/api_docs/actions.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/actions title: "actions" image: https://source.unsplash.com/400x175/?github description: API docs for the actions plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'actions'] --- import actionsObj from './actions.devdocs.json'; diff --git a/api_docs/advanced_settings.mdx b/api_docs/advanced_settings.mdx index 039e89c0d32d..dc62e81a5ffc 100644 --- a/api_docs/advanced_settings.mdx +++ b/api_docs/advanced_settings.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/advancedSettings title: "advancedSettings" image: https://source.unsplash.com/400x175/?github description: API docs for the advancedSettings plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'advancedSettings'] --- import advancedSettingsObj from './advanced_settings.devdocs.json'; diff --git a/api_docs/ai_assistant_management_selection.mdx b/api_docs/ai_assistant_management_selection.mdx index f11992d488a3..23028b42ea8a 100644 --- a/api_docs/ai_assistant_management_selection.mdx +++ b/api_docs/ai_assistant_management_selection.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/aiAssistantManagementSelection title: "aiAssistantManagementSelection" image: https://source.unsplash.com/400x175/?github description: API docs for the aiAssistantManagementSelection plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'aiAssistantManagementSelection'] --- import aiAssistantManagementSelectionObj from './ai_assistant_management_selection.devdocs.json'; diff --git a/api_docs/aiops.devdocs.json b/api_docs/aiops.devdocs.json index 5d8cd183f741..e0f9a16baedb 100644 --- a/api_docs/aiops.devdocs.json +++ b/api_docs/aiops.devdocs.json @@ -786,6 +786,29 @@ "path": "x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts", "deprecated": false, "trackAdoption": false + }, + { + "parentPluginId": "aiops", + "id": "def-public.AiopsAppDependencies.observabilityAIAssistant", + "type": "Object", + "tags": [], + "label": "observabilityAIAssistant", + "description": [ + "Observability AI Assistant" + ], + "signature": [ + { + "pluginId": "observabilityAIAssistant", + "scope": "public", + "docId": "kibObservabilityAIAssistantPluginApi", + "section": "def-public.ObservabilityAIAssistantPublicStart", + "text": "ObservabilityAIAssistantPublicStart" + }, + " | undefined" + ], + "path": "x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts", + "deprecated": false, + "trackAdoption": false } ], "initialIsOpen": false @@ -1068,6 +1091,22 @@ "deprecated": false, "trackAdoption": false }, + { + "parentPluginId": "aiops", + "id": "def-public.LogRateAnalysisAppStateProps.showContextualInsights", + "type": "CompoundType", + "tags": [], + "label": "showContextualInsights", + "description": [ + "Optional flag to indicate whether to show contextual insights" + ], + "signature": [ + "boolean | undefined" + ], + "path": "x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_app_state.tsx", + "deprecated": false, + "trackAdoption": false + }, { "parentPluginId": "aiops", "id": "def-public.LogRateAnalysisAppStateProps.showFrozenDataTierChoice", diff --git a/api_docs/aiops.mdx b/api_docs/aiops.mdx index 1aad1e8e6bbf..a848dde15865 100644 --- a/api_docs/aiops.mdx +++ b/api_docs/aiops.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/aiops title: "aiops" image: https://source.unsplash.com/400x175/?github description: API docs for the aiops plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'aiops'] --- import aiopsObj from './aiops.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) for questi | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 72 | 0 | 9 | 2 | +| 74 | 0 | 9 | 2 | ## Client diff --git a/api_docs/alerting.devdocs.json b/api_docs/alerting.devdocs.json index 010cbaafd57b..6bd7532c969c 100644 --- a/api_docs/alerting.devdocs.json +++ b/api_docs/alerting.devdocs.json @@ -8794,7 +8794,7 @@ { "parentPluginId": "alerting", "id": "def-common.Rule.alertDelay", - "type": "Object", + "type": "CompoundType", "tags": [], "label": "alertDelay", "description": [], @@ -8806,7 +8806,7 @@ "section": "def-common.AlertDelay", "text": "AlertDelay" }, - " | undefined" + " | null | undefined" ], "path": "packages/kbn-alerting-types/rule_types.ts", "deprecated": false, @@ -10456,6 +10456,18 @@ } ], "enums": [ + { + "parentPluginId": "alerting", + "id": "def-common.HealthStatus", + "type": "Enum", + "tags": [], + "label": "HealthStatus", + "description": [], + "path": "packages/kbn-alerting-types/alerting_framework_health_types.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "alerting", "id": "def-common.MaintenanceWindowStatus", diff --git a/api_docs/alerting.mdx b/api_docs/alerting.mdx index b14ecc6e651e..9a88ab625db7 100644 --- a/api_docs/alerting.mdx +++ b/api_docs/alerting.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/alerting title: "alerting" image: https://source.unsplash.com/400x175/?github description: API docs for the alerting plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'alerting'] --- import alertingObj from './alerting.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-o | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 870 | 1 | 838 | 52 | +| 871 | 1 | 839 | 52 | ## Client diff --git a/api_docs/apm.devdocs.json b/api_docs/apm.devdocs.json index 6454cad9c995..9890cb604324 100644 --- a/api_docs/apm.devdocs.json +++ b/api_docs/apm.devdocs.json @@ -273,7 +273,7 @@ "APMPluginSetupDependencies", ") => { config$: ", "Observable", - "; }>; autoCreateApmDataView: boolean; serviceMapEnabled: boolean; serviceMapFingerprintBucketSize: number; serviceMapFingerprintGlobalBucketSize: number; serviceMapTraceIdBucketSize: number; serviceMapTraceIdGlobalBucketSize: number; serviceMapMaxTracesPerRequest: number; serviceMapTerminateAfter: number; serviceMapMaxTraces: number; ui: Readonly<{} & { enabled: boolean; maxTraceItems: number; }>; searchAggregatedTransactions: ", + "; }>; autoCreateApmDataView: boolean; serviceMapEnabled: boolean; serviceMapFingerprintBucketSize: number; serviceMapFingerprintGlobalBucketSize: number; serviceMapMaxAllowableBytes: number; serviceMapTraceIdBucketSize: number; serviceMapTraceIdGlobalBucketSize: number; serviceMapMaxTracesPerRequest: number; serviceMapTerminateAfter: number; serviceMapMaxTraces: number; ui: Readonly<{} & { enabled: boolean; maxTraceItems: number; }>; searchAggregatedTransactions: ", "SearchAggregatedTransactionSetting", "; telemetryCollectionEnabled: boolean; metricsInterval: number; forceSyntheticSource: boolean; latestAgentVersionsUrl: string; serverless: Readonly<{} & { enabled: true; }>; serverlessOnboarding: boolean; managedServiceUrl: string; featureFlags: Readonly<{} & { agentConfigurationAvailable: boolean; configurableIndicesAvailable: boolean; infrastructureTabAvailable: boolean; infraUiAvailable: boolean; migrationToFleetAvailable: boolean; sourcemapApiAvailable: boolean; storageExplorerAvailable: boolean; profilingIntegrationAvailable: boolean; ruleFormV2Enabled: boolean; }>; }>>; }" ], @@ -448,7 +448,7 @@ "label": "APMConfig", "description": [], "signature": [ - "{ readonly enabled: boolean; readonly agent: Readonly<{} & { migrations: Readonly<{} & { enabled: boolean; }>; }>; readonly autoCreateApmDataView: boolean; readonly serviceMapEnabled: boolean; readonly serviceMapFingerprintBucketSize: number; readonly serviceMapFingerprintGlobalBucketSize: number; readonly serviceMapTraceIdBucketSize: number; readonly serviceMapTraceIdGlobalBucketSize: number; readonly serviceMapMaxTracesPerRequest: number; readonly serviceMapTerminateAfter: number; readonly serviceMapMaxTraces: number; readonly ui: Readonly<{} & { enabled: boolean; maxTraceItems: number; }>; readonly searchAggregatedTransactions: ", + "{ readonly enabled: boolean; readonly agent: Readonly<{} & { migrations: Readonly<{} & { enabled: boolean; }>; }>; readonly autoCreateApmDataView: boolean; readonly serviceMapEnabled: boolean; readonly serviceMapFingerprintBucketSize: number; readonly serviceMapFingerprintGlobalBucketSize: number; readonly serviceMapMaxAllowableBytes: number; readonly serviceMapTraceIdBucketSize: number; readonly serviceMapTraceIdGlobalBucketSize: number; readonly serviceMapMaxTracesPerRequest: number; readonly serviceMapTerminateAfter: number; readonly serviceMapMaxTraces: number; readonly ui: Readonly<{} & { enabled: boolean; maxTraceItems: number; }>; readonly searchAggregatedTransactions: ", "SearchAggregatedTransactionSetting", "; readonly telemetryCollectionEnabled: boolean; readonly metricsInterval: number; readonly forceSyntheticSource: boolean; readonly latestAgentVersionsUrl: string; readonly serverless: Readonly<{} & { enabled: true; }>; readonly serverlessOnboarding: boolean; readonly managedServiceUrl: string; readonly featureFlags: Readonly<{} & { agentConfigurationAvailable: boolean; configurableIndicesAvailable: boolean; infrastructureTabAvailable: boolean; infraUiAvailable: boolean; migrationToFleetAvailable: boolean; sourcemapApiAvailable: boolean; storageExplorerAvailable: boolean; profilingIntegrationAvailable: boolean; ruleFormV2Enabled: boolean; }>; }" ], @@ -7984,7 +7984,7 @@ "description": [], "signature": [ "Observable", - "; }>; autoCreateApmDataView: boolean; serviceMapEnabled: boolean; serviceMapFingerprintBucketSize: number; serviceMapFingerprintGlobalBucketSize: number; serviceMapTraceIdBucketSize: number; serviceMapTraceIdGlobalBucketSize: number; serviceMapMaxTracesPerRequest: number; serviceMapTerminateAfter: number; serviceMapMaxTraces: number; ui: Readonly<{} & { enabled: boolean; maxTraceItems: number; }>; searchAggregatedTransactions: ", + "; }>; autoCreateApmDataView: boolean; serviceMapEnabled: boolean; serviceMapFingerprintBucketSize: number; serviceMapFingerprintGlobalBucketSize: number; serviceMapMaxAllowableBytes: number; serviceMapTraceIdBucketSize: number; serviceMapTraceIdGlobalBucketSize: number; serviceMapMaxTracesPerRequest: number; serviceMapTerminateAfter: number; serviceMapMaxTraces: number; ui: Readonly<{} & { enabled: boolean; maxTraceItems: number; }>; searchAggregatedTransactions: ", "SearchAggregatedTransactionSetting", "; telemetryCollectionEnabled: boolean; metricsInterval: number; forceSyntheticSource: boolean; latestAgentVersionsUrl: string; serverless: Readonly<{} & { enabled: true; }>; serverlessOnboarding: boolean; managedServiceUrl: string; featureFlags: Readonly<{} & { agentConfigurationAvailable: boolean; configurableIndicesAvailable: boolean; infrastructureTabAvailable: boolean; infraUiAvailable: boolean; migrationToFleetAvailable: boolean; sourcemapApiAvailable: boolean; storageExplorerAvailable: boolean; profilingIntegrationAvailable: boolean; ruleFormV2Enabled: boolean; }>; }>>" ], diff --git a/api_docs/apm.mdx b/api_docs/apm.mdx index 7fbb41a2c94d..a458a7510759 100644 --- a/api_docs/apm.mdx +++ b/api_docs/apm.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/apm title: "apm" image: https://source.unsplash.com/400x175/?github description: API docs for the apm plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'apm'] --- import apmObj from './apm.devdocs.json'; diff --git a/api_docs/apm_data_access.mdx b/api_docs/apm_data_access.mdx index 6410a8ab709d..3cdbbc06563a 100644 --- a/api_docs/apm_data_access.mdx +++ b/api_docs/apm_data_access.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/apmDataAccess title: "apmDataAccess" image: https://source.unsplash.com/400x175/?github description: API docs for the apmDataAccess plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'apmDataAccess'] --- import apmDataAccessObj from './apm_data_access.devdocs.json'; diff --git a/api_docs/assets_data_access.mdx b/api_docs/assets_data_access.mdx index 477f922f93ae..5a3e45713282 100644 --- a/api_docs/assets_data_access.mdx +++ b/api_docs/assets_data_access.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/assetsDataAccess title: "assetsDataAccess" image: https://source.unsplash.com/400x175/?github description: API docs for the assetsDataAccess plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'assetsDataAccess'] --- import assetsDataAccessObj from './assets_data_access.devdocs.json'; diff --git a/api_docs/banners.mdx b/api_docs/banners.mdx index 8c69e714ed63..a5cf4003f841 100644 --- a/api_docs/banners.mdx +++ b/api_docs/banners.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/banners title: "banners" image: https://source.unsplash.com/400x175/?github description: API docs for the banners plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'banners'] --- import bannersObj from './banners.devdocs.json'; diff --git a/api_docs/bfetch.mdx b/api_docs/bfetch.mdx index b185eca40819..e7a24b638dd1 100644 --- a/api_docs/bfetch.mdx +++ b/api_docs/bfetch.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/bfetch title: "bfetch" image: https://source.unsplash.com/400x175/?github description: API docs for the bfetch plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'bfetch'] --- import bfetchObj from './bfetch.devdocs.json'; diff --git a/api_docs/canvas.mdx b/api_docs/canvas.mdx index 6f8996a3b807..0ae826313a98 100644 --- a/api_docs/canvas.mdx +++ b/api_docs/canvas.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/canvas title: "canvas" image: https://source.unsplash.com/400x175/?github description: API docs for the canvas plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'canvas'] --- import canvasObj from './canvas.devdocs.json'; diff --git a/api_docs/cases.devdocs.json b/api_docs/cases.devdocs.json index c62a4c8d7c4c..772e653c1624 100644 --- a/api_docs/cases.devdocs.json +++ b/api_docs/cases.devdocs.json @@ -501,15 +501,7 @@ "section": "def-common.CaseMetricsFeature", "text": "CaseMetricsFeature" }, - "[]; } & { from?: string | undefined; to?: string | undefined; owner?: string | string[] | undefined; }, signal?: AbortSignal | undefined) => Promise<{ mttr?: number | null | undefined; }>; bulkGet: (params: { ids: string[]; }, signal?: AbortSignal | undefined) => Promise<{ cases: ({ description: string; status: ", - { - "pluginId": "@kbn/cases-components", - "scope": "common", - "docId": "kibKbnCasesComponentsPluginApi", - "section": "def-common.CaseStatuses", - "text": "CaseStatuses" - }, - "; tags: string[]; title: string; connector: { id: string; } & (({ type: ", + "[]; } & { from?: string | undefined; to?: string | undefined; owner?: string | string[] | undefined; }, signal?: AbortSignal | undefined) => Promise<{ mttr?: number | null | undefined; }>; bulkGet: (params: { ids: string[]; }, signal?: AbortSignal | undefined) => Promise<{ cases: ({ description: string; tags: string[]; title: string; connector: { id: string; } & (({ type: ", { "pluginId": "cases", "scope": "common", @@ -565,7 +557,7 @@ "section": "def-common.ConnectorTypes", "text": "ConnectorTypes" }, - ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); settings: { syncAlerts: boolean; }; owner: string; severity: ", + ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); severity: ", { "pluginId": "cases", "scope": "common", @@ -577,7 +569,15 @@ "CustomFieldTypes", ".TEXT; value: string | null; } | { key: string; type: ", "CustomFieldTypes", - ".TOGGLE; value: boolean | null; })[]; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", + ".TOGGLE; value: boolean | null; })[]; settings: { syncAlerts: boolean; }; status: ", + { + "pluginId": "@kbn/cases-components", + "scope": "common", + "docId": "kibKbnCasesComponentsPluginApi", + "section": "def-common.CaseStatuses", + "text": "CaseStatuses" + }, + "; owner: string; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", { "pluginId": "cases", "scope": "common", @@ -1870,15 +1870,7 @@ "label": "Case", "description": [], "signature": [ - "{ description: string; status: ", - { - "pluginId": "@kbn/cases-components", - "scope": "common", - "docId": "kibKbnCasesComponentsPluginApi", - "section": "def-common.CaseStatuses", - "text": "CaseStatuses" - }, - "; tags: string[]; title: string; connector: { id: string; } & (({ type: ", + "{ description: string; tags: string[]; title: string; connector: { id: string; } & (({ type: ", { "pluginId": "cases", "scope": "common", @@ -1934,7 +1926,7 @@ "section": "def-common.ConnectorTypes", "text": "ConnectorTypes" }, - ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); settings: { syncAlerts: boolean; }; owner: string; severity: ", + ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); severity: ", { "pluginId": "cases", "scope": "common", @@ -1946,7 +1938,15 @@ "CustomFieldTypes", ".TEXT; value: string | null; } | { key: string; type: ", "CustomFieldTypes", - ".TOGGLE; value: boolean | null; })[]; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", + ".TOGGLE; value: boolean | null; })[]; settings: { syncAlerts: boolean; }; status: ", + { + "pluginId": "@kbn/cases-components", + "scope": "common", + "docId": "kibKbnCasesComponentsPluginApi", + "section": "def-common.CaseStatuses", + "text": "CaseStatuses" + }, + "; owner: string; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", { "pluginId": "cases", "scope": "common", @@ -2147,15 +2147,7 @@ "label": "Cases", "description": [], "signature": [ - "({ description: string; status: ", - { - "pluginId": "@kbn/cases-components", - "scope": "common", - "docId": "kibKbnCasesComponentsPluginApi", - "section": "def-common.CaseStatuses", - "text": "CaseStatuses" - }, - "; tags: string[]; title: string; connector: { id: string; } & (({ type: ", + "({ description: string; tags: string[]; title: string; connector: { id: string; } & (({ type: ", { "pluginId": "cases", "scope": "common", @@ -2211,7 +2203,7 @@ "section": "def-common.ConnectorTypes", "text": "ConnectorTypes" }, - ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); settings: { syncAlerts: boolean; }; owner: string; severity: ", + ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); severity: ", { "pluginId": "cases", "scope": "common", @@ -2223,7 +2215,15 @@ "CustomFieldTypes", ".TEXT; value: string | null; } | { key: string; type: ", "CustomFieldTypes", - ".TOGGLE; value: boolean | null; })[]; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", + ".TOGGLE; value: boolean | null; })[]; settings: { syncAlerts: boolean; }; status: ", + { + "pluginId": "@kbn/cases-components", + "scope": "common", + "docId": "kibKbnCasesComponentsPluginApi", + "section": "def-common.CaseStatuses", + "text": "CaseStatuses" + }, + "; owner: string; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", { "pluginId": "cases", "scope": "common", @@ -2373,15 +2373,7 @@ "label": "CasesBulkGetResponse", "description": [], "signature": [ - "{ cases: ({ description: string; status: ", - { - "pluginId": "@kbn/cases-components", - "scope": "common", - "docId": "kibKbnCasesComponentsPluginApi", - "section": "def-common.CaseStatuses", - "text": "CaseStatuses" - }, - "; tags: string[]; title: string; connector: { id: string; } & (({ type: ", + "{ cases: ({ description: string; tags: string[]; title: string; connector: { id: string; } & (({ type: ", { "pluginId": "cases", "scope": "common", @@ -2437,7 +2429,7 @@ "section": "def-common.ConnectorTypes", "text": "ConnectorTypes" }, - ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); settings: { syncAlerts: boolean; }; owner: string; severity: ", + ".swimlane; fields: { caseId: string | null; } | null; } & { name: string; })); severity: ", { "pluginId": "cases", "scope": "common", @@ -2449,7 +2441,15 @@ "CustomFieldTypes", ".TEXT; value: string | null; } | { key: string; type: ", "CustomFieldTypes", - ".TOGGLE; value: boolean | null; })[]; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", + ".TOGGLE; value: boolean | null; })[]; settings: { syncAlerts: boolean; }; status: ", + { + "pluginId": "@kbn/cases-components", + "scope": "common", + "docId": "kibKbnCasesComponentsPluginApi", + "section": "def-common.CaseStatuses", + "text": "CaseStatuses" + }, + "; owner: string; } & { duration: number | null; closed_at: string | null; closed_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; created_at: string; created_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; external_service: ({ connector_id: string; } & { connector_name: string; external_id: string; external_title: string; external_url: string; pushed_at: string; pushed_by: { email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }; }) | null; updated_at: string | null; updated_by: ({ email: string | null | undefined; full_name: string | null | undefined; username: string | null | undefined; } & { profile_uid?: string | undefined; }) | null; } & { id: string; totalComment: number; totalAlerts: number; version: string; } & { comments?: ((({ comment: string; type: ", { "pluginId": "cases", "scope": "common", @@ -2552,15 +2552,7 @@ "label": "CasesFindResponseUI", "description": [], "signature": [ - "Omit<{ cases: { description: string; status: ", - { - "pluginId": "@kbn/cases-components", - "scope": "common", - "docId": "kibKbnCasesComponentsPluginApi", - "section": "def-common.CaseStatuses", - "text": "CaseStatuses" - }, - "; tags: string[]; title: string; connector: { id: string; type: ", + "Omit<{ cases: { description: string; tags: string[]; title: string; connector: { id: string; type: ", { "pluginId": "cases", "scope": "common", @@ -2616,7 +2608,7 @@ "section": "def-common.ConnectorTypes", "text": "ConnectorTypes" }, - ".swimlane; fields: { caseId: string | null; } | null; name: string; }; settings: { syncAlerts: boolean; }; owner: string; severity: ", + ".swimlane; fields: { caseId: string | null; } | null; name: string; }; severity: ", { "pluginId": "cases", "scope": "common", @@ -2628,7 +2620,15 @@ "CustomFieldTypes", ".TEXT; value: string | null; } | { key: string; type: ", "CustomFieldTypes", - ".TOGGLE; value: boolean | null; })[]; duration: number | null; closedAt: string | null; closedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; createdAt: string; createdBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; externalService: { connectorId: string; connectorName: string; externalId: string; externalTitle: string; externalUrl: string; pushedAt: string; pushedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; } | null; updatedAt: string | null; updatedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; id: string; totalComment: number; totalAlerts: number; version: string; comments?: ({ comment: string; type: ", + ".TOGGLE; value: boolean | null; })[]; settings: { syncAlerts: boolean; }; status: ", + { + "pluginId": "@kbn/cases-components", + "scope": "common", + "docId": "kibKbnCasesComponentsPluginApi", + "section": "def-common.CaseStatuses", + "text": "CaseStatuses" + }, + "; owner: string; duration: number | null; closedAt: string | null; closedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; createdAt: string; createdBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; externalService: { connectorId: string; connectorName: string; externalId: string; externalTitle: string; externalUrl: string; pushedAt: string; pushedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; } | null; updatedAt: string | null; updatedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; id: string; totalComment: number; totalAlerts: number; version: string; comments?: ({ comment: string; type: ", { "pluginId": "cases", "scope": "common", @@ -2767,15 +2767,7 @@ "label": "CaseUI", "description": [], "signature": [ - "Omit<{ description: string; status: ", - { - "pluginId": "@kbn/cases-components", - "scope": "common", - "docId": "kibKbnCasesComponentsPluginApi", - "section": "def-common.CaseStatuses", - "text": "CaseStatuses" - }, - "; tags: string[]; title: string; connector: { id: string; type: ", + "Omit<{ description: string; tags: string[]; title: string; connector: { id: string; type: ", { "pluginId": "cases", "scope": "common", @@ -2831,7 +2823,7 @@ "section": "def-common.ConnectorTypes", "text": "ConnectorTypes" }, - ".swimlane; fields: { caseId: string | null; } | null; name: string; }; settings: { syncAlerts: boolean; }; owner: string; severity: ", + ".swimlane; fields: { caseId: string | null; } | null; name: string; }; severity: ", { "pluginId": "cases", "scope": "common", @@ -2843,7 +2835,15 @@ "CustomFieldTypes", ".TEXT; value: string | null; } | { key: string; type: ", "CustomFieldTypes", - ".TOGGLE; value: boolean | null; })[]; duration: number | null; closedAt: string | null; closedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; createdAt: string; createdBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; externalService: { connectorId: string; connectorName: string; externalId: string; externalTitle: string; externalUrl: string; pushedAt: string; pushedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; } | null; updatedAt: string | null; updatedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; id: string; totalComment: number; totalAlerts: number; version: string; comments?: ({ comment: string; type: ", + ".TOGGLE; value: boolean | null; })[]; settings: { syncAlerts: boolean; }; status: ", + { + "pluginId": "@kbn/cases-components", + "scope": "common", + "docId": "kibKbnCasesComponentsPluginApi", + "section": "def-common.CaseStatuses", + "text": "CaseStatuses" + }, + "; owner: string; duration: number | null; closedAt: string | null; closedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; createdAt: string; createdBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; externalService: { connectorId: string; connectorName: string; externalId: string; externalTitle: string; externalUrl: string; pushedAt: string; pushedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; }; } | null; updatedAt: string | null; updatedBy: { email: string | null | undefined; fullName: string | null | undefined; username: string | null | undefined; profileUid?: string | undefined; } | null; id: string; totalComment: number; totalAlerts: number; version: string; comments?: ({ comment: string; type: ", { "pluginId": "cases", "scope": "common", diff --git a/api_docs/cases.mdx b/api_docs/cases.mdx index 75cf18f601de..16e773f455e2 100644 --- a/api_docs/cases.mdx +++ b/api_docs/cases.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/cases title: "cases" image: https://source.unsplash.com/400x175/?github description: API docs for the cases plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'cases'] --- import casesObj from './cases.devdocs.json'; diff --git a/api_docs/charts.mdx b/api_docs/charts.mdx index bbb87a9bed96..0d73eba87c79 100644 --- a/api_docs/charts.mdx +++ b/api_docs/charts.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/charts title: "charts" image: https://source.unsplash.com/400x175/?github description: API docs for the charts plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'charts'] --- import chartsObj from './charts.devdocs.json'; diff --git a/api_docs/cloud.mdx b/api_docs/cloud.mdx index 9f8dccee05c0..a3c6b7dc61be 100644 --- a/api_docs/cloud.mdx +++ b/api_docs/cloud.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/cloud title: "cloud" image: https://source.unsplash.com/400x175/?github description: API docs for the cloud plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'cloud'] --- import cloudObj from './cloud.devdocs.json'; diff --git a/api_docs/cloud_data_migration.mdx b/api_docs/cloud_data_migration.mdx index c5e81bfdfdf8..3d1741ee97cc 100644 --- a/api_docs/cloud_data_migration.mdx +++ b/api_docs/cloud_data_migration.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/cloudDataMigration title: "cloudDataMigration" image: https://source.unsplash.com/400x175/?github description: API docs for the cloudDataMigration plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'cloudDataMigration'] --- import cloudDataMigrationObj from './cloud_data_migration.devdocs.json'; diff --git a/api_docs/cloud_defend.mdx b/api_docs/cloud_defend.mdx index c43c38b6a844..98baf5078cb4 100644 --- a/api_docs/cloud_defend.mdx +++ b/api_docs/cloud_defend.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/cloudDefend title: "cloudDefend" image: https://source.unsplash.com/400x175/?github description: API docs for the cloudDefend plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'cloudDefend'] --- import cloudDefendObj from './cloud_defend.devdocs.json'; diff --git a/api_docs/cloud_experiments.mdx b/api_docs/cloud_experiments.mdx index 3b4f57bb05b4..2e146c9771c9 100644 --- a/api_docs/cloud_experiments.mdx +++ b/api_docs/cloud_experiments.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/cloudExperiments title: "cloudExperiments" image: https://source.unsplash.com/400x175/?github description: API docs for the cloudExperiments plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'cloudExperiments'] --- import cloudExperimentsObj from './cloud_experiments.devdocs.json'; diff --git a/api_docs/cloud_security_posture.mdx b/api_docs/cloud_security_posture.mdx index 7d5f74fa7ed6..03402c8c5398 100644 --- a/api_docs/cloud_security_posture.mdx +++ b/api_docs/cloud_security_posture.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/cloudSecurityPosture title: "cloudSecurityPosture" image: https://source.unsplash.com/400x175/?github description: API docs for the cloudSecurityPosture plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'cloudSecurityPosture'] --- import cloudSecurityPostureObj from './cloud_security_posture.devdocs.json'; diff --git a/api_docs/console.mdx b/api_docs/console.mdx index 35b6fa65c27f..3ba10527aeae 100644 --- a/api_docs/console.mdx +++ b/api_docs/console.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/console title: "console" image: https://source.unsplash.com/400x175/?github description: API docs for the console plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'console'] --- import consoleObj from './console.devdocs.json'; diff --git a/api_docs/content_management.mdx b/api_docs/content_management.mdx index 04749d864863..449310eac3fa 100644 --- a/api_docs/content_management.mdx +++ b/api_docs/content_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/contentManagement title: "contentManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the contentManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'contentManagement'] --- import contentManagementObj from './content_management.devdocs.json'; diff --git a/api_docs/controls.mdx b/api_docs/controls.mdx index b25711e251b6..1992af56c5a1 100644 --- a/api_docs/controls.mdx +++ b/api_docs/controls.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/controls title: "controls" image: https://source.unsplash.com/400x175/?github description: API docs for the controls plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'controls'] --- import controlsObj from './controls.devdocs.json'; diff --git a/api_docs/custom_integrations.mdx b/api_docs/custom_integrations.mdx index a89b490d0208..dc1ad335a03b 100644 --- a/api_docs/custom_integrations.mdx +++ b/api_docs/custom_integrations.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/customIntegrations title: "customIntegrations" image: https://source.unsplash.com/400x175/?github description: API docs for the customIntegrations plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'customIntegrations'] --- import customIntegrationsObj from './custom_integrations.devdocs.json'; diff --git a/api_docs/dashboard.mdx b/api_docs/dashboard.mdx index 037301f34a6d..1907d6d2bffa 100644 --- a/api_docs/dashboard.mdx +++ b/api_docs/dashboard.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dashboard title: "dashboard" image: https://source.unsplash.com/400x175/?github description: API docs for the dashboard plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dashboard'] --- import dashboardObj from './dashboard.devdocs.json'; diff --git a/api_docs/dashboard_enhanced.mdx b/api_docs/dashboard_enhanced.mdx index b2b9a89dd96f..992fa5ef3a7e 100644 --- a/api_docs/dashboard_enhanced.mdx +++ b/api_docs/dashboard_enhanced.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dashboardEnhanced title: "dashboardEnhanced" image: https://source.unsplash.com/400x175/?github description: API docs for the dashboardEnhanced plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dashboardEnhanced'] --- import dashboardEnhancedObj from './dashboard_enhanced.devdocs.json'; diff --git a/api_docs/data.devdocs.json b/api_docs/data.devdocs.json index 296fbccb6cc3..f90123373cdb 100644 --- a/api_docs/data.devdocs.json +++ b/api_docs/data.devdocs.json @@ -11819,6 +11819,14 @@ "plugin": "dataVisualizer", "path": "x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx" }, + { + "plugin": "dataVisualizer", + "path": "x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx" + }, + { + "plugin": "dataVisualizer", + "path": "x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx" + }, { "plugin": "dataVisualizer", "path": "x-pack/plugins/data_visualizer/public/application/data_drift/charts/default_value_formatter.ts" diff --git a/api_docs/data.mdx b/api_docs/data.mdx index 8f7ddf0d03f1..e5a7220a0c4a 100644 --- a/api_docs/data.mdx +++ b/api_docs/data.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/data title: "data" image: https://source.unsplash.com/400x175/?github description: API docs for the data plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'data'] --- import dataObj from './data.devdocs.json'; diff --git a/api_docs/data_quality.mdx b/api_docs/data_quality.mdx index dbdf4acca0c3..9811b3652fa6 100644 --- a/api_docs/data_quality.mdx +++ b/api_docs/data_quality.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dataQuality title: "dataQuality" image: https://source.unsplash.com/400x175/?github description: API docs for the dataQuality plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dataQuality'] --- import dataQualityObj from './data_quality.devdocs.json'; diff --git a/api_docs/data_query.mdx b/api_docs/data_query.mdx index 3eaa517d10ae..d1accbc8147b 100644 --- a/api_docs/data_query.mdx +++ b/api_docs/data_query.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/data-query title: "data.query" image: https://source.unsplash.com/400x175/?github description: API docs for the data.query plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'data.query'] --- import dataQueryObj from './data_query.devdocs.json'; diff --git a/api_docs/data_search.mdx b/api_docs/data_search.mdx index ab920dfecade..26185cb25bab 100644 --- a/api_docs/data_search.mdx +++ b/api_docs/data_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/data-search title: "data.search" image: https://source.unsplash.com/400x175/?github description: API docs for the data.search plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'data.search'] --- import dataSearchObj from './data_search.devdocs.json'; diff --git a/api_docs/data_view_editor.mdx b/api_docs/data_view_editor.mdx index a934cb1b4294..3b1afc2f13ff 100644 --- a/api_docs/data_view_editor.mdx +++ b/api_docs/data_view_editor.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dataViewEditor title: "dataViewEditor" image: https://source.unsplash.com/400x175/?github description: API docs for the dataViewEditor plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dataViewEditor'] --- import dataViewEditorObj from './data_view_editor.devdocs.json'; diff --git a/api_docs/data_view_field_editor.mdx b/api_docs/data_view_field_editor.mdx index 1895bfd54817..8f874598ff42 100644 --- a/api_docs/data_view_field_editor.mdx +++ b/api_docs/data_view_field_editor.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dataViewFieldEditor title: "dataViewFieldEditor" image: https://source.unsplash.com/400x175/?github description: API docs for the dataViewFieldEditor plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dataViewFieldEditor'] --- import dataViewFieldEditorObj from './data_view_field_editor.devdocs.json'; diff --git a/api_docs/data_view_management.mdx b/api_docs/data_view_management.mdx index 3b333d0ad6cb..a99ef71bca1e 100644 --- a/api_docs/data_view_management.mdx +++ b/api_docs/data_view_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dataViewManagement title: "dataViewManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the dataViewManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dataViewManagement'] --- import dataViewManagementObj from './data_view_management.devdocs.json'; diff --git a/api_docs/data_views.devdocs.json b/api_docs/data_views.devdocs.json index d456103f28f6..0b0e2fb4147b 100644 --- a/api_docs/data_views.devdocs.json +++ b/api_docs/data_views.devdocs.json @@ -12926,24 +12926,24 @@ "references": [ { "plugin": "data", - "path": "src/plugins/data/common/search/search_source/inspect/inspector_stats.ts" - }, - { - "plugin": "data", - "path": "src/plugins/data/common/search/tabify/response_writer.ts" + "path": "src/plugins/data/public/query/filter_manager/lib/get_display_value.ts" }, { "plugin": "data", - "path": "src/plugins/data/common/search/aggs/param_types/field.ts" + "path": "src/plugins/data/common/search/search_source/inspect/inspector_stats.ts" }, { "plugin": "data", - "path": "src/plugins/data/public/query/filter_manager/lib/get_display_value.ts" + "path": "src/plugins/data/common/search/tabify/response_writer.ts" }, { "plugin": "@kbn/search-errors", "path": "packages/kbn-search-errors/src/painless_error.tsx" }, + { + "plugin": "data", + "path": "src/plugins/data/common/search/aggs/param_types/field.ts" + }, { "plugin": "savedObjectsManagement", "path": "src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx" diff --git a/api_docs/data_views.mdx b/api_docs/data_views.mdx index 937eddbcf232..0c3df626e601 100644 --- a/api_docs/data_views.mdx +++ b/api_docs/data_views.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dataViews title: "dataViews" image: https://source.unsplash.com/400x175/?github description: API docs for the dataViews plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dataViews'] --- import dataViewsObj from './data_views.devdocs.json'; diff --git a/api_docs/data_visualizer.mdx b/api_docs/data_visualizer.mdx index 04ee03717f54..c98028974648 100644 --- a/api_docs/data_visualizer.mdx +++ b/api_docs/data_visualizer.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/dataVisualizer title: "dataVisualizer" image: https://source.unsplash.com/400x175/?github description: API docs for the dataVisualizer plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'dataVisualizer'] --- import dataVisualizerObj from './data_visualizer.devdocs.json'; diff --git a/api_docs/dataset_quality.mdx b/api_docs/dataset_quality.mdx index 4d936c0381e8..153062b25a0f 100644 --- a/api_docs/dataset_quality.mdx +++ b/api_docs/dataset_quality.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/datasetQuality title: "datasetQuality" image: https://source.unsplash.com/400x175/?github description: API docs for the datasetQuality plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'datasetQuality'] --- import datasetQualityObj from './dataset_quality.devdocs.json'; diff --git a/api_docs/deprecations_by_api.mdx b/api_docs/deprecations_by_api.mdx index fecc2a93965c..b4f9423a7687 100644 --- a/api_docs/deprecations_by_api.mdx +++ b/api_docs/deprecations_by_api.mdx @@ -7,7 +7,7 @@ id: kibDevDocsDeprecationsByApi slug: /kibana-dev-docs/api-meta/deprecated-api-list-by-api title: Deprecated API usage by API description: A list of deprecated APIs, which plugins are still referencing them, and when they need to be removed by. -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana'] --- @@ -18,7 +18,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | ---------------|-----------|-----------| | | ml, stackAlerts | - | | | data, @kbn/search-errors, savedObjectsManagement, unifiedSearch, @kbn/unified-field-list, lens, controls, triggersActionsUi, dataVisualizer, canvas, presentationUtil, logsShared, fleet, ml, @kbn/lens-embeddable-utils, @kbn/ml-data-view-utils, enterpriseSearch, graph, visTypeTimeseries, exploratoryView, stackAlerts, infra, securitySolution, timelines, transform, upgradeAssistant, uptime, ux, maps, dataViewManagement, eventAnnotationListing, inputControlVis, visDefaultEditor, visTypeTimelion, visTypeVega | - | -| | encryptedSavedObjects, actions, ml, logstash, securitySolution, cloudChat | - | +| | encryptedSavedObjects, ml, securitySolution | - | | | actions, savedObjectsTagging, ml, enterpriseSearch | - | | | @kbn/core-saved-objects-browser-internal, @kbn/core, savedObjects, visualizations, aiops, dataVisualizer, ml, dashboardEnhanced, graph, lens, securitySolution, eventAnnotation, @kbn/core-saved-objects-browser-mocks | - | | | @kbn/core, savedObjects, embeddable, visualizations, canvas, graph, ml, @kbn/core-saved-objects-common, @kbn/core-saved-objects-server, actions, @kbn/alerting-types, alerting, savedSearch, enterpriseSearch, securitySolution, taskManager, @kbn/core-saved-objects-server-internal, @kbn/core-saved-objects-api-server | - | @@ -31,7 +31,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | triggersActionsUi | - | | | @kbn/core, visualizations, triggersActionsUi | - | | | ruleRegistry, securitySolution, synthetics, slo | - | -| | security, actions, alerting, files, ruleRegistry, cases, fleet, securitySolution | - | +| | security, actions, alerting, ruleRegistry, files, cases, fleet, securitySolution | - | | | alerting, discover, securitySolution | - | | | @kbn/core-saved-objects-api-browser, @kbn/core-saved-objects-browser-internal, @kbn/core-saved-objects-browser-mocks, @kbn/core-saved-objects-api-server-internal, @kbn/core-saved-objects-import-export-server-internal, @kbn/core-saved-objects-server-internal, fleet, graph, lists, osquery, securitySolution, alerting | - | | | @kbn/core-saved-objects-api-browser, @kbn/core-saved-objects-browser-internal, @kbn/core-saved-objects-browser-mocks, @kbn/core-saved-objects-api-server-internal, @kbn/core-saved-objects-import-export-server-internal, @kbn/core-saved-objects-server-internal, fleet, graph, lists, osquery, securitySolution, alerting | - | @@ -39,18 +39,18 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | securitySolution | - | | | cloudDefend, osquery, securitySolution, synthetics | - | | | cloudDefend, osquery, securitySolution, synthetics | - | -| | actions, alerting, cases, observabilityAIAssistant, fleet, cloudDefend, cloudSecurityPosture, enterpriseSearch, lists, securitySolution, serverlessSearch, transform, upgradeAssistant, apm, entityManager, observabilityOnboarding, synthetics, security | - | +| | alerting, observabilityAIAssistant, fleet, cloudSecurityPosture, enterpriseSearch, securitySolution, serverlessSearch, transform, upgradeAssistant, apm, entityManager, observabilityOnboarding, synthetics, security | - | | | cases, securitySolution, security | - | | | @kbn/securitysolution-data-table, securitySolution | - | | | @kbn/securitysolution-data-table, securitySolution | - | | | securitySolution | - | -| | securitySolution, @kbn/securitysolution-data-table | - | -| | securitySolution, @kbn/securitysolution-data-table | - | +| | @kbn/securitysolution-data-table, securitySolution | - | +| | @kbn/securitysolution-data-table, securitySolution | - | | | securitySolution | - | | | securitySolution | - | | | @kbn/core-saved-objects-api-browser, @kbn/core-saved-objects-browser-internal, @kbn/core-saved-objects-api-server, @kbn/core, home, savedObjectsTagging, canvas, savedObjects, @kbn/core-saved-objects-browser-mocks, @kbn/core-saved-objects-import-export-server-internal, savedObjectsTaggingOss, lists, securitySolution, upgradeAssistant, savedObjectsManagement, @kbn/core-ui-settings-server-internal | - | | | @kbn/core-saved-objects-migration-server-internal, actions, dataViews, data, alerting, lens, cases, savedSearch, canvas, savedObjectsTagging, graph, lists, maps, visualizations, securitySolution, dashboard, @kbn/core-test-helpers-so-type-serializer | - | -| | security, securitySolution, serverlessSearch, cloudLinks, observabilityAIAssistantApp, cases, apm | - | +| | security, securitySolution, cloudLinks, observabilityAIAssistantApp, cases | - | | | security, cases, searchPlayground, securitySolution | - | | | lists, securitySolution, @kbn/securitysolution-io-ts-list-types | - | | | lists, securitySolution, @kbn/securitysolution-io-ts-list-types | - | diff --git a/api_docs/deprecations_by_plugin.mdx b/api_docs/deprecations_by_plugin.mdx index a9a2dc7afbd8..651a7cedc816 100644 --- a/api_docs/deprecations_by_plugin.mdx +++ b/api_docs/deprecations_by_plugin.mdx @@ -7,7 +7,7 @@ id: kibDevDocsDeprecationsByPlugin slug: /kibana-dev-docs/api-meta/deprecated-api-list-by-plugin title: Deprecated API usage by plugin description: A list of deprecated APIs, which plugins are still referencing them, and when they need to be removed by. -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana'] --- @@ -447,9 +447,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| | | [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/plugin.ts#:~:text=license%24), [license_state.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/lib/license_state.test.ts#:~:text=license%24), [license_state.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/lib/license_state.test.ts#:~:text=license%24) | 8.8.0 | -| | [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/plugin.ts#:~:text=authc) | - | | | [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/plugin.ts#:~:text=authz) | - | -| | [action_executor.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/lib/action_executor.ts#:~:text=authc), [action_executor.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/lib/action_executor.ts#:~:text=authc) | - | | | [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/plugin.ts#:~:text=index) | - | | | [actions_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/actions_client/actions_client.ts#:~:text=SavedObjectAttributes), [actions_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/actions_client/actions_client.ts#:~:text=SavedObjectAttributes), [actions_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/actions_client/actions_client.ts#:~:text=SavedObjectAttributes), [actions_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/actions_client/actions_client.ts#:~:text=SavedObjectAttributes), [actions_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/actions_client/actions_client.ts#:~:text=SavedObjectAttributes), [actions_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/actions_client/actions_client.ts#:~:text=SavedObjectAttributes), [actions_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/actions_client/actions_client.ts#:~:text=SavedObjectAttributes), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/types.ts#:~:text=SavedObjectAttributes), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/types.ts#:~:text=SavedObjectAttributes), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/types.ts#:~:text=SavedObjectAttributes)+ 10 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/saved_objects/index.ts#:~:text=migrations), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/actions/server/saved_objects/index.ts#:~:text=migrations) | - | @@ -480,7 +478,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch), [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch), [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch), [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch), [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch), [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch), [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch), [wrap_search_source_client.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts#:~:text=fetch) | - | | | [plugin.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.test.ts#:~:text=getKibanaFeatures) | 8.8.0 | | | [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.ts#:~:text=license%24), [license_state.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/license_state.test.ts#:~:text=license%24), [license_state.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/lib/license_state.test.ts#:~:text=license%24) | 8.8.0 | -| | [rules_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client_factory.ts#:~:text=authc), [rules_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client_factory.ts#:~:text=authc), [rules_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client_factory.ts#:~:text=authc), [rules_settings_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_settings_client_factory.ts#:~:text=authc), [maintenance_window_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/maintenance_window_client_factory.ts#:~:text=authc), [task.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts#:~:text=authc), [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.ts#:~:text=authc), [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.ts#:~:text=authc), [rules_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client_factory.ts#:~:text=authc), [rules_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client_factory.ts#:~:text=authc)+ 6 more | - | +| | [rules_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client_factory.ts#:~:text=authc), [task.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts#:~:text=authc), [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.ts#:~:text=authc), [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.ts#:~:text=authc), [rules_client_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client_factory.ts#:~:text=authc), [task.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts#:~:text=authc), [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.ts#:~:text=authc), [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/plugin.ts#:~:text=authc) | - | | | [task.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/usage/task.ts#:~:text=index) | - | | | [rule.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/common/rule.ts#:~:text=SavedObjectAttributes), [rule.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/common/rule.ts#:~:text=SavedObjectAttributes), [rule_attributes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts#:~:text=SavedObjectAttributes), [rule_attributes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts#:~:text=SavedObjectAttributes), [rule_attributes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts#:~:text=SavedObjectAttributes), [rule_attributes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts#:~:text=SavedObjectAttributes), [rule_attributes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts#:~:text=SavedObjectAttributes), [inject_references.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts#:~:text=SavedObjectAttributes), [inject_references.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts#:~:text=SavedObjectAttributes), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/types.ts#:~:text=SavedObjectAttributes)+ 36 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/alerting/server/saved_objects/index.ts#:~:text=migrations) | - | @@ -499,7 +497,6 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode), [license_check.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/common/license_check.test.ts#:~:text=mode)+ 2 more | 8.8.0 | | | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/server/lib/helpers/get_random_sampler/index.ts#:~:text=authc), [get_agent_keys_privileges.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/server/routes/agent_keys/get_agent_keys_privileges.ts#:~:text=authc), [is_superuser.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/server/routes/fleet/is_superuser.ts#:~:text=authc), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/server/lib/helpers/get_random_sampler/index.ts#:~:text=authc), [get_agent_keys_privileges.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/server/routes/agent_keys/get_agent_keys_privileges.ts#:~:text=authc), [is_superuser.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/server/routes/fleet/is_superuser.ts#:~:text=authc) | - | | | [apm_service_groups.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/server/saved_objects/apm_service_groups.ts#:~:text=migrations) | - | -| | [use_current_user.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts#:~:text=authc), [use_current_user.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts#:~:text=authc) | - | @@ -543,7 +540,6 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| | | [factory.test.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/client/factory.test.ts#:~:text=getKibanaFeatures) | 8.8.0 | -| | [factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/client/factory.ts#:~:text=authc), [factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/client/factory.ts#:~:text=authc) | - | | | [email_notification_service.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts#:~:text=userProfiles), [factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/client/factory.ts#:~:text=userProfiles), [utils.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/client/cases/utils.ts#:~:text=userProfiles), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/services/user_profiles/index.ts#:~:text=userProfiles), [email_notification_service.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts#:~:text=userProfiles), [factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/client/factory.ts#:~:text=userProfiles), [utils.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/client/cases/utils.ts#:~:text=userProfiles), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/services/user_profiles/index.ts#:~:text=userProfiles) | - | | | [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/common/ui/types.ts#:~:text=ResolvedSimpleSavedObject), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/common/ui/types.ts#:~:text=ResolvedSimpleSavedObject), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/common/ui/types.ts#:~:text=ResolvedSimpleSavedObject), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/common/ui/types.ts#:~:text=ResolvedSimpleSavedObject) | - | | | [cases.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts#:~:text=migrations), [configure.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/saved_object_types/configure.ts#:~:text=migrations), [comments.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/saved_object_types/comments.ts#:~:text=migrations), [user_actions.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/saved_object_types/user_actions.ts#:~:text=migrations), [connector_mappings.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cases/server/saved_object_types/connector_mappings.ts#:~:text=migrations) | - | @@ -554,21 +550,12 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] -## cloudChat - -| Deprecated API | Reference location(s) | Remove By | -| ---------------|-----------|-----------| -| | [chat.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts#:~:text=authc) | - | - - - ## cloudDefend | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| | | [installation_stats_collector.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/server/lib/telemetry/collectors/installation_stats_collector.ts#:~:text=policy_id), [mocks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/public/test/mocks.ts#:~:text=policy_id), [mocks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/public/test/mocks.ts#:~:text=policy_id) | - | | | [installation_stats_collector.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/server/lib/telemetry/collectors/installation_stats_collector.ts#:~:text=policy_id), [mocks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/public/test/mocks.ts#:~:text=policy_id), [mocks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/public/test/mocks.ts#:~:text=policy_id) | - | -| | [setup_routes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts#:~:text=authc), [setup_routes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts#:~:text=authc) | - | @@ -654,7 +641,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| -| | [inspector_stats.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts#:~:text=title), [response_writer.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/tabify/response_writer.ts#:~:text=title), [field.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=title), [get_display_value.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts#:~:text=title), [agg_config.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/agg_config.test.ts#:~:text=title), [_terms_other_bucket_helper.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts#:~:text=title), [multi_terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts#:~:text=title), [multi_terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts#:~:text=title), [rare_terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/rare_terms.test.ts#:~:text=title), [terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/terms.test.ts#:~:text=title)+ 2 more | - | +| | [get_display_value.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts#:~:text=title), [inspector_stats.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts#:~:text=title), [response_writer.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/tabify/response_writer.ts#:~:text=title), [field.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=title), [agg_config.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/agg_config.test.ts#:~:text=title), [_terms_other_bucket_helper.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts#:~:text=title), [multi_terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts#:~:text=title), [multi_terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts#:~:text=title), [rare_terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/rare_terms.test.ts#:~:text=title), [terms.test.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/search/aggs/buckets/terms.test.ts#:~:text=title)+ 2 more | - | | | [data_table.tsx](https://github.com/elastic/kibana/tree/main/src/plugins/data/public/utils/table_inspector_view/components/data_table.tsx#:~:text=executeTriggerActions), [data_table.tsx](https://github.com/elastic/kibana/tree/main/src/plugins/data/public/utils/table_inspector_view/components/data_table.tsx#:~:text=executeTriggerActions) | - | | | [persistable_state.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/query/filters/persistable_state.ts#:~:text=SavedObjectReference), [persistable_state.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/query/filters/persistable_state.ts#:~:text=SavedObjectReference), [persistable_state.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/query/filters/persistable_state.ts#:~:text=SavedObjectReference), [persistable_state.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/query/persistable_state.ts#:~:text=SavedObjectReference), [persistable_state.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/query/persistable_state.ts#:~:text=SavedObjectReference), [persistable_state.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/common/query/persistable_state.ts#:~:text=SavedObjectReference) | - | | | [query.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/server/saved_objects/query.ts#:~:text=migrations), [search_telemetry.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/server/saved_objects/search_telemetry.ts#:~:text=migrations), [search_session.ts](https://github.com/elastic/kibana/tree/main/src/plugins/data/server/search/saved_objects/search_session.ts#:~:text=migrations) | - | @@ -701,7 +688,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| -| | [document_stats.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx#:~:text=fieldFormats), [distinct_values.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/distinct_values.tsx#:~:text=fieldFormats), [top_values.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx#:~:text=fieldFormats), [choropleth_map.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx#:~:text=fieldFormats), [default_value_formatter.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/data_drift/charts/default_value_formatter.ts#:~:text=fieldFormats) | - | +| | [document_stats.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx#:~:text=fieldFormats), [distinct_values.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/distinct_values.tsx#:~:text=fieldFormats), [top_values.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx#:~:text=fieldFormats), [choropleth_map.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx#:~:text=fieldFormats), [use_data_visualizer_esql_data.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx#:~:text=fieldFormats), [use_data_visualizer_esql_data.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx#:~:text=fieldFormats), [default_value_formatter.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/data_drift/charts/default_value_formatter.ts#:~:text=fieldFormats) | - | | | [use_data_visualizer_grid_data.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts#:~:text=title) | - | | | [index_data_visualizer.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx#:~:text=savedObjects) | - | | | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/common/types/index.ts#:~:text=SimpleSavedObject), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/data_visualizer/common/types/index.ts#:~:text=SimpleSavedObject) | - | @@ -1015,7 +1002,6 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | ---------------|-----------|-----------| | | [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=migrationVersion) | - | | | [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=migrationVersion), [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=migrationVersion), [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=migrationVersion), [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=migrationVersion) | - | -| | [get_user.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/get_user.ts#:~:text=authc), [get_user.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/get_user.ts#:~:text=authc) | - | | | [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=SavedObject), [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=SavedObject), [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=SavedObject), [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=SavedObject), [exception_list_client.mock.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts#:~:text=SavedObject) | - | | | [exception_list.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/saved_objects/exception_list.ts#:~:text=migrations), [exception_list.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/saved_objects/exception_list.ts#:~:text=migrations) | - | | | [exception_list.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/lists/server/saved_objects/exception_list.ts#:~:text=convertToMultiNamespaceTypeVersion) | - | @@ -1040,7 +1026,6 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| | | [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/logstash/public/plugin.ts#:~:text=license%24) | 8.8.0 | -| | [save.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/logstash/server/routes/pipeline/save.ts#:~:text=authc) | - | @@ -1351,8 +1336,8 @@ migrates to using the Kibana Privilege model: https://github.com/elastic/kibana/ | | [index.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx#:~:text=DeprecatedCellValueElementProps), [index.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx#:~:text=DeprecatedCellValueElementProps) | - | | | [index.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx#:~:text=DeprecatedRowRenderer), [index.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx#:~:text=DeprecatedRowRenderer) | - | | | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts#:~:text=BeatFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=BeatFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=BeatFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=BeatFields) | - | -| | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [columns.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx#:~:text=BrowserField), [columns.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx#:~:text=BrowserField), [columns.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx#:~:text=BrowserField), [enrichment_summary.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx#:~:text=BrowserField), [enrichment_summary.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx#:~:text=BrowserField)+ 32 more | - | -| | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/header_actions/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/header_actions/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields)+ 105 more | - | +| | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [helpers.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts#:~:text=BrowserField), [columns.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx#:~:text=BrowserField), [columns.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx#:~:text=BrowserField), [columns.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx#:~:text=BrowserField), [use_data_view.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx#:~:text=BrowserField), [use_data_view.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx#:~:text=BrowserField)+ 29 more | - | +| | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/header_actions/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/types/header_actions/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts#:~:text=BrowserFields)+ 101 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts#:~:text=IndexFieldsStrategyRequest), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=IndexFieldsStrategyRequest), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=IndexFieldsStrategyRequest), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=IndexFieldsStrategyRequest), [middleware.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts#:~:text=IndexFieldsStrategyRequest), [middleware.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts#:~:text=IndexFieldsStrategyRequest) | - | | | [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts#:~:text=IndexFieldsStrategyResponse), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=IndexFieldsStrategyResponse), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=IndexFieldsStrategyResponse), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/search_strategy/endpoint_fields/index.ts#:~:text=IndexFieldsStrategyResponse), [middleware.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts#:~:text=IndexFieldsStrategyResponse), [middleware.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts#:~:text=IndexFieldsStrategyResponse) | - | | | [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/hooks/types.ts#:~:text=SimpleSavedObject), [types.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/hooks/types.ts#:~:text=SimpleSavedObject) | - | @@ -1363,16 +1348,16 @@ migrates to using the Kibana Privilege model: https://github.com/elastic/kibana/ | | [links.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/links.ts#:~:text=authc), [hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts#:~:text=authc) | - | | | [use_bulk_get_user_profiles.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.tsx#:~:text=userProfiles), [use_get_current_user_profile.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user_profile.tsx#:~:text=userProfiles), [overlay.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/assistant/overlay.tsx#:~:text=userProfiles) | - | | | [request_context_factory.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/request_context_factory.ts#:~:text=audit), [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/plugin.ts#:~:text=audit) | - | -| | [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/api_client.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/api_client.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/api_client.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [trusted_app_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID)+ 25 more | - | +| | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/api_client.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/api_client.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/api_client.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID), [trusted_app_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_ID)+ 25 more | - | | | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_NAME), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_NAME) | - | | | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts#:~:text=ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION) | - | -| | [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [form.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [form.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID)+ 24 more | - | -| | [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME) | - | -| | [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION) | - | -| | [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [exceptions_list_item_generator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID)+ 8 more | - | +| | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [form.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [form.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [utils.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [utils.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [service_actions.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [service_actions.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID), [service_actions.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_ID)+ 24 more | - | +| | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_NAME) | - | +| | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [create_event_filters.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts#:~:text=ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION) | - | +| | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [host_isolation_exceptions_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID), [exceptions_list_item_generator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID)+ 8 more | - | | | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME) | - | | | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts#:~:text=ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION) | - | -| | [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklists_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/services/blocklists_api_client.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklists_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/services/blocklists_api_client.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklists_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/services/blocklists_api_client.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklist_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID)+ 8 more | - | +| | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklists_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/services/blocklists_api_client.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklists_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/services/blocklists_api_client.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklists_api_client.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/services/blocklists_api_client.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [policy_hooks.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [lists.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID), [blocklist_validator.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_ID)+ 8 more | - | | | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_NAME), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_NAME), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_NAME) | - | | | [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_DESCRIPTION), [constants.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_DESCRIPTION), [index.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts#:~:text=ENDPOINT_BLOCKLISTS_LIST_DESCRIPTION) | - | | | [use_colors.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts#:~:text=darkMode), [use_colors.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts#:~:text=darkMode), [use_colors.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts#:~:text=darkMode), [use_colors.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts#:~:text=darkMode), [use_colors.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts#:~:text=darkMode) | - | @@ -1384,7 +1369,6 @@ migrates to using the Kibana Privilege model: https://github.com/elastic/kibana/ | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| | | [api_key_routes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/serverless_search/server/routes/api_key_routes.ts#:~:text=authc), [api_key_routes.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/serverless_search/server/routes/api_key_routes.ts#:~:text=authc) | - | -| | [plugin.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/serverless_search/public/plugin.ts#:~:text=authc) | - | diff --git a/api_docs/deprecations_by_team.mdx b/api_docs/deprecations_by_team.mdx index 262ba3cdad4b..a7d49eac71da 100644 --- a/api_docs/deprecations_by_team.mdx +++ b/api_docs/deprecations_by_team.mdx @@ -7,7 +7,7 @@ id: kibDevDocsDeprecationsDueByTeam slug: /kibana-dev-docs/api-meta/deprecations-due-by-team title: Deprecated APIs due to be removed, by team description: Lists the teams that are referencing deprecated APIs with a remove by date. -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana'] --- diff --git a/api_docs/dev_tools.mdx b/api_docs/dev_tools.mdx index 634925f6c4cd..9514703a5dbb 100644 --- a/api_docs/dev_tools.mdx +++ b/api_docs/dev_tools.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/devTools title: "devTools" image: https://source.unsplash.com/400x175/?github description: API docs for the devTools plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'devTools'] --- import devToolsObj from './dev_tools.devdocs.json'; diff --git a/api_docs/discover.mdx b/api_docs/discover.mdx index 4db0306b7ff9..951a0fdc8c84 100644 --- a/api_docs/discover.mdx +++ b/api_docs/discover.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/discover title: "discover" image: https://source.unsplash.com/400x175/?github description: API docs for the discover plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'discover'] --- import discoverObj from './discover.devdocs.json'; diff --git a/api_docs/discover_enhanced.mdx b/api_docs/discover_enhanced.mdx index ca06ec77a80c..4bbd636677ff 100644 --- a/api_docs/discover_enhanced.mdx +++ b/api_docs/discover_enhanced.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/discoverEnhanced title: "discoverEnhanced" image: https://source.unsplash.com/400x175/?github description: API docs for the discoverEnhanced plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'discoverEnhanced'] --- import discoverEnhancedObj from './discover_enhanced.devdocs.json'; diff --git a/api_docs/discover_shared.mdx b/api_docs/discover_shared.mdx index ea4997dad852..24820743acd4 100644 --- a/api_docs/discover_shared.mdx +++ b/api_docs/discover_shared.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/discoverShared title: "discoverShared" image: https://source.unsplash.com/400x175/?github description: API docs for the discoverShared plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'discoverShared'] --- import discoverSharedObj from './discover_shared.devdocs.json'; diff --git a/api_docs/ecs_data_quality_dashboard.mdx b/api_docs/ecs_data_quality_dashboard.mdx index 35f72a116d98..d46c4eff5be3 100644 --- a/api_docs/ecs_data_quality_dashboard.mdx +++ b/api_docs/ecs_data_quality_dashboard.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/ecsDataQualityDashboard title: "ecsDataQualityDashboard" image: https://source.unsplash.com/400x175/?github description: API docs for the ecsDataQualityDashboard plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'ecsDataQualityDashboard'] --- import ecsDataQualityDashboardObj from './ecs_data_quality_dashboard.devdocs.json'; diff --git a/api_docs/elastic_assistant.mdx b/api_docs/elastic_assistant.mdx index dda65919d84e..d193fdc59617 100644 --- a/api_docs/elastic_assistant.mdx +++ b/api_docs/elastic_assistant.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/elasticAssistant title: "elasticAssistant" image: https://source.unsplash.com/400x175/?github description: API docs for the elasticAssistant plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'elasticAssistant'] --- import elasticAssistantObj from './elastic_assistant.devdocs.json'; diff --git a/api_docs/embeddable.mdx b/api_docs/embeddable.mdx index 8eafcadc8a55..143df9035fa4 100644 --- a/api_docs/embeddable.mdx +++ b/api_docs/embeddable.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/embeddable title: "embeddable" image: https://source.unsplash.com/400x175/?github description: API docs for the embeddable plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'embeddable'] --- import embeddableObj from './embeddable.devdocs.json'; diff --git a/api_docs/embeddable_enhanced.mdx b/api_docs/embeddable_enhanced.mdx index e66f58378f55..85de11513ad3 100644 --- a/api_docs/embeddable_enhanced.mdx +++ b/api_docs/embeddable_enhanced.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/embeddableEnhanced title: "embeddableEnhanced" image: https://source.unsplash.com/400x175/?github description: API docs for the embeddableEnhanced plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'embeddableEnhanced'] --- import embeddableEnhancedObj from './embeddable_enhanced.devdocs.json'; diff --git a/api_docs/encrypted_saved_objects.mdx b/api_docs/encrypted_saved_objects.mdx index 0ed1b3aca84c..73af7fd2425e 100644 --- a/api_docs/encrypted_saved_objects.mdx +++ b/api_docs/encrypted_saved_objects.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/encryptedSavedObjects title: "encryptedSavedObjects" image: https://source.unsplash.com/400x175/?github description: API docs for the encryptedSavedObjects plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'encryptedSavedObjects'] --- import encryptedSavedObjectsObj from './encrypted_saved_objects.devdocs.json'; diff --git a/api_docs/enterprise_search.mdx b/api_docs/enterprise_search.mdx index feab7d374a58..9bdb57a786cf 100644 --- a/api_docs/enterprise_search.mdx +++ b/api_docs/enterprise_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/enterpriseSearch title: "enterpriseSearch" image: https://source.unsplash.com/400x175/?github description: API docs for the enterpriseSearch plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'enterpriseSearch'] --- import enterpriseSearchObj from './enterprise_search.devdocs.json'; diff --git a/api_docs/entity_manager.mdx b/api_docs/entity_manager.mdx index a790ade29a99..c454acf8cb01 100644 --- a/api_docs/entity_manager.mdx +++ b/api_docs/entity_manager.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/entityManager title: "entityManager" image: https://source.unsplash.com/400x175/?github description: API docs for the entityManager plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'entityManager'] --- import entityManagerObj from './entity_manager.devdocs.json'; diff --git a/api_docs/es_ui_shared.mdx b/api_docs/es_ui_shared.mdx index 6c7f94839d6a..861fe5c3af63 100644 --- a/api_docs/es_ui_shared.mdx +++ b/api_docs/es_ui_shared.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/esUiShared title: "esUiShared" image: https://source.unsplash.com/400x175/?github description: API docs for the esUiShared plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'esUiShared'] --- import esUiSharedObj from './es_ui_shared.devdocs.json'; diff --git a/api_docs/esql_data_grid.mdx b/api_docs/esql_data_grid.mdx index 28efc8b72004..594a6b3e1a84 100644 --- a/api_docs/esql_data_grid.mdx +++ b/api_docs/esql_data_grid.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/esqlDataGrid title: "esqlDataGrid" image: https://source.unsplash.com/400x175/?github description: API docs for the esqlDataGrid plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'esqlDataGrid'] --- import esqlDataGridObj from './esql_data_grid.devdocs.json'; diff --git a/api_docs/event_annotation.mdx b/api_docs/event_annotation.mdx index 65b449bdb767..83727da21320 100644 --- a/api_docs/event_annotation.mdx +++ b/api_docs/event_annotation.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/eventAnnotation title: "eventAnnotation" image: https://source.unsplash.com/400x175/?github description: API docs for the eventAnnotation plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'eventAnnotation'] --- import eventAnnotationObj from './event_annotation.devdocs.json'; diff --git a/api_docs/event_annotation_listing.mdx b/api_docs/event_annotation_listing.mdx index 414f209ba6f5..1b30aa5156b0 100644 --- a/api_docs/event_annotation_listing.mdx +++ b/api_docs/event_annotation_listing.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/eventAnnotationListing title: "eventAnnotationListing" image: https://source.unsplash.com/400x175/?github description: API docs for the eventAnnotationListing plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'eventAnnotationListing'] --- import eventAnnotationListingObj from './event_annotation_listing.devdocs.json'; diff --git a/api_docs/event_log.mdx b/api_docs/event_log.mdx index 4894ce086df5..862f911ab068 100644 --- a/api_docs/event_log.mdx +++ b/api_docs/event_log.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/eventLog title: "eventLog" image: https://source.unsplash.com/400x175/?github description: API docs for the eventLog plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'eventLog'] --- import eventLogObj from './event_log.devdocs.json'; diff --git a/api_docs/exploratory_view.mdx b/api_docs/exploratory_view.mdx index fa8179ad726f..384e24252a63 100644 --- a/api_docs/exploratory_view.mdx +++ b/api_docs/exploratory_view.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/exploratoryView title: "exploratoryView" image: https://source.unsplash.com/400x175/?github description: API docs for the exploratoryView plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'exploratoryView'] --- import exploratoryViewObj from './exploratory_view.devdocs.json'; diff --git a/api_docs/expression_error.mdx b/api_docs/expression_error.mdx index 56d5db1464e5..1a60d5744254 100644 --- a/api_docs/expression_error.mdx +++ b/api_docs/expression_error.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionError title: "expressionError" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionError plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionError'] --- import expressionErrorObj from './expression_error.devdocs.json'; diff --git a/api_docs/expression_gauge.mdx b/api_docs/expression_gauge.mdx index 052de0e008e6..1f82bda684ad 100644 --- a/api_docs/expression_gauge.mdx +++ b/api_docs/expression_gauge.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionGauge title: "expressionGauge" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionGauge plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionGauge'] --- import expressionGaugeObj from './expression_gauge.devdocs.json'; diff --git a/api_docs/expression_heatmap.mdx b/api_docs/expression_heatmap.mdx index 897a2a11e950..f49917248cc1 100644 --- a/api_docs/expression_heatmap.mdx +++ b/api_docs/expression_heatmap.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionHeatmap title: "expressionHeatmap" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionHeatmap plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionHeatmap'] --- import expressionHeatmapObj from './expression_heatmap.devdocs.json'; diff --git a/api_docs/expression_image.mdx b/api_docs/expression_image.mdx index 3ad2a8ae8451..8d84415dac8b 100644 --- a/api_docs/expression_image.mdx +++ b/api_docs/expression_image.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionImage title: "expressionImage" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionImage plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionImage'] --- import expressionImageObj from './expression_image.devdocs.json'; diff --git a/api_docs/expression_legacy_metric_vis.mdx b/api_docs/expression_legacy_metric_vis.mdx index 7a0c9b682c05..290ef8afabcd 100644 --- a/api_docs/expression_legacy_metric_vis.mdx +++ b/api_docs/expression_legacy_metric_vis.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionLegacyMetricVis title: "expressionLegacyMetricVis" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionLegacyMetricVis plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionLegacyMetricVis'] --- import expressionLegacyMetricVisObj from './expression_legacy_metric_vis.devdocs.json'; diff --git a/api_docs/expression_metric.mdx b/api_docs/expression_metric.mdx index 164c987b26cc..4562b54274c1 100644 --- a/api_docs/expression_metric.mdx +++ b/api_docs/expression_metric.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionMetric title: "expressionMetric" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionMetric plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionMetric'] --- import expressionMetricObj from './expression_metric.devdocs.json'; diff --git a/api_docs/expression_metric_vis.mdx b/api_docs/expression_metric_vis.mdx index 049f560a73d0..5c0fce0eb1f2 100644 --- a/api_docs/expression_metric_vis.mdx +++ b/api_docs/expression_metric_vis.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionMetricVis title: "expressionMetricVis" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionMetricVis plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionMetricVis'] --- import expressionMetricVisObj from './expression_metric_vis.devdocs.json'; diff --git a/api_docs/expression_partition_vis.mdx b/api_docs/expression_partition_vis.mdx index 093efc63455a..a83c184c36f4 100644 --- a/api_docs/expression_partition_vis.mdx +++ b/api_docs/expression_partition_vis.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionPartitionVis title: "expressionPartitionVis" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionPartitionVis plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionPartitionVis'] --- import expressionPartitionVisObj from './expression_partition_vis.devdocs.json'; diff --git a/api_docs/expression_repeat_image.mdx b/api_docs/expression_repeat_image.mdx index ac624eed1d93..a2cad0f2f6b1 100644 --- a/api_docs/expression_repeat_image.mdx +++ b/api_docs/expression_repeat_image.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionRepeatImage title: "expressionRepeatImage" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionRepeatImage plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionRepeatImage'] --- import expressionRepeatImageObj from './expression_repeat_image.devdocs.json'; diff --git a/api_docs/expression_reveal_image.mdx b/api_docs/expression_reveal_image.mdx index a51d9b88d62a..26c579cd2429 100644 --- a/api_docs/expression_reveal_image.mdx +++ b/api_docs/expression_reveal_image.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionRevealImage title: "expressionRevealImage" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionRevealImage plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionRevealImage'] --- import expressionRevealImageObj from './expression_reveal_image.devdocs.json'; diff --git a/api_docs/expression_shape.mdx b/api_docs/expression_shape.mdx index 8e0403a10bb8..b955c09fe246 100644 --- a/api_docs/expression_shape.mdx +++ b/api_docs/expression_shape.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionShape title: "expressionShape" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionShape plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionShape'] --- import expressionShapeObj from './expression_shape.devdocs.json'; diff --git a/api_docs/expression_tagcloud.mdx b/api_docs/expression_tagcloud.mdx index 55b8815e1004..1adc253b59bf 100644 --- a/api_docs/expression_tagcloud.mdx +++ b/api_docs/expression_tagcloud.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionTagcloud title: "expressionTagcloud" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionTagcloud plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionTagcloud'] --- import expressionTagcloudObj from './expression_tagcloud.devdocs.json'; diff --git a/api_docs/expression_x_y.mdx b/api_docs/expression_x_y.mdx index c86f4f872417..7b00acb9611c 100644 --- a/api_docs/expression_x_y.mdx +++ b/api_docs/expression_x_y.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressionXY title: "expressionXY" image: https://source.unsplash.com/400x175/?github description: API docs for the expressionXY plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressionXY'] --- import expressionXYObj from './expression_x_y.devdocs.json'; diff --git a/api_docs/expressions.mdx b/api_docs/expressions.mdx index 514809c08f3e..f76eeee947d8 100644 --- a/api_docs/expressions.mdx +++ b/api_docs/expressions.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/expressions title: "expressions" image: https://source.unsplash.com/400x175/?github description: API docs for the expressions plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'expressions'] --- import expressionsObj from './expressions.devdocs.json'; diff --git a/api_docs/features.mdx b/api_docs/features.mdx index 70bf4fb43fc3..0b0f391e4ac8 100644 --- a/api_docs/features.mdx +++ b/api_docs/features.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/features title: "features" image: https://source.unsplash.com/400x175/?github description: API docs for the features plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'features'] --- import featuresObj from './features.devdocs.json'; diff --git a/api_docs/field_formats.mdx b/api_docs/field_formats.mdx index 59c254f6616d..ae8bdd69376f 100644 --- a/api_docs/field_formats.mdx +++ b/api_docs/field_formats.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/fieldFormats title: "fieldFormats" image: https://source.unsplash.com/400x175/?github description: API docs for the fieldFormats plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'fieldFormats'] --- import fieldFormatsObj from './field_formats.devdocs.json'; diff --git a/api_docs/fields_metadata.mdx b/api_docs/fields_metadata.mdx index e28e443d2afe..da48a9856d91 100644 --- a/api_docs/fields_metadata.mdx +++ b/api_docs/fields_metadata.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/fieldsMetadata title: "fieldsMetadata" image: https://source.unsplash.com/400x175/?github description: API docs for the fieldsMetadata plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'fieldsMetadata'] --- import fieldsMetadataObj from './fields_metadata.devdocs.json'; diff --git a/api_docs/file_upload.mdx b/api_docs/file_upload.mdx index 99f3196ca281..9e2f5abf99d3 100644 --- a/api_docs/file_upload.mdx +++ b/api_docs/file_upload.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/fileUpload title: "fileUpload" image: https://source.unsplash.com/400x175/?github description: API docs for the fileUpload plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'fileUpload'] --- import fileUploadObj from './file_upload.devdocs.json'; diff --git a/api_docs/files.mdx b/api_docs/files.mdx index b0650d780281..163d7562ea18 100644 --- a/api_docs/files.mdx +++ b/api_docs/files.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/files title: "files" image: https://source.unsplash.com/400x175/?github description: API docs for the files plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'files'] --- import filesObj from './files.devdocs.json'; diff --git a/api_docs/files_management.mdx b/api_docs/files_management.mdx index 6e9bedec2a5d..c693e7ec71ed 100644 --- a/api_docs/files_management.mdx +++ b/api_docs/files_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/filesManagement title: "filesManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the filesManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'filesManagement'] --- import filesManagementObj from './files_management.devdocs.json'; diff --git a/api_docs/fleet.mdx b/api_docs/fleet.mdx index ee18e4394bb9..35261f949a14 100644 --- a/api_docs/fleet.mdx +++ b/api_docs/fleet.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/fleet title: "fleet" image: https://source.unsplash.com/400x175/?github description: API docs for the fleet plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'fleet'] --- import fleetObj from './fleet.devdocs.json'; diff --git a/api_docs/global_search.mdx b/api_docs/global_search.mdx index 2e1c3588a0e3..e210aa1ada86 100644 --- a/api_docs/global_search.mdx +++ b/api_docs/global_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/globalSearch title: "globalSearch" image: https://source.unsplash.com/400x175/?github description: API docs for the globalSearch plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'globalSearch'] --- import globalSearchObj from './global_search.devdocs.json'; diff --git a/api_docs/guided_onboarding.mdx b/api_docs/guided_onboarding.mdx index 73b731b5fd86..f22d604d4ecf 100644 --- a/api_docs/guided_onboarding.mdx +++ b/api_docs/guided_onboarding.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/guidedOnboarding title: "guidedOnboarding" image: https://source.unsplash.com/400x175/?github description: API docs for the guidedOnboarding plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'guidedOnboarding'] --- import guidedOnboardingObj from './guided_onboarding.devdocs.json'; diff --git a/api_docs/home.mdx b/api_docs/home.mdx index 3c61ca3ebccc..d708d283ae03 100644 --- a/api_docs/home.mdx +++ b/api_docs/home.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/home title: "home" image: https://source.unsplash.com/400x175/?github description: API docs for the home plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'home'] --- import homeObj from './home.devdocs.json'; diff --git a/api_docs/image_embeddable.mdx b/api_docs/image_embeddable.mdx index 6e814b153948..b5f64ad1003d 100644 --- a/api_docs/image_embeddable.mdx +++ b/api_docs/image_embeddable.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/imageEmbeddable title: "imageEmbeddable" image: https://source.unsplash.com/400x175/?github description: API docs for the imageEmbeddable plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'imageEmbeddable'] --- import imageEmbeddableObj from './image_embeddable.devdocs.json'; diff --git a/api_docs/index_lifecycle_management.mdx b/api_docs/index_lifecycle_management.mdx index 0216a4b68466..bf57ddb676b3 100644 --- a/api_docs/index_lifecycle_management.mdx +++ b/api_docs/index_lifecycle_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/indexLifecycleManagement title: "indexLifecycleManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the indexLifecycleManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'indexLifecycleManagement'] --- import indexLifecycleManagementObj from './index_lifecycle_management.devdocs.json'; diff --git a/api_docs/index_management.mdx b/api_docs/index_management.mdx index 9c54a7acdea2..da486f257108 100644 --- a/api_docs/index_management.mdx +++ b/api_docs/index_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/indexManagement title: "indexManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the indexManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'indexManagement'] --- import indexManagementObj from './index_management.devdocs.json'; diff --git a/api_docs/infra.mdx b/api_docs/infra.mdx index 3c804fc032bb..530c32a0e69d 100644 --- a/api_docs/infra.mdx +++ b/api_docs/infra.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/infra title: "infra" image: https://source.unsplash.com/400x175/?github description: API docs for the infra plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'infra'] --- import infraObj from './infra.devdocs.json'; diff --git a/api_docs/ingest_pipelines.mdx b/api_docs/ingest_pipelines.mdx index 349a375fb3e8..9a13d3eb8cb2 100644 --- a/api_docs/ingest_pipelines.mdx +++ b/api_docs/ingest_pipelines.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/ingestPipelines title: "ingestPipelines" image: https://source.unsplash.com/400x175/?github description: API docs for the ingestPipelines plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'ingestPipelines'] --- import ingestPipelinesObj from './ingest_pipelines.devdocs.json'; diff --git a/api_docs/inspector.mdx b/api_docs/inspector.mdx index d4d820de4be2..3806f2ea0d90 100644 --- a/api_docs/inspector.mdx +++ b/api_docs/inspector.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/inspector title: "inspector" image: https://source.unsplash.com/400x175/?github description: API docs for the inspector plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'inspector'] --- import inspectorObj from './inspector.devdocs.json'; diff --git a/api_docs/integration_assistant.devdocs.json b/api_docs/integration_assistant.devdocs.json index 6611c8b64001..7bc21089a09f 100644 --- a/api_docs/integration_assistant.devdocs.json +++ b/api_docs/integration_assistant.devdocs.json @@ -34,13 +34,17 @@ "children": [ { "parentPluginId": "integrationAssistant", - "id": "def-public.IntegrationAssistantPluginStart.CreateIntegration", - "type": "CompoundType", + "id": "def-public.IntegrationAssistantPluginStart.components", + "type": "Object", "tags": [], - "label": "CreateIntegration", + "label": "components", "description": [], "signature": [ - "React.ComponentClass<{}, any> | React.FunctionComponent<{}>" + "{ CreateIntegration: ", + "CreateIntegrationComponent", + "; CreateIntegrationCardButton: ", + "CreateIntegrationCardButtonComponent", + "; }" ], "path": "x-pack/plugins/integration_assistant/public/types.ts", "deprecated": false, @@ -48,21 +52,37 @@ }, { "parentPluginId": "integrationAssistant", - "id": "def-public.IntegrationAssistantPluginStart.CreateIntegrationCardButton", - "type": "CompoundType", + "id": "def-public.IntegrationAssistantPluginStart.renderUpselling", + "type": "Function", "tags": [], - "label": "CreateIntegrationCardButton", - "description": [], + "label": "renderUpselling", + "description": [ + "\nSets the upselling to be rendered in the UI.\nIf defined, the section will be displayed and it will prevent\nthe user from interacting with the rest of the UI." + ], "signature": [ - "React.ComponentClass<", - "CreateIntegrationCardButtonProps", - ", any> | React.FunctionComponent<", - "CreateIntegrationCardButtonProps", - ">" + "(upselling: React.ReactNode) => void" ], "path": "x-pack/plugins/integration_assistant/public/types.ts", "deprecated": false, - "trackAdoption": false + "trackAdoption": false, + "children": [ + { + "parentPluginId": "integrationAssistant", + "id": "def-public.IntegrationAssistantPluginStart.renderUpselling.$1", + "type": "CompoundType", + "tags": [], + "label": "upselling", + "description": [], + "signature": [ + "React.ReactNode" + ], + "path": "x-pack/plugins/integration_assistant/public/types.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": false + } + ], + "returnComment": [] } ], "lifecycle": "start", @@ -86,7 +106,40 @@ "path": "x-pack/plugins/integration_assistant/server/types.ts", "deprecated": false, "trackAdoption": false, - "children": [], + "children": [ + { + "parentPluginId": "integrationAssistant", + "id": "def-server.IntegrationAssistantPluginSetup.setIsAvailable", + "type": "Function", + "tags": [], + "label": "setIsAvailable", + "description": [], + "signature": [ + "(isAvailable: boolean) => void" + ], + "path": "x-pack/plugins/integration_assistant/server/types.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "integrationAssistant", + "id": "def-server.IntegrationAssistantPluginSetup.setIsAvailable.$1", + "type": "boolean", + "tags": [], + "label": "isAvailable", + "description": [], + "signature": [ + "boolean" + ], + "path": "x-pack/plugins/integration_assistant/server/types.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": true + } + ], + "returnComment": [] + } + ], "lifecycle": "setup", "initialIsOpen": true }, diff --git a/api_docs/integration_assistant.mdx b/api_docs/integration_assistant.mdx index 20347bdcc388..b4278160dd92 100644 --- a/api_docs/integration_assistant.mdx +++ b/api_docs/integration_assistant.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/integrationAssistant title: "integrationAssistant" image: https://source.unsplash.com/400x175/?github description: API docs for the integrationAssistant plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'integrationAssistant'] --- import integrationAssistantObj from './integration_assistant.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/security-solution](https://github.com/orgs/elastic/teams/secur | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 44 | 0 | 38 | 2 | +| 47 | 0 | 40 | 3 | ## Client diff --git a/api_docs/interactive_setup.mdx b/api_docs/interactive_setup.mdx index b8cbbd3d78ac..87337ba4e3b3 100644 --- a/api_docs/interactive_setup.mdx +++ b/api_docs/interactive_setup.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/interactiveSetup title: "interactiveSetup" image: https://source.unsplash.com/400x175/?github description: API docs for the interactiveSetup plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'interactiveSetup'] --- import interactiveSetupObj from './interactive_setup.devdocs.json'; diff --git a/api_docs/investigate.mdx b/api_docs/investigate.mdx index 0b16f5cdb968..3d2113d30163 100644 --- a/api_docs/investigate.mdx +++ b/api_docs/investigate.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/investigate title: "investigate" image: https://source.unsplash.com/400x175/?github description: API docs for the investigate plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'investigate'] --- import investigateObj from './investigate.devdocs.json'; diff --git a/api_docs/kbn_ace.mdx b/api_docs/kbn_ace.mdx index 9a093415123c..d5cdb2ce9587 100644 --- a/api_docs/kbn_ace.mdx +++ b/api_docs/kbn_ace.mdx @@ -8,7 +8,7 @@ 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-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ace'] --- import kbnAceObj from './kbn_ace.devdocs.json'; diff --git a/api_docs/kbn_actions_types.mdx b/api_docs/kbn_actions_types.mdx index c7ac1d4316a4..9fc3108fe687 100644 --- a/api_docs/kbn_actions_types.mdx +++ b/api_docs/kbn_actions_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-actions-types title: "@kbn/actions-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/actions-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/actions-types'] --- import kbnActionsTypesObj from './kbn_actions_types.devdocs.json'; diff --git a/api_docs/kbn_aiops_components.mdx b/api_docs/kbn_aiops_components.mdx index 225abcce7395..cfa9af7ad676 100644 --- a/api_docs/kbn_aiops_components.mdx +++ b/api_docs/kbn_aiops_components.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-aiops-components title: "@kbn/aiops-components" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/aiops-components plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/aiops-components'] --- import kbnAiopsComponentsObj from './kbn_aiops_components.devdocs.json'; diff --git a/api_docs/kbn_aiops_log_pattern_analysis.mdx b/api_docs/kbn_aiops_log_pattern_analysis.mdx index 4a27280f5b67..738a2eb60268 100644 --- a/api_docs/kbn_aiops_log_pattern_analysis.mdx +++ b/api_docs/kbn_aiops_log_pattern_analysis.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-aiops-log-pattern-analysis title: "@kbn/aiops-log-pattern-analysis" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/aiops-log-pattern-analysis plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/aiops-log-pattern-analysis'] --- import kbnAiopsLogPatternAnalysisObj from './kbn_aiops_log_pattern_analysis.devdocs.json'; diff --git a/api_docs/kbn_aiops_log_rate_analysis.mdx b/api_docs/kbn_aiops_log_rate_analysis.mdx index e64ef4230888..de759389c82e 100644 --- a/api_docs/kbn_aiops_log_rate_analysis.mdx +++ b/api_docs/kbn_aiops_log_rate_analysis.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-aiops-log-rate-analysis title: "@kbn/aiops-log-rate-analysis" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/aiops-log-rate-analysis plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/aiops-log-rate-analysis'] --- import kbnAiopsLogRateAnalysisObj from './kbn_aiops_log_rate_analysis.devdocs.json'; diff --git a/api_docs/kbn_alerting_api_integration_helpers.mdx b/api_docs/kbn_alerting_api_integration_helpers.mdx index 48efda58caf8..9977315783ea 100644 --- a/api_docs/kbn_alerting_api_integration_helpers.mdx +++ b/api_docs/kbn_alerting_api_integration_helpers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-alerting-api-integration-helpers title: "@kbn/alerting-api-integration-helpers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/alerting-api-integration-helpers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/alerting-api-integration-helpers'] --- import kbnAlertingApiIntegrationHelpersObj from './kbn_alerting_api_integration_helpers.devdocs.json'; diff --git a/api_docs/kbn_alerting_comparators.mdx b/api_docs/kbn_alerting_comparators.mdx index b5406dd3f260..bd6b2f1f9e3c 100644 --- a/api_docs/kbn_alerting_comparators.mdx +++ b/api_docs/kbn_alerting_comparators.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-alerting-comparators title: "@kbn/alerting-comparators" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/alerting-comparators plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/alerting-comparators'] --- import kbnAlertingComparatorsObj from './kbn_alerting_comparators.devdocs.json'; diff --git a/api_docs/kbn_alerting_state_types.mdx b/api_docs/kbn_alerting_state_types.mdx index 65c24075b8a0..3b7d7514be48 100644 --- a/api_docs/kbn_alerting_state_types.mdx +++ b/api_docs/kbn_alerting_state_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-alerting-state-types title: "@kbn/alerting-state-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/alerting-state-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/alerting-state-types'] --- import kbnAlertingStateTypesObj from './kbn_alerting_state_types.devdocs.json'; diff --git a/api_docs/kbn_alerting_types.devdocs.json b/api_docs/kbn_alerting_types.devdocs.json index 0ac97eba4147..399fa3574fcb 100644 --- a/api_docs/kbn_alerting_types.devdocs.json +++ b/api_docs/kbn_alerting_types.devdocs.json @@ -1345,7 +1345,7 @@ { "parentPluginId": "@kbn/alerting-types", "id": "def-common.Rule.alertDelay", - "type": "Object", + "type": "CompoundType", "tags": [], "label": "alertDelay", "description": [], @@ -1357,7 +1357,7 @@ "section": "def-common.AlertDelay", "text": "AlertDelay" }, - " | undefined" + " | null | undefined" ], "path": "packages/kbn-alerting-types/rule_types.ts", "deprecated": false, @@ -2784,6 +2784,21 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/alerting-types", + "id": "def-common.errorMessageHeader", + "type": "string", + "tags": [], + "label": "errorMessageHeader", + "description": [], + "signature": [ + "\"Error validating circuit breaker\"" + ], + "path": "packages/kbn-alerting-types/circuit_breaker_message_header.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/alerting-types", "id": "def-common.IsoWeekday", diff --git a/api_docs/kbn_alerting_types.mdx b/api_docs/kbn_alerting_types.mdx index 1767708808e4..eaf12747ee15 100644 --- a/api_docs/kbn_alerting_types.mdx +++ b/api_docs/kbn_alerting_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-alerting-types title: "@kbn/alerting-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/alerting-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/alerting-types'] --- import kbnAlertingTypesObj from './kbn_alerting_types.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-o | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 193 | 0 | 190 | 0 | +| 194 | 0 | 191 | 0 | ## Common diff --git a/api_docs/kbn_alerts_as_data_utils.mdx b/api_docs/kbn_alerts_as_data_utils.mdx index b407cbcb0c39..140e9ebc8e34 100644 --- a/api_docs/kbn_alerts_as_data_utils.mdx +++ b/api_docs/kbn_alerts_as_data_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-alerts-as-data-utils title: "@kbn/alerts-as-data-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/alerts-as-data-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/alerts-as-data-utils'] --- import kbnAlertsAsDataUtilsObj from './kbn_alerts_as_data_utils.devdocs.json'; diff --git a/api_docs/kbn_alerts_ui_shared.devdocs.json b/api_docs/kbn_alerts_ui_shared.devdocs.json index ad5bd582ef9a..db705acc3d45 100644 --- a/api_docs/kbn_alerts_ui_shared.devdocs.json +++ b/api_docs/kbn_alerts_ui_shared.devdocs.json @@ -1012,8 +1012,8 @@ "pluginId": "@kbn/alerts-ui-shared", "scope": "common", "docId": "kibKbnAlertsUiSharedPluginApi", - "section": "def-common.RuleFormErrors", - "text": "RuleFormErrors" + "section": "def-common.RuleFormParamsErrors", + "text": "RuleFormParamsErrors" } ], "path": "packages/kbn-alerts-ui-shared/src/common/types/action_types.ts", @@ -3181,10 +3181,10 @@ }, { "parentPluginId": "@kbn/alerts-ui-shared", - "id": "def-common.RuleFormErrors", + "id": "def-common.RuleFormBaseErrors", "type": "Interface", "tags": [], - "label": "RuleFormErrors", + "label": "RuleFormBaseErrors", "description": [], "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", "deprecated": false, @@ -3192,10 +3192,122 @@ "children": [ { "parentPluginId": "@kbn/alerts-ui-shared", - "id": "def-common.RuleFormErrors.Unnamed", + "id": "def-common.RuleFormBaseErrors.name", + "type": "Array", + "tags": [], + "label": "name", + "description": [], + "signature": [ + "string[] | undefined" + ], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormBaseErrors.interval", + "type": "Array", + "tags": [], + "label": "interval", + "description": [], + "signature": [ + "string[] | undefined" + ], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormBaseErrors.consumer", + "type": "Array", + "tags": [], + "label": "consumer", + "description": [], + "signature": [ + "string[] | undefined" + ], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormBaseErrors.ruleTypeId", + "type": "Array", + "tags": [], + "label": "ruleTypeId", + "description": [], + "signature": [ + "string[] | undefined" + ], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormBaseErrors.actionConnectors", + "type": "Array", + "tags": [], + "label": "actionConnectors", + "description": [], + "signature": [ + "string[] | undefined" + ], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormBaseErrors.alertDelay", + "type": "Array", + "tags": [], + "label": "alertDelay", + "description": [], + "signature": [ + "string[] | undefined" + ], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormBaseErrors.tags", + "type": "Array", + "tags": [], + "label": "tags", + "description": [], + "signature": [ + "string[] | undefined" + ], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false + } + ], + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormParamsErrors", + "type": "Interface", + "tags": [], + "label": "RuleFormParamsErrors", + "description": [], + "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/alerts-ui-shared", + "id": "def-common.RuleFormParamsErrors.Unnamed", "type": "IndexSignature", "tags": [], - "label": "[key: string]: string | string[] | RuleFormErrors", + "label": "[key: string]: string | string[] | RuleFormParamsErrors", "description": [], "signature": [ "[key: string]: string | string[] | ", @@ -3203,8 +3315,8 @@ "pluginId": "@kbn/alerts-ui-shared", "scope": "common", "docId": "kibKbnAlertsUiSharedPluginApi", - "section": "def-common.RuleFormErrors", - "text": "RuleFormErrors" + "section": "def-common.RuleFormParamsErrors", + "text": "RuleFormParamsErrors" } ], "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", @@ -3691,8 +3803,8 @@ "pluginId": "@kbn/alerts-ui-shared", "scope": "common", "docId": "kibKbnAlertsUiSharedPluginApi", - "section": "def-common.RuleFormErrors", - "text": "RuleFormErrors" + "section": "def-common.RuleFormParamsErrors", + "text": "RuleFormParamsErrors" } ], "path": "packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts", diff --git a/api_docs/kbn_alerts_ui_shared.mdx b/api_docs/kbn_alerts_ui_shared.mdx index 36d36c3e8f15..3d9460e9d793 100644 --- a/api_docs/kbn_alerts_ui_shared.mdx +++ b/api_docs/kbn_alerts_ui_shared.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-alerts-ui-shared title: "@kbn/alerts-ui-shared" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/alerts-ui-shared plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/alerts-ui-shared'] --- import kbnAlertsUiSharedObj from './kbn_alerts_ui_shared.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-o | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 237 | 0 | 223 | 2 | +| 245 | 0 | 231 | 2 | ## Common diff --git a/api_docs/kbn_analytics.mdx b/api_docs/kbn_analytics.mdx index 04f4f2ac4b40..5752c900bff1 100644 --- a/api_docs/kbn_analytics.mdx +++ b/api_docs/kbn_analytics.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-analytics title: "@kbn/analytics" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/analytics plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/analytics'] --- import kbnAnalyticsObj from './kbn_analytics.devdocs.json'; diff --git a/api_docs/kbn_analytics_collection_utils.mdx b/api_docs/kbn_analytics_collection_utils.mdx index b5eb4213aefd..540ad398c9e8 100644 --- a/api_docs/kbn_analytics_collection_utils.mdx +++ b/api_docs/kbn_analytics_collection_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-analytics-collection-utils title: "@kbn/analytics-collection-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/analytics-collection-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/analytics-collection-utils'] --- import kbnAnalyticsCollectionUtilsObj from './kbn_analytics_collection_utils.devdocs.json'; diff --git a/api_docs/kbn_apm_config_loader.mdx b/api_docs/kbn_apm_config_loader.mdx index 715442645478..cecbf6d87dfe 100644 --- a/api_docs/kbn_apm_config_loader.mdx +++ b/api_docs/kbn_apm_config_loader.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-apm-config-loader title: "@kbn/apm-config-loader" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/apm-config-loader plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/apm-config-loader'] --- import kbnApmConfigLoaderObj from './kbn_apm_config_loader.devdocs.json'; diff --git a/api_docs/kbn_apm_data_view.mdx b/api_docs/kbn_apm_data_view.mdx index abd28affc539..f85c9d57d365 100644 --- a/api_docs/kbn_apm_data_view.mdx +++ b/api_docs/kbn_apm_data_view.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-apm-data-view title: "@kbn/apm-data-view" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/apm-data-view plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/apm-data-view'] --- import kbnApmDataViewObj from './kbn_apm_data_view.devdocs.json'; diff --git a/api_docs/kbn_apm_synthtrace.mdx b/api_docs/kbn_apm_synthtrace.mdx index 774853b7d0b8..0ce6f02362ad 100644 --- a/api_docs/kbn_apm_synthtrace.mdx +++ b/api_docs/kbn_apm_synthtrace.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-apm-synthtrace title: "@kbn/apm-synthtrace" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/apm-synthtrace plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/apm-synthtrace'] --- import kbnApmSynthtraceObj from './kbn_apm_synthtrace.devdocs.json'; diff --git a/api_docs/kbn_apm_synthtrace_client.mdx b/api_docs/kbn_apm_synthtrace_client.mdx index ce2e6658c019..57129a7e5d96 100644 --- a/api_docs/kbn_apm_synthtrace_client.mdx +++ b/api_docs/kbn_apm_synthtrace_client.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-apm-synthtrace-client title: "@kbn/apm-synthtrace-client" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/apm-synthtrace-client plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/apm-synthtrace-client'] --- import kbnApmSynthtraceClientObj from './kbn_apm_synthtrace_client.devdocs.json'; diff --git a/api_docs/kbn_apm_utils.mdx b/api_docs/kbn_apm_utils.mdx index e3eedbcd4b06..6371dfd7d612 100644 --- a/api_docs/kbn_apm_utils.mdx +++ b/api_docs/kbn_apm_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-apm-utils title: "@kbn/apm-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/apm-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/apm-utils'] --- import kbnApmUtilsObj from './kbn_apm_utils.devdocs.json'; diff --git a/api_docs/kbn_axe_config.mdx b/api_docs/kbn_axe_config.mdx index 6e6a15ce200b..a7a9c93dbdb7 100644 --- a/api_docs/kbn_axe_config.mdx +++ b/api_docs/kbn_axe_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-axe-config title: "@kbn/axe-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/axe-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/axe-config'] --- import kbnAxeConfigObj from './kbn_axe_config.devdocs.json'; diff --git a/api_docs/kbn_bfetch_error.mdx b/api_docs/kbn_bfetch_error.mdx index 0cd7d0a352e7..c95f74cf6590 100644 --- a/api_docs/kbn_bfetch_error.mdx +++ b/api_docs/kbn_bfetch_error.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-bfetch-error title: "@kbn/bfetch-error" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/bfetch-error plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/bfetch-error'] --- import kbnBfetchErrorObj from './kbn_bfetch_error.devdocs.json'; diff --git a/api_docs/kbn_calculate_auto.mdx b/api_docs/kbn_calculate_auto.mdx index c715c5b4d05a..f328b26e73f3 100644 --- a/api_docs/kbn_calculate_auto.mdx +++ b/api_docs/kbn_calculate_auto.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-calculate-auto title: "@kbn/calculate-auto" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/calculate-auto plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/calculate-auto'] --- import kbnCalculateAutoObj from './kbn_calculate_auto.devdocs.json'; diff --git a/api_docs/kbn_calculate_width_from_char_count.mdx b/api_docs/kbn_calculate_width_from_char_count.mdx index 580bbd3871f8..30eade23f7d1 100644 --- a/api_docs/kbn_calculate_width_from_char_count.mdx +++ b/api_docs/kbn_calculate_width_from_char_count.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-calculate-width-from-char-count title: "@kbn/calculate-width-from-char-count" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/calculate-width-from-char-count plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/calculate-width-from-char-count'] --- import kbnCalculateWidthFromCharCountObj from './kbn_calculate_width_from_char_count.devdocs.json'; diff --git a/api_docs/kbn_cases_components.mdx b/api_docs/kbn_cases_components.mdx index c4a7446a0745..49b697256d4a 100644 --- a/api_docs/kbn_cases_components.mdx +++ b/api_docs/kbn_cases_components.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-cases-components title: "@kbn/cases-components" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/cases-components plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/cases-components'] --- import kbnCasesComponentsObj from './kbn_cases_components.devdocs.json'; diff --git a/api_docs/kbn_cell_actions.mdx b/api_docs/kbn_cell_actions.mdx index 17d3bbfd535f..c1e2fc81a015 100644 --- a/api_docs/kbn_cell_actions.mdx +++ b/api_docs/kbn_cell_actions.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-cell-actions title: "@kbn/cell-actions" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/cell-actions plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/cell-actions'] --- import kbnCellActionsObj from './kbn_cell_actions.devdocs.json'; diff --git a/api_docs/kbn_chart_expressions_common.mdx b/api_docs/kbn_chart_expressions_common.mdx index 07ca5fefbe76..438e9a326293 100644 --- a/api_docs/kbn_chart_expressions_common.mdx +++ b/api_docs/kbn_chart_expressions_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-chart-expressions-common title: "@kbn/chart-expressions-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/chart-expressions-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/chart-expressions-common'] --- import kbnChartExpressionsCommonObj from './kbn_chart_expressions_common.devdocs.json'; diff --git a/api_docs/kbn_chart_icons.mdx b/api_docs/kbn_chart_icons.mdx index 86a0cbeaa9b6..7025c7c97d6a 100644 --- a/api_docs/kbn_chart_icons.mdx +++ b/api_docs/kbn_chart_icons.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-chart-icons title: "@kbn/chart-icons" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/chart-icons plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/chart-icons'] --- import kbnChartIconsObj from './kbn_chart_icons.devdocs.json'; diff --git a/api_docs/kbn_ci_stats_core.mdx b/api_docs/kbn_ci_stats_core.mdx index 2e62a277d53e..75f46ca2c08f 100644 --- a/api_docs/kbn_ci_stats_core.mdx +++ b/api_docs/kbn_ci_stats_core.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ci-stats-core title: "@kbn/ci-stats-core" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ci-stats-core plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ci-stats-core'] --- import kbnCiStatsCoreObj from './kbn_ci_stats_core.devdocs.json'; diff --git a/api_docs/kbn_ci_stats_performance_metrics.mdx b/api_docs/kbn_ci_stats_performance_metrics.mdx index aabb69cd5ceb..c20a0443afd1 100644 --- a/api_docs/kbn_ci_stats_performance_metrics.mdx +++ b/api_docs/kbn_ci_stats_performance_metrics.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ci-stats-performance-metrics title: "@kbn/ci-stats-performance-metrics" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ci-stats-performance-metrics plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ci-stats-performance-metrics'] --- import kbnCiStatsPerformanceMetricsObj from './kbn_ci_stats_performance_metrics.devdocs.json'; diff --git a/api_docs/kbn_ci_stats_reporter.mdx b/api_docs/kbn_ci_stats_reporter.mdx index f71656c22fab..f7452a05fd4a 100644 --- a/api_docs/kbn_ci_stats_reporter.mdx +++ b/api_docs/kbn_ci_stats_reporter.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ci-stats-reporter title: "@kbn/ci-stats-reporter" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ci-stats-reporter plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ci-stats-reporter'] --- import kbnCiStatsReporterObj from './kbn_ci_stats_reporter.devdocs.json'; diff --git a/api_docs/kbn_cli_dev_mode.mdx b/api_docs/kbn_cli_dev_mode.mdx index 8663fdc6065b..ffe2574b16f3 100644 --- a/api_docs/kbn_cli_dev_mode.mdx +++ b/api_docs/kbn_cli_dev_mode.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-cli-dev-mode title: "@kbn/cli-dev-mode" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/cli-dev-mode plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/cli-dev-mode'] --- import kbnCliDevModeObj from './kbn_cli_dev_mode.devdocs.json'; diff --git a/api_docs/kbn_code_editor.mdx b/api_docs/kbn_code_editor.mdx index 170d196cb9f6..b35ef9b77b93 100644 --- a/api_docs/kbn_code_editor.mdx +++ b/api_docs/kbn_code_editor.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-code-editor title: "@kbn/code-editor" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/code-editor plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/code-editor'] --- import kbnCodeEditorObj from './kbn_code_editor.devdocs.json'; diff --git a/api_docs/kbn_code_editor_mock.mdx b/api_docs/kbn_code_editor_mock.mdx index 6a616c46795d..fa2a193c8f8a 100644 --- a/api_docs/kbn_code_editor_mock.mdx +++ b/api_docs/kbn_code_editor_mock.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-code-editor-mock title: "@kbn/code-editor-mock" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/code-editor-mock plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/code-editor-mock'] --- import kbnCodeEditorMockObj from './kbn_code_editor_mock.devdocs.json'; diff --git a/api_docs/kbn_code_owners.mdx b/api_docs/kbn_code_owners.mdx index 788a8b97ae25..c62a5a7d7d69 100644 --- a/api_docs/kbn_code_owners.mdx +++ b/api_docs/kbn_code_owners.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-code-owners title: "@kbn/code-owners" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/code-owners plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/code-owners'] --- import kbnCodeOwnersObj from './kbn_code_owners.devdocs.json'; diff --git a/api_docs/kbn_coloring.mdx b/api_docs/kbn_coloring.mdx index 9b656b7034af..1f0e57101a9f 100644 --- a/api_docs/kbn_coloring.mdx +++ b/api_docs/kbn_coloring.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-coloring title: "@kbn/coloring" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/coloring plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/coloring'] --- import kbnColoringObj from './kbn_coloring.devdocs.json'; diff --git a/api_docs/kbn_config.mdx b/api_docs/kbn_config.mdx index dc20112e0dcc..b39d344030a5 100644 --- a/api_docs/kbn_config.mdx +++ b/api_docs/kbn_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-config title: "@kbn/config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/config'] --- import kbnConfigObj from './kbn_config.devdocs.json'; diff --git a/api_docs/kbn_config_mocks.devdocs.json b/api_docs/kbn_config_mocks.devdocs.json index c470bc5513b2..44c2f2ea9106 100644 --- a/api_docs/kbn_config_mocks.devdocs.json +++ b/api_docs/kbn_config_mocks.devdocs.json @@ -268,7 +268,7 @@ "Observable", "<", "Config", - ">, [], unknown>; setSchema: jest.MockInstance, [], unknown>; setGlobalStripUnknownKeys: jest.MockInstance; setSchema: jest.MockInstance, namespace?: string | undefined) => V" + "(value: unknown, context?: Record, namespace?: string | undefined, validationOptions?: ", + { + "pluginId": "@kbn/config-schema", + "scope": "common", + "docId": "kibKbnConfigSchemaPluginApi", + "section": "def-common.SchemaValidationOptions", + "text": "SchemaValidationOptions" + }, + " | undefined) => V" ], "path": "packages/kbn-config-schema/src/types/type.ts", "deprecated": false, @@ -1223,6 +1231,28 @@ "deprecated": false, "trackAdoption": false, "isRequired": false + }, + { + "parentPluginId": "@kbn/config-schema", + "id": "def-common.Type.validate.$4", + "type": "Object", + "tags": [], + "label": "validationOptions", + "description": [], + "signature": [ + { + "pluginId": "@kbn/config-schema", + "scope": "common", + "docId": "kibKbnConfigSchemaPluginApi", + "section": "def-common.SchemaValidationOptions", + "text": "SchemaValidationOptions" + }, + " | undefined" + ], + "path": "packages/kbn-config-schema/src/types/type.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": false } ], "returnComment": [] @@ -1612,6 +1642,38 @@ } ], "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/config-schema", + "id": "def-common.SchemaValidationOptions", + "type": "Interface", + "tags": [], + "label": "SchemaValidationOptions", + "description": [ + "\nGlobal validation Options to be provided when calling the `schema.validate()` method." + ], + "path": "packages/kbn-config-schema/src/types/type.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/config-schema", + "id": "def-common.SchemaValidationOptions.stripUnknownKeys", + "type": "CompoundType", + "tags": [], + "label": "stripUnknownKeys", + "description": [ + "\nRemove unknown config keys" + ], + "signature": [ + "boolean | undefined" + ], + "path": "packages/kbn-config-schema/src/types/type.ts", + "deprecated": false, + "trackAdoption": false + } + ], + "initialIsOpen": false } ], "enums": [], diff --git a/api_docs/kbn_config_schema.mdx b/api_docs/kbn_config_schema.mdx index 29e45a635b80..d8f89a770b84 100644 --- a/api_docs/kbn_config_schema.mdx +++ b/api_docs/kbn_config_schema.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-config-schema title: "@kbn/config-schema" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/config-schema plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/config-schema'] --- import kbnConfigSchemaObj from './kbn_config_schema.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 146 | 2 | 142 | 20 | +| 149 | 2 | 143 | 20 | ## Common diff --git a/api_docs/kbn_content_management_content_editor.mdx b/api_docs/kbn_content_management_content_editor.mdx index 6e73ac203c7b..b54c440c5c10 100644 --- a/api_docs/kbn_content_management_content_editor.mdx +++ b/api_docs/kbn_content_management_content_editor.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-content-management-content-editor title: "@kbn/content-management-content-editor" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/content-management-content-editor plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/content-management-content-editor'] --- import kbnContentManagementContentEditorObj from './kbn_content_management_content_editor.devdocs.json'; diff --git a/api_docs/kbn_content_management_tabbed_table_list_view.mdx b/api_docs/kbn_content_management_tabbed_table_list_view.mdx index 1db277cded5d..b434e2769fb0 100644 --- a/api_docs/kbn_content_management_tabbed_table_list_view.mdx +++ b/api_docs/kbn_content_management_tabbed_table_list_view.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-content-management-tabbed-table-list-view title: "@kbn/content-management-tabbed-table-list-view" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/content-management-tabbed-table-list-view plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/content-management-tabbed-table-list-view'] --- import kbnContentManagementTabbedTableListViewObj from './kbn_content_management_tabbed_table_list_view.devdocs.json'; diff --git a/api_docs/kbn_content_management_table_list_view.mdx b/api_docs/kbn_content_management_table_list_view.mdx index b1c3f8aa67d9..a5bdb8d78e64 100644 --- a/api_docs/kbn_content_management_table_list_view.mdx +++ b/api_docs/kbn_content_management_table_list_view.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-content-management-table-list-view title: "@kbn/content-management-table-list-view" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/content-management-table-list-view plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/content-management-table-list-view'] --- import kbnContentManagementTableListViewObj from './kbn_content_management_table_list_view.devdocs.json'; diff --git a/api_docs/kbn_content_management_table_list_view_common.mdx b/api_docs/kbn_content_management_table_list_view_common.mdx index 081858b8f854..563e4d0348aa 100644 --- a/api_docs/kbn_content_management_table_list_view_common.mdx +++ b/api_docs/kbn_content_management_table_list_view_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-content-management-table-list-view-common title: "@kbn/content-management-table-list-view-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/content-management-table-list-view-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/content-management-table-list-view-common'] --- import kbnContentManagementTableListViewCommonObj from './kbn_content_management_table_list_view_common.devdocs.json'; diff --git a/api_docs/kbn_content_management_table_list_view_table.mdx b/api_docs/kbn_content_management_table_list_view_table.mdx index 82aeb3a088a3..39ab11cb8ba1 100644 --- a/api_docs/kbn_content_management_table_list_view_table.mdx +++ b/api_docs/kbn_content_management_table_list_view_table.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-content-management-table-list-view-table title: "@kbn/content-management-table-list-view-table" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/content-management-table-list-view-table plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/content-management-table-list-view-table'] --- import kbnContentManagementTableListViewTableObj from './kbn_content_management_table_list_view_table.devdocs.json'; diff --git a/api_docs/kbn_content_management_user_profiles.mdx b/api_docs/kbn_content_management_user_profiles.mdx index 771368e16c52..872ce70372c7 100644 --- a/api_docs/kbn_content_management_user_profiles.mdx +++ b/api_docs/kbn_content_management_user_profiles.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-content-management-user-profiles title: "@kbn/content-management-user-profiles" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/content-management-user-profiles plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/content-management-user-profiles'] --- import kbnContentManagementUserProfilesObj from './kbn_content_management_user_profiles.devdocs.json'; diff --git a/api_docs/kbn_content_management_utils.mdx b/api_docs/kbn_content_management_utils.mdx index 4c3f859769bd..2feef09b8d4c 100644 --- a/api_docs/kbn_content_management_utils.mdx +++ b/api_docs/kbn_content_management_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-content-management-utils title: "@kbn/content-management-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/content-management-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/content-management-utils'] --- import kbnContentManagementUtilsObj from './kbn_content_management_utils.devdocs.json'; diff --git a/api_docs/kbn_core_analytics_browser.devdocs.json b/api_docs/kbn_core_analytics_browser.devdocs.json index 2bdcb21161c4..0937f6f89d0f 100644 --- a/api_docs/kbn_core_analytics_browser.devdocs.json +++ b/api_docs/kbn_core_analytics_browser.devdocs.json @@ -691,14 +691,6 @@ "plugin": "@kbn/cloud", "path": "packages/cloud/connection_details/kibana/kibana_connection_details_provider.tsx" }, - { - "plugin": "dashboard", - "path": "src/plugins/dashboard/public/services/analytics/types.ts" - }, - { - "plugin": "dashboard", - "path": "src/plugins/dashboard/public/services/analytics/analytics_service.ts" - }, { "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/recall_and_score.ts" @@ -707,6 +699,14 @@ "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/public/analytics/index.ts" }, + { + "plugin": "dashboard", + "path": "src/plugins/dashboard/public/services/analytics/types.ts" + }, + { + "plugin": "dashboard", + "path": "src/plugins/dashboard/public/services/analytics/analytics_service.ts" + }, { "plugin": "integrationAssistant", "path": "x-pack/plugins/integration_assistant/public/services/telemetry/service.ts" @@ -731,6 +731,18 @@ "plugin": "fleet", "path": "x-pack/plugins/fleet/server/services/telemetry/fleet_usage_sender.ts" }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, { "plugin": "elasticAssistant", "path": "x-pack/plugins/elastic_assistant/server/lib/langchain/elasticsearch_store/elasticsearch_store.ts" @@ -755,6 +767,18 @@ "plugin": "elasticAssistant", "path": "x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts" }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/helpers.ts" + }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/helpers.ts" + }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts" + }, { "plugin": "globalSearchBar", "path": "x-pack/plugins/global_search_bar/public/telemetry/event_reporter.ts" @@ -796,32 +820,32 @@ "path": "x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", @@ -931,6 +955,34 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts" @@ -1243,6 +1295,38 @@ "plugin": "apm", "path": "x-pack/plugins/observability_solution/apm/public/services/telemetry/telemetry_service.test.ts" }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, { "plugin": "infra", "path": "x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_service.test.ts" diff --git a/api_docs/kbn_core_analytics_browser.mdx b/api_docs/kbn_core_analytics_browser.mdx index eecd390df0fb..5d60e7bbbf98 100644 --- a/api_docs/kbn_core_analytics_browser.mdx +++ b/api_docs/kbn_core_analytics_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-analytics-browser title: "@kbn/core-analytics-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-analytics-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-analytics-browser'] --- import kbnCoreAnalyticsBrowserObj from './kbn_core_analytics_browser.devdocs.json'; diff --git a/api_docs/kbn_core_analytics_browser_internal.mdx b/api_docs/kbn_core_analytics_browser_internal.mdx index 38a4b24e4ec4..1051a8fc0984 100644 --- a/api_docs/kbn_core_analytics_browser_internal.mdx +++ b/api_docs/kbn_core_analytics_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-analytics-browser-internal title: "@kbn/core-analytics-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-analytics-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-analytics-browser-internal'] --- import kbnCoreAnalyticsBrowserInternalObj from './kbn_core_analytics_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_analytics_browser_mocks.mdx b/api_docs/kbn_core_analytics_browser_mocks.mdx index 551f378be492..f1d6e83ca996 100644 --- a/api_docs/kbn_core_analytics_browser_mocks.mdx +++ b/api_docs/kbn_core_analytics_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-analytics-browser-mocks title: "@kbn/core-analytics-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-analytics-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-analytics-browser-mocks'] --- import kbnCoreAnalyticsBrowserMocksObj from './kbn_core_analytics_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_analytics_server.devdocs.json b/api_docs/kbn_core_analytics_server.devdocs.json index d9a4cef58932..24de9bb5afba 100644 --- a/api_docs/kbn_core_analytics_server.devdocs.json +++ b/api_docs/kbn_core_analytics_server.devdocs.json @@ -691,14 +691,6 @@ "plugin": "@kbn/cloud", "path": "packages/cloud/connection_details/kibana/kibana_connection_details_provider.tsx" }, - { - "plugin": "dashboard", - "path": "src/plugins/dashboard/public/services/analytics/types.ts" - }, - { - "plugin": "dashboard", - "path": "src/plugins/dashboard/public/services/analytics/analytics_service.ts" - }, { "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/recall_and_score.ts" @@ -707,6 +699,14 @@ "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/public/analytics/index.ts" }, + { + "plugin": "dashboard", + "path": "src/plugins/dashboard/public/services/analytics/types.ts" + }, + { + "plugin": "dashboard", + "path": "src/plugins/dashboard/public/services/analytics/analytics_service.ts" + }, { "plugin": "integrationAssistant", "path": "x-pack/plugins/integration_assistant/public/services/telemetry/service.ts" @@ -731,6 +731,18 @@ "plugin": "fleet", "path": "x-pack/plugins/fleet/server/services/telemetry/fleet_usage_sender.ts" }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, { "plugin": "elasticAssistant", "path": "x-pack/plugins/elastic_assistant/server/lib/langchain/elasticsearch_store/elasticsearch_store.ts" @@ -755,6 +767,18 @@ "plugin": "elasticAssistant", "path": "x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts" }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/helpers.ts" + }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/helpers.ts" + }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts" + }, { "plugin": "globalSearchBar", "path": "x-pack/plugins/global_search_bar/public/telemetry/event_reporter.ts" @@ -796,32 +820,32 @@ "path": "x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", @@ -931,6 +955,34 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts" @@ -1243,6 +1295,38 @@ "plugin": "apm", "path": "x-pack/plugins/observability_solution/apm/public/services/telemetry/telemetry_service.test.ts" }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, { "plugin": "infra", "path": "x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_service.test.ts" diff --git a/api_docs/kbn_core_analytics_server.mdx b/api_docs/kbn_core_analytics_server.mdx index 1509db148466..da84a8ca0f2f 100644 --- a/api_docs/kbn_core_analytics_server.mdx +++ b/api_docs/kbn_core_analytics_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-analytics-server title: "@kbn/core-analytics-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-analytics-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-analytics-server'] --- import kbnCoreAnalyticsServerObj from './kbn_core_analytics_server.devdocs.json'; diff --git a/api_docs/kbn_core_analytics_server_internal.mdx b/api_docs/kbn_core_analytics_server_internal.mdx index 5ddb90b0be97..129879916d7b 100644 --- a/api_docs/kbn_core_analytics_server_internal.mdx +++ b/api_docs/kbn_core_analytics_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-analytics-server-internal title: "@kbn/core-analytics-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-analytics-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-analytics-server-internal'] --- import kbnCoreAnalyticsServerInternalObj from './kbn_core_analytics_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_analytics_server_mocks.mdx b/api_docs/kbn_core_analytics_server_mocks.mdx index 12850d166ca0..141bf88ba6f8 100644 --- a/api_docs/kbn_core_analytics_server_mocks.mdx +++ b/api_docs/kbn_core_analytics_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-analytics-server-mocks title: "@kbn/core-analytics-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-analytics-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-analytics-server-mocks'] --- import kbnCoreAnalyticsServerMocksObj from './kbn_core_analytics_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_application_browser.mdx b/api_docs/kbn_core_application_browser.mdx index f165aa99f106..ab72b17de203 100644 --- a/api_docs/kbn_core_application_browser.mdx +++ b/api_docs/kbn_core_application_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-application-browser title: "@kbn/core-application-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-application-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-application-browser'] --- import kbnCoreApplicationBrowserObj from './kbn_core_application_browser.devdocs.json'; diff --git a/api_docs/kbn_core_application_browser_internal.mdx b/api_docs/kbn_core_application_browser_internal.mdx index 20f003eb5c46..ec1a2decce51 100644 --- a/api_docs/kbn_core_application_browser_internal.mdx +++ b/api_docs/kbn_core_application_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-application-browser-internal title: "@kbn/core-application-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-application-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-application-browser-internal'] --- import kbnCoreApplicationBrowserInternalObj from './kbn_core_application_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_application_browser_mocks.mdx b/api_docs/kbn_core_application_browser_mocks.mdx index ad5a645c8ede..3071caccc3e8 100644 --- a/api_docs/kbn_core_application_browser_mocks.mdx +++ b/api_docs/kbn_core_application_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-application-browser-mocks title: "@kbn/core-application-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-application-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-application-browser-mocks'] --- import kbnCoreApplicationBrowserMocksObj from './kbn_core_application_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_application_common.mdx b/api_docs/kbn_core_application_common.mdx index ced05032952f..4b9061264e7b 100644 --- a/api_docs/kbn_core_application_common.mdx +++ b/api_docs/kbn_core_application_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-application-common title: "@kbn/core-application-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-application-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-application-common'] --- import kbnCoreApplicationCommonObj from './kbn_core_application_common.devdocs.json'; diff --git a/api_docs/kbn_core_apps_browser_internal.mdx b/api_docs/kbn_core_apps_browser_internal.mdx index d3075749ef0a..15c671d2d786 100644 --- a/api_docs/kbn_core_apps_browser_internal.mdx +++ b/api_docs/kbn_core_apps_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-apps-browser-internal title: "@kbn/core-apps-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-apps-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-apps-browser-internal'] --- import kbnCoreAppsBrowserInternalObj from './kbn_core_apps_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_apps_browser_mocks.mdx b/api_docs/kbn_core_apps_browser_mocks.mdx index 179fe9f25546..729f7776f3c6 100644 --- a/api_docs/kbn_core_apps_browser_mocks.mdx +++ b/api_docs/kbn_core_apps_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-apps-browser-mocks title: "@kbn/core-apps-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-apps-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-apps-browser-mocks'] --- import kbnCoreAppsBrowserMocksObj from './kbn_core_apps_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_apps_server_internal.mdx b/api_docs/kbn_core_apps_server_internal.mdx index a99f0b5d4e7d..72c3247afee8 100644 --- a/api_docs/kbn_core_apps_server_internal.mdx +++ b/api_docs/kbn_core_apps_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-apps-server-internal title: "@kbn/core-apps-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-apps-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-apps-server-internal'] --- import kbnCoreAppsServerInternalObj from './kbn_core_apps_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_base_browser_mocks.mdx b/api_docs/kbn_core_base_browser_mocks.mdx index c0e3a1e72f66..707e32c73261 100644 --- a/api_docs/kbn_core_base_browser_mocks.mdx +++ b/api_docs/kbn_core_base_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-base-browser-mocks title: "@kbn/core-base-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-base-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-base-browser-mocks'] --- import kbnCoreBaseBrowserMocksObj from './kbn_core_base_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_base_common.mdx b/api_docs/kbn_core_base_common.mdx index 5885a63e0149..b3dfbcee0efc 100644 --- a/api_docs/kbn_core_base_common.mdx +++ b/api_docs/kbn_core_base_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-base-common title: "@kbn/core-base-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-base-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-base-common'] --- import kbnCoreBaseCommonObj from './kbn_core_base_common.devdocs.json'; diff --git a/api_docs/kbn_core_base_server_internal.mdx b/api_docs/kbn_core_base_server_internal.mdx index 0d327bc716bc..352da914f0bb 100644 --- a/api_docs/kbn_core_base_server_internal.mdx +++ b/api_docs/kbn_core_base_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-base-server-internal title: "@kbn/core-base-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-base-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-base-server-internal'] --- import kbnCoreBaseServerInternalObj from './kbn_core_base_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_base_server_mocks.mdx b/api_docs/kbn_core_base_server_mocks.mdx index 4ec160616978..b0ace97a8c08 100644 --- a/api_docs/kbn_core_base_server_mocks.mdx +++ b/api_docs/kbn_core_base_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-base-server-mocks title: "@kbn/core-base-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-base-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-base-server-mocks'] --- import kbnCoreBaseServerMocksObj from './kbn_core_base_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_capabilities_browser_mocks.mdx b/api_docs/kbn_core_capabilities_browser_mocks.mdx index 10a5aae9ddf5..d1c5a1f91fe7 100644 --- a/api_docs/kbn_core_capabilities_browser_mocks.mdx +++ b/api_docs/kbn_core_capabilities_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-capabilities-browser-mocks title: "@kbn/core-capabilities-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-capabilities-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-capabilities-browser-mocks'] --- import kbnCoreCapabilitiesBrowserMocksObj from './kbn_core_capabilities_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_capabilities_common.mdx b/api_docs/kbn_core_capabilities_common.mdx index 6975f0696048..7e8a8f1d16c8 100644 --- a/api_docs/kbn_core_capabilities_common.mdx +++ b/api_docs/kbn_core_capabilities_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-capabilities-common title: "@kbn/core-capabilities-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-capabilities-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-capabilities-common'] --- import kbnCoreCapabilitiesCommonObj from './kbn_core_capabilities_common.devdocs.json'; diff --git a/api_docs/kbn_core_capabilities_server.mdx b/api_docs/kbn_core_capabilities_server.mdx index 63e74da3c69e..ae37b966a291 100644 --- a/api_docs/kbn_core_capabilities_server.mdx +++ b/api_docs/kbn_core_capabilities_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-capabilities-server title: "@kbn/core-capabilities-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-capabilities-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-capabilities-server'] --- import kbnCoreCapabilitiesServerObj from './kbn_core_capabilities_server.devdocs.json'; diff --git a/api_docs/kbn_core_capabilities_server_mocks.mdx b/api_docs/kbn_core_capabilities_server_mocks.mdx index dcffa3602346..e83019eb7a49 100644 --- a/api_docs/kbn_core_capabilities_server_mocks.mdx +++ b/api_docs/kbn_core_capabilities_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-capabilities-server-mocks title: "@kbn/core-capabilities-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-capabilities-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-capabilities-server-mocks'] --- import kbnCoreCapabilitiesServerMocksObj from './kbn_core_capabilities_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_chrome_browser.mdx b/api_docs/kbn_core_chrome_browser.mdx index 017e6d0533e5..9cb5d2a53d91 100644 --- a/api_docs/kbn_core_chrome_browser.mdx +++ b/api_docs/kbn_core_chrome_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-chrome-browser title: "@kbn/core-chrome-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-chrome-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-chrome-browser'] --- import kbnCoreChromeBrowserObj from './kbn_core_chrome_browser.devdocs.json'; diff --git a/api_docs/kbn_core_chrome_browser_mocks.mdx b/api_docs/kbn_core_chrome_browser_mocks.mdx index aaf4a47e832d..7373765d0d01 100644 --- a/api_docs/kbn_core_chrome_browser_mocks.mdx +++ b/api_docs/kbn_core_chrome_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-chrome-browser-mocks title: "@kbn/core-chrome-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-chrome-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-chrome-browser-mocks'] --- import kbnCoreChromeBrowserMocksObj from './kbn_core_chrome_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_config_server_internal.devdocs.json b/api_docs/kbn_core_config_server_internal.devdocs.json index 83a0b76b35fc..e9d32490a956 100644 --- a/api_docs/kbn_core_config_server_internal.devdocs.json +++ b/api_docs/kbn_core_config_server_internal.devdocs.json @@ -48,15 +48,25 @@ "parentPluginId": "@kbn/core-config-server-internal", "id": "def-common.ensureValidConfiguration", "type": "Function", - "tags": [], + "tags": [ + "private" + ], "label": "ensureValidConfiguration", - "description": [], + "description": [ + "\nValidate the entire Kibana configuration object, including the detection of extra keys." + ], "signature": [ "(configService: ", - "ConfigService", + "IConfigService", ", params: ", - "ConfigValidateParameters", - " | undefined) => Promise" + { + "pluginId": "@kbn/core-config-server-internal", + "scope": "common", + "docId": "kibKbnCoreConfigServerInternalPluginApi", + "section": "def-common.EnsureValidConfigurationParameters", + "text": "EnsureValidConfigurationParameters" + }, + ") => Promise" ], "path": "packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts", "deprecated": false, @@ -68,9 +78,11 @@ "type": "Object", "tags": [], "label": "configService", - "description": [], + "description": [ + "The {@link IConfigService } instance that has the raw configuration preloaded." + ], "signature": [ - "ConfigService" + "IConfigService" ], "path": "packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts", "deprecated": false, @@ -83,22 +95,75 @@ "type": "Object", "tags": [], "label": "params", - "description": [], + "description": [ + "{@link EnsureValidConfigurationParameters | Options} to enable/disable extra edge-cases." + ], "signature": [ - "ConfigValidateParameters", - " | undefined" + { + "pluginId": "@kbn/core-config-server-internal", + "scope": "common", + "docId": "kibKbnCoreConfigServerInternalPluginApi", + "section": "def-common.EnsureValidConfigurationParameters", + "text": "EnsureValidConfigurationParameters" + } ], "path": "packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts", "deprecated": false, "trackAdoption": false, - "isRequired": false + "isRequired": true } ], "returnComment": [], "initialIsOpen": false } ], - "interfaces": [], + "interfaces": [ + { + "parentPluginId": "@kbn/core-config-server-internal", + "id": "def-common.EnsureValidConfigurationParameters", + "type": "Interface", + "tags": [ + "private" + ], + "label": "EnsureValidConfigurationParameters", + "description": [ + "\nParameters for the helper {@link ensureValidConfiguration}\n" + ], + "signature": [ + { + "pluginId": "@kbn/core-config-server-internal", + "scope": "common", + "docId": "kibKbnCoreConfigServerInternalPluginApi", + "section": "def-common.EnsureValidConfigurationParameters", + "text": "EnsureValidConfigurationParameters" + }, + " extends ", + "ConfigValidateParameters" + ], + "path": "packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/core-config-server-internal", + "id": "def-common.EnsureValidConfigurationParameters.stripUnknownKeys", + "type": "CompoundType", + "tags": [], + "label": "stripUnknownKeys", + "description": [ + "\nSet to `true` to ignore any unknown keys and discard them from the final validated config object." + ], + "signature": [ + "boolean | undefined" + ], + "path": "packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts", + "deprecated": false, + "trackAdoption": false + } + ], + "initialIsOpen": false + } + ], "enums": [], "misc": [], "objects": [] diff --git a/api_docs/kbn_core_config_server_internal.mdx b/api_docs/kbn_core_config_server_internal.mdx index 3712642a26db..378c94028bf6 100644 --- a/api_docs/kbn_core_config_server_internal.mdx +++ b/api_docs/kbn_core_config_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-config-server-internal title: "@kbn/core-config-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-config-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-config-server-internal'] --- import kbnCoreConfigServerInternalObj from './kbn_core_config_server_internal.devdocs.json'; @@ -21,10 +21,13 @@ Contact [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 4 | 0 | 4 | 0 | +| 6 | 0 | 1 | 0 | ## Common ### Functions +### Interfaces + + diff --git a/api_docs/kbn_core_custom_branding_browser.mdx b/api_docs/kbn_core_custom_branding_browser.mdx index 10cc793ffcc6..06b410cd0731 100644 --- a/api_docs/kbn_core_custom_branding_browser.mdx +++ b/api_docs/kbn_core_custom_branding_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-custom-branding-browser title: "@kbn/core-custom-branding-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-custom-branding-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-custom-branding-browser'] --- import kbnCoreCustomBrandingBrowserObj from './kbn_core_custom_branding_browser.devdocs.json'; diff --git a/api_docs/kbn_core_custom_branding_browser_internal.mdx b/api_docs/kbn_core_custom_branding_browser_internal.mdx index ac93010aa83b..9ed9b49db2da 100644 --- a/api_docs/kbn_core_custom_branding_browser_internal.mdx +++ b/api_docs/kbn_core_custom_branding_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-custom-branding-browser-internal title: "@kbn/core-custom-branding-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-custom-branding-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-custom-branding-browser-internal'] --- import kbnCoreCustomBrandingBrowserInternalObj from './kbn_core_custom_branding_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_custom_branding_browser_mocks.mdx b/api_docs/kbn_core_custom_branding_browser_mocks.mdx index 9e1193b6bf5d..d69e098b41aa 100644 --- a/api_docs/kbn_core_custom_branding_browser_mocks.mdx +++ b/api_docs/kbn_core_custom_branding_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-custom-branding-browser-mocks title: "@kbn/core-custom-branding-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-custom-branding-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-custom-branding-browser-mocks'] --- import kbnCoreCustomBrandingBrowserMocksObj from './kbn_core_custom_branding_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_custom_branding_common.mdx b/api_docs/kbn_core_custom_branding_common.mdx index a0a7d454d1aa..b89d96a9f13d 100644 --- a/api_docs/kbn_core_custom_branding_common.mdx +++ b/api_docs/kbn_core_custom_branding_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-custom-branding-common title: "@kbn/core-custom-branding-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-custom-branding-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-custom-branding-common'] --- import kbnCoreCustomBrandingCommonObj from './kbn_core_custom_branding_common.devdocs.json'; diff --git a/api_docs/kbn_core_custom_branding_server.mdx b/api_docs/kbn_core_custom_branding_server.mdx index 54ce4e6fa9dd..b3be44399b31 100644 --- a/api_docs/kbn_core_custom_branding_server.mdx +++ b/api_docs/kbn_core_custom_branding_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-custom-branding-server title: "@kbn/core-custom-branding-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-custom-branding-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-custom-branding-server'] --- import kbnCoreCustomBrandingServerObj from './kbn_core_custom_branding_server.devdocs.json'; diff --git a/api_docs/kbn_core_custom_branding_server_internal.mdx b/api_docs/kbn_core_custom_branding_server_internal.mdx index 808ce26979f9..7b5f869203f7 100644 --- a/api_docs/kbn_core_custom_branding_server_internal.mdx +++ b/api_docs/kbn_core_custom_branding_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-custom-branding-server-internal title: "@kbn/core-custom-branding-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-custom-branding-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-custom-branding-server-internal'] --- import kbnCoreCustomBrandingServerInternalObj from './kbn_core_custom_branding_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_custom_branding_server_mocks.mdx b/api_docs/kbn_core_custom_branding_server_mocks.mdx index 92e11bb2f6ef..bbf0a59e376b 100644 --- a/api_docs/kbn_core_custom_branding_server_mocks.mdx +++ b/api_docs/kbn_core_custom_branding_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-custom-branding-server-mocks title: "@kbn/core-custom-branding-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-custom-branding-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-custom-branding-server-mocks'] --- import kbnCoreCustomBrandingServerMocksObj from './kbn_core_custom_branding_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_deprecations_browser.mdx b/api_docs/kbn_core_deprecations_browser.mdx index d1808d4dcdf7..c451a8f7c07c 100644 --- a/api_docs/kbn_core_deprecations_browser.mdx +++ b/api_docs/kbn_core_deprecations_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-deprecations-browser title: "@kbn/core-deprecations-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-deprecations-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-deprecations-browser'] --- import kbnCoreDeprecationsBrowserObj from './kbn_core_deprecations_browser.devdocs.json'; diff --git a/api_docs/kbn_core_deprecations_browser_internal.mdx b/api_docs/kbn_core_deprecations_browser_internal.mdx index 65563dc39f4a..7db21e9ea3a7 100644 --- a/api_docs/kbn_core_deprecations_browser_internal.mdx +++ b/api_docs/kbn_core_deprecations_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-deprecations-browser-internal title: "@kbn/core-deprecations-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-deprecations-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-deprecations-browser-internal'] --- import kbnCoreDeprecationsBrowserInternalObj from './kbn_core_deprecations_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_deprecations_browser_mocks.mdx b/api_docs/kbn_core_deprecations_browser_mocks.mdx index 1c3ea8396d64..25c2c6800cc6 100644 --- a/api_docs/kbn_core_deprecations_browser_mocks.mdx +++ b/api_docs/kbn_core_deprecations_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-deprecations-browser-mocks title: "@kbn/core-deprecations-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-deprecations-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-deprecations-browser-mocks'] --- import kbnCoreDeprecationsBrowserMocksObj from './kbn_core_deprecations_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_deprecations_common.mdx b/api_docs/kbn_core_deprecations_common.mdx index 49b1e55361f6..15acea6cb1d3 100644 --- a/api_docs/kbn_core_deprecations_common.mdx +++ b/api_docs/kbn_core_deprecations_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-deprecations-common title: "@kbn/core-deprecations-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-deprecations-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-deprecations-common'] --- import kbnCoreDeprecationsCommonObj from './kbn_core_deprecations_common.devdocs.json'; diff --git a/api_docs/kbn_core_deprecations_server.mdx b/api_docs/kbn_core_deprecations_server.mdx index 6c02cf281647..641b5a185ff0 100644 --- a/api_docs/kbn_core_deprecations_server.mdx +++ b/api_docs/kbn_core_deprecations_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-deprecations-server title: "@kbn/core-deprecations-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-deprecations-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-deprecations-server'] --- import kbnCoreDeprecationsServerObj from './kbn_core_deprecations_server.devdocs.json'; diff --git a/api_docs/kbn_core_deprecations_server_internal.mdx b/api_docs/kbn_core_deprecations_server_internal.mdx index ae91d5ac4646..f34fdd8d60df 100644 --- a/api_docs/kbn_core_deprecations_server_internal.mdx +++ b/api_docs/kbn_core_deprecations_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-deprecations-server-internal title: "@kbn/core-deprecations-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-deprecations-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-deprecations-server-internal'] --- import kbnCoreDeprecationsServerInternalObj from './kbn_core_deprecations_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_deprecations_server_mocks.mdx b/api_docs/kbn_core_deprecations_server_mocks.mdx index 4b206ac9ab04..75a2b74b810a 100644 --- a/api_docs/kbn_core_deprecations_server_mocks.mdx +++ b/api_docs/kbn_core_deprecations_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-deprecations-server-mocks title: "@kbn/core-deprecations-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-deprecations-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-deprecations-server-mocks'] --- import kbnCoreDeprecationsServerMocksObj from './kbn_core_deprecations_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_doc_links_browser.mdx b/api_docs/kbn_core_doc_links_browser.mdx index 3fe7277c5657..1f5a4189168d 100644 --- a/api_docs/kbn_core_doc_links_browser.mdx +++ b/api_docs/kbn_core_doc_links_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-doc-links-browser title: "@kbn/core-doc-links-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-doc-links-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-doc-links-browser'] --- import kbnCoreDocLinksBrowserObj from './kbn_core_doc_links_browser.devdocs.json'; diff --git a/api_docs/kbn_core_doc_links_browser_mocks.mdx b/api_docs/kbn_core_doc_links_browser_mocks.mdx index dfa5a046a6c8..942dc89e6907 100644 --- a/api_docs/kbn_core_doc_links_browser_mocks.mdx +++ b/api_docs/kbn_core_doc_links_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-doc-links-browser-mocks title: "@kbn/core-doc-links-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-doc-links-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-doc-links-browser-mocks'] --- import kbnCoreDocLinksBrowserMocksObj from './kbn_core_doc_links_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_doc_links_server.mdx b/api_docs/kbn_core_doc_links_server.mdx index 57d3948bcb98..0fbdab2dc825 100644 --- a/api_docs/kbn_core_doc_links_server.mdx +++ b/api_docs/kbn_core_doc_links_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-doc-links-server title: "@kbn/core-doc-links-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-doc-links-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-doc-links-server'] --- import kbnCoreDocLinksServerObj from './kbn_core_doc_links_server.devdocs.json'; diff --git a/api_docs/kbn_core_doc_links_server_mocks.mdx b/api_docs/kbn_core_doc_links_server_mocks.mdx index 119dbf61d580..3205139fd555 100644 --- a/api_docs/kbn_core_doc_links_server_mocks.mdx +++ b/api_docs/kbn_core_doc_links_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-doc-links-server-mocks title: "@kbn/core-doc-links-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-doc-links-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-doc-links-server-mocks'] --- import kbnCoreDocLinksServerMocksObj from './kbn_core_doc_links_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_elasticsearch_client_server_internal.mdx b/api_docs/kbn_core_elasticsearch_client_server_internal.mdx index d4bbe7a6f7fe..7dd318b0c67e 100644 --- a/api_docs/kbn_core_elasticsearch_client_server_internal.mdx +++ b/api_docs/kbn_core_elasticsearch_client_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-elasticsearch-client-server-internal title: "@kbn/core-elasticsearch-client-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-elasticsearch-client-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-elasticsearch-client-server-internal'] --- import kbnCoreElasticsearchClientServerInternalObj from './kbn_core_elasticsearch_client_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_elasticsearch_client_server_mocks.mdx b/api_docs/kbn_core_elasticsearch_client_server_mocks.mdx index c1e530e6a062..f0bf95a1cd52 100644 --- a/api_docs/kbn_core_elasticsearch_client_server_mocks.mdx +++ b/api_docs/kbn_core_elasticsearch_client_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-elasticsearch-client-server-mocks title: "@kbn/core-elasticsearch-client-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-elasticsearch-client-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-elasticsearch-client-server-mocks'] --- import kbnCoreElasticsearchClientServerMocksObj from './kbn_core_elasticsearch_client_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_elasticsearch_server.mdx b/api_docs/kbn_core_elasticsearch_server.mdx index 2f4d8458bad4..335186893109 100644 --- a/api_docs/kbn_core_elasticsearch_server.mdx +++ b/api_docs/kbn_core_elasticsearch_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-elasticsearch-server title: "@kbn/core-elasticsearch-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-elasticsearch-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-elasticsearch-server'] --- import kbnCoreElasticsearchServerObj from './kbn_core_elasticsearch_server.devdocs.json'; diff --git a/api_docs/kbn_core_elasticsearch_server_internal.mdx b/api_docs/kbn_core_elasticsearch_server_internal.mdx index 3959f46b6ea2..8633efabde5e 100644 --- a/api_docs/kbn_core_elasticsearch_server_internal.mdx +++ b/api_docs/kbn_core_elasticsearch_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-elasticsearch-server-internal title: "@kbn/core-elasticsearch-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-elasticsearch-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-elasticsearch-server-internal'] --- import kbnCoreElasticsearchServerInternalObj from './kbn_core_elasticsearch_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_elasticsearch_server_mocks.mdx b/api_docs/kbn_core_elasticsearch_server_mocks.mdx index 8342da915ca2..7b443a8b11fe 100644 --- a/api_docs/kbn_core_elasticsearch_server_mocks.mdx +++ b/api_docs/kbn_core_elasticsearch_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-elasticsearch-server-mocks title: "@kbn/core-elasticsearch-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-elasticsearch-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-elasticsearch-server-mocks'] --- import kbnCoreElasticsearchServerMocksObj from './kbn_core_elasticsearch_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_environment_server_internal.mdx b/api_docs/kbn_core_environment_server_internal.mdx index 6684b48924fc..38b4146f2478 100644 --- a/api_docs/kbn_core_environment_server_internal.mdx +++ b/api_docs/kbn_core_environment_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-environment-server-internal title: "@kbn/core-environment-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-environment-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-environment-server-internal'] --- import kbnCoreEnvironmentServerInternalObj from './kbn_core_environment_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_environment_server_mocks.mdx b/api_docs/kbn_core_environment_server_mocks.mdx index ea07cc7d7f9c..a1a858bb5be8 100644 --- a/api_docs/kbn_core_environment_server_mocks.mdx +++ b/api_docs/kbn_core_environment_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-environment-server-mocks title: "@kbn/core-environment-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-environment-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-environment-server-mocks'] --- import kbnCoreEnvironmentServerMocksObj from './kbn_core_environment_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_execution_context_browser.mdx b/api_docs/kbn_core_execution_context_browser.mdx index 6b804747f3eb..da02a8935a0d 100644 --- a/api_docs/kbn_core_execution_context_browser.mdx +++ b/api_docs/kbn_core_execution_context_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-execution-context-browser title: "@kbn/core-execution-context-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-execution-context-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-execution-context-browser'] --- import kbnCoreExecutionContextBrowserObj from './kbn_core_execution_context_browser.devdocs.json'; diff --git a/api_docs/kbn_core_execution_context_browser_internal.mdx b/api_docs/kbn_core_execution_context_browser_internal.mdx index ddece1d90b5d..f59af70e4685 100644 --- a/api_docs/kbn_core_execution_context_browser_internal.mdx +++ b/api_docs/kbn_core_execution_context_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-execution-context-browser-internal title: "@kbn/core-execution-context-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-execution-context-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-execution-context-browser-internal'] --- import kbnCoreExecutionContextBrowserInternalObj from './kbn_core_execution_context_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_execution_context_browser_mocks.mdx b/api_docs/kbn_core_execution_context_browser_mocks.mdx index f29e3d3854a7..4d65437673e3 100644 --- a/api_docs/kbn_core_execution_context_browser_mocks.mdx +++ b/api_docs/kbn_core_execution_context_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-execution-context-browser-mocks title: "@kbn/core-execution-context-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-execution-context-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-execution-context-browser-mocks'] --- import kbnCoreExecutionContextBrowserMocksObj from './kbn_core_execution_context_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_execution_context_common.mdx b/api_docs/kbn_core_execution_context_common.mdx index e9bc32f9e237..717c142b3729 100644 --- a/api_docs/kbn_core_execution_context_common.mdx +++ b/api_docs/kbn_core_execution_context_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-execution-context-common title: "@kbn/core-execution-context-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-execution-context-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-execution-context-common'] --- import kbnCoreExecutionContextCommonObj from './kbn_core_execution_context_common.devdocs.json'; diff --git a/api_docs/kbn_core_execution_context_server.mdx b/api_docs/kbn_core_execution_context_server.mdx index a3d3c63220fa..e42630e08b84 100644 --- a/api_docs/kbn_core_execution_context_server.mdx +++ b/api_docs/kbn_core_execution_context_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-execution-context-server title: "@kbn/core-execution-context-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-execution-context-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-execution-context-server'] --- import kbnCoreExecutionContextServerObj from './kbn_core_execution_context_server.devdocs.json'; diff --git a/api_docs/kbn_core_execution_context_server_internal.mdx b/api_docs/kbn_core_execution_context_server_internal.mdx index 834d784d5270..d38a76050d05 100644 --- a/api_docs/kbn_core_execution_context_server_internal.mdx +++ b/api_docs/kbn_core_execution_context_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-execution-context-server-internal title: "@kbn/core-execution-context-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-execution-context-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-execution-context-server-internal'] --- import kbnCoreExecutionContextServerInternalObj from './kbn_core_execution_context_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_execution_context_server_mocks.mdx b/api_docs/kbn_core_execution_context_server_mocks.mdx index 271bb191bce0..19cebee1adc2 100644 --- a/api_docs/kbn_core_execution_context_server_mocks.mdx +++ b/api_docs/kbn_core_execution_context_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-execution-context-server-mocks title: "@kbn/core-execution-context-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-execution-context-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-execution-context-server-mocks'] --- import kbnCoreExecutionContextServerMocksObj from './kbn_core_execution_context_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_fatal_errors_browser.mdx b/api_docs/kbn_core_fatal_errors_browser.mdx index e63199b6c143..09df105043fc 100644 --- a/api_docs/kbn_core_fatal_errors_browser.mdx +++ b/api_docs/kbn_core_fatal_errors_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-fatal-errors-browser title: "@kbn/core-fatal-errors-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-fatal-errors-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-fatal-errors-browser'] --- import kbnCoreFatalErrorsBrowserObj from './kbn_core_fatal_errors_browser.devdocs.json'; diff --git a/api_docs/kbn_core_fatal_errors_browser_mocks.mdx b/api_docs/kbn_core_fatal_errors_browser_mocks.mdx index 7299b715aea7..37b248aad2a7 100644 --- a/api_docs/kbn_core_fatal_errors_browser_mocks.mdx +++ b/api_docs/kbn_core_fatal_errors_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-fatal-errors-browser-mocks title: "@kbn/core-fatal-errors-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-fatal-errors-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-fatal-errors-browser-mocks'] --- import kbnCoreFatalErrorsBrowserMocksObj from './kbn_core_fatal_errors_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_http_browser.mdx b/api_docs/kbn_core_http_browser.mdx index b522cf9a1786..e7f94c4069ab 100644 --- a/api_docs/kbn_core_http_browser.mdx +++ b/api_docs/kbn_core_http_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-browser title: "@kbn/core-http-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-browser'] --- import kbnCoreHttpBrowserObj from './kbn_core_http_browser.devdocs.json'; diff --git a/api_docs/kbn_core_http_browser_internal.mdx b/api_docs/kbn_core_http_browser_internal.mdx index dd75e9540c6f..7ac33b69787c 100644 --- a/api_docs/kbn_core_http_browser_internal.mdx +++ b/api_docs/kbn_core_http_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-browser-internal title: "@kbn/core-http-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-browser-internal'] --- import kbnCoreHttpBrowserInternalObj from './kbn_core_http_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_http_browser_mocks.mdx b/api_docs/kbn_core_http_browser_mocks.mdx index 1e4ebbf7591a..0344c80534a3 100644 --- a/api_docs/kbn_core_http_browser_mocks.mdx +++ b/api_docs/kbn_core_http_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-browser-mocks title: "@kbn/core-http-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-browser-mocks'] --- import kbnCoreHttpBrowserMocksObj from './kbn_core_http_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_http_common.mdx b/api_docs/kbn_core_http_common.mdx index 812a72f9987a..27e9324ee391 100644 --- a/api_docs/kbn_core_http_common.mdx +++ b/api_docs/kbn_core_http_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-common title: "@kbn/core-http-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-common'] --- import kbnCoreHttpCommonObj from './kbn_core_http_common.devdocs.json'; diff --git a/api_docs/kbn_core_http_context_server_mocks.mdx b/api_docs/kbn_core_http_context_server_mocks.mdx index bea41d87518e..9cd138367f4b 100644 --- a/api_docs/kbn_core_http_context_server_mocks.mdx +++ b/api_docs/kbn_core_http_context_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-context-server-mocks title: "@kbn/core-http-context-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-context-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-context-server-mocks'] --- import kbnCoreHttpContextServerMocksObj from './kbn_core_http_context_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_http_request_handler_context_server.mdx b/api_docs/kbn_core_http_request_handler_context_server.mdx index 4452bf197ada..8802133a859e 100644 --- a/api_docs/kbn_core_http_request_handler_context_server.mdx +++ b/api_docs/kbn_core_http_request_handler_context_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-request-handler-context-server title: "@kbn/core-http-request-handler-context-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-request-handler-context-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-request-handler-context-server'] --- import kbnCoreHttpRequestHandlerContextServerObj from './kbn_core_http_request_handler_context_server.devdocs.json'; diff --git a/api_docs/kbn_core_http_resources_server.mdx b/api_docs/kbn_core_http_resources_server.mdx index e1088e0f9ace..8077c6d89855 100644 --- a/api_docs/kbn_core_http_resources_server.mdx +++ b/api_docs/kbn_core_http_resources_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-resources-server title: "@kbn/core-http-resources-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-resources-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-resources-server'] --- import kbnCoreHttpResourcesServerObj from './kbn_core_http_resources_server.devdocs.json'; diff --git a/api_docs/kbn_core_http_resources_server_internal.mdx b/api_docs/kbn_core_http_resources_server_internal.mdx index e0d408dd7237..9446bbd64772 100644 --- a/api_docs/kbn_core_http_resources_server_internal.mdx +++ b/api_docs/kbn_core_http_resources_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-resources-server-internal title: "@kbn/core-http-resources-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-resources-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-resources-server-internal'] --- import kbnCoreHttpResourcesServerInternalObj from './kbn_core_http_resources_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_http_resources_server_mocks.mdx b/api_docs/kbn_core_http_resources_server_mocks.mdx index 6ba4c1c0f4d8..573d50f8a802 100644 --- a/api_docs/kbn_core_http_resources_server_mocks.mdx +++ b/api_docs/kbn_core_http_resources_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-resources-server-mocks title: "@kbn/core-http-resources-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-resources-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-resources-server-mocks'] --- import kbnCoreHttpResourcesServerMocksObj from './kbn_core_http_resources_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_http_router_server_internal.mdx b/api_docs/kbn_core_http_router_server_internal.mdx index bf8de99b3ffd..05d5c6f837f2 100644 --- a/api_docs/kbn_core_http_router_server_internal.mdx +++ b/api_docs/kbn_core_http_router_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-router-server-internal title: "@kbn/core-http-router-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-router-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-router-server-internal'] --- import kbnCoreHttpRouterServerInternalObj from './kbn_core_http_router_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_http_router_server_mocks.mdx b/api_docs/kbn_core_http_router_server_mocks.mdx index 12b8270da3cb..72329d8b5e6d 100644 --- a/api_docs/kbn_core_http_router_server_mocks.mdx +++ b/api_docs/kbn_core_http_router_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-router-server-mocks title: "@kbn/core-http-router-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-router-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-router-server-mocks'] --- import kbnCoreHttpRouterServerMocksObj from './kbn_core_http_router_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_http_server.devdocs.json b/api_docs/kbn_core_http_server.devdocs.json index 3ba2c76647c3..ec187c7f4458 100644 --- a/api_docs/kbn_core_http_server.devdocs.json +++ b/api_docs/kbn_core_http_server.devdocs.json @@ -4198,6 +4198,10 @@ "plugin": "enterpriseSearch", "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts" }, + { + "plugin": "enterpriseSearch", + "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts" + }, { "plugin": "enterpriseSearch", "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.ts" @@ -6828,6 +6832,10 @@ "plugin": "enterpriseSearch", "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts" }, + { + "plugin": "enterpriseSearch", + "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts" + }, { "plugin": "enterpriseSearch", "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler_crawl_rules.ts" @@ -15922,15 +15930,19 @@ }, { "plugin": "elasticAssistant", - "path": "x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts" + "path": "x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.ts" }, { "plugin": "elasticAssistant", - "path": "x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.ts" + "path": "x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.ts" }, { "plugin": "elasticAssistant", - "path": "x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.ts" + "path": "x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts" + }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts" }, { "plugin": "elasticAssistant", diff --git a/api_docs/kbn_core_http_server.mdx b/api_docs/kbn_core_http_server.mdx index 94d8029051d2..b9d08d707757 100644 --- a/api_docs/kbn_core_http_server.mdx +++ b/api_docs/kbn_core_http_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-server title: "@kbn/core-http-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-server'] --- import kbnCoreHttpServerObj from './kbn_core_http_server.devdocs.json'; diff --git a/api_docs/kbn_core_http_server_internal.mdx b/api_docs/kbn_core_http_server_internal.mdx index 4be20318917f..23dd73e1eb0e 100644 --- a/api_docs/kbn_core_http_server_internal.mdx +++ b/api_docs/kbn_core_http_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-server-internal title: "@kbn/core-http-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-server-internal'] --- import kbnCoreHttpServerInternalObj from './kbn_core_http_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_http_server_mocks.mdx b/api_docs/kbn_core_http_server_mocks.mdx index ad20ff9d895e..22068d6c6344 100644 --- a/api_docs/kbn_core_http_server_mocks.mdx +++ b/api_docs/kbn_core_http_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-http-server-mocks title: "@kbn/core-http-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-http-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-http-server-mocks'] --- import kbnCoreHttpServerMocksObj from './kbn_core_http_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_i18n_browser.mdx b/api_docs/kbn_core_i18n_browser.mdx index dcbae1a74714..0d334ee353c3 100644 --- a/api_docs/kbn_core_i18n_browser.mdx +++ b/api_docs/kbn_core_i18n_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-i18n-browser title: "@kbn/core-i18n-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-i18n-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-i18n-browser'] --- import kbnCoreI18nBrowserObj from './kbn_core_i18n_browser.devdocs.json'; diff --git a/api_docs/kbn_core_i18n_browser_mocks.mdx b/api_docs/kbn_core_i18n_browser_mocks.mdx index 63762fc7eb36..5b6acb331145 100644 --- a/api_docs/kbn_core_i18n_browser_mocks.mdx +++ b/api_docs/kbn_core_i18n_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-i18n-browser-mocks title: "@kbn/core-i18n-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-i18n-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-i18n-browser-mocks'] --- import kbnCoreI18nBrowserMocksObj from './kbn_core_i18n_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_i18n_server.mdx b/api_docs/kbn_core_i18n_server.mdx index b6d911633838..6cd1be3691db 100644 --- a/api_docs/kbn_core_i18n_server.mdx +++ b/api_docs/kbn_core_i18n_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-i18n-server title: "@kbn/core-i18n-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-i18n-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-i18n-server'] --- import kbnCoreI18nServerObj from './kbn_core_i18n_server.devdocs.json'; diff --git a/api_docs/kbn_core_i18n_server_internal.mdx b/api_docs/kbn_core_i18n_server_internal.mdx index cfcddc7e4d1e..74f03676bf70 100644 --- a/api_docs/kbn_core_i18n_server_internal.mdx +++ b/api_docs/kbn_core_i18n_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-i18n-server-internal title: "@kbn/core-i18n-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-i18n-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-i18n-server-internal'] --- import kbnCoreI18nServerInternalObj from './kbn_core_i18n_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_i18n_server_mocks.mdx b/api_docs/kbn_core_i18n_server_mocks.mdx index df8dcb5c4623..03b7e3166b31 100644 --- a/api_docs/kbn_core_i18n_server_mocks.mdx +++ b/api_docs/kbn_core_i18n_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-i18n-server-mocks title: "@kbn/core-i18n-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-i18n-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-i18n-server-mocks'] --- import kbnCoreI18nServerMocksObj from './kbn_core_i18n_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_injected_metadata_browser_mocks.mdx b/api_docs/kbn_core_injected_metadata_browser_mocks.mdx index 7f533a196ef8..9343732449d4 100644 --- a/api_docs/kbn_core_injected_metadata_browser_mocks.mdx +++ b/api_docs/kbn_core_injected_metadata_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-injected-metadata-browser-mocks title: "@kbn/core-injected-metadata-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-injected-metadata-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-injected-metadata-browser-mocks'] --- import kbnCoreInjectedMetadataBrowserMocksObj from './kbn_core_injected_metadata_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_integrations_browser_internal.mdx b/api_docs/kbn_core_integrations_browser_internal.mdx index 382a6015fe7c..09321f2d90d6 100644 --- a/api_docs/kbn_core_integrations_browser_internal.mdx +++ b/api_docs/kbn_core_integrations_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-integrations-browser-internal title: "@kbn/core-integrations-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-integrations-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-integrations-browser-internal'] --- import kbnCoreIntegrationsBrowserInternalObj from './kbn_core_integrations_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_integrations_browser_mocks.mdx b/api_docs/kbn_core_integrations_browser_mocks.mdx index c492077ee4a3..ea1efdb2409e 100644 --- a/api_docs/kbn_core_integrations_browser_mocks.mdx +++ b/api_docs/kbn_core_integrations_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-integrations-browser-mocks title: "@kbn/core-integrations-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-integrations-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-integrations-browser-mocks'] --- import kbnCoreIntegrationsBrowserMocksObj from './kbn_core_integrations_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_lifecycle_browser.mdx b/api_docs/kbn_core_lifecycle_browser.mdx index 042a640c74da..edc79194fb8c 100644 --- a/api_docs/kbn_core_lifecycle_browser.mdx +++ b/api_docs/kbn_core_lifecycle_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-lifecycle-browser title: "@kbn/core-lifecycle-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-lifecycle-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-lifecycle-browser'] --- import kbnCoreLifecycleBrowserObj from './kbn_core_lifecycle_browser.devdocs.json'; diff --git a/api_docs/kbn_core_lifecycle_browser_mocks.mdx b/api_docs/kbn_core_lifecycle_browser_mocks.mdx index b3a5ff753bcc..0e0a13a837f2 100644 --- a/api_docs/kbn_core_lifecycle_browser_mocks.mdx +++ b/api_docs/kbn_core_lifecycle_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-lifecycle-browser-mocks title: "@kbn/core-lifecycle-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-lifecycle-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-lifecycle-browser-mocks'] --- import kbnCoreLifecycleBrowserMocksObj from './kbn_core_lifecycle_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_lifecycle_server.mdx b/api_docs/kbn_core_lifecycle_server.mdx index 8aa9dcf9ae9e..5dae2c747e3a 100644 --- a/api_docs/kbn_core_lifecycle_server.mdx +++ b/api_docs/kbn_core_lifecycle_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-lifecycle-server title: "@kbn/core-lifecycle-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-lifecycle-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-lifecycle-server'] --- import kbnCoreLifecycleServerObj from './kbn_core_lifecycle_server.devdocs.json'; diff --git a/api_docs/kbn_core_lifecycle_server_mocks.mdx b/api_docs/kbn_core_lifecycle_server_mocks.mdx index 61d4806a9f95..016e933ac292 100644 --- a/api_docs/kbn_core_lifecycle_server_mocks.mdx +++ b/api_docs/kbn_core_lifecycle_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-lifecycle-server-mocks title: "@kbn/core-lifecycle-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-lifecycle-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-lifecycle-server-mocks'] --- import kbnCoreLifecycleServerMocksObj from './kbn_core_lifecycle_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_logging_browser_mocks.mdx b/api_docs/kbn_core_logging_browser_mocks.mdx index 323176bd0054..5a8771709a08 100644 --- a/api_docs/kbn_core_logging_browser_mocks.mdx +++ b/api_docs/kbn_core_logging_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-logging-browser-mocks title: "@kbn/core-logging-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-logging-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-logging-browser-mocks'] --- import kbnCoreLoggingBrowserMocksObj from './kbn_core_logging_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_logging_common_internal.mdx b/api_docs/kbn_core_logging_common_internal.mdx index ea14bf5a7344..ea76e4f731e3 100644 --- a/api_docs/kbn_core_logging_common_internal.mdx +++ b/api_docs/kbn_core_logging_common_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-logging-common-internal title: "@kbn/core-logging-common-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-logging-common-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-logging-common-internal'] --- import kbnCoreLoggingCommonInternalObj from './kbn_core_logging_common_internal.devdocs.json'; diff --git a/api_docs/kbn_core_logging_server.mdx b/api_docs/kbn_core_logging_server.mdx index edaa3736cc19..16c903cab313 100644 --- a/api_docs/kbn_core_logging_server.mdx +++ b/api_docs/kbn_core_logging_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-logging-server title: "@kbn/core-logging-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-logging-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-logging-server'] --- import kbnCoreLoggingServerObj from './kbn_core_logging_server.devdocs.json'; diff --git a/api_docs/kbn_core_logging_server_internal.mdx b/api_docs/kbn_core_logging_server_internal.mdx index e0538342de41..6465278b4bf6 100644 --- a/api_docs/kbn_core_logging_server_internal.mdx +++ b/api_docs/kbn_core_logging_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-logging-server-internal title: "@kbn/core-logging-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-logging-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-logging-server-internal'] --- import kbnCoreLoggingServerInternalObj from './kbn_core_logging_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_logging_server_mocks.mdx b/api_docs/kbn_core_logging_server_mocks.mdx index f93329970f1f..fd4364735523 100644 --- a/api_docs/kbn_core_logging_server_mocks.mdx +++ b/api_docs/kbn_core_logging_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-logging-server-mocks title: "@kbn/core-logging-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-logging-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-logging-server-mocks'] --- import kbnCoreLoggingServerMocksObj from './kbn_core_logging_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_metrics_collectors_server_internal.mdx b/api_docs/kbn_core_metrics_collectors_server_internal.mdx index f8b6bad9f403..9378f2bf4e6c 100644 --- a/api_docs/kbn_core_metrics_collectors_server_internal.mdx +++ b/api_docs/kbn_core_metrics_collectors_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-metrics-collectors-server-internal title: "@kbn/core-metrics-collectors-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-metrics-collectors-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-metrics-collectors-server-internal'] --- import kbnCoreMetricsCollectorsServerInternalObj from './kbn_core_metrics_collectors_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_metrics_collectors_server_mocks.mdx b/api_docs/kbn_core_metrics_collectors_server_mocks.mdx index 800ddb3ffae6..52011a479a28 100644 --- a/api_docs/kbn_core_metrics_collectors_server_mocks.mdx +++ b/api_docs/kbn_core_metrics_collectors_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-metrics-collectors-server-mocks title: "@kbn/core-metrics-collectors-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-metrics-collectors-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-metrics-collectors-server-mocks'] --- import kbnCoreMetricsCollectorsServerMocksObj from './kbn_core_metrics_collectors_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_metrics_server.mdx b/api_docs/kbn_core_metrics_server.mdx index f782685751b2..4b73f7c6e5c6 100644 --- a/api_docs/kbn_core_metrics_server.mdx +++ b/api_docs/kbn_core_metrics_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-metrics-server title: "@kbn/core-metrics-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-metrics-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-metrics-server'] --- import kbnCoreMetricsServerObj from './kbn_core_metrics_server.devdocs.json'; diff --git a/api_docs/kbn_core_metrics_server_internal.mdx b/api_docs/kbn_core_metrics_server_internal.mdx index 51026687dcac..6164195df38c 100644 --- a/api_docs/kbn_core_metrics_server_internal.mdx +++ b/api_docs/kbn_core_metrics_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-metrics-server-internal title: "@kbn/core-metrics-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-metrics-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-metrics-server-internal'] --- import kbnCoreMetricsServerInternalObj from './kbn_core_metrics_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_metrics_server_mocks.mdx b/api_docs/kbn_core_metrics_server_mocks.mdx index 49332a21d628..6ccf8f3b59c5 100644 --- a/api_docs/kbn_core_metrics_server_mocks.mdx +++ b/api_docs/kbn_core_metrics_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-metrics-server-mocks title: "@kbn/core-metrics-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-metrics-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-metrics-server-mocks'] --- import kbnCoreMetricsServerMocksObj from './kbn_core_metrics_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_mount_utils_browser.mdx b/api_docs/kbn_core_mount_utils_browser.mdx index 379832a984b8..d52a9efb9c93 100644 --- a/api_docs/kbn_core_mount_utils_browser.mdx +++ b/api_docs/kbn_core_mount_utils_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-mount-utils-browser title: "@kbn/core-mount-utils-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-mount-utils-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-mount-utils-browser'] --- import kbnCoreMountUtilsBrowserObj from './kbn_core_mount_utils_browser.devdocs.json'; diff --git a/api_docs/kbn_core_node_server.mdx b/api_docs/kbn_core_node_server.mdx index 05dd7b871a27..3aeeb1d4836d 100644 --- a/api_docs/kbn_core_node_server.mdx +++ b/api_docs/kbn_core_node_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-node-server title: "@kbn/core-node-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-node-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-node-server'] --- import kbnCoreNodeServerObj from './kbn_core_node_server.devdocs.json'; diff --git a/api_docs/kbn_core_node_server_internal.mdx b/api_docs/kbn_core_node_server_internal.mdx index 0dfece7c5812..53f6518c996f 100644 --- a/api_docs/kbn_core_node_server_internal.mdx +++ b/api_docs/kbn_core_node_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-node-server-internal title: "@kbn/core-node-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-node-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-node-server-internal'] --- import kbnCoreNodeServerInternalObj from './kbn_core_node_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_node_server_mocks.mdx b/api_docs/kbn_core_node_server_mocks.mdx index 435722411ef8..f5d0a6b98fd4 100644 --- a/api_docs/kbn_core_node_server_mocks.mdx +++ b/api_docs/kbn_core_node_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-node-server-mocks title: "@kbn/core-node-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-node-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-node-server-mocks'] --- import kbnCoreNodeServerMocksObj from './kbn_core_node_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_notifications_browser.mdx b/api_docs/kbn_core_notifications_browser.mdx index 80fdb376b269..d1d47b2400ce 100644 --- a/api_docs/kbn_core_notifications_browser.mdx +++ b/api_docs/kbn_core_notifications_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-notifications-browser title: "@kbn/core-notifications-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-notifications-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-notifications-browser'] --- import kbnCoreNotificationsBrowserObj from './kbn_core_notifications_browser.devdocs.json'; diff --git a/api_docs/kbn_core_notifications_browser_internal.mdx b/api_docs/kbn_core_notifications_browser_internal.mdx index 25e3073ec446..ea61f98a8799 100644 --- a/api_docs/kbn_core_notifications_browser_internal.mdx +++ b/api_docs/kbn_core_notifications_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-notifications-browser-internal title: "@kbn/core-notifications-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-notifications-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-notifications-browser-internal'] --- import kbnCoreNotificationsBrowserInternalObj from './kbn_core_notifications_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_notifications_browser_mocks.mdx b/api_docs/kbn_core_notifications_browser_mocks.mdx index 15d480e4bf8e..8e7b5f8eab11 100644 --- a/api_docs/kbn_core_notifications_browser_mocks.mdx +++ b/api_docs/kbn_core_notifications_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-notifications-browser-mocks title: "@kbn/core-notifications-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-notifications-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-notifications-browser-mocks'] --- import kbnCoreNotificationsBrowserMocksObj from './kbn_core_notifications_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_overlays_browser.mdx b/api_docs/kbn_core_overlays_browser.mdx index a037b9ddab91..eeba1761a32f 100644 --- a/api_docs/kbn_core_overlays_browser.mdx +++ b/api_docs/kbn_core_overlays_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-overlays-browser title: "@kbn/core-overlays-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-overlays-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-overlays-browser'] --- import kbnCoreOverlaysBrowserObj from './kbn_core_overlays_browser.devdocs.json'; diff --git a/api_docs/kbn_core_overlays_browser_internal.mdx b/api_docs/kbn_core_overlays_browser_internal.mdx index 37c8e5814ebb..b7efa868694d 100644 --- a/api_docs/kbn_core_overlays_browser_internal.mdx +++ b/api_docs/kbn_core_overlays_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-overlays-browser-internal title: "@kbn/core-overlays-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-overlays-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-overlays-browser-internal'] --- import kbnCoreOverlaysBrowserInternalObj from './kbn_core_overlays_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_overlays_browser_mocks.mdx b/api_docs/kbn_core_overlays_browser_mocks.mdx index 8d81b7702924..84db54c13198 100644 --- a/api_docs/kbn_core_overlays_browser_mocks.mdx +++ b/api_docs/kbn_core_overlays_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-overlays-browser-mocks title: "@kbn/core-overlays-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-overlays-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-overlays-browser-mocks'] --- import kbnCoreOverlaysBrowserMocksObj from './kbn_core_overlays_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_plugins_browser.mdx b/api_docs/kbn_core_plugins_browser.mdx index dfc051e8f622..a9c377b5092e 100644 --- a/api_docs/kbn_core_plugins_browser.mdx +++ b/api_docs/kbn_core_plugins_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-plugins-browser title: "@kbn/core-plugins-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-plugins-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-plugins-browser'] --- import kbnCorePluginsBrowserObj from './kbn_core_plugins_browser.devdocs.json'; diff --git a/api_docs/kbn_core_plugins_browser_mocks.mdx b/api_docs/kbn_core_plugins_browser_mocks.mdx index 052da752c1a4..d5b661bdeb58 100644 --- a/api_docs/kbn_core_plugins_browser_mocks.mdx +++ b/api_docs/kbn_core_plugins_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-plugins-browser-mocks title: "@kbn/core-plugins-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-plugins-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-plugins-browser-mocks'] --- import kbnCorePluginsBrowserMocksObj from './kbn_core_plugins_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_plugins_contracts_browser.mdx b/api_docs/kbn_core_plugins_contracts_browser.mdx index e52bc9257af3..6e36c980e38f 100644 --- a/api_docs/kbn_core_plugins_contracts_browser.mdx +++ b/api_docs/kbn_core_plugins_contracts_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-plugins-contracts-browser title: "@kbn/core-plugins-contracts-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-plugins-contracts-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-plugins-contracts-browser'] --- import kbnCorePluginsContractsBrowserObj from './kbn_core_plugins_contracts_browser.devdocs.json'; diff --git a/api_docs/kbn_core_plugins_contracts_server.mdx b/api_docs/kbn_core_plugins_contracts_server.mdx index 482fbf5611af..cd59df2d717e 100644 --- a/api_docs/kbn_core_plugins_contracts_server.mdx +++ b/api_docs/kbn_core_plugins_contracts_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-plugins-contracts-server title: "@kbn/core-plugins-contracts-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-plugins-contracts-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-plugins-contracts-server'] --- import kbnCorePluginsContractsServerObj from './kbn_core_plugins_contracts_server.devdocs.json'; diff --git a/api_docs/kbn_core_plugins_server.mdx b/api_docs/kbn_core_plugins_server.mdx index 852878215d70..88830270e4e1 100644 --- a/api_docs/kbn_core_plugins_server.mdx +++ b/api_docs/kbn_core_plugins_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-plugins-server title: "@kbn/core-plugins-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-plugins-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-plugins-server'] --- import kbnCorePluginsServerObj from './kbn_core_plugins_server.devdocs.json'; diff --git a/api_docs/kbn_core_plugins_server_mocks.mdx b/api_docs/kbn_core_plugins_server_mocks.mdx index e029fc0456d7..3267edeb8fe8 100644 --- a/api_docs/kbn_core_plugins_server_mocks.mdx +++ b/api_docs/kbn_core_plugins_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-plugins-server-mocks title: "@kbn/core-plugins-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-plugins-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-plugins-server-mocks'] --- import kbnCorePluginsServerMocksObj from './kbn_core_plugins_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_preboot_server.mdx b/api_docs/kbn_core_preboot_server.mdx index ca8888550f2e..83d3f9b77fa6 100644 --- a/api_docs/kbn_core_preboot_server.mdx +++ b/api_docs/kbn_core_preboot_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-preboot-server title: "@kbn/core-preboot-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-preboot-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-preboot-server'] --- import kbnCorePrebootServerObj from './kbn_core_preboot_server.devdocs.json'; diff --git a/api_docs/kbn_core_preboot_server_mocks.mdx b/api_docs/kbn_core_preboot_server_mocks.mdx index 19306387d1fc..739236c54011 100644 --- a/api_docs/kbn_core_preboot_server_mocks.mdx +++ b/api_docs/kbn_core_preboot_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-preboot-server-mocks title: "@kbn/core-preboot-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-preboot-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-preboot-server-mocks'] --- import kbnCorePrebootServerMocksObj from './kbn_core_preboot_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_rendering_browser_mocks.mdx b/api_docs/kbn_core_rendering_browser_mocks.mdx index 187bbb0564f8..a46f291daab3 100644 --- a/api_docs/kbn_core_rendering_browser_mocks.mdx +++ b/api_docs/kbn_core_rendering_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-rendering-browser-mocks title: "@kbn/core-rendering-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-rendering-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-rendering-browser-mocks'] --- import kbnCoreRenderingBrowserMocksObj from './kbn_core_rendering_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_rendering_server_internal.mdx b/api_docs/kbn_core_rendering_server_internal.mdx index 90457862834a..c1831f182f50 100644 --- a/api_docs/kbn_core_rendering_server_internal.mdx +++ b/api_docs/kbn_core_rendering_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-rendering-server-internal title: "@kbn/core-rendering-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-rendering-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-rendering-server-internal'] --- import kbnCoreRenderingServerInternalObj from './kbn_core_rendering_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_rendering_server_mocks.mdx b/api_docs/kbn_core_rendering_server_mocks.mdx index 87040268b7e2..ee41e82b5e4f 100644 --- a/api_docs/kbn_core_rendering_server_mocks.mdx +++ b/api_docs/kbn_core_rendering_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-rendering-server-mocks title: "@kbn/core-rendering-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-rendering-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-rendering-server-mocks'] --- import kbnCoreRenderingServerMocksObj from './kbn_core_rendering_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_root_server_internal.mdx b/api_docs/kbn_core_root_server_internal.mdx index 7b5f6af55871..e44d1dcd2fe8 100644 --- a/api_docs/kbn_core_root_server_internal.mdx +++ b/api_docs/kbn_core_root_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-root-server-internal title: "@kbn/core-root-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-root-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-root-server-internal'] --- import kbnCoreRootServerInternalObj from './kbn_core_root_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_api_browser.mdx b/api_docs/kbn_core_saved_objects_api_browser.mdx index e5bb1cbaef45..33f2e7a47a52 100644 --- a/api_docs/kbn_core_saved_objects_api_browser.mdx +++ b/api_docs/kbn_core_saved_objects_api_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-api-browser title: "@kbn/core-saved-objects-api-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-api-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-api-browser'] --- import kbnCoreSavedObjectsApiBrowserObj from './kbn_core_saved_objects_api_browser.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_api_server.mdx b/api_docs/kbn_core_saved_objects_api_server.mdx index 3871b997f9fa..086644833edd 100644 --- a/api_docs/kbn_core_saved_objects_api_server.mdx +++ b/api_docs/kbn_core_saved_objects_api_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-api-server title: "@kbn/core-saved-objects-api-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-api-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-api-server'] --- import kbnCoreSavedObjectsApiServerObj from './kbn_core_saved_objects_api_server.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_api_server_mocks.mdx b/api_docs/kbn_core_saved_objects_api_server_mocks.mdx index 28bf2513a4f4..d4e7a50e5aea 100644 --- a/api_docs/kbn_core_saved_objects_api_server_mocks.mdx +++ b/api_docs/kbn_core_saved_objects_api_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-api-server-mocks title: "@kbn/core-saved-objects-api-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-api-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-api-server-mocks'] --- import kbnCoreSavedObjectsApiServerMocksObj from './kbn_core_saved_objects_api_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_base_server_internal.mdx b/api_docs/kbn_core_saved_objects_base_server_internal.mdx index 1dfd391c76ca..3325b2331e36 100644 --- a/api_docs/kbn_core_saved_objects_base_server_internal.mdx +++ b/api_docs/kbn_core_saved_objects_base_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-base-server-internal title: "@kbn/core-saved-objects-base-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-base-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-base-server-internal'] --- import kbnCoreSavedObjectsBaseServerInternalObj from './kbn_core_saved_objects_base_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_base_server_mocks.mdx b/api_docs/kbn_core_saved_objects_base_server_mocks.mdx index 7811d13026f4..752b9f56fea3 100644 --- a/api_docs/kbn_core_saved_objects_base_server_mocks.mdx +++ b/api_docs/kbn_core_saved_objects_base_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-base-server-mocks title: "@kbn/core-saved-objects-base-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-base-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-base-server-mocks'] --- import kbnCoreSavedObjectsBaseServerMocksObj from './kbn_core_saved_objects_base_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_browser.mdx b/api_docs/kbn_core_saved_objects_browser.mdx index 58fceebfb41f..b9b665a3f0c4 100644 --- a/api_docs/kbn_core_saved_objects_browser.mdx +++ b/api_docs/kbn_core_saved_objects_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-browser title: "@kbn/core-saved-objects-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-browser'] --- import kbnCoreSavedObjectsBrowserObj from './kbn_core_saved_objects_browser.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_browser_internal.mdx b/api_docs/kbn_core_saved_objects_browser_internal.mdx index 58ebce545707..d20be59eacc1 100644 --- a/api_docs/kbn_core_saved_objects_browser_internal.mdx +++ b/api_docs/kbn_core_saved_objects_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-browser-internal title: "@kbn/core-saved-objects-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-browser-internal'] --- import kbnCoreSavedObjectsBrowserInternalObj from './kbn_core_saved_objects_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_browser_mocks.mdx b/api_docs/kbn_core_saved_objects_browser_mocks.mdx index cb3fe61c0584..0395ba563b8f 100644 --- a/api_docs/kbn_core_saved_objects_browser_mocks.mdx +++ b/api_docs/kbn_core_saved_objects_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-browser-mocks title: "@kbn/core-saved-objects-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-browser-mocks'] --- import kbnCoreSavedObjectsBrowserMocksObj from './kbn_core_saved_objects_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_common.mdx b/api_docs/kbn_core_saved_objects_common.mdx index 0aab9f8221f1..106011e4167f 100644 --- a/api_docs/kbn_core_saved_objects_common.mdx +++ b/api_docs/kbn_core_saved_objects_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-common title: "@kbn/core-saved-objects-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-common'] --- import kbnCoreSavedObjectsCommonObj from './kbn_core_saved_objects_common.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_import_export_server_internal.mdx b/api_docs/kbn_core_saved_objects_import_export_server_internal.mdx index a775eed798cb..454b5140dfec 100644 --- a/api_docs/kbn_core_saved_objects_import_export_server_internal.mdx +++ b/api_docs/kbn_core_saved_objects_import_export_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-import-export-server-internal title: "@kbn/core-saved-objects-import-export-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-import-export-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-import-export-server-internal'] --- import kbnCoreSavedObjectsImportExportServerInternalObj from './kbn_core_saved_objects_import_export_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_import_export_server_mocks.mdx b/api_docs/kbn_core_saved_objects_import_export_server_mocks.mdx index 216f1c668117..3ac43e85365c 100644 --- a/api_docs/kbn_core_saved_objects_import_export_server_mocks.mdx +++ b/api_docs/kbn_core_saved_objects_import_export_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-import-export-server-mocks title: "@kbn/core-saved-objects-import-export-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-import-export-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-import-export-server-mocks'] --- import kbnCoreSavedObjectsImportExportServerMocksObj from './kbn_core_saved_objects_import_export_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_migration_server_internal.mdx b/api_docs/kbn_core_saved_objects_migration_server_internal.mdx index c4548e53af6a..77267d3eace4 100644 --- a/api_docs/kbn_core_saved_objects_migration_server_internal.mdx +++ b/api_docs/kbn_core_saved_objects_migration_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-migration-server-internal title: "@kbn/core-saved-objects-migration-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-migration-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-migration-server-internal'] --- import kbnCoreSavedObjectsMigrationServerInternalObj from './kbn_core_saved_objects_migration_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_migration_server_mocks.mdx b/api_docs/kbn_core_saved_objects_migration_server_mocks.mdx index fde7f1e54a51..438cf754e4bc 100644 --- a/api_docs/kbn_core_saved_objects_migration_server_mocks.mdx +++ b/api_docs/kbn_core_saved_objects_migration_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-migration-server-mocks title: "@kbn/core-saved-objects-migration-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-migration-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-migration-server-mocks'] --- import kbnCoreSavedObjectsMigrationServerMocksObj from './kbn_core_saved_objects_migration_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_server.mdx b/api_docs/kbn_core_saved_objects_server.mdx index 1e6079fbdb0f..72465203160c 100644 --- a/api_docs/kbn_core_saved_objects_server.mdx +++ b/api_docs/kbn_core_saved_objects_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-server title: "@kbn/core-saved-objects-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-server'] --- import kbnCoreSavedObjectsServerObj from './kbn_core_saved_objects_server.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_server_internal.mdx b/api_docs/kbn_core_saved_objects_server_internal.mdx index f2496e78c763..f15712351492 100644 --- a/api_docs/kbn_core_saved_objects_server_internal.mdx +++ b/api_docs/kbn_core_saved_objects_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-server-internal title: "@kbn/core-saved-objects-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-server-internal'] --- import kbnCoreSavedObjectsServerInternalObj from './kbn_core_saved_objects_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_server_mocks.mdx b/api_docs/kbn_core_saved_objects_server_mocks.mdx index c1dc647749a7..6618e3fe4f03 100644 --- a/api_docs/kbn_core_saved_objects_server_mocks.mdx +++ b/api_docs/kbn_core_saved_objects_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-server-mocks title: "@kbn/core-saved-objects-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-server-mocks'] --- import kbnCoreSavedObjectsServerMocksObj from './kbn_core_saved_objects_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_saved_objects_utils_server.mdx b/api_docs/kbn_core_saved_objects_utils_server.mdx index e50a285d7820..b5acf243006f 100644 --- a/api_docs/kbn_core_saved_objects_utils_server.mdx +++ b/api_docs/kbn_core_saved_objects_utils_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-saved-objects-utils-server title: "@kbn/core-saved-objects-utils-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-saved-objects-utils-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-saved-objects-utils-server'] --- import kbnCoreSavedObjectsUtilsServerObj from './kbn_core_saved_objects_utils_server.devdocs.json'; diff --git a/api_docs/kbn_core_security_browser.mdx b/api_docs/kbn_core_security_browser.mdx index e78d5b65016b..b69eaa7701e9 100644 --- a/api_docs/kbn_core_security_browser.mdx +++ b/api_docs/kbn_core_security_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-security-browser title: "@kbn/core-security-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-security-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-security-browser'] --- import kbnCoreSecurityBrowserObj from './kbn_core_security_browser.devdocs.json'; diff --git a/api_docs/kbn_core_security_browser_internal.mdx b/api_docs/kbn_core_security_browser_internal.mdx index b4c371df3cb6..b45fb35f4623 100644 --- a/api_docs/kbn_core_security_browser_internal.mdx +++ b/api_docs/kbn_core_security_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-security-browser-internal title: "@kbn/core-security-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-security-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-security-browser-internal'] --- import kbnCoreSecurityBrowserInternalObj from './kbn_core_security_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_security_browser_mocks.devdocs.json b/api_docs/kbn_core_security_browser_mocks.devdocs.json index 9d1edf7048bc..57254746eb34 100644 --- a/api_docs/kbn_core_security_browser_mocks.devdocs.json +++ b/api_docs/kbn_core_security_browser_mocks.devdocs.json @@ -145,6 +145,78 @@ "trackAdoption": false, "returnComment": [], "children": [] + }, + { + "parentPluginId": "@kbn/core-security-browser-mocks", + "id": "def-common.securityServiceMock.createMockAuthenticatedUser", + "type": "Function", + "tags": [], + "label": "createMockAuthenticatedUser", + "description": [], + "signature": [ + "(props?: Partial & { roles: string[]; }>) => { username: string; enabled: boolean; email: string; full_name: string; profile_uid: string; metadata: { _reserved: boolean; _deprecated?: boolean | undefined; _deprecated_reason?: string | undefined; }; authentication_provider: ", + { + "pluginId": "@kbn/core-security-common", + "scope": "common", + "docId": "kibKbnCoreSecurityCommonPluginApi", + "section": "def-common.AuthenticationProvider", + "text": "AuthenticationProvider" + }, + "; authentication_realm: ", + { + "pluginId": "@kbn/core-security-common", + "scope": "common", + "docId": "kibKbnCoreSecurityCommonPluginApi", + "section": "def-common.UserRealm", + "text": "UserRealm" + }, + "; lookup_realm: ", + { + "pluginId": "@kbn/core-security-common", + "scope": "common", + "docId": "kibKbnCoreSecurityCommonPluginApi", + "section": "def-common.UserRealm", + "text": "UserRealm" + }, + "; authentication_type: string; elastic_cloud_user: boolean; roles: string[]; }" + ], + "path": "packages/core/security/core-security-browser-mocks/src/security_service.mock.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/core-security-browser-mocks", + "id": "def-common.securityServiceMock.createMockAuthenticatedUser.$1", + "type": "Object", + "tags": [], + "label": "props", + "description": [], + "signature": [ + "Partial & { roles: string[]; }>" + ], + "path": "packages/core/security/core-security-browser-mocks/src/security_service.mock.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": true + } + ], + "returnComment": [] } ], "initialIsOpen": false diff --git a/api_docs/kbn_core_security_browser_mocks.mdx b/api_docs/kbn_core_security_browser_mocks.mdx index 5cb043b4c8fb..3edd53cc76d7 100644 --- a/api_docs/kbn_core_security_browser_mocks.mdx +++ b/api_docs/kbn_core_security_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-security-browser-mocks title: "@kbn/core-security-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-security-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-security-browser-mocks'] --- import kbnCoreSecurityBrowserMocksObj from './kbn_core_security_browser_mocks.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 6 | 0 | 6 | 0 | +| 8 | 0 | 8 | 0 | ## Common diff --git a/api_docs/kbn_core_security_common.mdx b/api_docs/kbn_core_security_common.mdx index 3893e6f685e7..8039694a533d 100644 --- a/api_docs/kbn_core_security_common.mdx +++ b/api_docs/kbn_core_security_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-security-common title: "@kbn/core-security-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-security-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-security-common'] --- import kbnCoreSecurityCommonObj from './kbn_core_security_common.devdocs.json'; diff --git a/api_docs/kbn_core_security_server.devdocs.json b/api_docs/kbn_core_security_server.devdocs.json index 849c687d7a97..fb144f1f8e2d 100644 --- a/api_docs/kbn_core_security_server.devdocs.json +++ b/api_docs/kbn_core_security_server.devdocs.json @@ -711,6 +711,40 @@ ], "initialIsOpen": false }, + { + "parentPluginId": "@kbn/core-security-server", + "id": "def-common.CoreFipsService", + "type": "Interface", + "tags": [], + "label": "CoreFipsService", + "description": [ + "\nCore's FIPS service\n" + ], + "path": "packages/core/security/core-security-server/src/fips.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/core-security-server", + "id": "def-common.CoreFipsService.isEnabled", + "type": "Function", + "tags": [], + "label": "isEnabled", + "description": [ + "\nCheck if Kibana is configured to run in FIPS mode" + ], + "signature": [ + "() => boolean" + ], + "path": "packages/core/security/core-security-server/src/fips.ts", + "deprecated": false, + "trackAdoption": false, + "children": [], + "returnComment": [] + } + ], + "initialIsOpen": false + }, { "parentPluginId": "@kbn/core-security-server", "id": "def-common.CoreSecurityDelegateContract", @@ -881,6 +915,28 @@ } ], "returnComment": [] + }, + { + "parentPluginId": "@kbn/core-security-server", + "id": "def-common.SecurityServiceSetup.fips", + "type": "Object", + "tags": [], + "label": "fips", + "description": [ + "\nThe {@link CoreFipsService | FIPS service}" + ], + "signature": [ + { + "pluginId": "@kbn/core-security-server", + "scope": "common", + "docId": "kibKbnCoreSecurityServerPluginApi", + "section": "def-common.CoreFipsService", + "text": "CoreFipsService" + } + ], + "path": "packages/core/security/core-security-server/src/contracts.ts", + "deprecated": false, + "trackAdoption": false } ], "initialIsOpen": false diff --git a/api_docs/kbn_core_security_server.mdx b/api_docs/kbn_core_security_server.mdx index b2ba35876928..e729f9f52e31 100644 --- a/api_docs/kbn_core_security_server.mdx +++ b/api_docs/kbn_core_security_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-security-server title: "@kbn/core-security-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-security-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-security-server'] --- import kbnCoreSecurityServerObj from './kbn_core_security_server.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 49 | 0 | 16 | 0 | +| 52 | 0 | 16 | 0 | ## Common diff --git a/api_docs/kbn_core_security_server_internal.mdx b/api_docs/kbn_core_security_server_internal.mdx index 124b72a99dc9..f7eb563902da 100644 --- a/api_docs/kbn_core_security_server_internal.mdx +++ b/api_docs/kbn_core_security_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-security-server-internal title: "@kbn/core-security-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-security-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-security-server-internal'] --- import kbnCoreSecurityServerInternalObj from './kbn_core_security_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_security_server_mocks.devdocs.json b/api_docs/kbn_core_security_server_mocks.devdocs.json index c7c7f3a2d3c8..b9910d935968 100644 --- a/api_docs/kbn_core_security_server_mocks.devdocs.json +++ b/api_docs/kbn_core_security_server_mocks.devdocs.json @@ -265,6 +265,78 @@ "trackAdoption": false, "returnComment": [], "children": [] + }, + { + "parentPluginId": "@kbn/core-security-server-mocks", + "id": "def-common.securityServiceMock.createMockAuthenticatedUser", + "type": "Function", + "tags": [], + "label": "createMockAuthenticatedUser", + "description": [], + "signature": [ + "(props?: Partial & { roles: string[]; }>) => { username: string; enabled: boolean; email: string; full_name: string; profile_uid: string; metadata: { _reserved: boolean; _deprecated?: boolean | undefined; _deprecated_reason?: string | undefined; }; authentication_provider: ", + { + "pluginId": "@kbn/core-security-common", + "scope": "common", + "docId": "kibKbnCoreSecurityCommonPluginApi", + "section": "def-common.AuthenticationProvider", + "text": "AuthenticationProvider" + }, + "; authentication_realm: ", + { + "pluginId": "@kbn/core-security-common", + "scope": "common", + "docId": "kibKbnCoreSecurityCommonPluginApi", + "section": "def-common.UserRealm", + "text": "UserRealm" + }, + "; lookup_realm: ", + { + "pluginId": "@kbn/core-security-common", + "scope": "common", + "docId": "kibKbnCoreSecurityCommonPluginApi", + "section": "def-common.UserRealm", + "text": "UserRealm" + }, + "; authentication_type: string; elastic_cloud_user: boolean; roles: string[]; }" + ], + "path": "packages/core/security/core-security-server-mocks/src/security_service.mock.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/core-security-server-mocks", + "id": "def-common.securityServiceMock.createMockAuthenticatedUser.$1", + "type": "Object", + "tags": [], + "label": "props", + "description": [], + "signature": [ + "Partial & { roles: string[]; }>" + ], + "path": "packages/core/security/core-security-server-mocks/src/security_service.mock.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": true + } + ], + "returnComment": [] } ], "initialIsOpen": false diff --git a/api_docs/kbn_core_security_server_mocks.mdx b/api_docs/kbn_core_security_server_mocks.mdx index d8e39739400a..7c4e8bdaf6a3 100644 --- a/api_docs/kbn_core_security_server_mocks.mdx +++ b/api_docs/kbn_core_security_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-security-server-mocks title: "@kbn/core-security-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-security-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-security-server-mocks'] --- import kbnCoreSecurityServerMocksObj from './kbn_core_security_server_mocks.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 11 | 0 | 11 | 2 | +| 13 | 0 | 13 | 2 | ## Common diff --git a/api_docs/kbn_core_status_common.mdx b/api_docs/kbn_core_status_common.mdx index 3d2d4082dbf9..94d811e90ba7 100644 --- a/api_docs/kbn_core_status_common.mdx +++ b/api_docs/kbn_core_status_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-status-common title: "@kbn/core-status-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-status-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-status-common'] --- import kbnCoreStatusCommonObj from './kbn_core_status_common.devdocs.json'; diff --git a/api_docs/kbn_core_status_common_internal.mdx b/api_docs/kbn_core_status_common_internal.mdx index 2d4e1d85de69..7c24950586dc 100644 --- a/api_docs/kbn_core_status_common_internal.mdx +++ b/api_docs/kbn_core_status_common_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-status-common-internal title: "@kbn/core-status-common-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-status-common-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-status-common-internal'] --- import kbnCoreStatusCommonInternalObj from './kbn_core_status_common_internal.devdocs.json'; diff --git a/api_docs/kbn_core_status_server.mdx b/api_docs/kbn_core_status_server.mdx index d0d09f327060..1f0093e2561d 100644 --- a/api_docs/kbn_core_status_server.mdx +++ b/api_docs/kbn_core_status_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-status-server title: "@kbn/core-status-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-status-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-status-server'] --- import kbnCoreStatusServerObj from './kbn_core_status_server.devdocs.json'; diff --git a/api_docs/kbn_core_status_server_internal.mdx b/api_docs/kbn_core_status_server_internal.mdx index 90c9acef6cf7..41c0c5279bf9 100644 --- a/api_docs/kbn_core_status_server_internal.mdx +++ b/api_docs/kbn_core_status_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-status-server-internal title: "@kbn/core-status-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-status-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-status-server-internal'] --- import kbnCoreStatusServerInternalObj from './kbn_core_status_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_status_server_mocks.mdx b/api_docs/kbn_core_status_server_mocks.mdx index 157e116cd80b..4777ddfe711e 100644 --- a/api_docs/kbn_core_status_server_mocks.mdx +++ b/api_docs/kbn_core_status_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-status-server-mocks title: "@kbn/core-status-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-status-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-status-server-mocks'] --- import kbnCoreStatusServerMocksObj from './kbn_core_status_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_test_helpers_deprecations_getters.mdx b/api_docs/kbn_core_test_helpers_deprecations_getters.mdx index 0dc7cd20288a..591980e5254c 100644 --- a/api_docs/kbn_core_test_helpers_deprecations_getters.mdx +++ b/api_docs/kbn_core_test_helpers_deprecations_getters.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-test-helpers-deprecations-getters title: "@kbn/core-test-helpers-deprecations-getters" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-test-helpers-deprecations-getters plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-test-helpers-deprecations-getters'] --- import kbnCoreTestHelpersDeprecationsGettersObj from './kbn_core_test_helpers_deprecations_getters.devdocs.json'; diff --git a/api_docs/kbn_core_test_helpers_http_setup_browser.mdx b/api_docs/kbn_core_test_helpers_http_setup_browser.mdx index 8dd893a58848..84122a889e22 100644 --- a/api_docs/kbn_core_test_helpers_http_setup_browser.mdx +++ b/api_docs/kbn_core_test_helpers_http_setup_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-test-helpers-http-setup-browser title: "@kbn/core-test-helpers-http-setup-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-test-helpers-http-setup-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-test-helpers-http-setup-browser'] --- import kbnCoreTestHelpersHttpSetupBrowserObj from './kbn_core_test_helpers_http_setup_browser.devdocs.json'; diff --git a/api_docs/kbn_core_test_helpers_kbn_server.mdx b/api_docs/kbn_core_test_helpers_kbn_server.mdx index 681f2ddd449d..a5cad061f959 100644 --- a/api_docs/kbn_core_test_helpers_kbn_server.mdx +++ b/api_docs/kbn_core_test_helpers_kbn_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-test-helpers-kbn-server title: "@kbn/core-test-helpers-kbn-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-test-helpers-kbn-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-test-helpers-kbn-server'] --- import kbnCoreTestHelpersKbnServerObj from './kbn_core_test_helpers_kbn_server.devdocs.json'; diff --git a/api_docs/kbn_core_test_helpers_model_versions.mdx b/api_docs/kbn_core_test_helpers_model_versions.mdx index 521f5ca0c7d9..bc29ce7ffecb 100644 --- a/api_docs/kbn_core_test_helpers_model_versions.mdx +++ b/api_docs/kbn_core_test_helpers_model_versions.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-test-helpers-model-versions title: "@kbn/core-test-helpers-model-versions" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-test-helpers-model-versions plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-test-helpers-model-versions'] --- import kbnCoreTestHelpersModelVersionsObj from './kbn_core_test_helpers_model_versions.devdocs.json'; diff --git a/api_docs/kbn_core_test_helpers_so_type_serializer.mdx b/api_docs/kbn_core_test_helpers_so_type_serializer.mdx index def1b3685d0d..4dceb5861ab4 100644 --- a/api_docs/kbn_core_test_helpers_so_type_serializer.mdx +++ b/api_docs/kbn_core_test_helpers_so_type_serializer.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-test-helpers-so-type-serializer title: "@kbn/core-test-helpers-so-type-serializer" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-test-helpers-so-type-serializer plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-test-helpers-so-type-serializer'] --- import kbnCoreTestHelpersSoTypeSerializerObj from './kbn_core_test_helpers_so_type_serializer.devdocs.json'; diff --git a/api_docs/kbn_core_test_helpers_test_utils.mdx b/api_docs/kbn_core_test_helpers_test_utils.mdx index 52bf0e32a18d..13b07110dbe4 100644 --- a/api_docs/kbn_core_test_helpers_test_utils.mdx +++ b/api_docs/kbn_core_test_helpers_test_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-test-helpers-test-utils title: "@kbn/core-test-helpers-test-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-test-helpers-test-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-test-helpers-test-utils'] --- import kbnCoreTestHelpersTestUtilsObj from './kbn_core_test_helpers_test_utils.devdocs.json'; diff --git a/api_docs/kbn_core_theme_browser.mdx b/api_docs/kbn_core_theme_browser.mdx index 071d44929a41..01ebb462ca4a 100644 --- a/api_docs/kbn_core_theme_browser.mdx +++ b/api_docs/kbn_core_theme_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-theme-browser title: "@kbn/core-theme-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-theme-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-theme-browser'] --- import kbnCoreThemeBrowserObj from './kbn_core_theme_browser.devdocs.json'; diff --git a/api_docs/kbn_core_theme_browser_mocks.mdx b/api_docs/kbn_core_theme_browser_mocks.mdx index c4697007b6f4..15466105466b 100644 --- a/api_docs/kbn_core_theme_browser_mocks.mdx +++ b/api_docs/kbn_core_theme_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-theme-browser-mocks title: "@kbn/core-theme-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-theme-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-theme-browser-mocks'] --- import kbnCoreThemeBrowserMocksObj from './kbn_core_theme_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_ui_settings_browser.mdx b/api_docs/kbn_core_ui_settings_browser.mdx index 49a8e03bc18f..9884cab812da 100644 --- a/api_docs/kbn_core_ui_settings_browser.mdx +++ b/api_docs/kbn_core_ui_settings_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-ui-settings-browser title: "@kbn/core-ui-settings-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-ui-settings-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-ui-settings-browser'] --- import kbnCoreUiSettingsBrowserObj from './kbn_core_ui_settings_browser.devdocs.json'; diff --git a/api_docs/kbn_core_ui_settings_browser_internal.mdx b/api_docs/kbn_core_ui_settings_browser_internal.mdx index c966e3ae649e..e1ec8f4e2d89 100644 --- a/api_docs/kbn_core_ui_settings_browser_internal.mdx +++ b/api_docs/kbn_core_ui_settings_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-ui-settings-browser-internal title: "@kbn/core-ui-settings-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-ui-settings-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-ui-settings-browser-internal'] --- import kbnCoreUiSettingsBrowserInternalObj from './kbn_core_ui_settings_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_ui_settings_browser_mocks.mdx b/api_docs/kbn_core_ui_settings_browser_mocks.mdx index 2d8c2dec1173..2603428a344c 100644 --- a/api_docs/kbn_core_ui_settings_browser_mocks.mdx +++ b/api_docs/kbn_core_ui_settings_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-ui-settings-browser-mocks title: "@kbn/core-ui-settings-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-ui-settings-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-ui-settings-browser-mocks'] --- import kbnCoreUiSettingsBrowserMocksObj from './kbn_core_ui_settings_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_ui_settings_common.mdx b/api_docs/kbn_core_ui_settings_common.mdx index 3fe744b96ae4..b37d365a0b71 100644 --- a/api_docs/kbn_core_ui_settings_common.mdx +++ b/api_docs/kbn_core_ui_settings_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-ui-settings-common title: "@kbn/core-ui-settings-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-ui-settings-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-ui-settings-common'] --- import kbnCoreUiSettingsCommonObj from './kbn_core_ui_settings_common.devdocs.json'; diff --git a/api_docs/kbn_core_ui_settings_server.mdx b/api_docs/kbn_core_ui_settings_server.mdx index 806e6a7e8b39..e2e2c0eec22a 100644 --- a/api_docs/kbn_core_ui_settings_server.mdx +++ b/api_docs/kbn_core_ui_settings_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-ui-settings-server title: "@kbn/core-ui-settings-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-ui-settings-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-ui-settings-server'] --- import kbnCoreUiSettingsServerObj from './kbn_core_ui_settings_server.devdocs.json'; diff --git a/api_docs/kbn_core_ui_settings_server_internal.mdx b/api_docs/kbn_core_ui_settings_server_internal.mdx index 6263261ea859..5c6790d3f864 100644 --- a/api_docs/kbn_core_ui_settings_server_internal.mdx +++ b/api_docs/kbn_core_ui_settings_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-ui-settings-server-internal title: "@kbn/core-ui-settings-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-ui-settings-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-ui-settings-server-internal'] --- import kbnCoreUiSettingsServerInternalObj from './kbn_core_ui_settings_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_ui_settings_server_mocks.mdx b/api_docs/kbn_core_ui_settings_server_mocks.mdx index 6521fe2541a2..7cb2b5817e90 100644 --- a/api_docs/kbn_core_ui_settings_server_mocks.mdx +++ b/api_docs/kbn_core_ui_settings_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-ui-settings-server-mocks title: "@kbn/core-ui-settings-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-ui-settings-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-ui-settings-server-mocks'] --- import kbnCoreUiSettingsServerMocksObj from './kbn_core_ui_settings_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_usage_data_server.mdx b/api_docs/kbn_core_usage_data_server.mdx index 963487f6a43f..9beb50d12fc7 100644 --- a/api_docs/kbn_core_usage_data_server.mdx +++ b/api_docs/kbn_core_usage_data_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-usage-data-server title: "@kbn/core-usage-data-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-usage-data-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-usage-data-server'] --- import kbnCoreUsageDataServerObj from './kbn_core_usage_data_server.devdocs.json'; diff --git a/api_docs/kbn_core_usage_data_server_internal.mdx b/api_docs/kbn_core_usage_data_server_internal.mdx index 2b7935a84a8a..6d02ecd97380 100644 --- a/api_docs/kbn_core_usage_data_server_internal.mdx +++ b/api_docs/kbn_core_usage_data_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-usage-data-server-internal title: "@kbn/core-usage-data-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-usage-data-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-usage-data-server-internal'] --- import kbnCoreUsageDataServerInternalObj from './kbn_core_usage_data_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_usage_data_server_mocks.mdx b/api_docs/kbn_core_usage_data_server_mocks.mdx index 1daf358976e4..52d469b7344a 100644 --- a/api_docs/kbn_core_usage_data_server_mocks.mdx +++ b/api_docs/kbn_core_usage_data_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-usage-data-server-mocks title: "@kbn/core-usage-data-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-usage-data-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-usage-data-server-mocks'] --- import kbnCoreUsageDataServerMocksObj from './kbn_core_usage_data_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_user_profile_browser.mdx b/api_docs/kbn_core_user_profile_browser.mdx index 4d7ece985e01..13fc386f8b20 100644 --- a/api_docs/kbn_core_user_profile_browser.mdx +++ b/api_docs/kbn_core_user_profile_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-profile-browser title: "@kbn/core-user-profile-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-profile-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-profile-browser'] --- import kbnCoreUserProfileBrowserObj from './kbn_core_user_profile_browser.devdocs.json'; diff --git a/api_docs/kbn_core_user_profile_browser_internal.mdx b/api_docs/kbn_core_user_profile_browser_internal.mdx index 8d3b40fcfb83..8e69c6a18e44 100644 --- a/api_docs/kbn_core_user_profile_browser_internal.mdx +++ b/api_docs/kbn_core_user_profile_browser_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-profile-browser-internal title: "@kbn/core-user-profile-browser-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-profile-browser-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-profile-browser-internal'] --- import kbnCoreUserProfileBrowserInternalObj from './kbn_core_user_profile_browser_internal.devdocs.json'; diff --git a/api_docs/kbn_core_user_profile_browser_mocks.mdx b/api_docs/kbn_core_user_profile_browser_mocks.mdx index dd71a24df206..4cb7228e7ba3 100644 --- a/api_docs/kbn_core_user_profile_browser_mocks.mdx +++ b/api_docs/kbn_core_user_profile_browser_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-profile-browser-mocks title: "@kbn/core-user-profile-browser-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-profile-browser-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-profile-browser-mocks'] --- import kbnCoreUserProfileBrowserMocksObj from './kbn_core_user_profile_browser_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_user_profile_common.mdx b/api_docs/kbn_core_user_profile_common.mdx index cacc58012491..36895083829a 100644 --- a/api_docs/kbn_core_user_profile_common.mdx +++ b/api_docs/kbn_core_user_profile_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-profile-common title: "@kbn/core-user-profile-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-profile-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-profile-common'] --- import kbnCoreUserProfileCommonObj from './kbn_core_user_profile_common.devdocs.json'; diff --git a/api_docs/kbn_core_user_profile_server.mdx b/api_docs/kbn_core_user_profile_server.mdx index 0c772d2ab5d6..31efa53412ec 100644 --- a/api_docs/kbn_core_user_profile_server.mdx +++ b/api_docs/kbn_core_user_profile_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-profile-server title: "@kbn/core-user-profile-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-profile-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-profile-server'] --- import kbnCoreUserProfileServerObj from './kbn_core_user_profile_server.devdocs.json'; diff --git a/api_docs/kbn_core_user_profile_server_internal.mdx b/api_docs/kbn_core_user_profile_server_internal.mdx index 5a9ed2dc7719..a4af1347dcbd 100644 --- a/api_docs/kbn_core_user_profile_server_internal.mdx +++ b/api_docs/kbn_core_user_profile_server_internal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-profile-server-internal title: "@kbn/core-user-profile-server-internal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-profile-server-internal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-profile-server-internal'] --- import kbnCoreUserProfileServerInternalObj from './kbn_core_user_profile_server_internal.devdocs.json'; diff --git a/api_docs/kbn_core_user_profile_server_mocks.mdx b/api_docs/kbn_core_user_profile_server_mocks.mdx index b7ec7fcc1eca..c580396871c7 100644 --- a/api_docs/kbn_core_user_profile_server_mocks.mdx +++ b/api_docs/kbn_core_user_profile_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-profile-server-mocks title: "@kbn/core-user-profile-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-profile-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-profile-server-mocks'] --- import kbnCoreUserProfileServerMocksObj from './kbn_core_user_profile_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_core_user_settings_server.mdx b/api_docs/kbn_core_user_settings_server.mdx index dfc1cde71bc4..e6520a108e06 100644 --- a/api_docs/kbn_core_user_settings_server.mdx +++ b/api_docs/kbn_core_user_settings_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-settings-server title: "@kbn/core-user-settings-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-settings-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-settings-server'] --- import kbnCoreUserSettingsServerObj from './kbn_core_user_settings_server.devdocs.json'; diff --git a/api_docs/kbn_core_user_settings_server_mocks.mdx b/api_docs/kbn_core_user_settings_server_mocks.mdx index e4c7d18e0dc8..2590924c0c29 100644 --- a/api_docs/kbn_core_user_settings_server_mocks.mdx +++ b/api_docs/kbn_core_user_settings_server_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-core-user-settings-server-mocks title: "@kbn/core-user-settings-server-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/core-user-settings-server-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/core-user-settings-server-mocks'] --- import kbnCoreUserSettingsServerMocksObj from './kbn_core_user_settings_server_mocks.devdocs.json'; diff --git a/api_docs/kbn_crypto.mdx b/api_docs/kbn_crypto.mdx index 45ad05516534..c9207cad137a 100644 --- a/api_docs/kbn_crypto.mdx +++ b/api_docs/kbn_crypto.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-crypto title: "@kbn/crypto" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/crypto plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/crypto'] --- import kbnCryptoObj from './kbn_crypto.devdocs.json'; diff --git a/api_docs/kbn_crypto_browser.mdx b/api_docs/kbn_crypto_browser.mdx index 176fc2b4cd8f..58ddbdb08f50 100644 --- a/api_docs/kbn_crypto_browser.mdx +++ b/api_docs/kbn_crypto_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-crypto-browser title: "@kbn/crypto-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/crypto-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/crypto-browser'] --- import kbnCryptoBrowserObj from './kbn_crypto_browser.devdocs.json'; diff --git a/api_docs/kbn_custom_icons.mdx b/api_docs/kbn_custom_icons.mdx index 00252333394f..6494698dbc11 100644 --- a/api_docs/kbn_custom_icons.mdx +++ b/api_docs/kbn_custom_icons.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-custom-icons title: "@kbn/custom-icons" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/custom-icons plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/custom-icons'] --- import kbnCustomIconsObj from './kbn_custom_icons.devdocs.json'; diff --git a/api_docs/kbn_custom_integrations.mdx b/api_docs/kbn_custom_integrations.mdx index 4568730b74e0..b3e0d7ea63ed 100644 --- a/api_docs/kbn_custom_integrations.mdx +++ b/api_docs/kbn_custom_integrations.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-custom-integrations title: "@kbn/custom-integrations" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/custom-integrations plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/custom-integrations'] --- import kbnCustomIntegrationsObj from './kbn_custom_integrations.devdocs.json'; diff --git a/api_docs/kbn_cypress_config.mdx b/api_docs/kbn_cypress_config.mdx index d086abcbd206..08b84e310d55 100644 --- a/api_docs/kbn_cypress_config.mdx +++ b/api_docs/kbn_cypress_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-cypress-config title: "@kbn/cypress-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/cypress-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/cypress-config'] --- import kbnCypressConfigObj from './kbn_cypress_config.devdocs.json'; diff --git a/api_docs/kbn_data_forge.mdx b/api_docs/kbn_data_forge.mdx index 0a8e2010a5e7..f5e689706d2f 100644 --- a/api_docs/kbn_data_forge.mdx +++ b/api_docs/kbn_data_forge.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-data-forge title: "@kbn/data-forge" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/data-forge plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/data-forge'] --- import kbnDataForgeObj from './kbn_data_forge.devdocs.json'; diff --git a/api_docs/kbn_data_service.mdx b/api_docs/kbn_data_service.mdx index 5b381c4f8935..450d1e27aadc 100644 --- a/api_docs/kbn_data_service.mdx +++ b/api_docs/kbn_data_service.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-data-service title: "@kbn/data-service" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/data-service plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/data-service'] --- import kbnDataServiceObj from './kbn_data_service.devdocs.json'; diff --git a/api_docs/kbn_data_stream_adapter.mdx b/api_docs/kbn_data_stream_adapter.mdx index 186ce951ef7b..4ba2da1295d7 100644 --- a/api_docs/kbn_data_stream_adapter.mdx +++ b/api_docs/kbn_data_stream_adapter.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-data-stream-adapter title: "@kbn/data-stream-adapter" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/data-stream-adapter plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/data-stream-adapter'] --- import kbnDataStreamAdapterObj from './kbn_data_stream_adapter.devdocs.json'; diff --git a/api_docs/kbn_data_view_utils.mdx b/api_docs/kbn_data_view_utils.mdx index d54d479f8f22..abff26dd8777 100644 --- a/api_docs/kbn_data_view_utils.mdx +++ b/api_docs/kbn_data_view_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-data-view-utils title: "@kbn/data-view-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/data-view-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/data-view-utils'] --- import kbnDataViewUtilsObj from './kbn_data_view_utils.devdocs.json'; diff --git a/api_docs/kbn_datemath.mdx b/api_docs/kbn_datemath.mdx index f6f4827f5794..3b3347cf1c0d 100644 --- a/api_docs/kbn_datemath.mdx +++ b/api_docs/kbn_datemath.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-datemath title: "@kbn/datemath" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/datemath plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/datemath'] --- import kbnDatemathObj from './kbn_datemath.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_analytics.mdx b/api_docs/kbn_deeplinks_analytics.mdx index 6498e1820954..07eb21343d8b 100644 --- a/api_docs/kbn_deeplinks_analytics.mdx +++ b/api_docs/kbn_deeplinks_analytics.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-analytics title: "@kbn/deeplinks-analytics" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-analytics plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-analytics'] --- import kbnDeeplinksAnalyticsObj from './kbn_deeplinks_analytics.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_devtools.mdx b/api_docs/kbn_deeplinks_devtools.mdx index f7bfae41e863..7421cd3c8553 100644 --- a/api_docs/kbn_deeplinks_devtools.mdx +++ b/api_docs/kbn_deeplinks_devtools.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-devtools title: "@kbn/deeplinks-devtools" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-devtools plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-devtools'] --- import kbnDeeplinksDevtoolsObj from './kbn_deeplinks_devtools.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_fleet.mdx b/api_docs/kbn_deeplinks_fleet.mdx index 6980c7283b29..8302e2e922c3 100644 --- a/api_docs/kbn_deeplinks_fleet.mdx +++ b/api_docs/kbn_deeplinks_fleet.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-fleet title: "@kbn/deeplinks-fleet" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-fleet plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-fleet'] --- import kbnDeeplinksFleetObj from './kbn_deeplinks_fleet.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_management.mdx b/api_docs/kbn_deeplinks_management.mdx index 2b6740dfd4bc..131331d7a6f5 100644 --- a/api_docs/kbn_deeplinks_management.mdx +++ b/api_docs/kbn_deeplinks_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-management title: "@kbn/deeplinks-management" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-management plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-management'] --- import kbnDeeplinksManagementObj from './kbn_deeplinks_management.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_ml.mdx b/api_docs/kbn_deeplinks_ml.mdx index 36f930070071..09181dd34288 100644 --- a/api_docs/kbn_deeplinks_ml.mdx +++ b/api_docs/kbn_deeplinks_ml.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-ml title: "@kbn/deeplinks-ml" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-ml plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-ml'] --- import kbnDeeplinksMlObj from './kbn_deeplinks_ml.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_observability.mdx b/api_docs/kbn_deeplinks_observability.mdx index abe17442e309..ac984b80fe85 100644 --- a/api_docs/kbn_deeplinks_observability.mdx +++ b/api_docs/kbn_deeplinks_observability.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-observability title: "@kbn/deeplinks-observability" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-observability plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-observability'] --- import kbnDeeplinksObservabilityObj from './kbn_deeplinks_observability.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_search.mdx b/api_docs/kbn_deeplinks_search.mdx index 6b788269c6ae..036266ce738e 100644 --- a/api_docs/kbn_deeplinks_search.mdx +++ b/api_docs/kbn_deeplinks_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-search title: "@kbn/deeplinks-search" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-search plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-search'] --- import kbnDeeplinksSearchObj from './kbn_deeplinks_search.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_security.mdx b/api_docs/kbn_deeplinks_security.mdx index 24d1e14accff..3ed04e94438f 100644 --- a/api_docs/kbn_deeplinks_security.mdx +++ b/api_docs/kbn_deeplinks_security.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-security title: "@kbn/deeplinks-security" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-security plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-security'] --- import kbnDeeplinksSecurityObj from './kbn_deeplinks_security.devdocs.json'; diff --git a/api_docs/kbn_deeplinks_shared.mdx b/api_docs/kbn_deeplinks_shared.mdx index 35028a3e267f..2a7a079e7094 100644 --- a/api_docs/kbn_deeplinks_shared.mdx +++ b/api_docs/kbn_deeplinks_shared.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-deeplinks-shared title: "@kbn/deeplinks-shared" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/deeplinks-shared plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/deeplinks-shared'] --- import kbnDeeplinksSharedObj from './kbn_deeplinks_shared.devdocs.json'; diff --git a/api_docs/kbn_default_nav_analytics.mdx b/api_docs/kbn_default_nav_analytics.mdx index eca0518b8676..2a955bdd9590 100644 --- a/api_docs/kbn_default_nav_analytics.mdx +++ b/api_docs/kbn_default_nav_analytics.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-default-nav-analytics title: "@kbn/default-nav-analytics" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/default-nav-analytics plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/default-nav-analytics'] --- import kbnDefaultNavAnalyticsObj from './kbn_default_nav_analytics.devdocs.json'; diff --git a/api_docs/kbn_default_nav_devtools.mdx b/api_docs/kbn_default_nav_devtools.mdx index f0807dc4e7ab..8c52d0973c5d 100644 --- a/api_docs/kbn_default_nav_devtools.mdx +++ b/api_docs/kbn_default_nav_devtools.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-default-nav-devtools title: "@kbn/default-nav-devtools" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/default-nav-devtools plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/default-nav-devtools'] --- import kbnDefaultNavDevtoolsObj from './kbn_default_nav_devtools.devdocs.json'; diff --git a/api_docs/kbn_default_nav_management.mdx b/api_docs/kbn_default_nav_management.mdx index 109bd7d72bd3..35869c2d6c35 100644 --- a/api_docs/kbn_default_nav_management.mdx +++ b/api_docs/kbn_default_nav_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-default-nav-management title: "@kbn/default-nav-management" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/default-nav-management plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/default-nav-management'] --- import kbnDefaultNavManagementObj from './kbn_default_nav_management.devdocs.json'; diff --git a/api_docs/kbn_default_nav_ml.mdx b/api_docs/kbn_default_nav_ml.mdx index f741e25bd891..c6081004286e 100644 --- a/api_docs/kbn_default_nav_ml.mdx +++ b/api_docs/kbn_default_nav_ml.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-default-nav-ml title: "@kbn/default-nav-ml" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/default-nav-ml plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/default-nav-ml'] --- import kbnDefaultNavMlObj from './kbn_default_nav_ml.devdocs.json'; diff --git a/api_docs/kbn_dev_cli_errors.mdx b/api_docs/kbn_dev_cli_errors.mdx index 21119753a1ce..767159ccf2e5 100644 --- a/api_docs/kbn_dev_cli_errors.mdx +++ b/api_docs/kbn_dev_cli_errors.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-dev-cli-errors title: "@kbn/dev-cli-errors" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/dev-cli-errors plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/dev-cli-errors'] --- import kbnDevCliErrorsObj from './kbn_dev_cli_errors.devdocs.json'; diff --git a/api_docs/kbn_dev_cli_runner.mdx b/api_docs/kbn_dev_cli_runner.mdx index d6adb46c48cf..71374e71863a 100644 --- a/api_docs/kbn_dev_cli_runner.mdx +++ b/api_docs/kbn_dev_cli_runner.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-dev-cli-runner title: "@kbn/dev-cli-runner" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/dev-cli-runner plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/dev-cli-runner'] --- import kbnDevCliRunnerObj from './kbn_dev_cli_runner.devdocs.json'; diff --git a/api_docs/kbn_dev_proc_runner.mdx b/api_docs/kbn_dev_proc_runner.mdx index fa0a1d0df522..29fb9a8bdaf0 100644 --- a/api_docs/kbn_dev_proc_runner.mdx +++ b/api_docs/kbn_dev_proc_runner.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-dev-proc-runner title: "@kbn/dev-proc-runner" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/dev-proc-runner plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/dev-proc-runner'] --- import kbnDevProcRunnerObj from './kbn_dev_proc_runner.devdocs.json'; diff --git a/api_docs/kbn_dev_utils.mdx b/api_docs/kbn_dev_utils.mdx index 23243db7c88c..5c9b58e39fb8 100644 --- a/api_docs/kbn_dev_utils.mdx +++ b/api_docs/kbn_dev_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-dev-utils title: "@kbn/dev-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/dev-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/dev-utils'] --- import kbnDevUtilsObj from './kbn_dev_utils.devdocs.json'; diff --git a/api_docs/kbn_discover_utils.mdx b/api_docs/kbn_discover_utils.mdx index cf8a88d0e505..df15394ec10c 100644 --- a/api_docs/kbn_discover_utils.mdx +++ b/api_docs/kbn_discover_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-discover-utils title: "@kbn/discover-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/discover-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/discover-utils'] --- import kbnDiscoverUtilsObj from './kbn_discover_utils.devdocs.json'; diff --git a/api_docs/kbn_doc_links.devdocs.json b/api_docs/kbn_doc_links.devdocs.json index f34c6c85b7fe..ab331022baa0 100644 --- a/api_docs/kbn_doc_links.devdocs.json +++ b/api_docs/kbn_doc_links.devdocs.json @@ -840,7 +840,7 @@ "label": "fleet", "description": [], "signature": [ - "{ readonly beatsAgentComparison: string; readonly guide: string; readonly fleetServer: string; readonly fleetServerAddFleetServer: string; readonly esSettings: string; readonly settings: string; readonly logstashSettings: string; readonly kafkaSettings: string; readonly settingsFleetServerHostSettings: string; readonly settingsFleetServerProxySettings: string; readonly troubleshooting: string; readonly elasticAgent: string; readonly datastreams: string; readonly datastreamsILM: string; readonly datastreamsNamingScheme: string; readonly datastreamsManualRollover: string; readonly datastreamsTSDS: string; readonly datastreamsTSDSMetrics: string; readonly datastreamsDownsampling: string; readonly installElasticAgent: string; readonly installElasticAgentStandalone: string; readonly packageSignatures: string; readonly upgradeElasticAgent: string; readonly learnMoreBlog: string; readonly apiKeysLearnMore: string; readonly onPremRegistry: string; readonly secureLogstash: string; readonly agentPolicy: string; readonly api: string; readonly uninstallAgent: string; readonly installAndUninstallIntegrationAssets: string; readonly elasticAgentInputConfiguration: string; readonly policySecrets: string; readonly remoteESOoutput: string; readonly performancePresets: string; readonly scalingKubernetesResourcesAndLimits: string; readonly roleAndPrivileges: string; readonly proxiesSettings: string; }" + "{ readonly beatsAgentComparison: string; readonly guide: string; readonly fleetServer: string; readonly fleetServerAddFleetServer: string; readonly esSettings: string; readonly settings: string; readonly logstashSettings: string; readonly kafkaSettings: string; readonly settingsFleetServerHostSettings: string; readonly settingsFleetServerProxySettings: string; readonly troubleshooting: string; readonly elasticAgent: string; readonly datastreams: string; readonly datastreamsILM: string; readonly datastreamsNamingScheme: string; readonly datastreamsManualRollover: string; readonly datastreamsTSDS: string; readonly datastreamsTSDSMetrics: string; readonly datastreamsDownsampling: string; readonly installElasticAgent: string; readonly installElasticAgentStandalone: string; readonly grantESAccessToStandaloneAgents: string; readonly packageSignatures: string; readonly upgradeElasticAgent: string; readonly learnMoreBlog: string; readonly apiKeysLearnMore: string; readonly onPremRegistry: string; readonly secureLogstash: string; readonly agentPolicy: string; readonly api: string; readonly uninstallAgent: string; readonly installAndUninstallIntegrationAssets: string; readonly elasticAgentInputConfiguration: string; readonly policySecrets: string; readonly remoteESOoutput: string; readonly performancePresets: string; readonly scalingKubernetesResourcesAndLimits: string; readonly roleAndPrivileges: string; readonly proxiesSettings: string; readonly unprivilegedMode: string; }" ], "path": "packages/kbn-doc-links/src/types.ts", "deprecated": false, diff --git a/api_docs/kbn_doc_links.mdx b/api_docs/kbn_doc_links.mdx index c5c0e93c28ce..590eb9d6ce74 100644 --- a/api_docs/kbn_doc_links.mdx +++ b/api_docs/kbn_doc_links.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-doc-links title: "@kbn/doc-links" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/doc-links plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/doc-links'] --- import kbnDocLinksObj from './kbn_doc_links.devdocs.json'; diff --git a/api_docs/kbn_docs_utils.mdx b/api_docs/kbn_docs_utils.mdx index 64afe1211103..c276d865b1fd 100644 --- a/api_docs/kbn_docs_utils.mdx +++ b/api_docs/kbn_docs_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-docs-utils title: "@kbn/docs-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/docs-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/docs-utils'] --- import kbnDocsUtilsObj from './kbn_docs_utils.devdocs.json'; diff --git a/api_docs/kbn_dom_drag_drop.mdx b/api_docs/kbn_dom_drag_drop.mdx index e70b968c6d23..e62932c51307 100644 --- a/api_docs/kbn_dom_drag_drop.mdx +++ b/api_docs/kbn_dom_drag_drop.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-dom-drag-drop title: "@kbn/dom-drag-drop" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/dom-drag-drop plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/dom-drag-drop'] --- import kbnDomDragDropObj from './kbn_dom_drag_drop.devdocs.json'; diff --git a/api_docs/kbn_ebt.devdocs.json b/api_docs/kbn_ebt.devdocs.json index 6a608e785f53..1c5899d553ac 100644 --- a/api_docs/kbn_ebt.devdocs.json +++ b/api_docs/kbn_ebt.devdocs.json @@ -1826,14 +1826,6 @@ "plugin": "@kbn/cloud", "path": "packages/cloud/connection_details/kibana/kibana_connection_details_provider.tsx" }, - { - "plugin": "dashboard", - "path": "src/plugins/dashboard/public/services/analytics/types.ts" - }, - { - "plugin": "dashboard", - "path": "src/plugins/dashboard/public/services/analytics/analytics_service.ts" - }, { "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/recall_and_score.ts" @@ -1842,6 +1834,14 @@ "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/public/analytics/index.ts" }, + { + "plugin": "dashboard", + "path": "src/plugins/dashboard/public/services/analytics/types.ts" + }, + { + "plugin": "dashboard", + "path": "src/plugins/dashboard/public/services/analytics/analytics_service.ts" + }, { "plugin": "integrationAssistant", "path": "x-pack/plugins/integration_assistant/public/services/telemetry/service.ts" @@ -1866,6 +1866,18 @@ "plugin": "fleet", "path": "x-pack/plugins/fleet/server/services/telemetry/fleet_usage_sender.ts" }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts" + }, { "plugin": "elasticAssistant", "path": "x-pack/plugins/elastic_assistant/server/lib/langchain/elasticsearch_store/elasticsearch_store.ts" @@ -1890,6 +1902,18 @@ "plugin": "elasticAssistant", "path": "x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts" }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/helpers.ts" + }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/helpers.ts" + }, + { + "plugin": "elasticAssistant", + "path": "x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts" + }, { "plugin": "globalSearchBar", "path": "x-pack/plugins/global_search_bar/public/telemetry/event_reporter.ts" @@ -1931,32 +1955,32 @@ "path": "x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { - "plugin": "osquery", - "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, { "plugin": "securitySolution", @@ -2066,6 +2090,34 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts" }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "osquery", + "path": "x-pack/plugins/osquery/server/lib/telemetry/sender.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/tasks/risk_scoring_task.ts" + }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts" @@ -2234,6 +2286,38 @@ "plugin": "apm", "path": "x-pack/plugins/observability_solution/apm/public/services/telemetry/telemetry_service.test.ts" }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, + { + "plugin": "datasetQuality", + "path": "x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts" + }, { "plugin": "infra", "path": "x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_service.test.ts" diff --git a/api_docs/kbn_ebt.mdx b/api_docs/kbn_ebt.mdx index 4364b48e9471..36baf746095d 100644 --- a/api_docs/kbn_ebt.mdx +++ b/api_docs/kbn_ebt.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ebt title: "@kbn/ebt" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ebt plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ebt'] --- import kbnEbtObj from './kbn_ebt.devdocs.json'; diff --git a/api_docs/kbn_ebt_tools.mdx b/api_docs/kbn_ebt_tools.mdx index 6617cd999251..3e18cfac2dc2 100644 --- a/api_docs/kbn_ebt_tools.mdx +++ b/api_docs/kbn_ebt_tools.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ebt-tools title: "@kbn/ebt-tools" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ebt-tools plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ebt-tools'] --- import kbnEbtToolsObj from './kbn_ebt_tools.devdocs.json'; diff --git a/api_docs/kbn_ecs_data_quality_dashboard.mdx b/api_docs/kbn_ecs_data_quality_dashboard.mdx index e3d86df01b0b..adb48638d82d 100644 --- a/api_docs/kbn_ecs_data_quality_dashboard.mdx +++ b/api_docs/kbn_ecs_data_quality_dashboard.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ecs-data-quality-dashboard title: "@kbn/ecs-data-quality-dashboard" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ecs-data-quality-dashboard plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ecs-data-quality-dashboard'] --- import kbnEcsDataQualityDashboardObj from './kbn_ecs_data_quality_dashboard.devdocs.json'; diff --git a/api_docs/kbn_elastic_agent_utils.mdx b/api_docs/kbn_elastic_agent_utils.mdx index 5e6647beacc1..0d14951c4cc0 100644 --- a/api_docs/kbn_elastic_agent_utils.mdx +++ b/api_docs/kbn_elastic_agent_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-elastic-agent-utils title: "@kbn/elastic-agent-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/elastic-agent-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/elastic-agent-utils'] --- import kbnElasticAgentUtilsObj from './kbn_elastic_agent_utils.devdocs.json'; diff --git a/api_docs/kbn_elastic_assistant.devdocs.json b/api_docs/kbn_elastic_assistant.devdocs.json index c85859f2fe60..b0a4d5220786 100644 --- a/api_docs/kbn_elastic_assistant.devdocs.json +++ b/api_docs/kbn_elastic_assistant.devdocs.json @@ -159,7 +159,7 @@ "label": "AssistantProvider", "description": [], "signature": [ - "({ actionTypeRegistry, alertsIndexPattern, assistantAvailability, assistantTelemetry, augmentMessageCodeBlocks, docLinks, basePath, basePromptContexts, baseQuickPrompts, baseSystemPrompts, children, getComments, http, baseConversations, navigateToApp, nameSpace, title, toasts, }: React.PropsWithChildren<", + "({ actionTypeRegistry, alertsIndexPattern, assistantAvailability, assistantTelemetry, augmentMessageCodeBlocks, docLinks, basePath, basePromptContexts, children, getComments, http, baseConversations, navigateToApp, nameSpace, title, toasts, currentAppId, }: React.PropsWithChildren<", "AssistantProviderProps", ">) => JSX.Element" ], @@ -172,7 +172,7 @@ "id": "def-public.AssistantProvider.$1", "type": "CompoundType", "tags": [], - "label": "{\n actionTypeRegistry,\n alertsIndexPattern,\n assistantAvailability,\n assistantTelemetry,\n augmentMessageCodeBlocks,\n docLinks,\n basePath,\n basePromptContexts = [],\n baseQuickPrompts = [],\n baseSystemPrompts = BASE_SYSTEM_PROMPTS,\n children,\n getComments,\n http,\n baseConversations,\n navigateToApp,\n nameSpace = DEFAULT_ASSISTANT_NAMESPACE,\n title = DEFAULT_ASSISTANT_TITLE,\n toasts,\n}", + "label": "{\n actionTypeRegistry,\n alertsIndexPattern,\n assistantAvailability,\n assistantTelemetry,\n augmentMessageCodeBlocks,\n docLinks,\n basePath,\n basePromptContexts = [],\n children,\n getComments,\n http,\n baseConversations,\n navigateToApp,\n nameSpace = DEFAULT_ASSISTANT_NAMESPACE,\n title = DEFAULT_ASSISTANT_TITLE,\n toasts,\n currentAppId,\n}", "description": [], "signature": [ "React.PropsWithChildren<", @@ -302,6 +302,98 @@ "returnComment": [], "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.bulkUpdatePrompts", + "type": "Function", + "tags": [], + "label": "bulkUpdatePrompts", + "description": [], + "signature": [ + "(http: ", + { + "pluginId": "@kbn/core-http-browser", + "scope": "common", + "docId": "kibKbnCoreHttpBrowserPluginApi", + "section": "def-common.HttpSetup", + "text": "HttpSetup" + }, + ", prompts: { delete?: { query?: string | undefined; ids?: string[] | undefined; } | undefined; create?: { name: string; content: string; promptType: \"system\" | \"quick\"; color?: string | undefined; categories?: string[] | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; }[] | undefined; update?: { id: string; content?: string | undefined; color?: string | undefined; categories?: string[] | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; }[] | undefined; }, toasts?: ", + { + "pluginId": "@kbn/core-notifications-browser", + "scope": "common", + "docId": "kibKbnCoreNotificationsBrowserPluginApi", + "section": "def-common.IToasts", + "text": "IToasts" + }, + " | undefined) => Promise<{ attributes: { results: { created: { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }[]; updated: { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }[]; skipped: { id: string; skip_reason: \"PROMPT_FIELD_NOT_MODIFIED\"; name?: string | undefined; }[]; deleted: string[]; }; summary: { total: number; succeeded: number; failed: number; skipped: number; }; errors?: { message: string; status_code: number; prompts: { id: string; name?: string | undefined; }[]; err_code?: string | undefined; }[] | undefined; }; success?: boolean | undefined; status_code?: number | undefined; message?: string | undefined; prompts_count?: number | undefined; } | undefined>" + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.bulkUpdatePrompts.$1", + "type": "Object", + "tags": [], + "label": "http", + "description": [], + "signature": [ + { + "pluginId": "@kbn/core-http-browser", + "scope": "common", + "docId": "kibKbnCoreHttpBrowserPluginApi", + "section": "def-common.HttpSetup", + "text": "HttpSetup" + } + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": true + }, + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.bulkUpdatePrompts.$2", + "type": "Object", + "tags": [], + "label": "prompts", + "description": [], + "signature": [ + "{ delete?: { query?: string | undefined; ids?: string[] | undefined; } | undefined; create?: { name: string; content: string; promptType: \"system\" | \"quick\"; color?: string | undefined; categories?: string[] | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; }[] | undefined; update?: { id: string; content?: string | undefined; color?: string | undefined; categories?: string[] | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; }[] | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": true + }, + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.bulkUpdatePrompts.$3", + "type": "Object", + "tags": [], + "label": "toasts", + "description": [], + "signature": [ + { + "pluginId": "@kbn/core-notifications-browser", + "scope": "common", + "docId": "kibKbnCoreNotificationsBrowserPluginApi", + "section": "def-common.IToasts", + "text": "IToasts" + }, + " | undefined" + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": false + } + ], + "returnComment": [], + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant", "id": "def-public.ConnectorSelectorInline", @@ -395,6 +487,107 @@ "returnComment": [], "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.getPrompts", + "type": "Function", + "tags": [], + "label": "getPrompts", + "description": [], + "signature": [ + "({ http, signal, toasts, }: { http: ", + { + "pluginId": "@kbn/core-http-browser", + "scope": "common", + "docId": "kibKbnCoreHttpBrowserPluginApi", + "section": "def-common.HttpSetup", + "text": "HttpSetup" + }, + "; toasts: ", + { + "pluginId": "@kbn/core-notifications-browser", + "scope": "common", + "docId": "kibKbnCoreNotificationsBrowserPluginApi", + "section": "def-common.IToasts", + "text": "IToasts" + }, + "; signal?: AbortSignal | undefined; }) => Promise<{ page: number; perPage: number; total: number; data: { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }[]; }>" + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.getPrompts.$1", + "type": "Object", + "tags": [], + "label": "{\n http,\n signal,\n toasts,\n}", + "description": [], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.getPrompts.$1.http", + "type": "Object", + "tags": [], + "label": "http", + "description": [], + "signature": [ + { + "pluginId": "@kbn/core-http-browser", + "scope": "common", + "docId": "kibKbnCoreHttpBrowserPluginApi", + "section": "def-common.HttpSetup", + "text": "HttpSetup" + } + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.getPrompts.$1.toasts", + "type": "Object", + "tags": [], + "label": "toasts", + "description": [], + "signature": [ + { + "pluginId": "@kbn/core-notifications-browser", + "scope": "common", + "docId": "kibKbnCoreNotificationsBrowserPluginApi", + "section": "def-common.IToasts", + "text": "IToasts" + } + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "@kbn/elastic-assistant", + "id": "def-public.getPrompts.$1.signal", + "type": "Object", + "tags": [], + "label": "signal", + "description": [], + "signature": [ + "AbortSignal | undefined" + ], + "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts", + "deprecated": false, + "trackAdoption": false + } + ] + } + ], + "returnComment": [], + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant", "id": "def-public.getUserConversations", @@ -2192,123 +2385,6 @@ ], "initialIsOpen": false }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt", - "type": "Interface", - "tags": [], - "label": "Prompt", - "description": [], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false, - "children": [ - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.id", - "type": "string", - "tags": [], - "label": "id", - "description": [], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.content", - "type": "string", - "tags": [], - "label": "content", - "description": [], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.name", - "type": "string", - "tags": [], - "label": "name", - "description": [], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.promptType", - "type": "CompoundType", - "tags": [], - "label": "promptType", - "description": [], - "signature": [ - "\"user\" | \"system\"" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.isDefault", - "type": "CompoundType", - "tags": [], - "label": "isDefault", - "description": [], - "signature": [ - "boolean | undefined" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.isNewConversationDefault", - "type": "CompoundType", - "tags": [], - "label": "isNewConversationDefault", - "description": [], - "signature": [ - "boolean | undefined" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.isFlyoutMode", - "type": "CompoundType", - "tags": [], - "label": "isFlyoutMode", - "description": [], - "signature": [ - "boolean | undefined" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.Prompt.label", - "type": "string", - "tags": [], - "label": "label", - "description": [], - "signature": [ - "string | undefined" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts", - "deprecated": false, - "trackAdoption": false - } - ], - "initialIsOpen": false - }, { "parentPluginId": "@kbn/elastic-assistant", "id": "def-public.PromptContext", @@ -2429,83 +2505,6 @@ } ], "initialIsOpen": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.QuickPrompt", - "type": "Interface", - "tags": [], - "label": "QuickPrompt", - "description": [ - "\nA QuickPrompt is a badge that is displayed below the Assistant's input field. They provide\na quick way for users to insert prompts as templates into the Assistant's input field. If no\ncategories are provided they will always display with the assistant, however categories can be\nsupplied to only display the QuickPrompt when the Assistant is registered with corresponding\nPromptContext's containing the same category.\n\nisDefault: If true, this QuickPrompt cannot be deleted by the user" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx", - "deprecated": false, - "trackAdoption": false, - "children": [ - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.QuickPrompt.title", - "type": "string", - "tags": [], - "label": "title", - "description": [], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.QuickPrompt.prompt", - "type": "string", - "tags": [], - "label": "prompt", - "description": [], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.QuickPrompt.color", - "type": "string", - "tags": [], - "label": "color", - "description": [], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.QuickPrompt.categories", - "type": "Array", - "tags": [], - "label": "categories", - "description": [], - "signature": [ - "string[] | undefined" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx", - "deprecated": false, - "trackAdoption": false - }, - { - "parentPluginId": "@kbn/elastic-assistant", - "id": "def-public.QuickPrompt.isDefault", - "type": "CompoundType", - "tags": [], - "label": "isDefault", - "description": [], - "signature": [ - "boolean | undefined" - ], - "path": "x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx", - "deprecated": false, - "trackAdoption": false - } - ], - "initialIsOpen": false } ], "enums": [], diff --git a/api_docs/kbn_elastic_assistant.mdx b/api_docs/kbn_elastic_assistant.mdx index 5ac68007aff9..31c67756716f 100644 --- a/api_docs/kbn_elastic_assistant.mdx +++ b/api_docs/kbn_elastic_assistant.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-elastic-assistant title: "@kbn/elastic-assistant" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/elastic-assistant plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/elastic-assistant'] --- import kbnElasticAssistantObj from './kbn_elastic_assistant.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/security-generative-ai](https://github.com/orgs/elastic/teams/ | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 166 | 0 | 139 | 9 | +| 160 | 0 | 134 | 9 | ## Client diff --git a/api_docs/kbn_elastic_assistant_common.devdocs.json b/api_docs/kbn_elastic_assistant_common.devdocs.json index 346c99107638..8de92399115d 100644 --- a/api_docs/kbn_elastic_assistant_common.devdocs.json +++ b/api_docs/kbn_elastic_assistant_common.devdocs.json @@ -1221,6 +1221,100 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatCompleteProps", + "type": "Type", + "tags": [], + "label": "ChatCompleteProps", + "description": [], + "signature": [ + "{ connectorId: string; persist: boolean; messages: { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }[]; conversationId?: string | undefined; promptId?: string | undefined; isStream?: boolean | undefined; responseLanguage?: string | undefined; langSmithProject?: string | undefined; langSmithApiKey?: string | undefined; model?: string | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatCompleteRequestBody", + "type": "Type", + "tags": [], + "label": "ChatCompleteRequestBody", + "description": [], + "signature": [ + "{ connectorId: string; persist: boolean; messages: { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }[]; conversationId?: string | undefined; promptId?: string | undefined; isStream?: boolean | undefined; responseLanguage?: string | undefined; langSmithProject?: string | undefined; langSmithApiKey?: string | undefined; model?: string | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatCompleteRequestBodyInput", + "type": "Type", + "tags": [], + "label": "ChatCompleteRequestBodyInput", + "description": [], + "signature": [ + "{ connectorId: string; persist: boolean; messages: { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }[]; conversationId?: string | undefined; promptId?: string | undefined; isStream?: boolean | undefined; responseLanguage?: string | undefined; langSmithProject?: string | undefined; langSmithApiKey?: string | undefined; model?: string | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatMessage", + "type": "Type", + "tags": [], + "label": "ChatMessage", + "description": [ + "\nAI assistant message." + ], + "signature": [ + "{ role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatMessageRole", + "type": "Type", + "tags": [], + "label": "ChatMessageRole", + "description": [ + "\nMessage role." + ], + "signature": [ + "\"user\" | \"system\" | \"assistant\"" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatMessageRoleEnum", + "type": "Type", + "tags": [], + "label": "ChatMessageRoleEnum", + "description": [], + "signature": [ + "{ user: \"user\"; system: \"system\"; assistant: \"assistant\"; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.ConversationCategory", @@ -1711,6 +1805,18 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ELASTIC_AI_ASSISTANT_CHAT_COMPLETE_URL", + "type": "string", + "tags": [], + "label": "ELASTIC_AI_ASSISTANT_CHAT_COMPLETE_URL", + "description": [], + "path": "x-pack/packages/kbn-elastic-assistant-common/constants.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL", @@ -1893,7 +1999,7 @@ "label": "ELASTIC_AI_ASSISTANT_URL", "description": [], "signature": [ - "\"/api/elastic_assistant\"" + "\"/api/security_ai_assistant\"" ], "path": "x-pack/packages/kbn-elastic-assistant-common/constants.ts", "deprecated": false, @@ -2185,6 +2291,81 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsRequestQuery", + "type": "Type", + "tags": [], + "label": "FindPromptsRequestQuery", + "description": [], + "signature": [ + "{ per_page: number; page: number; fields?: string[] | undefined; filter?: string | undefined; sort_field?: \"name\" | \"updated_at\" | \"created_at\" | \"is_default\" | undefined; sort_order?: \"asc\" | \"desc\" | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsRequestQueryInput", + "type": "Type", + "tags": [], + "label": "FindPromptsRequestQueryInput", + "description": [], + "signature": [ + "{ fields?: unknown; filter?: string | undefined; sort_field?: \"name\" | \"updated_at\" | \"created_at\" | \"is_default\" | undefined; sort_order?: \"asc\" | \"desc\" | undefined; page?: number | undefined; per_page?: number | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsResponse", + "type": "Type", + "tags": [], + "label": "FindPromptsResponse", + "description": [], + "signature": [ + "{ page: number; perPage: number; total: number; data: { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }[]; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsSortField", + "type": "Type", + "tags": [], + "label": "FindPromptsSortField", + "description": [], + "signature": [ + "\"name\" | \"updated_at\" | \"created_at\" | \"is_default\"" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsSortFieldEnum", + "type": "Type", + "tags": [], + "label": "FindPromptsSortFieldEnum", + "description": [], + "signature": [ + "{ name: \"name\"; updated_at: \"updated_at\"; created_at: \"created_at\"; is_default: \"is_default\"; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.GenerationInterval", @@ -2446,6 +2627,21 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.MessageData", + "type": "Type", + "tags": [], + "label": "MessageData", + "description": [], + "signature": [ + "{} & { [k: string]: unknown; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.MessageRole", @@ -2737,6 +2933,36 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.PromptResponse", + "type": "Type", + "tags": [], + "label": "PromptResponse", + "description": [], + "signature": [ + "{ id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.PromptTypeEnum", + "type": "Type", + "tags": [], + "label": "PromptTypeEnum", + "description": [], + "signature": [ + "{ system: \"system\"; quick: \"quick\"; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.Provider", @@ -2951,6 +3177,21 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.RootContext", + "type": "Type", + "tags": [], + "label": "RootContext", + "description": [], + "signature": [ + "\"security\"" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.SortOrder", @@ -3638,6 +3879,81 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatCompleteProps", + "type": "Object", + "tags": [], + "label": "ChatCompleteProps", + "description": [], + "signature": [ + "Zod.ZodObject<{ conversationId: Zod.ZodOptional; promptId: Zod.ZodOptional; isStream: Zod.ZodOptional; responseLanguage: Zod.ZodOptional; langSmithProject: Zod.ZodOptional; langSmithApiKey: Zod.ZodOptional; connectorId: Zod.ZodString; model: Zod.ZodOptional; persist: Zod.ZodBoolean; messages: Zod.ZodArray; role: Zod.ZodEnum<[\"system\", \"user\", \"assistant\"]>; data: Zod.ZodOptional, Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\">>>; fields_to_anonymize: Zod.ZodOptional>; }, \"strip\", Zod.ZodTypeAny, { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }, { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }>, \"many\">; }, \"strip\", Zod.ZodTypeAny, { connectorId: string; persist: boolean; messages: { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }[]; conversationId?: string | undefined; promptId?: string | undefined; isStream?: boolean | undefined; responseLanguage?: string | undefined; langSmithProject?: string | undefined; langSmithApiKey?: string | undefined; model?: string | undefined; }, { connectorId: string; persist: boolean; messages: { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }[]; conversationId?: string | undefined; promptId?: string | undefined; isStream?: boolean | undefined; responseLanguage?: string | undefined; langSmithProject?: string | undefined; langSmithApiKey?: string | undefined; model?: string | undefined; }>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatCompleteRequestBody", + "type": "Object", + "tags": [], + "label": "ChatCompleteRequestBody", + "description": [], + "signature": [ + "Zod.ZodObject<{ conversationId: Zod.ZodOptional; promptId: Zod.ZodOptional; isStream: Zod.ZodOptional; responseLanguage: Zod.ZodOptional; langSmithProject: Zod.ZodOptional; langSmithApiKey: Zod.ZodOptional; connectorId: Zod.ZodString; model: Zod.ZodOptional; persist: Zod.ZodBoolean; messages: Zod.ZodArray; role: Zod.ZodEnum<[\"system\", \"user\", \"assistant\"]>; data: Zod.ZodOptional, Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\">>>; fields_to_anonymize: Zod.ZodOptional>; }, \"strip\", Zod.ZodTypeAny, { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }, { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }>, \"many\">; }, \"strip\", Zod.ZodTypeAny, { connectorId: string; persist: boolean; messages: { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }[]; conversationId?: string | undefined; promptId?: string | undefined; isStream?: boolean | undefined; responseLanguage?: string | undefined; langSmithProject?: string | undefined; langSmithApiKey?: string | undefined; model?: string | undefined; }, { connectorId: string; persist: boolean; messages: { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }[]; conversationId?: string | undefined; promptId?: string | undefined; isStream?: boolean | undefined; responseLanguage?: string | undefined; langSmithProject?: string | undefined; langSmithApiKey?: string | undefined; model?: string | undefined; }>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatMessage", + "type": "Object", + "tags": [], + "label": "ChatMessage", + "description": [], + "signature": [ + "Zod.ZodObject<{ content: Zod.ZodOptional; role: Zod.ZodEnum<[\"system\", \"user\", \"assistant\"]>; data: Zod.ZodOptional, Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\">>>; fields_to_anonymize: Zod.ZodOptional>; }, \"strip\", Zod.ZodTypeAny, { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }, { role: \"user\" | \"system\" | \"assistant\"; content?: string | undefined; data?: Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; fields_to_anonymize?: string[] | undefined; }>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatMessageRole", + "type": "Object", + "tags": [], + "label": "ChatMessageRole", + "description": [], + "signature": [ + "Zod.ZodEnum<[\"system\", \"user\", \"assistant\"]>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.ChatMessageRoleEnum", + "type": "Object", + "tags": [], + "label": "ChatMessageRoleEnum", + "description": [], + "signature": [ + "{ user: \"user\"; system: \"system\"; assistant: \"assistant\"; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.ConversationCategory", @@ -4225,6 +4541,66 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsRequestQuery", + "type": "Object", + "tags": [], + "label": "FindPromptsRequestQuery", + "description": [], + "signature": [ + "Zod.ZodObject<{ fields: Zod.ZodOptional, string[], unknown>>; filter: Zod.ZodOptional; sort_field: Zod.ZodOptional>; sort_order: Zod.ZodOptional>; page: Zod.ZodDefault>; per_page: Zod.ZodDefault>; }, \"strip\", Zod.ZodTypeAny, { per_page: number; page: number; fields?: string[] | undefined; filter?: string | undefined; sort_field?: \"name\" | \"updated_at\" | \"created_at\" | \"is_default\" | undefined; sort_order?: \"asc\" | \"desc\" | undefined; }, { fields?: unknown; filter?: string | undefined; sort_field?: \"name\" | \"updated_at\" | \"created_at\" | \"is_default\" | undefined; sort_order?: \"asc\" | \"desc\" | undefined; page?: number | undefined; per_page?: number | undefined; }>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsResponse", + "type": "Object", + "tags": [], + "label": "FindPromptsResponse", + "description": [], + "signature": [ + "Zod.ZodObject<{ page: Zod.ZodNumber; perPage: Zod.ZodNumber; total: Zod.ZodNumber; data: Zod.ZodArray; name: Zod.ZodString; promptType: Zod.ZodEnum<[\"system\", \"quick\"]>; content: Zod.ZodString; categories: Zod.ZodOptional>; color: Zod.ZodOptional; isNewConversationDefault: Zod.ZodOptional; isDefault: Zod.ZodOptional; consumer: Zod.ZodOptional; updatedAt: Zod.ZodOptional; updatedBy: Zod.ZodOptional; createdAt: Zod.ZodOptional; createdBy: Zod.ZodOptional; users: Zod.ZodOptional; name: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id?: string | undefined; name?: string | undefined; }, { id?: string | undefined; name?: string | undefined; }>, \"many\">>; namespace: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }, { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }>, \"many\">; }, \"strip\", Zod.ZodTypeAny, { page: number; perPage: number; total: number; data: { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }[]; }, { page: number; perPage: number; total: number; data: { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }[]; }>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsSortField", + "type": "Object", + "tags": [], + "label": "FindPromptsSortField", + "description": [], + "signature": [ + "Zod.ZodEnum<[\"created_at\", \"is_default\", \"name\", \"updated_at\"]>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.FindPromptsSortFieldEnum", + "type": "Object", + "tags": [], + "label": "FindPromptsSortFieldEnum", + "description": [], + "signature": [ + "{ name: \"name\"; updated_at: \"updated_at\"; created_at: \"created_at\"; is_default: \"is_default\"; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.GenerationInterval", @@ -4465,6 +4841,21 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.MessageData", + "type": "Object", + "tags": [], + "label": "MessageData", + "description": [], + "signature": [ + "Zod.ZodObject<{}, \"strip\", Zod.ZodUnknown, Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\">, Zod.objectInputType<{}, Zod.ZodUnknown, \"strip\">>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.MessageRole", @@ -4690,6 +5081,36 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.PromptResponse", + "type": "Object", + "tags": [], + "label": "PromptResponse", + "description": [], + "signature": [ + "Zod.ZodObject<{ id: Zod.ZodString; timestamp: Zod.ZodOptional; name: Zod.ZodString; promptType: Zod.ZodEnum<[\"system\", \"quick\"]>; content: Zod.ZodString; categories: Zod.ZodOptional>; color: Zod.ZodOptional; isNewConversationDefault: Zod.ZodOptional; isDefault: Zod.ZodOptional; consumer: Zod.ZodOptional; updatedAt: Zod.ZodOptional; updatedBy: Zod.ZodOptional; createdAt: Zod.ZodOptional; createdBy: Zod.ZodOptional; users: Zod.ZodOptional; name: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id?: string | undefined; name?: string | undefined; }, { id?: string | undefined; name?: string | undefined; }>, \"many\">>; namespace: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }, { id: string; name: string; content: string; promptType: \"system\" | \"quick\"; timestamp?: string | undefined; categories?: string[] | undefined; color?: string | undefined; isNewConversationDefault?: boolean | undefined; isDefault?: boolean | undefined; consumer?: string | undefined; updatedAt?: string | undefined; updatedBy?: string | undefined; createdAt?: string | undefined; createdBy?: string | undefined; users?: { id?: string | undefined; name?: string | undefined; }[] | undefined; namespace?: string | undefined; }>" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.PromptTypeEnum", + "type": "Object", + "tags": [], + "label": "PromptTypeEnum", + "description": [], + "signature": [ + "{ system: \"system\"; quick: \"quick\"; }" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.Provider", @@ -4840,6 +5261,21 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/elastic-assistant-common", + "id": "def-common.RootContext", + "type": "Object", + "tags": [], + "label": "RootContext", + "description": [], + "signature": [ + "Zod.ZodLiteral<\"security\">" + ], + "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/elastic-assistant-common", "id": "def-common.SortOrder", diff --git a/api_docs/kbn_elastic_assistant_common.mdx b/api_docs/kbn_elastic_assistant_common.mdx index 4ad36d69ac30..3d5fa3e46128 100644 --- a/api_docs/kbn_elastic_assistant_common.mdx +++ b/api_docs/kbn_elastic_assistant_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-elastic-assistant-common title: "@kbn/elastic-assistant-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/elastic-assistant-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/elastic-assistant-common'] --- import kbnElasticAssistantCommonObj from './kbn_elastic_assistant_common.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/security-generative-ai](https://github.com/orgs/elastic/teams/ | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 333 | 0 | 309 | 0 | +| 362 | 0 | 336 | 0 | ## Common diff --git a/api_docs/kbn_entities_schema.devdocs.json b/api_docs/kbn_entities_schema.devdocs.json index b021defec9a7..8df1c3650456 100644 --- a/api_docs/kbn_entities_schema.devdocs.json +++ b/api_docs/kbn_entities_schema.devdocs.json @@ -32,18 +32,6 @@ "deprecated": false, "trackAdoption": false, "initialIsOpen": false - }, - { - "parentPluginId": "@kbn/entities-schema", - "id": "def-common.EntityType", - "type": "Enum", - "tags": [], - "label": "EntityType", - "description": [], - "path": "x-pack/packages/kbn-entities-schema/src/schema/common.ts", - "deprecated": false, - "trackAdoption": false, - "initialIsOpen": false } ], "misc": [ @@ -55,15 +43,7 @@ "label": "EntityDefinition", "description": [], "signature": [ - "{ id: string; type: ", - { - "pluginId": "@kbn/entities-schema", - "scope": "common", - "docId": "kibKbnEntitiesSchemaPluginApi", - "section": "def-common.EntityType", - "text": "EntityType" - }, - "; name: string; history: { interval: moment.Duration; timestampField: string; lookbackPeriod?: moment.Duration | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }; managed: boolean; indexPatterns: string[]; identityFields: ({ field: string; optional: boolean; } | { field: string; optional: boolean; })[]; displayNameTemplate: string; description?: string | undefined; filter?: string | undefined; metadata?: ({ source: string; destination?: string | undefined; limit?: number | undefined; } | { source: string; destination: string; limit: number; })[] | undefined; metrics?: { name: string; metrics: ({ name: string; field: string; aggregation: ", + "{ id: string; type: string; name: string; history: { interval: moment.Duration; timestampField: string; lookbackPeriod?: moment.Duration | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }; managed: boolean; indexPatterns: string[]; identityFields: ({ field: string; optional: boolean; } | { field: string; optional: boolean; })[]; displayNameTemplate: string; description?: string | undefined; filter?: string | undefined; metadata?: ({ source: string; destination?: string | undefined; limit?: number | undefined; } | { source: string; destination: string; limit: number; })[] | undefined; metrics?: { name: string; metrics: ({ name: string; field: string; aggregation: ", { "pluginId": "@kbn/entities-schema", "scope": "common", @@ -241,15 +221,7 @@ "label": "entityDefinitionSchema", "description": [], "signature": [ - "Zod.ZodObject<{ id: Zod.ZodString; name: Zod.ZodString; description: Zod.ZodOptional; type: Zod.ZodNativeEnum; filter: Zod.ZodOptional; indexPatterns: Zod.ZodArray; identityFields: Zod.ZodArray, Zod.ZodEffects]>, \"many\">; displayNameTemplate: Zod.ZodString; metadata: Zod.ZodOptional; limit: Zod.ZodOptional>; }, \"strip\", Zod.ZodTypeAny, { source: string; destination?: string | undefined; limit?: number | undefined; }, { source: string; destination?: string | undefined; limit?: number | undefined; }>, Zod.ZodEffects]>, \"many\">>; metrics: Zod.ZodOptional; type: Zod.ZodString; filter: Zod.ZodOptional; indexPatterns: Zod.ZodArray; identityFields: Zod.ZodArray, Zod.ZodEffects]>, \"many\">; displayNameTemplate: Zod.ZodString; metadata: Zod.ZodOptional; limit: Zod.ZodOptional>; }, \"strip\", Zod.ZodTypeAny, { source: string; destination?: string | undefined; limit?: number | undefined; }, { source: string; destination?: string | undefined; limit?: number | undefined; }>, Zod.ZodEffects]>, \"many\">>; metrics: Zod.ZodOptional, \"many\">>; staticFields: Zod.ZodOptional>; managed: Zod.ZodDefault>; history: Zod.ZodObject<{ timestampField: Zod.ZodString; interval: Zod.ZodEffects, moment.Duration, string>; lookbackPeriod: Zod.ZodOptional>; settings: Zod.ZodOptional; syncDelay: Zod.ZodOptional; frequency: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }>>; }, \"strip\", Zod.ZodTypeAny, { interval: moment.Duration; timestampField: string; lookbackPeriod?: moment.Duration | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }, { interval: string; timestampField: string; lookbackPeriod?: string | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }>; latest: Zod.ZodOptional; syncDelay: Zod.ZodOptional; frequency: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }>>; }, \"strip\", Zod.ZodTypeAny, { settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }, { settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }>>; }, \"strip\", Zod.ZodTypeAny, { id: string; type: ", - { - "pluginId": "@kbn/entities-schema", - "scope": "common", - "docId": "kibKbnEntitiesSchemaPluginApi", - "section": "def-common.EntityType", - "text": "EntityType" - }, - "; name: string; history: { interval: moment.Duration; timestampField: string; lookbackPeriod?: moment.Duration | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }; managed: boolean; indexPatterns: string[]; identityFields: ({ field: string; optional: boolean; } | { field: string; optional: boolean; })[]; displayNameTemplate: string; description?: string | undefined; filter?: string | undefined; metadata?: ({ source: string; destination?: string | undefined; limit?: number | undefined; } | { source: string; destination: string; limit: number; })[] | undefined; metrics?: { name: string; metrics: ({ name: string; field: string; aggregation: ", + "; filter?: string | undefined; } | { name: string; aggregation: \"doc_count\"; filter?: string | undefined; } | { name: string; field: string; percentile: number; aggregation: \"percentile\"; filter?: string | undefined; })[]; equation: string; }>, \"many\">>; staticFields: Zod.ZodOptional>; managed: Zod.ZodDefault>; history: Zod.ZodObject<{ timestampField: Zod.ZodString; interval: Zod.ZodEffects, moment.Duration, string>; lookbackPeriod: Zod.ZodOptional>; settings: Zod.ZodOptional; syncDelay: Zod.ZodOptional; frequency: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }>>; }, \"strip\", Zod.ZodTypeAny, { interval: moment.Duration; timestampField: string; lookbackPeriod?: moment.Duration | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }, { interval: string; timestampField: string; lookbackPeriod?: string | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }>; latest: Zod.ZodOptional; syncDelay: Zod.ZodOptional; frequency: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }, { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; }>>; }, \"strip\", Zod.ZodTypeAny, { settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }, { settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }>>; }, \"strip\", Zod.ZodTypeAny, { id: string; type: string; name: string; history: { interval: moment.Duration; timestampField: string; lookbackPeriod?: moment.Duration | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }; managed: boolean; indexPatterns: string[]; identityFields: ({ field: string; optional: boolean; } | { field: string; optional: boolean; })[]; displayNameTemplate: string; description?: string | undefined; filter?: string | undefined; metadata?: ({ source: string; destination?: string | undefined; limit?: number | undefined; } | { source: string; destination: string; limit: number; })[] | undefined; metrics?: { name: string; metrics: ({ name: string; field: string; aggregation: ", { "pluginId": "@kbn/entities-schema", "scope": "common", @@ -305,15 +269,7 @@ "section": "def-common.BasicAggregations", "text": "BasicAggregations" }, - "; filter?: string | undefined; } | { name: string; aggregation: \"doc_count\"; filter?: string | undefined; } | { name: string; field: string; percentile: number; aggregation: \"percentile\"; filter?: string | undefined; })[]; equation: string; }[] | undefined; staticFields?: Record | undefined; latest?: { settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; } | undefined; }, { id: string; type: ", - { - "pluginId": "@kbn/entities-schema", - "scope": "common", - "docId": "kibKbnEntitiesSchemaPluginApi", - "section": "def-common.EntityType", - "text": "EntityType" - }, - "; name: string; history: { interval: string; timestampField: string; lookbackPeriod?: string | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }; indexPatterns: string[]; identityFields: (string | { field: string; optional: boolean; })[]; displayNameTemplate: string; description?: string | undefined; filter?: string | undefined; metadata?: (string | { source: string; destination?: string | undefined; limit?: number | undefined; })[] | undefined; metrics?: { name: string; metrics: ({ name: string; field: string; aggregation: ", + "; filter?: string | undefined; } | { name: string; aggregation: \"doc_count\"; filter?: string | undefined; } | { name: string; field: string; percentile: number; aggregation: \"percentile\"; filter?: string | undefined; })[]; equation: string; }[] | undefined; staticFields?: Record | undefined; latest?: { settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; } | undefined; }, { id: string; type: string; name: string; history: { interval: string; timestampField: string; lookbackPeriod?: string | undefined; settings?: { syncField?: string | undefined; syncDelay?: string | undefined; frequency?: string | undefined; } | undefined; }; indexPatterns: string[]; identityFields: (string | { field: string; optional: boolean; })[]; displayNameTemplate: string; description?: string | undefined; filter?: string | undefined; metadata?: (string | { source: string; destination?: string | undefined; limit?: number | undefined; })[] | undefined; metrics?: { name: string; metrics: ({ name: string; field: string; aggregation: ", { "pluginId": "@kbn/entities-schema", "scope": "common", @@ -358,29 +314,6 @@ "trackAdoption": false, "initialIsOpen": false }, - { - "parentPluginId": "@kbn/entities-schema", - "id": "def-common.entityTypeSchema", - "type": "Object", - "tags": [], - "label": "entityTypeSchema", - "description": [], - "signature": [ - "Zod.ZodNativeEnum" - ], - "path": "x-pack/packages/kbn-entities-schema/src/schema/common.ts", - "deprecated": false, - "trackAdoption": false, - "initialIsOpen": false - }, { "parentPluginId": "@kbn/entities-schema", "id": "def-common.filterSchema", diff --git a/api_docs/kbn_entities_schema.mdx b/api_docs/kbn_entities_schema.mdx index ce9c1e6758ab..20e0f042937f 100644 --- a/api_docs/kbn_entities_schema.mdx +++ b/api_docs/kbn_entities_schema.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-entities-schema title: "@kbn/entities-schema" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/entities-schema plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/entities-schema'] --- import kbnEntitiesSchemaObj from './kbn_entities_schema.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/obs-knowledge-team](https://github.com/orgs/elastic/teams/obs- | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 20 | 0 | 20 | 0 | +| 18 | 0 | 18 | 0 | ## Common diff --git a/api_docs/kbn_es.mdx b/api_docs/kbn_es.mdx index 0261ccb17987..1486693030f7 100644 --- a/api_docs/kbn_es.mdx +++ b/api_docs/kbn_es.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-es title: "@kbn/es" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/es plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/es'] --- import kbnEsObj from './kbn_es.devdocs.json'; diff --git a/api_docs/kbn_es_archiver.mdx b/api_docs/kbn_es_archiver.mdx index 7ec81ffb1ef0..6c66e97b0b2f 100644 --- a/api_docs/kbn_es_archiver.mdx +++ b/api_docs/kbn_es_archiver.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-es-archiver title: "@kbn/es-archiver" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/es-archiver plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/es-archiver'] --- import kbnEsArchiverObj from './kbn_es_archiver.devdocs.json'; diff --git a/api_docs/kbn_es_errors.mdx b/api_docs/kbn_es_errors.mdx index a74b91a72272..1dcbb7c11436 100644 --- a/api_docs/kbn_es_errors.mdx +++ b/api_docs/kbn_es_errors.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-es-errors title: "@kbn/es-errors" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/es-errors plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/es-errors'] --- import kbnEsErrorsObj from './kbn_es_errors.devdocs.json'; diff --git a/api_docs/kbn_es_query.mdx b/api_docs/kbn_es_query.mdx index 0eb13fd6699d..986b33fa6491 100644 --- a/api_docs/kbn_es_query.mdx +++ b/api_docs/kbn_es_query.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-es-query title: "@kbn/es-query" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/es-query plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/es-query'] --- import kbnEsQueryObj from './kbn_es_query.devdocs.json'; diff --git a/api_docs/kbn_es_types.mdx b/api_docs/kbn_es_types.mdx index f5e1272c398a..14471f6c9514 100644 --- a/api_docs/kbn_es_types.mdx +++ b/api_docs/kbn_es_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-es-types title: "@kbn/es-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/es-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/es-types'] --- import kbnEsTypesObj from './kbn_es_types.devdocs.json'; diff --git a/api_docs/kbn_eslint_plugin_imports.mdx b/api_docs/kbn_eslint_plugin_imports.mdx index 63ec260fbd6d..c0edb00b1f37 100644 --- a/api_docs/kbn_eslint_plugin_imports.mdx +++ b/api_docs/kbn_eslint_plugin_imports.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-eslint-plugin-imports title: "@kbn/eslint-plugin-imports" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/eslint-plugin-imports plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/eslint-plugin-imports'] --- import kbnEslintPluginImportsObj from './kbn_eslint_plugin_imports.devdocs.json'; diff --git a/api_docs/kbn_esql_ast.mdx b/api_docs/kbn_esql_ast.mdx index 5e17da9b5556..f5add9c43c98 100644 --- a/api_docs/kbn_esql_ast.mdx +++ b/api_docs/kbn_esql_ast.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-esql-ast title: "@kbn/esql-ast" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/esql-ast plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/esql-ast'] --- import kbnEsqlAstObj from './kbn_esql_ast.devdocs.json'; diff --git a/api_docs/kbn_esql_utils.mdx b/api_docs/kbn_esql_utils.mdx index b7f6b9d5852c..9bd6881bc449 100644 --- a/api_docs/kbn_esql_utils.mdx +++ b/api_docs/kbn_esql_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-esql-utils title: "@kbn/esql-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/esql-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/esql-utils'] --- import kbnEsqlUtilsObj from './kbn_esql_utils.devdocs.json'; diff --git a/api_docs/kbn_esql_validation_autocomplete.devdocs.json b/api_docs/kbn_esql_validation_autocomplete.devdocs.json index 7f6aa1393fd1..79100a05ac28 100644 --- a/api_docs/kbn_esql_validation_autocomplete.devdocs.json +++ b/api_docs/kbn_esql_validation_autocomplete.devdocs.json @@ -2075,7 +2075,7 @@ "section": "def-common.ESQLFunction", "text": "ESQLFunction" }, - ") => string" + ", useCaps: boolean) => string" ], "path": "packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts", "deprecated": false, @@ -2101,6 +2101,21 @@ "deprecated": false, "trackAdoption": false, "isRequired": true + }, + { + "parentPluginId": "@kbn/esql-validation-autocomplete", + "id": "def-common.printFunctionSignature.$2", + "type": "boolean", + "tags": [], + "label": "useCaps", + "description": [], + "signature": [ + "boolean" + ], + "path": "packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": true } ], "returnComment": [], diff --git a/api_docs/kbn_esql_validation_autocomplete.mdx b/api_docs/kbn_esql_validation_autocomplete.mdx index c4ffd0abae26..691acdff1c9b 100644 --- a/api_docs/kbn_esql_validation_autocomplete.mdx +++ b/api_docs/kbn_esql_validation_autocomplete.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-esql-validation-autocomplete title: "@kbn/esql-validation-autocomplete" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/esql-validation-autocomplete plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/esql-validation-autocomplete'] --- import kbnEsqlValidationAutocompleteObj from './kbn_esql_validation_autocomplete.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/kibana-esql](https://github.com/orgs/elastic/teams/kibana-esql | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 192 | 0 | 181 | 10 | +| 193 | 0 | 182 | 10 | ## Common diff --git a/api_docs/kbn_event_annotation_common.mdx b/api_docs/kbn_event_annotation_common.mdx index e74c70bb345b..8424f280baab 100644 --- a/api_docs/kbn_event_annotation_common.mdx +++ b/api_docs/kbn_event_annotation_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-event-annotation-common title: "@kbn/event-annotation-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/event-annotation-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/event-annotation-common'] --- import kbnEventAnnotationCommonObj from './kbn_event_annotation_common.devdocs.json'; diff --git a/api_docs/kbn_event_annotation_components.mdx b/api_docs/kbn_event_annotation_components.mdx index 3bbdbec46ab1..656c01770fd8 100644 --- a/api_docs/kbn_event_annotation_components.mdx +++ b/api_docs/kbn_event_annotation_components.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-event-annotation-components title: "@kbn/event-annotation-components" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/event-annotation-components plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/event-annotation-components'] --- import kbnEventAnnotationComponentsObj from './kbn_event_annotation_components.devdocs.json'; diff --git a/api_docs/kbn_expandable_flyout.mdx b/api_docs/kbn_expandable_flyout.mdx index 116dc037842e..baf9db952ef9 100644 --- a/api_docs/kbn_expandable_flyout.mdx +++ b/api_docs/kbn_expandable_flyout.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-expandable-flyout title: "@kbn/expandable-flyout" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/expandable-flyout plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/expandable-flyout'] --- import kbnExpandableFlyoutObj from './kbn_expandable_flyout.devdocs.json'; diff --git a/api_docs/kbn_field_types.mdx b/api_docs/kbn_field_types.mdx index b9d04dcd2e26..e31ceb0e0f10 100644 --- a/api_docs/kbn_field_types.mdx +++ b/api_docs/kbn_field_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-field-types title: "@kbn/field-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/field-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/field-types'] --- import kbnFieldTypesObj from './kbn_field_types.devdocs.json'; diff --git a/api_docs/kbn_field_utils.mdx b/api_docs/kbn_field_utils.mdx index 0d7b3960a9d8..4c75aa026214 100644 --- a/api_docs/kbn_field_utils.mdx +++ b/api_docs/kbn_field_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-field-utils title: "@kbn/field-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/field-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/field-utils'] --- import kbnFieldUtilsObj from './kbn_field_utils.devdocs.json'; diff --git a/api_docs/kbn_find_used_node_modules.mdx b/api_docs/kbn_find_used_node_modules.mdx index 1473eac64b5b..ef1d6d89ee8a 100644 --- a/api_docs/kbn_find_used_node_modules.mdx +++ b/api_docs/kbn_find_used_node_modules.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-find-used-node-modules title: "@kbn/find-used-node-modules" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/find-used-node-modules plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/find-used-node-modules'] --- import kbnFindUsedNodeModulesObj from './kbn_find_used_node_modules.devdocs.json'; diff --git a/api_docs/kbn_formatters.mdx b/api_docs/kbn_formatters.mdx index 805fff502f32..a44c4afb1079 100644 --- a/api_docs/kbn_formatters.mdx +++ b/api_docs/kbn_formatters.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-formatters title: "@kbn/formatters" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/formatters plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/formatters'] --- import kbnFormattersObj from './kbn_formatters.devdocs.json'; diff --git a/api_docs/kbn_ftr_common_functional_services.mdx b/api_docs/kbn_ftr_common_functional_services.mdx index 6c8adbcc1551..c17ce93d4e15 100644 --- a/api_docs/kbn_ftr_common_functional_services.mdx +++ b/api_docs/kbn_ftr_common_functional_services.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ftr-common-functional-services title: "@kbn/ftr-common-functional-services" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ftr-common-functional-services plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ftr-common-functional-services'] --- import kbnFtrCommonFunctionalServicesObj from './kbn_ftr_common_functional_services.devdocs.json'; diff --git a/api_docs/kbn_ftr_common_functional_ui_services.mdx b/api_docs/kbn_ftr_common_functional_ui_services.mdx index 8742eff5c728..43d1c80c3497 100644 --- a/api_docs/kbn_ftr_common_functional_ui_services.mdx +++ b/api_docs/kbn_ftr_common_functional_ui_services.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ftr-common-functional-ui-services title: "@kbn/ftr-common-functional-ui-services" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ftr-common-functional-ui-services plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ftr-common-functional-ui-services'] --- import kbnFtrCommonFunctionalUiServicesObj from './kbn_ftr_common_functional_ui_services.devdocs.json'; diff --git a/api_docs/kbn_generate.mdx b/api_docs/kbn_generate.mdx index b6fd436c0a3f..83cea2ae8cbe 100644 --- a/api_docs/kbn_generate.mdx +++ b/api_docs/kbn_generate.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-generate title: "@kbn/generate" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/generate plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/generate'] --- import kbnGenerateObj from './kbn_generate.devdocs.json'; diff --git a/api_docs/kbn_generate_console_definitions.mdx b/api_docs/kbn_generate_console_definitions.mdx index 585eadadf0b8..be09e0f47604 100644 --- a/api_docs/kbn_generate_console_definitions.mdx +++ b/api_docs/kbn_generate_console_definitions.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-generate-console-definitions title: "@kbn/generate-console-definitions" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/generate-console-definitions plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/generate-console-definitions'] --- import kbnGenerateConsoleDefinitionsObj from './kbn_generate_console_definitions.devdocs.json'; diff --git a/api_docs/kbn_generate_csv.mdx b/api_docs/kbn_generate_csv.mdx index 8b2fc2558d85..4a7bcbf08705 100644 --- a/api_docs/kbn_generate_csv.mdx +++ b/api_docs/kbn_generate_csv.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-generate-csv title: "@kbn/generate-csv" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/generate-csv plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/generate-csv'] --- import kbnGenerateCsvObj from './kbn_generate_csv.devdocs.json'; diff --git a/api_docs/kbn_grouping.mdx b/api_docs/kbn_grouping.mdx index 812df52b659e..93f4cf0e68e9 100644 --- a/api_docs/kbn_grouping.mdx +++ b/api_docs/kbn_grouping.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-grouping title: "@kbn/grouping" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/grouping plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/grouping'] --- import kbnGroupingObj from './kbn_grouping.devdocs.json'; diff --git a/api_docs/kbn_guided_onboarding.mdx b/api_docs/kbn_guided_onboarding.mdx index 980c4e73e278..c2d0c0877fc8 100644 --- a/api_docs/kbn_guided_onboarding.mdx +++ b/api_docs/kbn_guided_onboarding.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-guided-onboarding title: "@kbn/guided-onboarding" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/guided-onboarding plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/guided-onboarding'] --- import kbnGuidedOnboardingObj from './kbn_guided_onboarding.devdocs.json'; diff --git a/api_docs/kbn_handlebars.mdx b/api_docs/kbn_handlebars.mdx index 30b096fbffc5..d687a2034bd2 100644 --- a/api_docs/kbn_handlebars.mdx +++ b/api_docs/kbn_handlebars.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-handlebars title: "@kbn/handlebars" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/handlebars plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/handlebars'] --- import kbnHandlebarsObj from './kbn_handlebars.devdocs.json'; diff --git a/api_docs/kbn_hapi_mocks.mdx b/api_docs/kbn_hapi_mocks.mdx index 8b2c6394ffe9..6f8e5d72854b 100644 --- a/api_docs/kbn_hapi_mocks.mdx +++ b/api_docs/kbn_hapi_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-hapi-mocks title: "@kbn/hapi-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/hapi-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/hapi-mocks'] --- import kbnHapiMocksObj from './kbn_hapi_mocks.devdocs.json'; diff --git a/api_docs/kbn_health_gateway_server.mdx b/api_docs/kbn_health_gateway_server.mdx index 64297db8f9b1..642fae2eda79 100644 --- a/api_docs/kbn_health_gateway_server.mdx +++ b/api_docs/kbn_health_gateway_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-health-gateway-server title: "@kbn/health-gateway-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/health-gateway-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/health-gateway-server'] --- import kbnHealthGatewayServerObj from './kbn_health_gateway_server.devdocs.json'; diff --git a/api_docs/kbn_home_sample_data_card.mdx b/api_docs/kbn_home_sample_data_card.mdx index e3fe5578ba37..7ca305798ab2 100644 --- a/api_docs/kbn_home_sample_data_card.mdx +++ b/api_docs/kbn_home_sample_data_card.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-home-sample-data-card title: "@kbn/home-sample-data-card" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/home-sample-data-card plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/home-sample-data-card'] --- import kbnHomeSampleDataCardObj from './kbn_home_sample_data_card.devdocs.json'; diff --git a/api_docs/kbn_home_sample_data_tab.mdx b/api_docs/kbn_home_sample_data_tab.mdx index 49da5aceb83c..4a1b189dc7e0 100644 --- a/api_docs/kbn_home_sample_data_tab.mdx +++ b/api_docs/kbn_home_sample_data_tab.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-home-sample-data-tab title: "@kbn/home-sample-data-tab" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/home-sample-data-tab plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/home-sample-data-tab'] --- import kbnHomeSampleDataTabObj from './kbn_home_sample_data_tab.devdocs.json'; diff --git a/api_docs/kbn_i18n.mdx b/api_docs/kbn_i18n.mdx index 4860fef10d48..a031d7533418 100644 --- a/api_docs/kbn_i18n.mdx +++ b/api_docs/kbn_i18n.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-i18n title: "@kbn/i18n" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/i18n plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/i18n'] --- import kbnI18nObj from './kbn_i18n.devdocs.json'; diff --git a/api_docs/kbn_i18n_react.mdx b/api_docs/kbn_i18n_react.mdx index e0d9a2539e0e..657cd13ebde6 100644 --- a/api_docs/kbn_i18n_react.mdx +++ b/api_docs/kbn_i18n_react.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-i18n-react title: "@kbn/i18n-react" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/i18n-react plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/i18n-react'] --- import kbnI18nReactObj from './kbn_i18n_react.devdocs.json'; diff --git a/api_docs/kbn_import_resolver.mdx b/api_docs/kbn_import_resolver.mdx index b22e26feaad1..284c1c2d06b9 100644 --- a/api_docs/kbn_import_resolver.mdx +++ b/api_docs/kbn_import_resolver.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-import-resolver title: "@kbn/import-resolver" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/import-resolver plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/import-resolver'] --- import kbnImportResolverObj from './kbn_import_resolver.devdocs.json'; diff --git a/api_docs/kbn_index_management.mdx b/api_docs/kbn_index_management.mdx index 6e407ace8a8d..525c62831ae8 100644 --- a/api_docs/kbn_index_management.mdx +++ b/api_docs/kbn_index_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-index-management title: "@kbn/index-management" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/index-management plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/index-management'] --- import kbnIndexManagementObj from './kbn_index_management.devdocs.json'; diff --git a/api_docs/kbn_inference_integration_flyout.mdx b/api_docs/kbn_inference_integration_flyout.mdx index 2f615365c232..7ebff19cd775 100644 --- a/api_docs/kbn_inference_integration_flyout.mdx +++ b/api_docs/kbn_inference_integration_flyout.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-inference_integration_flyout title: "@kbn/inference_integration_flyout" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/inference_integration_flyout plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/inference_integration_flyout'] --- import kbnInferenceIntegrationFlyoutObj from './kbn_inference_integration_flyout.devdocs.json'; diff --git a/api_docs/kbn_infra_forge.mdx b/api_docs/kbn_infra_forge.mdx index 144fe55a44a7..746628f6111f 100644 --- a/api_docs/kbn_infra_forge.mdx +++ b/api_docs/kbn_infra_forge.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-infra-forge title: "@kbn/infra-forge" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/infra-forge plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/infra-forge'] --- import kbnInfraForgeObj from './kbn_infra_forge.devdocs.json'; diff --git a/api_docs/kbn_interpreter.mdx b/api_docs/kbn_interpreter.mdx index 3c8a443904a5..03caecb2e0ee 100644 --- a/api_docs/kbn_interpreter.mdx +++ b/api_docs/kbn_interpreter.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-interpreter title: "@kbn/interpreter" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/interpreter plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/interpreter'] --- import kbnInterpreterObj from './kbn_interpreter.devdocs.json'; diff --git a/api_docs/kbn_io_ts_utils.mdx b/api_docs/kbn_io_ts_utils.mdx index ef58f001144c..c2b3fa851c4a 100644 --- a/api_docs/kbn_io_ts_utils.mdx +++ b/api_docs/kbn_io_ts_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-io-ts-utils title: "@kbn/io-ts-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/io-ts-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/io-ts-utils'] --- import kbnIoTsUtilsObj from './kbn_io_ts_utils.devdocs.json'; diff --git a/api_docs/kbn_ipynb.mdx b/api_docs/kbn_ipynb.mdx index a0b5352cb941..090d71c7d3a6 100644 --- a/api_docs/kbn_ipynb.mdx +++ b/api_docs/kbn_ipynb.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ipynb title: "@kbn/ipynb" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ipynb plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ipynb'] --- import kbnIpynbObj from './kbn_ipynb.devdocs.json'; diff --git a/api_docs/kbn_jest_serializers.mdx b/api_docs/kbn_jest_serializers.mdx index 62db47870223..7ebe91d1affa 100644 --- a/api_docs/kbn_jest_serializers.mdx +++ b/api_docs/kbn_jest_serializers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-jest-serializers title: "@kbn/jest-serializers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/jest-serializers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/jest-serializers'] --- import kbnJestSerializersObj from './kbn_jest_serializers.devdocs.json'; diff --git a/api_docs/kbn_journeys.mdx b/api_docs/kbn_journeys.mdx index 6aaf038c061d..c72865248ee7 100644 --- a/api_docs/kbn_journeys.mdx +++ b/api_docs/kbn_journeys.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-journeys title: "@kbn/journeys" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/journeys plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/journeys'] --- import kbnJourneysObj from './kbn_journeys.devdocs.json'; diff --git a/api_docs/kbn_json_ast.mdx b/api_docs/kbn_json_ast.mdx index 0cc2134bbcd5..801f62bd1591 100644 --- a/api_docs/kbn_json_ast.mdx +++ b/api_docs/kbn_json_ast.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-json-ast title: "@kbn/json-ast" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/json-ast plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/json-ast'] --- import kbnJsonAstObj from './kbn_json_ast.devdocs.json'; diff --git a/api_docs/kbn_json_schemas.mdx b/api_docs/kbn_json_schemas.mdx index 36b3d6608627..8fa30ba33fe1 100644 --- a/api_docs/kbn_json_schemas.mdx +++ b/api_docs/kbn_json_schemas.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-json-schemas title: "@kbn/json-schemas" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/json-schemas plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/json-schemas'] --- import kbnJsonSchemasObj from './kbn_json_schemas.devdocs.json'; diff --git a/api_docs/kbn_kibana_manifest_schema.mdx b/api_docs/kbn_kibana_manifest_schema.mdx index f225b3e7acad..c11aa7b62a53 100644 --- a/api_docs/kbn_kibana_manifest_schema.mdx +++ b/api_docs/kbn_kibana_manifest_schema.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-kibana-manifest-schema title: "@kbn/kibana-manifest-schema" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/kibana-manifest-schema plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/kibana-manifest-schema'] --- import kbnKibanaManifestSchemaObj from './kbn_kibana_manifest_schema.devdocs.json'; diff --git a/api_docs/kbn_language_documentation_popover.mdx b/api_docs/kbn_language_documentation_popover.mdx index 4e33fed36680..b53279e8b82d 100644 --- a/api_docs/kbn_language_documentation_popover.mdx +++ b/api_docs/kbn_language_documentation_popover.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-language-documentation-popover title: "@kbn/language-documentation-popover" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/language-documentation-popover plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/language-documentation-popover'] --- import kbnLanguageDocumentationPopoverObj from './kbn_language_documentation_popover.devdocs.json'; diff --git a/api_docs/kbn_lens_embeddable_utils.mdx b/api_docs/kbn_lens_embeddable_utils.mdx index 8d91cafdb7ee..e98e15c4292e 100644 --- a/api_docs/kbn_lens_embeddable_utils.mdx +++ b/api_docs/kbn_lens_embeddable_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-lens-embeddable-utils title: "@kbn/lens-embeddable-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/lens-embeddable-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/lens-embeddable-utils'] --- import kbnLensEmbeddableUtilsObj from './kbn_lens_embeddable_utils.devdocs.json'; diff --git a/api_docs/kbn_lens_formula_docs.mdx b/api_docs/kbn_lens_formula_docs.mdx index ef78af397d76..bd77300b74d0 100644 --- a/api_docs/kbn_lens_formula_docs.mdx +++ b/api_docs/kbn_lens_formula_docs.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-lens-formula-docs title: "@kbn/lens-formula-docs" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/lens-formula-docs plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/lens-formula-docs'] --- import kbnLensFormulaDocsObj from './kbn_lens_formula_docs.devdocs.json'; diff --git a/api_docs/kbn_logging.mdx b/api_docs/kbn_logging.mdx index 91dd5aeb8bd7..0d333bb0bc5b 100644 --- a/api_docs/kbn_logging.mdx +++ b/api_docs/kbn_logging.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-logging title: "@kbn/logging" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/logging plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/logging'] --- import kbnLoggingObj from './kbn_logging.devdocs.json'; diff --git a/api_docs/kbn_logging_mocks.mdx b/api_docs/kbn_logging_mocks.mdx index d774413cc385..c34cac43ecb9 100644 --- a/api_docs/kbn_logging_mocks.mdx +++ b/api_docs/kbn_logging_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-logging-mocks title: "@kbn/logging-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/logging-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/logging-mocks'] --- import kbnLoggingMocksObj from './kbn_logging_mocks.devdocs.json'; diff --git a/api_docs/kbn_managed_content_badge.mdx b/api_docs/kbn_managed_content_badge.mdx index 6a3517374371..8a275002f77c 100644 --- a/api_docs/kbn_managed_content_badge.mdx +++ b/api_docs/kbn_managed_content_badge.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-managed-content-badge title: "@kbn/managed-content-badge" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/managed-content-badge plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/managed-content-badge'] --- import kbnManagedContentBadgeObj from './kbn_managed_content_badge.devdocs.json'; diff --git a/api_docs/kbn_managed_vscode_config.mdx b/api_docs/kbn_managed_vscode_config.mdx index a65dae67d619..b1cc770eb0a4 100644 --- a/api_docs/kbn_managed_vscode_config.mdx +++ b/api_docs/kbn_managed_vscode_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-managed-vscode-config title: "@kbn/managed-vscode-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/managed-vscode-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/managed-vscode-config'] --- import kbnManagedVscodeConfigObj from './kbn_managed_vscode_config.devdocs.json'; diff --git a/api_docs/kbn_management_cards_navigation.mdx b/api_docs/kbn_management_cards_navigation.mdx index 9b6731111061..c7331c526dcf 100644 --- a/api_docs/kbn_management_cards_navigation.mdx +++ b/api_docs/kbn_management_cards_navigation.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-cards-navigation title: "@kbn/management-cards-navigation" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-cards-navigation plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-cards-navigation'] --- import kbnManagementCardsNavigationObj from './kbn_management_cards_navigation.devdocs.json'; diff --git a/api_docs/kbn_management_settings_application.mdx b/api_docs/kbn_management_settings_application.mdx index ca126be4df24..93d0d9733e10 100644 --- a/api_docs/kbn_management_settings_application.mdx +++ b/api_docs/kbn_management_settings_application.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-application title: "@kbn/management-settings-application" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-application plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-application'] --- import kbnManagementSettingsApplicationObj from './kbn_management_settings_application.devdocs.json'; diff --git a/api_docs/kbn_management_settings_components_field_category.mdx b/api_docs/kbn_management_settings_components_field_category.mdx index c511dd54a15e..d61d33f61857 100644 --- a/api_docs/kbn_management_settings_components_field_category.mdx +++ b/api_docs/kbn_management_settings_components_field_category.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-components-field-category title: "@kbn/management-settings-components-field-category" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-components-field-category plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-components-field-category'] --- import kbnManagementSettingsComponentsFieldCategoryObj from './kbn_management_settings_components_field_category.devdocs.json'; diff --git a/api_docs/kbn_management_settings_components_field_input.mdx b/api_docs/kbn_management_settings_components_field_input.mdx index 994da45ca770..ff72a4457891 100644 --- a/api_docs/kbn_management_settings_components_field_input.mdx +++ b/api_docs/kbn_management_settings_components_field_input.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-components-field-input title: "@kbn/management-settings-components-field-input" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-components-field-input plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-components-field-input'] --- import kbnManagementSettingsComponentsFieldInputObj from './kbn_management_settings_components_field_input.devdocs.json'; diff --git a/api_docs/kbn_management_settings_components_field_row.mdx b/api_docs/kbn_management_settings_components_field_row.mdx index c5d50373067c..3635b926cc37 100644 --- a/api_docs/kbn_management_settings_components_field_row.mdx +++ b/api_docs/kbn_management_settings_components_field_row.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-components-field-row title: "@kbn/management-settings-components-field-row" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-components-field-row plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-components-field-row'] --- import kbnManagementSettingsComponentsFieldRowObj from './kbn_management_settings_components_field_row.devdocs.json'; diff --git a/api_docs/kbn_management_settings_components_form.mdx b/api_docs/kbn_management_settings_components_form.mdx index 1882aa39ab30..65b4a55c1d99 100644 --- a/api_docs/kbn_management_settings_components_form.mdx +++ b/api_docs/kbn_management_settings_components_form.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-components-form title: "@kbn/management-settings-components-form" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-components-form plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-components-form'] --- import kbnManagementSettingsComponentsFormObj from './kbn_management_settings_components_form.devdocs.json'; diff --git a/api_docs/kbn_management_settings_field_definition.mdx b/api_docs/kbn_management_settings_field_definition.mdx index f5c335646fa9..f3a66c51dc52 100644 --- a/api_docs/kbn_management_settings_field_definition.mdx +++ b/api_docs/kbn_management_settings_field_definition.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-field-definition title: "@kbn/management-settings-field-definition" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-field-definition plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-field-definition'] --- import kbnManagementSettingsFieldDefinitionObj from './kbn_management_settings_field_definition.devdocs.json'; diff --git a/api_docs/kbn_management_settings_ids.devdocs.json b/api_docs/kbn_management_settings_ids.devdocs.json index d3906a812c56..5ba648240dcd 100644 --- a/api_docs/kbn_management_settings_ids.devdocs.json +++ b/api_docs/kbn_management_settings_ids.devdocs.json @@ -1402,6 +1402,21 @@ "trackAdoption": false, "initialIsOpen": false }, + { + "parentPluginId": "@kbn/management-settings-ids", + "id": "def-common.OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID", + "type": "string", + "tags": [], + "label": "OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID", + "description": [], + "signature": [ + "\"observability:logSources\"" + ], + "path": "packages/kbn-management/settings/setting_ids/index.ts", + "deprecated": false, + "trackAdoption": false, + "initialIsOpen": false + }, { "parentPluginId": "@kbn/management-settings-ids", "id": "def-common.OBSERVABILITY_LOGS_EXPLORER_ALLOWED_DATA_VIEWS_ID", diff --git a/api_docs/kbn_management_settings_ids.mdx b/api_docs/kbn_management_settings_ids.mdx index ce8b290ef67f..261348381e3e 100644 --- a/api_docs/kbn_management_settings_ids.mdx +++ b/api_docs/kbn_management_settings_ids.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-ids title: "@kbn/management-settings-ids" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-ids plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-ids'] --- import kbnManagementSettingsIdsObj from './kbn_management_settings_ids.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/appex-sharedux @elastic/kibana-management](https://github.com/ | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 138 | 0 | 136 | 0 | +| 139 | 0 | 137 | 0 | ## Common diff --git a/api_docs/kbn_management_settings_section_registry.mdx b/api_docs/kbn_management_settings_section_registry.mdx index 84021653d84c..f60833845067 100644 --- a/api_docs/kbn_management_settings_section_registry.mdx +++ b/api_docs/kbn_management_settings_section_registry.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-section-registry title: "@kbn/management-settings-section-registry" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-section-registry plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-section-registry'] --- import kbnManagementSettingsSectionRegistryObj from './kbn_management_settings_section_registry.devdocs.json'; diff --git a/api_docs/kbn_management_settings_types.mdx b/api_docs/kbn_management_settings_types.mdx index 3a7abe56c028..7eba08c9e7d8 100644 --- a/api_docs/kbn_management_settings_types.mdx +++ b/api_docs/kbn_management_settings_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-types title: "@kbn/management-settings-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-types'] --- import kbnManagementSettingsTypesObj from './kbn_management_settings_types.devdocs.json'; diff --git a/api_docs/kbn_management_settings_utilities.mdx b/api_docs/kbn_management_settings_utilities.mdx index 1b0172b92d3d..804fe91a09dd 100644 --- a/api_docs/kbn_management_settings_utilities.mdx +++ b/api_docs/kbn_management_settings_utilities.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-settings-utilities title: "@kbn/management-settings-utilities" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-settings-utilities plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-settings-utilities'] --- import kbnManagementSettingsUtilitiesObj from './kbn_management_settings_utilities.devdocs.json'; diff --git a/api_docs/kbn_management_storybook_config.mdx b/api_docs/kbn_management_storybook_config.mdx index 5efe08aceb6c..ba63f756f4de 100644 --- a/api_docs/kbn_management_storybook_config.mdx +++ b/api_docs/kbn_management_storybook_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-management-storybook-config title: "@kbn/management-storybook-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/management-storybook-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/management-storybook-config'] --- import kbnManagementStorybookConfigObj from './kbn_management_storybook_config.devdocs.json'; diff --git a/api_docs/kbn_mapbox_gl.mdx b/api_docs/kbn_mapbox_gl.mdx index efc0dffc6745..c557c3a2b8c7 100644 --- a/api_docs/kbn_mapbox_gl.mdx +++ b/api_docs/kbn_mapbox_gl.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-mapbox-gl title: "@kbn/mapbox-gl" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/mapbox-gl plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/mapbox-gl'] --- import kbnMapboxGlObj from './kbn_mapbox_gl.devdocs.json'; diff --git a/api_docs/kbn_maps_vector_tile_utils.mdx b/api_docs/kbn_maps_vector_tile_utils.mdx index 644e14408c50..a99960aab7b7 100644 --- a/api_docs/kbn_maps_vector_tile_utils.mdx +++ b/api_docs/kbn_maps_vector_tile_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-maps-vector-tile-utils title: "@kbn/maps-vector-tile-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/maps-vector-tile-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/maps-vector-tile-utils'] --- import kbnMapsVectorTileUtilsObj from './kbn_maps_vector_tile_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_agg_utils.mdx b/api_docs/kbn_ml_agg_utils.mdx index 243b5128e2eb..d2c9848d3c25 100644 --- a/api_docs/kbn_ml_agg_utils.mdx +++ b/api_docs/kbn_ml_agg_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-agg-utils title: "@kbn/ml-agg-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-agg-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-agg-utils'] --- import kbnMlAggUtilsObj from './kbn_ml_agg_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_anomaly_utils.mdx b/api_docs/kbn_ml_anomaly_utils.mdx index e4a465a5ffd4..de5e88c80664 100644 --- a/api_docs/kbn_ml_anomaly_utils.mdx +++ b/api_docs/kbn_ml_anomaly_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-anomaly-utils title: "@kbn/ml-anomaly-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-anomaly-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-anomaly-utils'] --- import kbnMlAnomalyUtilsObj from './kbn_ml_anomaly_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_cancellable_search.mdx b/api_docs/kbn_ml_cancellable_search.mdx index 13db611d4feb..04fdfd3fbe85 100644 --- a/api_docs/kbn_ml_cancellable_search.mdx +++ b/api_docs/kbn_ml_cancellable_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-cancellable-search title: "@kbn/ml-cancellable-search" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-cancellable-search plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-cancellable-search'] --- import kbnMlCancellableSearchObj from './kbn_ml_cancellable_search.devdocs.json'; diff --git a/api_docs/kbn_ml_category_validator.mdx b/api_docs/kbn_ml_category_validator.mdx index 10e5dfe203c4..b0d491a2e0c2 100644 --- a/api_docs/kbn_ml_category_validator.mdx +++ b/api_docs/kbn_ml_category_validator.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-category-validator title: "@kbn/ml-category-validator" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-category-validator plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-category-validator'] --- import kbnMlCategoryValidatorObj from './kbn_ml_category_validator.devdocs.json'; diff --git a/api_docs/kbn_ml_chi2test.mdx b/api_docs/kbn_ml_chi2test.mdx index 48679bb87226..e0244c98f484 100644 --- a/api_docs/kbn_ml_chi2test.mdx +++ b/api_docs/kbn_ml_chi2test.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-chi2test title: "@kbn/ml-chi2test" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-chi2test plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-chi2test'] --- import kbnMlChi2testObj from './kbn_ml_chi2test.devdocs.json'; diff --git a/api_docs/kbn_ml_data_frame_analytics_utils.mdx b/api_docs/kbn_ml_data_frame_analytics_utils.mdx index 746b82fecc2d..5b22e9a8077b 100644 --- a/api_docs/kbn_ml_data_frame_analytics_utils.mdx +++ b/api_docs/kbn_ml_data_frame_analytics_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-data-frame-analytics-utils title: "@kbn/ml-data-frame-analytics-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-data-frame-analytics-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-data-frame-analytics-utils'] --- import kbnMlDataFrameAnalyticsUtilsObj from './kbn_ml_data_frame_analytics_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_data_grid.mdx b/api_docs/kbn_ml_data_grid.mdx index 378adfc2637b..6597e2a1a88b 100644 --- a/api_docs/kbn_ml_data_grid.mdx +++ b/api_docs/kbn_ml_data_grid.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-data-grid title: "@kbn/ml-data-grid" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-data-grid plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-data-grid'] --- import kbnMlDataGridObj from './kbn_ml_data_grid.devdocs.json'; diff --git a/api_docs/kbn_ml_date_picker.mdx b/api_docs/kbn_ml_date_picker.mdx index 70b9dc59c763..88e5c19c868a 100644 --- a/api_docs/kbn_ml_date_picker.mdx +++ b/api_docs/kbn_ml_date_picker.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-date-picker title: "@kbn/ml-date-picker" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-date-picker plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-date-picker'] --- import kbnMlDatePickerObj from './kbn_ml_date_picker.devdocs.json'; diff --git a/api_docs/kbn_ml_date_utils.mdx b/api_docs/kbn_ml_date_utils.mdx index 0f62730b9a54..ba97c53c9c92 100644 --- a/api_docs/kbn_ml_date_utils.mdx +++ b/api_docs/kbn_ml_date_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-date-utils title: "@kbn/ml-date-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-date-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-date-utils'] --- import kbnMlDateUtilsObj from './kbn_ml_date_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_error_utils.mdx b/api_docs/kbn_ml_error_utils.mdx index ec67830327ab..9aa6d4c55e3c 100644 --- a/api_docs/kbn_ml_error_utils.mdx +++ b/api_docs/kbn_ml_error_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-error-utils title: "@kbn/ml-error-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-error-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-error-utils'] --- import kbnMlErrorUtilsObj from './kbn_ml_error_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_in_memory_table.mdx b/api_docs/kbn_ml_in_memory_table.mdx index 8aec48f17033..b4b44ee8d41b 100644 --- a/api_docs/kbn_ml_in_memory_table.mdx +++ b/api_docs/kbn_ml_in_memory_table.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-in-memory-table title: "@kbn/ml-in-memory-table" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-in-memory-table plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-in-memory-table'] --- import kbnMlInMemoryTableObj from './kbn_ml_in_memory_table.devdocs.json'; diff --git a/api_docs/kbn_ml_is_defined.mdx b/api_docs/kbn_ml_is_defined.mdx index 002a605ebf49..e4ebc025c215 100644 --- a/api_docs/kbn_ml_is_defined.mdx +++ b/api_docs/kbn_ml_is_defined.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-is-defined title: "@kbn/ml-is-defined" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-is-defined plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-is-defined'] --- import kbnMlIsDefinedObj from './kbn_ml_is_defined.devdocs.json'; diff --git a/api_docs/kbn_ml_is_populated_object.mdx b/api_docs/kbn_ml_is_populated_object.mdx index af6997c16c98..2600c1a9d8d2 100644 --- a/api_docs/kbn_ml_is_populated_object.mdx +++ b/api_docs/kbn_ml_is_populated_object.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-is-populated-object title: "@kbn/ml-is-populated-object" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-is-populated-object plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-is-populated-object'] --- import kbnMlIsPopulatedObjectObj from './kbn_ml_is_populated_object.devdocs.json'; diff --git a/api_docs/kbn_ml_kibana_theme.mdx b/api_docs/kbn_ml_kibana_theme.mdx index d31e99268e4f..dc096bc4ad96 100644 --- a/api_docs/kbn_ml_kibana_theme.mdx +++ b/api_docs/kbn_ml_kibana_theme.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-kibana-theme title: "@kbn/ml-kibana-theme" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-kibana-theme plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-kibana-theme'] --- import kbnMlKibanaThemeObj from './kbn_ml_kibana_theme.devdocs.json'; diff --git a/api_docs/kbn_ml_local_storage.mdx b/api_docs/kbn_ml_local_storage.mdx index 3a47fa1f25b5..de89382bcf1f 100644 --- a/api_docs/kbn_ml_local_storage.mdx +++ b/api_docs/kbn_ml_local_storage.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-local-storage title: "@kbn/ml-local-storage" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-local-storage plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-local-storage'] --- import kbnMlLocalStorageObj from './kbn_ml_local_storage.devdocs.json'; diff --git a/api_docs/kbn_ml_nested_property.mdx b/api_docs/kbn_ml_nested_property.mdx index 938c89b33d66..71ee9c742da1 100644 --- a/api_docs/kbn_ml_nested_property.mdx +++ b/api_docs/kbn_ml_nested_property.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-nested-property title: "@kbn/ml-nested-property" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-nested-property plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-nested-property'] --- import kbnMlNestedPropertyObj from './kbn_ml_nested_property.devdocs.json'; diff --git a/api_docs/kbn_ml_number_utils.mdx b/api_docs/kbn_ml_number_utils.mdx index cfceaaf1cee0..812432087622 100644 --- a/api_docs/kbn_ml_number_utils.mdx +++ b/api_docs/kbn_ml_number_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-number-utils title: "@kbn/ml-number-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-number-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-number-utils'] --- import kbnMlNumberUtilsObj from './kbn_ml_number_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_query_utils.devdocs.json b/api_docs/kbn_ml_query_utils.devdocs.json index b9520a8965d4..a6c6032165c6 100644 --- a/api_docs/kbn_ml_query_utils.devdocs.json +++ b/api_docs/kbn_ml_query_utils.devdocs.json @@ -73,7 +73,7 @@ "\nBuilds the base filter criteria used in queries,\nadding criteria for the time range and an optional query.\n" ], "signature": [ - "(timeFieldName: string | undefined, earliestMs: number | undefined, latestMs: number | undefined, query: string | { [key: string]: any; } | undefined) => ", + "(timeFieldName: string | undefined, earliestMs: string | number | undefined, latestMs: string | number | undefined, query: string | { [key: string]: any; } | undefined, timeFormat: string) => ", "QueryDslQueryContainer", "[]" ], @@ -101,14 +101,14 @@ { "parentPluginId": "@kbn/ml-query-utils", "id": "def-common.buildBaseFilterCriteria.$2", - "type": "number", + "type": "CompoundType", "tags": [], "label": "earliestMs", "description": [ "- optional earliest timestamp of the selected time range" ], "signature": [ - "number | undefined" + "string | number | undefined" ], "path": "x-pack/packages/ml/query_utils/src/build_base_filter_criteria.ts", "deprecated": false, @@ -118,14 +118,14 @@ { "parentPluginId": "@kbn/ml-query-utils", "id": "def-common.buildBaseFilterCriteria.$3", - "type": "number", + "type": "CompoundType", "tags": [], "label": "latestMs", "description": [ "- optional latest timestamp of the selected time range" ], "signature": [ - "number | undefined" + "string | number | undefined" ], "path": "x-pack/packages/ml/query_utils/src/build_base_filter_criteria.ts", "deprecated": false, @@ -148,6 +148,21 @@ "deprecated": false, "trackAdoption": false, "isRequired": false + }, + { + "parentPluginId": "@kbn/ml-query-utils", + "id": "def-common.buildBaseFilterCriteria.$5", + "type": "string", + "tags": [], + "label": "timeFormat", + "description": [], + "signature": [ + "string" + ], + "path": "x-pack/packages/ml/query_utils/src/build_base_filter_criteria.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": true } ], "returnComment": [ diff --git a/api_docs/kbn_ml_query_utils.mdx b/api_docs/kbn_ml_query_utils.mdx index ec8d7ac43fb0..113ef4815c07 100644 --- a/api_docs/kbn_ml_query_utils.mdx +++ b/api_docs/kbn_ml_query_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-query-utils title: "@kbn/ml-query-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-query-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-query-utils'] --- import kbnMlQueryUtilsObj from './kbn_ml_query_utils.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) for questi | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 28 | 0 | 0 | 0 | +| 29 | 0 | 1 | 0 | ## Common diff --git a/api_docs/kbn_ml_random_sampler_utils.mdx b/api_docs/kbn_ml_random_sampler_utils.mdx index 7529b194ba3f..c202080d43d5 100644 --- a/api_docs/kbn_ml_random_sampler_utils.mdx +++ b/api_docs/kbn_ml_random_sampler_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-random-sampler-utils title: "@kbn/ml-random-sampler-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-random-sampler-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-random-sampler-utils'] --- import kbnMlRandomSamplerUtilsObj from './kbn_ml_random_sampler_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_route_utils.mdx b/api_docs/kbn_ml_route_utils.mdx index 7dcf78a15b0e..573fc92042f4 100644 --- a/api_docs/kbn_ml_route_utils.mdx +++ b/api_docs/kbn_ml_route_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-route-utils title: "@kbn/ml-route-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-route-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-route-utils'] --- import kbnMlRouteUtilsObj from './kbn_ml_route_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_runtime_field_utils.mdx b/api_docs/kbn_ml_runtime_field_utils.mdx index 5e7bb0216a04..c482e6d34d39 100644 --- a/api_docs/kbn_ml_runtime_field_utils.mdx +++ b/api_docs/kbn_ml_runtime_field_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-runtime-field-utils title: "@kbn/ml-runtime-field-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-runtime-field-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-runtime-field-utils'] --- import kbnMlRuntimeFieldUtilsObj from './kbn_ml_runtime_field_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_string_hash.mdx b/api_docs/kbn_ml_string_hash.mdx index 8a380e1c5c71..b24d0ebec9a4 100644 --- a/api_docs/kbn_ml_string_hash.mdx +++ b/api_docs/kbn_ml_string_hash.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-string-hash title: "@kbn/ml-string-hash" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-string-hash plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-string-hash'] --- import kbnMlStringHashObj from './kbn_ml_string_hash.devdocs.json'; diff --git a/api_docs/kbn_ml_time_buckets.mdx b/api_docs/kbn_ml_time_buckets.mdx index 83157f471559..df48e410183f 100644 --- a/api_docs/kbn_ml_time_buckets.mdx +++ b/api_docs/kbn_ml_time_buckets.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-time-buckets title: "@kbn/ml-time-buckets" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-time-buckets plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-time-buckets'] --- import kbnMlTimeBucketsObj from './kbn_ml_time_buckets.devdocs.json'; diff --git a/api_docs/kbn_ml_trained_models_utils.mdx b/api_docs/kbn_ml_trained_models_utils.mdx index 1e43444a046e..4a65edb43c46 100644 --- a/api_docs/kbn_ml_trained_models_utils.mdx +++ b/api_docs/kbn_ml_trained_models_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-trained-models-utils title: "@kbn/ml-trained-models-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-trained-models-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-trained-models-utils'] --- import kbnMlTrainedModelsUtilsObj from './kbn_ml_trained_models_utils.devdocs.json'; diff --git a/api_docs/kbn_ml_ui_actions.mdx b/api_docs/kbn_ml_ui_actions.mdx index c254789f0940..b9528b3fd9f8 100644 --- a/api_docs/kbn_ml_ui_actions.mdx +++ b/api_docs/kbn_ml_ui_actions.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-ui-actions title: "@kbn/ml-ui-actions" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-ui-actions plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-ui-actions'] --- import kbnMlUiActionsObj from './kbn_ml_ui_actions.devdocs.json'; diff --git a/api_docs/kbn_ml_url_state.mdx b/api_docs/kbn_ml_url_state.mdx index a19dcb7c8e1c..29826c0bc7ff 100644 --- a/api_docs/kbn_ml_url_state.mdx +++ b/api_docs/kbn_ml_url_state.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ml-url-state title: "@kbn/ml-url-state" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ml-url-state plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ml-url-state'] --- import kbnMlUrlStateObj from './kbn_ml_url_state.devdocs.json'; diff --git a/api_docs/kbn_mock_idp_utils.mdx b/api_docs/kbn_mock_idp_utils.mdx index 2d029f807a85..faa009fc5808 100644 --- a/api_docs/kbn_mock_idp_utils.mdx +++ b/api_docs/kbn_mock_idp_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-mock-idp-utils title: "@kbn/mock-idp-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/mock-idp-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/mock-idp-utils'] --- import kbnMockIdpUtilsObj from './kbn_mock_idp_utils.devdocs.json'; diff --git a/api_docs/kbn_monaco.mdx b/api_docs/kbn_monaco.mdx index 5a3f9bf8e21a..44de1094cccf 100644 --- a/api_docs/kbn_monaco.mdx +++ b/api_docs/kbn_monaco.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-monaco title: "@kbn/monaco" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/monaco plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/monaco'] --- import kbnMonacoObj from './kbn_monaco.devdocs.json'; diff --git a/api_docs/kbn_object_versioning.mdx b/api_docs/kbn_object_versioning.mdx index e2f0320d8572..0cdd112d2586 100644 --- a/api_docs/kbn_object_versioning.mdx +++ b/api_docs/kbn_object_versioning.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-object-versioning title: "@kbn/object-versioning" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/object-versioning plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/object-versioning'] --- import kbnObjectVersioningObj from './kbn_object_versioning.devdocs.json'; diff --git a/api_docs/kbn_observability_alert_details.mdx b/api_docs/kbn_observability_alert_details.mdx index 123229a48bf3..48088869aac6 100644 --- a/api_docs/kbn_observability_alert_details.mdx +++ b/api_docs/kbn_observability_alert_details.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-observability-alert-details title: "@kbn/observability-alert-details" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/observability-alert-details plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/observability-alert-details'] --- import kbnObservabilityAlertDetailsObj from './kbn_observability_alert_details.devdocs.json'; diff --git a/api_docs/kbn_observability_alerting_test_data.mdx b/api_docs/kbn_observability_alerting_test_data.mdx index d246f88f2212..ae29534c8abf 100644 --- a/api_docs/kbn_observability_alerting_test_data.mdx +++ b/api_docs/kbn_observability_alerting_test_data.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-observability-alerting-test-data title: "@kbn/observability-alerting-test-data" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/observability-alerting-test-data plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/observability-alerting-test-data'] --- import kbnObservabilityAlertingTestDataObj from './kbn_observability_alerting_test_data.devdocs.json'; diff --git a/api_docs/kbn_observability_get_padded_alert_time_range_util.mdx b/api_docs/kbn_observability_get_padded_alert_time_range_util.mdx index 2da12ab68f9d..3aeb8ede66d8 100644 --- a/api_docs/kbn_observability_get_padded_alert_time_range_util.mdx +++ b/api_docs/kbn_observability_get_padded_alert_time_range_util.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-observability-get-padded-alert-time-range-util title: "@kbn/observability-get-padded-alert-time-range-util" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/observability-get-padded-alert-time-range-util plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/observability-get-padded-alert-time-range-util'] --- import kbnObservabilityGetPaddedAlertTimeRangeUtilObj from './kbn_observability_get_padded_alert_time_range_util.devdocs.json'; diff --git a/api_docs/kbn_openapi_bundler.mdx b/api_docs/kbn_openapi_bundler.mdx index a726c416f28a..19f7bc2af5e5 100644 --- a/api_docs/kbn_openapi_bundler.mdx +++ b/api_docs/kbn_openapi_bundler.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-openapi-bundler title: "@kbn/openapi-bundler" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/openapi-bundler plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/openapi-bundler'] --- import kbnOpenapiBundlerObj from './kbn_openapi_bundler.devdocs.json'; diff --git a/api_docs/kbn_openapi_generator.mdx b/api_docs/kbn_openapi_generator.mdx index 17914914724f..eebae8cde4d4 100644 --- a/api_docs/kbn_openapi_generator.mdx +++ b/api_docs/kbn_openapi_generator.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-openapi-generator title: "@kbn/openapi-generator" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/openapi-generator plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/openapi-generator'] --- import kbnOpenapiGeneratorObj from './kbn_openapi_generator.devdocs.json'; diff --git a/api_docs/kbn_optimizer.mdx b/api_docs/kbn_optimizer.mdx index b565754a2cde..2b36ba70c014 100644 --- a/api_docs/kbn_optimizer.mdx +++ b/api_docs/kbn_optimizer.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-optimizer title: "@kbn/optimizer" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/optimizer plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/optimizer'] --- import kbnOptimizerObj from './kbn_optimizer.devdocs.json'; diff --git a/api_docs/kbn_optimizer_webpack_helpers.mdx b/api_docs/kbn_optimizer_webpack_helpers.mdx index 0a7a1daa777f..c79c2416e444 100644 --- a/api_docs/kbn_optimizer_webpack_helpers.mdx +++ b/api_docs/kbn_optimizer_webpack_helpers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-optimizer-webpack-helpers title: "@kbn/optimizer-webpack-helpers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/optimizer-webpack-helpers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/optimizer-webpack-helpers'] --- import kbnOptimizerWebpackHelpersObj from './kbn_optimizer_webpack_helpers.devdocs.json'; diff --git a/api_docs/kbn_osquery_io_ts_types.mdx b/api_docs/kbn_osquery_io_ts_types.mdx index b831c6325fe8..1dd1cba3f776 100644 --- a/api_docs/kbn_osquery_io_ts_types.mdx +++ b/api_docs/kbn_osquery_io_ts_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-osquery-io-ts-types title: "@kbn/osquery-io-ts-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/osquery-io-ts-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/osquery-io-ts-types'] --- import kbnOsqueryIoTsTypesObj from './kbn_osquery_io_ts_types.devdocs.json'; diff --git a/api_docs/kbn_panel_loader.mdx b/api_docs/kbn_panel_loader.mdx index e3bd6ae55b14..c99b483ad6c4 100644 --- a/api_docs/kbn_panel_loader.mdx +++ b/api_docs/kbn_panel_loader.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-panel-loader title: "@kbn/panel-loader" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/panel-loader plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/panel-loader'] --- import kbnPanelLoaderObj from './kbn_panel_loader.devdocs.json'; diff --git a/api_docs/kbn_performance_testing_dataset_extractor.mdx b/api_docs/kbn_performance_testing_dataset_extractor.mdx index c84c74c85d67..35817380bf09 100644 --- a/api_docs/kbn_performance_testing_dataset_extractor.mdx +++ b/api_docs/kbn_performance_testing_dataset_extractor.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-performance-testing-dataset-extractor title: "@kbn/performance-testing-dataset-extractor" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/performance-testing-dataset-extractor plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/performance-testing-dataset-extractor'] --- import kbnPerformanceTestingDatasetExtractorObj from './kbn_performance_testing_dataset_extractor.devdocs.json'; diff --git a/api_docs/kbn_plugin_check.mdx b/api_docs/kbn_plugin_check.mdx index 329016801e93..f8dbbd0aa5d3 100644 --- a/api_docs/kbn_plugin_check.mdx +++ b/api_docs/kbn_plugin_check.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-plugin-check title: "@kbn/plugin-check" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/plugin-check plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/plugin-check'] --- import kbnPluginCheckObj from './kbn_plugin_check.devdocs.json'; diff --git a/api_docs/kbn_plugin_generator.mdx b/api_docs/kbn_plugin_generator.mdx index 0ab117cea7a1..585de235d00c 100644 --- a/api_docs/kbn_plugin_generator.mdx +++ b/api_docs/kbn_plugin_generator.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-plugin-generator title: "@kbn/plugin-generator" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/plugin-generator plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/plugin-generator'] --- import kbnPluginGeneratorObj from './kbn_plugin_generator.devdocs.json'; diff --git a/api_docs/kbn_plugin_helpers.mdx b/api_docs/kbn_plugin_helpers.mdx index cca8a11fd49b..a45ccc921f44 100644 --- a/api_docs/kbn_plugin_helpers.mdx +++ b/api_docs/kbn_plugin_helpers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-plugin-helpers title: "@kbn/plugin-helpers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/plugin-helpers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/plugin-helpers'] --- import kbnPluginHelpersObj from './kbn_plugin_helpers.devdocs.json'; diff --git a/api_docs/kbn_presentation_containers.mdx b/api_docs/kbn_presentation_containers.mdx index add98c69763e..328c9ee94ba8 100644 --- a/api_docs/kbn_presentation_containers.mdx +++ b/api_docs/kbn_presentation_containers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-presentation-containers title: "@kbn/presentation-containers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/presentation-containers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/presentation-containers'] --- import kbnPresentationContainersObj from './kbn_presentation_containers.devdocs.json'; diff --git a/api_docs/kbn_presentation_publishing.mdx b/api_docs/kbn_presentation_publishing.mdx index 5c1be27f915f..fe6817612b29 100644 --- a/api_docs/kbn_presentation_publishing.mdx +++ b/api_docs/kbn_presentation_publishing.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-presentation-publishing title: "@kbn/presentation-publishing" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/presentation-publishing plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/presentation-publishing'] --- import kbnPresentationPublishingObj from './kbn_presentation_publishing.devdocs.json'; diff --git a/api_docs/kbn_profiling_utils.mdx b/api_docs/kbn_profiling_utils.mdx index 2c2df8f37440..c0c8c05b4a2c 100644 --- a/api_docs/kbn_profiling_utils.mdx +++ b/api_docs/kbn_profiling_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-profiling-utils title: "@kbn/profiling-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/profiling-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/profiling-utils'] --- import kbnProfilingUtilsObj from './kbn_profiling_utils.devdocs.json'; diff --git a/api_docs/kbn_random_sampling.mdx b/api_docs/kbn_random_sampling.mdx index 5e05cc537786..8a19557b81cb 100644 --- a/api_docs/kbn_random_sampling.mdx +++ b/api_docs/kbn_random_sampling.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-random-sampling title: "@kbn/random-sampling" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/random-sampling plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/random-sampling'] --- import kbnRandomSamplingObj from './kbn_random_sampling.devdocs.json'; diff --git a/api_docs/kbn_react_field.mdx b/api_docs/kbn_react_field.mdx index 2977c785b876..a814aece8c0a 100644 --- a/api_docs/kbn_react_field.mdx +++ b/api_docs/kbn_react_field.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-field title: "@kbn/react-field" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-field plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-field'] --- import kbnReactFieldObj from './kbn_react_field.devdocs.json'; diff --git a/api_docs/kbn_react_hooks.mdx b/api_docs/kbn_react_hooks.mdx index bc71501219c7..9bffc52ba86d 100644 --- a/api_docs/kbn_react_hooks.mdx +++ b/api_docs/kbn_react_hooks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-hooks title: "@kbn/react-hooks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-hooks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-hooks'] --- import kbnReactHooksObj from './kbn_react_hooks.devdocs.json'; diff --git a/api_docs/kbn_react_kibana_context_common.mdx b/api_docs/kbn_react_kibana_context_common.mdx index 8b4b71697aec..66dfa11b94e8 100644 --- a/api_docs/kbn_react_kibana_context_common.mdx +++ b/api_docs/kbn_react_kibana_context_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-kibana-context-common title: "@kbn/react-kibana-context-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-kibana-context-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-kibana-context-common'] --- import kbnReactKibanaContextCommonObj from './kbn_react_kibana_context_common.devdocs.json'; diff --git a/api_docs/kbn_react_kibana_context_render.mdx b/api_docs/kbn_react_kibana_context_render.mdx index f6ead3e92c19..951f6c6ea4bf 100644 --- a/api_docs/kbn_react_kibana_context_render.mdx +++ b/api_docs/kbn_react_kibana_context_render.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-kibana-context-render title: "@kbn/react-kibana-context-render" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-kibana-context-render plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-kibana-context-render'] --- import kbnReactKibanaContextRenderObj from './kbn_react_kibana_context_render.devdocs.json'; diff --git a/api_docs/kbn_react_kibana_context_root.mdx b/api_docs/kbn_react_kibana_context_root.mdx index b7734231d1e7..44535e9c7e45 100644 --- a/api_docs/kbn_react_kibana_context_root.mdx +++ b/api_docs/kbn_react_kibana_context_root.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-kibana-context-root title: "@kbn/react-kibana-context-root" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-kibana-context-root plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-kibana-context-root'] --- import kbnReactKibanaContextRootObj from './kbn_react_kibana_context_root.devdocs.json'; diff --git a/api_docs/kbn_react_kibana_context_styled.mdx b/api_docs/kbn_react_kibana_context_styled.mdx index 36117343efd0..feab9cefca61 100644 --- a/api_docs/kbn_react_kibana_context_styled.mdx +++ b/api_docs/kbn_react_kibana_context_styled.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-kibana-context-styled title: "@kbn/react-kibana-context-styled" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-kibana-context-styled plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-kibana-context-styled'] --- import kbnReactKibanaContextStyledObj from './kbn_react_kibana_context_styled.devdocs.json'; diff --git a/api_docs/kbn_react_kibana_context_theme.mdx b/api_docs/kbn_react_kibana_context_theme.mdx index 7f88ba874d70..d83fd0970cdf 100644 --- a/api_docs/kbn_react_kibana_context_theme.mdx +++ b/api_docs/kbn_react_kibana_context_theme.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-kibana-context-theme title: "@kbn/react-kibana-context-theme" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-kibana-context-theme plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-kibana-context-theme'] --- import kbnReactKibanaContextThemeObj from './kbn_react_kibana_context_theme.devdocs.json'; diff --git a/api_docs/kbn_react_kibana_mount.mdx b/api_docs/kbn_react_kibana_mount.mdx index f5159b1261a2..b87d6b150a42 100644 --- a/api_docs/kbn_react_kibana_mount.mdx +++ b/api_docs/kbn_react_kibana_mount.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-react-kibana-mount title: "@kbn/react-kibana-mount" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/react-kibana-mount plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/react-kibana-mount'] --- import kbnReactKibanaMountObj from './kbn_react_kibana_mount.devdocs.json'; diff --git a/api_docs/kbn_repo_file_maps.mdx b/api_docs/kbn_repo_file_maps.mdx index 5a594199d8bb..f9760eda4bb1 100644 --- a/api_docs/kbn_repo_file_maps.mdx +++ b/api_docs/kbn_repo_file_maps.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-repo-file-maps title: "@kbn/repo-file-maps" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/repo-file-maps plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/repo-file-maps'] --- import kbnRepoFileMapsObj from './kbn_repo_file_maps.devdocs.json'; diff --git a/api_docs/kbn_repo_linter.mdx b/api_docs/kbn_repo_linter.mdx index 427ce9e83c79..ba87d94c375e 100644 --- a/api_docs/kbn_repo_linter.mdx +++ b/api_docs/kbn_repo_linter.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-repo-linter title: "@kbn/repo-linter" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/repo-linter plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/repo-linter'] --- import kbnRepoLinterObj from './kbn_repo_linter.devdocs.json'; diff --git a/api_docs/kbn_repo_path.mdx b/api_docs/kbn_repo_path.mdx index b73b77009aff..740573d536a4 100644 --- a/api_docs/kbn_repo_path.mdx +++ b/api_docs/kbn_repo_path.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-repo-path title: "@kbn/repo-path" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/repo-path plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/repo-path'] --- import kbnRepoPathObj from './kbn_repo_path.devdocs.json'; diff --git a/api_docs/kbn_repo_source_classifier.mdx b/api_docs/kbn_repo_source_classifier.mdx index e4eb589b3bc5..0808dddf4a15 100644 --- a/api_docs/kbn_repo_source_classifier.mdx +++ b/api_docs/kbn_repo_source_classifier.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-repo-source-classifier title: "@kbn/repo-source-classifier" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/repo-source-classifier plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/repo-source-classifier'] --- import kbnRepoSourceClassifierObj from './kbn_repo_source_classifier.devdocs.json'; diff --git a/api_docs/kbn_reporting_common.mdx b/api_docs/kbn_reporting_common.mdx index 81cd7606b0a5..0320e13262a1 100644 --- a/api_docs/kbn_reporting_common.mdx +++ b/api_docs/kbn_reporting_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-common title: "@kbn/reporting-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-common'] --- import kbnReportingCommonObj from './kbn_reporting_common.devdocs.json'; diff --git a/api_docs/kbn_reporting_csv_share_panel.mdx b/api_docs/kbn_reporting_csv_share_panel.mdx index 354148ad5aed..07c987083c0e 100644 --- a/api_docs/kbn_reporting_csv_share_panel.mdx +++ b/api_docs/kbn_reporting_csv_share_panel.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-csv-share-panel title: "@kbn/reporting-csv-share-panel" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-csv-share-panel plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-csv-share-panel'] --- import kbnReportingCsvSharePanelObj from './kbn_reporting_csv_share_panel.devdocs.json'; diff --git a/api_docs/kbn_reporting_export_types_csv.mdx b/api_docs/kbn_reporting_export_types_csv.mdx index ea1a578565b1..6eac15588bcb 100644 --- a/api_docs/kbn_reporting_export_types_csv.mdx +++ b/api_docs/kbn_reporting_export_types_csv.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-export-types-csv title: "@kbn/reporting-export-types-csv" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-export-types-csv plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-export-types-csv'] --- import kbnReportingExportTypesCsvObj from './kbn_reporting_export_types_csv.devdocs.json'; diff --git a/api_docs/kbn_reporting_export_types_csv_common.mdx b/api_docs/kbn_reporting_export_types_csv_common.mdx index 9528febd84be..ed2b73db07b1 100644 --- a/api_docs/kbn_reporting_export_types_csv_common.mdx +++ b/api_docs/kbn_reporting_export_types_csv_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-export-types-csv-common title: "@kbn/reporting-export-types-csv-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-export-types-csv-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-export-types-csv-common'] --- import kbnReportingExportTypesCsvCommonObj from './kbn_reporting_export_types_csv_common.devdocs.json'; diff --git a/api_docs/kbn_reporting_export_types_pdf.mdx b/api_docs/kbn_reporting_export_types_pdf.mdx index 266d5747cae9..fa1ce3b66e1b 100644 --- a/api_docs/kbn_reporting_export_types_pdf.mdx +++ b/api_docs/kbn_reporting_export_types_pdf.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-export-types-pdf title: "@kbn/reporting-export-types-pdf" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-export-types-pdf plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-export-types-pdf'] --- import kbnReportingExportTypesPdfObj from './kbn_reporting_export_types_pdf.devdocs.json'; diff --git a/api_docs/kbn_reporting_export_types_pdf_common.mdx b/api_docs/kbn_reporting_export_types_pdf_common.mdx index 6e6009aa4e81..07068f24bbcd 100644 --- a/api_docs/kbn_reporting_export_types_pdf_common.mdx +++ b/api_docs/kbn_reporting_export_types_pdf_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-export-types-pdf-common title: "@kbn/reporting-export-types-pdf-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-export-types-pdf-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-export-types-pdf-common'] --- import kbnReportingExportTypesPdfCommonObj from './kbn_reporting_export_types_pdf_common.devdocs.json'; diff --git a/api_docs/kbn_reporting_export_types_png.mdx b/api_docs/kbn_reporting_export_types_png.mdx index d2e4072824d0..490c7ba18a64 100644 --- a/api_docs/kbn_reporting_export_types_png.mdx +++ b/api_docs/kbn_reporting_export_types_png.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-export-types-png title: "@kbn/reporting-export-types-png" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-export-types-png plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-export-types-png'] --- import kbnReportingExportTypesPngObj from './kbn_reporting_export_types_png.devdocs.json'; diff --git a/api_docs/kbn_reporting_export_types_png_common.mdx b/api_docs/kbn_reporting_export_types_png_common.mdx index a53584fa6bc1..03d0eb8a2660 100644 --- a/api_docs/kbn_reporting_export_types_png_common.mdx +++ b/api_docs/kbn_reporting_export_types_png_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-export-types-png-common title: "@kbn/reporting-export-types-png-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-export-types-png-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-export-types-png-common'] --- import kbnReportingExportTypesPngCommonObj from './kbn_reporting_export_types_png_common.devdocs.json'; diff --git a/api_docs/kbn_reporting_mocks_server.mdx b/api_docs/kbn_reporting_mocks_server.mdx index dd09bc098389..02f300ecf625 100644 --- a/api_docs/kbn_reporting_mocks_server.mdx +++ b/api_docs/kbn_reporting_mocks_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-mocks-server title: "@kbn/reporting-mocks-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-mocks-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-mocks-server'] --- import kbnReportingMocksServerObj from './kbn_reporting_mocks_server.devdocs.json'; diff --git a/api_docs/kbn_reporting_public.mdx b/api_docs/kbn_reporting_public.mdx index d2003dfc1a03..35b72c18a923 100644 --- a/api_docs/kbn_reporting_public.mdx +++ b/api_docs/kbn_reporting_public.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-public title: "@kbn/reporting-public" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-public plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-public'] --- import kbnReportingPublicObj from './kbn_reporting_public.devdocs.json'; diff --git a/api_docs/kbn_reporting_server.mdx b/api_docs/kbn_reporting_server.mdx index 1abb779525d1..5496a590082a 100644 --- a/api_docs/kbn_reporting_server.mdx +++ b/api_docs/kbn_reporting_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-reporting-server title: "@kbn/reporting-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/reporting-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/reporting-server'] --- import kbnReportingServerObj from './kbn_reporting_server.devdocs.json'; diff --git a/api_docs/kbn_resizable_layout.mdx b/api_docs/kbn_resizable_layout.mdx index 33506e6f341c..2ab0ccdc67e2 100644 --- a/api_docs/kbn_resizable_layout.mdx +++ b/api_docs/kbn_resizable_layout.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-resizable-layout title: "@kbn/resizable-layout" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/resizable-layout plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/resizable-layout'] --- import kbnResizableLayoutObj from './kbn_resizable_layout.devdocs.json'; diff --git a/api_docs/kbn_response_ops_feature_flag_service.mdx b/api_docs/kbn_response_ops_feature_flag_service.mdx index 5c62928970ef..ea36ed4b94f4 100644 --- a/api_docs/kbn_response_ops_feature_flag_service.mdx +++ b/api_docs/kbn_response_ops_feature_flag_service.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-response-ops-feature-flag-service title: "@kbn/response-ops-feature-flag-service" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/response-ops-feature-flag-service plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/response-ops-feature-flag-service'] --- import kbnResponseOpsFeatureFlagServiceObj from './kbn_response_ops_feature_flag_service.devdocs.json'; diff --git a/api_docs/kbn_rison.mdx b/api_docs/kbn_rison.mdx index 04fc7439a272..21ee3dc3a197 100644 --- a/api_docs/kbn_rison.mdx +++ b/api_docs/kbn_rison.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-rison title: "@kbn/rison" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/rison plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/rison'] --- import kbnRisonObj from './kbn_rison.devdocs.json'; diff --git a/api_docs/kbn_rollup.mdx b/api_docs/kbn_rollup.mdx index db46af8a8e5b..565701c8b58b 100644 --- a/api_docs/kbn_rollup.mdx +++ b/api_docs/kbn_rollup.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-rollup title: "@kbn/rollup" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/rollup plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/rollup'] --- import kbnRollupObj from './kbn_rollup.devdocs.json'; diff --git a/api_docs/kbn_router_to_openapispec.mdx b/api_docs/kbn_router_to_openapispec.mdx index b3cf778876e5..34e132bb97bc 100644 --- a/api_docs/kbn_router_to_openapispec.mdx +++ b/api_docs/kbn_router_to_openapispec.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-router-to-openapispec title: "@kbn/router-to-openapispec" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/router-to-openapispec plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/router-to-openapispec'] --- import kbnRouterToOpenapispecObj from './kbn_router_to_openapispec.devdocs.json'; diff --git a/api_docs/kbn_router_utils.mdx b/api_docs/kbn_router_utils.mdx index e15033d40071..bda7681dee2d 100644 --- a/api_docs/kbn_router_utils.mdx +++ b/api_docs/kbn_router_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-router-utils title: "@kbn/router-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/router-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/router-utils'] --- import kbnRouterUtilsObj from './kbn_router_utils.devdocs.json'; diff --git a/api_docs/kbn_rrule.mdx b/api_docs/kbn_rrule.mdx index 4c4f54d969af..30b91fc5f14f 100644 --- a/api_docs/kbn_rrule.mdx +++ b/api_docs/kbn_rrule.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-rrule title: "@kbn/rrule" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/rrule plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/rrule'] --- import kbnRruleObj from './kbn_rrule.devdocs.json'; diff --git a/api_docs/kbn_rule_data_utils.mdx b/api_docs/kbn_rule_data_utils.mdx index c772094c9a10..c53f4d15510b 100644 --- a/api_docs/kbn_rule_data_utils.mdx +++ b/api_docs/kbn_rule_data_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-rule-data-utils title: "@kbn/rule-data-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/rule-data-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/rule-data-utils'] --- import kbnRuleDataUtilsObj from './kbn_rule_data_utils.devdocs.json'; diff --git a/api_docs/kbn_saved_objects_settings.mdx b/api_docs/kbn_saved_objects_settings.mdx index 394a545dc751..609b9777a0d0 100644 --- a/api_docs/kbn_saved_objects_settings.mdx +++ b/api_docs/kbn_saved_objects_settings.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-saved-objects-settings title: "@kbn/saved-objects-settings" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/saved-objects-settings plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/saved-objects-settings'] --- import kbnSavedObjectsSettingsObj from './kbn_saved_objects_settings.devdocs.json'; diff --git a/api_docs/kbn_search_api_panels.devdocs.json b/api_docs/kbn_search_api_panels.devdocs.json index 3e17ad34f998..60f8ab7069ab 100644 --- a/api_docs/kbn_search_api_panels.devdocs.json +++ b/api_docs/kbn_search_api_panels.devdocs.json @@ -74,7 +74,7 @@ "label": "CodeBox", "description": [], "signature": [ - "({ application, codeSnippet, consolePlugin, languageType, languages, assetBasePath, selectedLanguage, setSelectedLanguage, sharePlugin, consoleRequest, }: React.PropsWithChildren) => JSX.Element" + "({ application, codeSnippet, consolePlugin, languageType, languages, assetBasePath, selectedLanguage, setSelectedLanguage, sharePlugin, consoleRequest, showTopBar, }: React.PropsWithChildren) => JSX.Element" ], "path": "packages/kbn-search-api-panels/components/code_box.tsx", "deprecated": false, @@ -85,7 +85,7 @@ "id": "def-common.CodeBox.$1", "type": "CompoundType", "tags": [], - "label": "{\n application,\n codeSnippet,\n consolePlugin,\n languageType,\n languages,\n assetBasePath,\n selectedLanguage,\n setSelectedLanguage,\n sharePlugin,\n consoleRequest,\n}", + "label": "{\n application,\n codeSnippet,\n consolePlugin,\n languageType,\n languages,\n assetBasePath,\n selectedLanguage,\n setSelectedLanguage,\n sharePlugin,\n consoleRequest,\n showTopBar = true,\n}", "description": [], "signature": [ "React.PropsWithChildren" diff --git a/api_docs/kbn_search_api_panels.mdx b/api_docs/kbn_search_api_panels.mdx index 1b5c545958eb..4e02bf7f2a89 100644 --- a/api_docs/kbn_search_api_panels.mdx +++ b/api_docs/kbn_search_api_panels.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-search-api-panels title: "@kbn/search-api-panels" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/search-api-panels plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/search-api-panels'] --- import kbnSearchApiPanelsObj from './kbn_search_api_panels.devdocs.json'; diff --git a/api_docs/kbn_search_connectors.mdx b/api_docs/kbn_search_connectors.mdx index 5e94eab9d22f..b778bdf1db17 100644 --- a/api_docs/kbn_search_connectors.mdx +++ b/api_docs/kbn_search_connectors.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-search-connectors title: "@kbn/search-connectors" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/search-connectors plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/search-connectors'] --- import kbnSearchConnectorsObj from './kbn_search_connectors.devdocs.json'; diff --git a/api_docs/kbn_search_errors.mdx b/api_docs/kbn_search_errors.mdx index 625594fd8684..4ea96f2ca524 100644 --- a/api_docs/kbn_search_errors.mdx +++ b/api_docs/kbn_search_errors.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-search-errors title: "@kbn/search-errors" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/search-errors plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/search-errors'] --- import kbnSearchErrorsObj from './kbn_search_errors.devdocs.json'; diff --git a/api_docs/kbn_search_index_documents.mdx b/api_docs/kbn_search_index_documents.mdx index 20425e0d945a..7c2c709daab8 100644 --- a/api_docs/kbn_search_index_documents.mdx +++ b/api_docs/kbn_search_index_documents.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-search-index-documents title: "@kbn/search-index-documents" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/search-index-documents plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/search-index-documents'] --- import kbnSearchIndexDocumentsObj from './kbn_search_index_documents.devdocs.json'; diff --git a/api_docs/kbn_search_response_warnings.mdx b/api_docs/kbn_search_response_warnings.mdx index 0576ce9c9e8d..d9ac4cacf89d 100644 --- a/api_docs/kbn_search_response_warnings.mdx +++ b/api_docs/kbn_search_response_warnings.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-search-response-warnings title: "@kbn/search-response-warnings" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/search-response-warnings plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/search-response-warnings'] --- import kbnSearchResponseWarningsObj from './kbn_search_response_warnings.devdocs.json'; diff --git a/api_docs/kbn_search_types.mdx b/api_docs/kbn_search_types.mdx index e2cffcd09d21..07ea5fad9bca 100644 --- a/api_docs/kbn_search_types.mdx +++ b/api_docs/kbn_search_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-search-types title: "@kbn/search-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/search-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/search-types'] --- import kbnSearchTypesObj from './kbn_search_types.devdocs.json'; diff --git a/api_docs/kbn_security_api_key_management.mdx b/api_docs/kbn_security_api_key_management.mdx index 18079bdc7bef..8afb71115e25 100644 --- a/api_docs/kbn_security_api_key_management.mdx +++ b/api_docs/kbn_security_api_key_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-api-key-management title: "@kbn/security-api-key-management" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-api-key-management plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-api-key-management'] --- import kbnSecurityApiKeyManagementObj from './kbn_security_api_key_management.devdocs.json'; diff --git a/api_docs/kbn_security_form_components.mdx b/api_docs/kbn_security_form_components.mdx index afb84e65ebfb..50267cc4264d 100644 --- a/api_docs/kbn_security_form_components.mdx +++ b/api_docs/kbn_security_form_components.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-form-components title: "@kbn/security-form-components" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-form-components plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-form-components'] --- import kbnSecurityFormComponentsObj from './kbn_security_form_components.devdocs.json'; diff --git a/api_docs/kbn_security_hardening.mdx b/api_docs/kbn_security_hardening.mdx index 538fdc4d5b46..1a63e35c11b1 100644 --- a/api_docs/kbn_security_hardening.mdx +++ b/api_docs/kbn_security_hardening.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-hardening title: "@kbn/security-hardening" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-hardening plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-hardening'] --- import kbnSecurityHardeningObj from './kbn_security_hardening.devdocs.json'; diff --git a/api_docs/kbn_security_plugin_types_common.devdocs.json b/api_docs/kbn_security_plugin_types_common.devdocs.json index 4bb4d0397c70..c19a34122d79 100644 --- a/api_docs/kbn_security_plugin_types_common.devdocs.json +++ b/api_docs/kbn_security_plugin_types_common.devdocs.json @@ -1057,6 +1057,22 @@ "children": [], "returnComment": [] }, + { + "parentPluginId": "@kbn/security-plugin-types-common", + "id": "def-common.SecurityLicense.getLicenseType", + "type": "Function", + "tags": [], + "label": "getLicenseType", + "description": [], + "signature": [ + "() => string | undefined" + ], + "path": "x-pack/packages/security/plugin_types_common/src/licensing/license.ts", + "deprecated": false, + "trackAdoption": false, + "children": [], + "returnComment": [] + }, { "parentPluginId": "@kbn/security-plugin-types-common", "id": "def-common.SecurityLicense.getUnavailableReason", @@ -1374,6 +1390,19 @@ "path": "x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts", "deprecated": false, "trackAdoption": false + }, + { + "parentPluginId": "@kbn/security-plugin-types-common", + "id": "def-common.SecurityLicenseFeatures.allowFips", + "type": "boolean", + "tags": [], + "label": "allowFips", + "description": [ + "\nIndicates whether we allow FIPS mode" + ], + "path": "x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts", + "deprecated": false, + "trackAdoption": false } ], "initialIsOpen": false diff --git a/api_docs/kbn_security_plugin_types_common.mdx b/api_docs/kbn_security_plugin_types_common.mdx index d32f80275b96..f13241dcbb46 100644 --- a/api_docs/kbn_security_plugin_types_common.mdx +++ b/api_docs/kbn_security_plugin_types_common.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-plugin-types-common title: "@kbn/security-plugin-types-common" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-plugin-types-common plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-plugin-types-common'] --- import kbnSecurityPluginTypesCommonObj from './kbn_security_plugin_types_common.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana- | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 116 | 0 | 58 | 0 | +| 118 | 0 | 59 | 0 | ## Common diff --git a/api_docs/kbn_security_plugin_types_public.devdocs.json b/api_docs/kbn_security_plugin_types_public.devdocs.json index 17c1ecb22d34..257698aef3bd 100644 --- a/api_docs/kbn_security_plugin_types_public.devdocs.json +++ b/api_docs/kbn_security_plugin_types_public.devdocs.json @@ -635,10 +635,6 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/management/links.ts" }, - { - "plugin": "serverlessSearch", - "path": "x-pack/plugins/serverless_search/public/plugin.ts" - }, { "plugin": "cloudLinks", "path": "x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts" @@ -654,14 +650,6 @@ { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts" - }, - { - "plugin": "apm", - "path": "x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts" - }, - { - "plugin": "apm", - "path": "x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts" } ] }, diff --git a/api_docs/kbn_security_plugin_types_public.mdx b/api_docs/kbn_security_plugin_types_public.mdx index 0155b967d30a..46e64f938529 100644 --- a/api_docs/kbn_security_plugin_types_public.mdx +++ b/api_docs/kbn_security_plugin_types_public.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-plugin-types-public title: "@kbn/security-plugin-types-public" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-plugin-types-public plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-plugin-types-public'] --- import kbnSecurityPluginTypesPublicObj from './kbn_security_plugin_types_public.devdocs.json'; diff --git a/api_docs/kbn_security_plugin_types_server.devdocs.json b/api_docs/kbn_security_plugin_types_server.devdocs.json index f0f437b5ae00..dbd001d28def 100644 --- a/api_docs/kbn_security_plugin_types_server.devdocs.json +++ b/api_docs/kbn_security_plugin_types_server.devdocs.json @@ -3128,14 +3128,6 @@ "plugin": "alerting", "path": "x-pack/plugins/alerting/server/rules_client_factory.ts" }, - { - "plugin": "files", - "path": "src/plugins/files/server/file_service/file_service_factory.ts" - }, - { - "plugin": "files", - "path": "src/plugins/files/server/file_service/file_service_factory.ts" - }, { "plugin": "ruleRegistry", "path": "x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts" @@ -3144,6 +3136,14 @@ "plugin": "ruleRegistry", "path": "x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts" }, + { + "plugin": "files", + "path": "src/plugins/files/server/file_service/file_service_factory.ts" + }, + { + "plugin": "files", + "path": "src/plugins/files/server/file_service/file_service_factory.ts" + }, { "plugin": "cases", "path": "x-pack/plugins/cases/server/client/factory.ts" @@ -3232,30 +3232,10 @@ "plugin": "security", "path": "x-pack/plugins/security/server/plugin.ts" }, - { - "plugin": "actions", - "path": "x-pack/plugins/actions/server/lib/action_executor.ts" - }, { "plugin": "alerting", "path": "x-pack/plugins/alerting/server/rules_client_factory.ts" }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/rules_client_factory.ts" - }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/rules_client_factory.ts" - }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/rules_settings_client_factory.ts" - }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/maintenance_window_client_factory.ts" - }, { "plugin": "alerting", "path": "x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts" @@ -3268,10 +3248,6 @@ "plugin": "alerting", "path": "x-pack/plugins/alerting/server/plugin.ts" }, - { - "plugin": "cases", - "path": "x-pack/plugins/cases/server/client/factory.ts" - }, { "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/server/service/index.ts" @@ -3292,10 +3268,6 @@ "plugin": "fleet", "path": "x-pack/plugins/fleet/server/routes/setup/handlers.ts" }, - { - "plugin": "cloudDefend", - "path": "x-pack/plugins/cloud_defend/server/routes/setup_routes.ts" - }, { "plugin": "cloudSecurityPosture", "path": "x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts" @@ -3308,10 +3280,6 @@ "plugin": "enterpriseSearch", "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts" }, - { - "plugin": "lists", - "path": "x-pack/plugins/lists/server/get_user.ts" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts" diff --git a/api_docs/kbn_security_plugin_types_server.mdx b/api_docs/kbn_security_plugin_types_server.mdx index 4e888f6c93ad..8ff72147e8d3 100644 --- a/api_docs/kbn_security_plugin_types_server.mdx +++ b/api_docs/kbn_security_plugin_types_server.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-plugin-types-server title: "@kbn/security-plugin-types-server" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-plugin-types-server plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-plugin-types-server'] --- import kbnSecurityPluginTypesServerObj from './kbn_security_plugin_types_server.devdocs.json'; diff --git a/api_docs/kbn_security_solution_features.mdx b/api_docs/kbn_security_solution_features.mdx index ea8bc02e018c..152d22301190 100644 --- a/api_docs/kbn_security_solution_features.mdx +++ b/api_docs/kbn_security_solution_features.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-solution-features title: "@kbn/security-solution-features" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-solution-features plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-solution-features'] --- import kbnSecuritySolutionFeaturesObj from './kbn_security_solution_features.devdocs.json'; diff --git a/api_docs/kbn_security_solution_navigation.mdx b/api_docs/kbn_security_solution_navigation.mdx index 86a9b9c3cc52..6e31397905d6 100644 --- a/api_docs/kbn_security_solution_navigation.mdx +++ b/api_docs/kbn_security_solution_navigation.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-solution-navigation title: "@kbn/security-solution-navigation" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-solution-navigation plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-solution-navigation'] --- import kbnSecuritySolutionNavigationObj from './kbn_security_solution_navigation.devdocs.json'; diff --git a/api_docs/kbn_security_solution_side_nav.mdx b/api_docs/kbn_security_solution_side_nav.mdx index b16abf3e7df5..47d138031a17 100644 --- a/api_docs/kbn_security_solution_side_nav.mdx +++ b/api_docs/kbn_security_solution_side_nav.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-solution-side-nav title: "@kbn/security-solution-side-nav" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-solution-side-nav plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-solution-side-nav'] --- import kbnSecuritySolutionSideNavObj from './kbn_security_solution_side_nav.devdocs.json'; diff --git a/api_docs/kbn_security_solution_storybook_config.mdx b/api_docs/kbn_security_solution_storybook_config.mdx index d38432acfc9e..479ada4dffb1 100644 --- a/api_docs/kbn_security_solution_storybook_config.mdx +++ b/api_docs/kbn_security_solution_storybook_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-security-solution-storybook-config title: "@kbn/security-solution-storybook-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/security-solution-storybook-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/security-solution-storybook-config'] --- import kbnSecuritySolutionStorybookConfigObj from './kbn_security_solution_storybook_config.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_autocomplete.mdx b/api_docs/kbn_securitysolution_autocomplete.mdx index ddebdecf5e87..d337ec8da2f9 100644 --- a/api_docs/kbn_securitysolution_autocomplete.mdx +++ b/api_docs/kbn_securitysolution_autocomplete.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-autocomplete title: "@kbn/securitysolution-autocomplete" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-autocomplete plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-autocomplete'] --- import kbnSecuritysolutionAutocompleteObj from './kbn_securitysolution_autocomplete.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_data_table.mdx b/api_docs/kbn_securitysolution_data_table.mdx index bfaa0fe9cc30..3b5a0950ac1b 100644 --- a/api_docs/kbn_securitysolution_data_table.mdx +++ b/api_docs/kbn_securitysolution_data_table.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-data-table title: "@kbn/securitysolution-data-table" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-data-table plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-data-table'] --- import kbnSecuritysolutionDataTableObj from './kbn_securitysolution_data_table.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_ecs.mdx b/api_docs/kbn_securitysolution_ecs.mdx index 6a8e99c792b1..a0d3c010a783 100644 --- a/api_docs/kbn_securitysolution_ecs.mdx +++ b/api_docs/kbn_securitysolution_ecs.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-ecs title: "@kbn/securitysolution-ecs" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-ecs plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-ecs'] --- import kbnSecuritysolutionEcsObj from './kbn_securitysolution_ecs.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_es_utils.mdx b/api_docs/kbn_securitysolution_es_utils.mdx index addb0ac1c6b0..1aef92127570 100644 --- a/api_docs/kbn_securitysolution_es_utils.mdx +++ b/api_docs/kbn_securitysolution_es_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-es-utils title: "@kbn/securitysolution-es-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-es-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-es-utils'] --- import kbnSecuritysolutionEsUtilsObj from './kbn_securitysolution_es_utils.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_exception_list_components.mdx b/api_docs/kbn_securitysolution_exception_list_components.mdx index 78935a513971..53846ba0e382 100644 --- a/api_docs/kbn_securitysolution_exception_list_components.mdx +++ b/api_docs/kbn_securitysolution_exception_list_components.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-exception-list-components title: "@kbn/securitysolution-exception-list-components" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-exception-list-components plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-exception-list-components'] --- import kbnSecuritysolutionExceptionListComponentsObj from './kbn_securitysolution_exception_list_components.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_hook_utils.mdx b/api_docs/kbn_securitysolution_hook_utils.mdx index 9c6c4d8e1483..0ed014cb136b 100644 --- a/api_docs/kbn_securitysolution_hook_utils.mdx +++ b/api_docs/kbn_securitysolution_hook_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-hook-utils title: "@kbn/securitysolution-hook-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-hook-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-hook-utils'] --- import kbnSecuritysolutionHookUtilsObj from './kbn_securitysolution_hook_utils.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_io_ts_alerting_types.mdx b/api_docs/kbn_securitysolution_io_ts_alerting_types.mdx index ab3cbced84de..33cdd4715ac0 100644 --- a/api_docs/kbn_securitysolution_io_ts_alerting_types.mdx +++ b/api_docs/kbn_securitysolution_io_ts_alerting_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-io-ts-alerting-types title: "@kbn/securitysolution-io-ts-alerting-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-io-ts-alerting-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-io-ts-alerting-types'] --- import kbnSecuritysolutionIoTsAlertingTypesObj from './kbn_securitysolution_io_ts_alerting_types.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_io_ts_list_types.mdx b/api_docs/kbn_securitysolution_io_ts_list_types.mdx index af455c1c031c..a367a1629a9e 100644 --- a/api_docs/kbn_securitysolution_io_ts_list_types.mdx +++ b/api_docs/kbn_securitysolution_io_ts_list_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-io-ts-list-types title: "@kbn/securitysolution-io-ts-list-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-io-ts-list-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-io-ts-list-types'] --- import kbnSecuritysolutionIoTsListTypesObj from './kbn_securitysolution_io_ts_list_types.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_io_ts_types.mdx b/api_docs/kbn_securitysolution_io_ts_types.mdx index e072f168d4fd..11a27ebeb19c 100644 --- a/api_docs/kbn_securitysolution_io_ts_types.mdx +++ b/api_docs/kbn_securitysolution_io_ts_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-io-ts-types title: "@kbn/securitysolution-io-ts-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-io-ts-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-io-ts-types'] --- import kbnSecuritysolutionIoTsTypesObj from './kbn_securitysolution_io_ts_types.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_io_ts_utils.mdx b/api_docs/kbn_securitysolution_io_ts_utils.mdx index 79a845c2b380..230f10e9ff23 100644 --- a/api_docs/kbn_securitysolution_io_ts_utils.mdx +++ b/api_docs/kbn_securitysolution_io_ts_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-io-ts-utils title: "@kbn/securitysolution-io-ts-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-io-ts-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-io-ts-utils'] --- import kbnSecuritysolutionIoTsUtilsObj from './kbn_securitysolution_io_ts_utils.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_list_api.mdx b/api_docs/kbn_securitysolution_list_api.mdx index 72ebc77bf13e..07a3718c94ec 100644 --- a/api_docs/kbn_securitysolution_list_api.mdx +++ b/api_docs/kbn_securitysolution_list_api.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-list-api title: "@kbn/securitysolution-list-api" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-list-api plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-list-api'] --- import kbnSecuritysolutionListApiObj from './kbn_securitysolution_list_api.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_list_constants.devdocs.json b/api_docs/kbn_securitysolution_list_constants.devdocs.json index 889e28f5dc20..f4fe485c20c7 100644 --- a/api_docs/kbn_securitysolution_list_constants.devdocs.json +++ b/api_docs/kbn_securitysolution_list_constants.devdocs.json @@ -67,14 +67,6 @@ "deprecated": true, "trackAdoption": false, "references": [ - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts" @@ -103,6 +95,14 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts" }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts" @@ -189,19 +189,19 @@ "references": [ { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" + "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" + "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" }, { "plugin": "securitySolution", @@ -227,26 +227,6 @@ "deprecated": true, "trackAdoption": false, "references": [ - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" @@ -307,6 +287,26 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts" }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" + }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts" @@ -381,19 +381,19 @@ "references": [ { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" + "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts" + "path": "x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts" }, { "plugin": "securitySolution", @@ -453,19 +453,19 @@ "references": [ { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts" + "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts" }, { "plugin": "securitySolution", @@ -473,11 +473,11 @@ }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts" + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/host_isolation_exceptions_api_client.ts" + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" }, { "plugin": "securitySolution", @@ -752,14 +752,6 @@ "plugin": "lists", "path": "x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts" }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts" @@ -788,6 +780,14 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts" }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts" + }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts" diff --git a/api_docs/kbn_securitysolution_list_constants.mdx b/api_docs/kbn_securitysolution_list_constants.mdx index eb22e282972f..cfd156223a6a 100644 --- a/api_docs/kbn_securitysolution_list_constants.mdx +++ b/api_docs/kbn_securitysolution_list_constants.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-list-constants title: "@kbn/securitysolution-list-constants" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-list-constants plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-list-constants'] --- import kbnSecuritysolutionListConstantsObj from './kbn_securitysolution_list_constants.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_list_hooks.mdx b/api_docs/kbn_securitysolution_list_hooks.mdx index 1abbf420157f..224320b96755 100644 --- a/api_docs/kbn_securitysolution_list_hooks.mdx +++ b/api_docs/kbn_securitysolution_list_hooks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-list-hooks title: "@kbn/securitysolution-list-hooks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-list-hooks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-list-hooks'] --- import kbnSecuritysolutionListHooksObj from './kbn_securitysolution_list_hooks.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_list_utils.mdx b/api_docs/kbn_securitysolution_list_utils.mdx index 6a06044a954d..7b2f62b6b05c 100644 --- a/api_docs/kbn_securitysolution_list_utils.mdx +++ b/api_docs/kbn_securitysolution_list_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-list-utils title: "@kbn/securitysolution-list-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-list-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-list-utils'] --- import kbnSecuritysolutionListUtilsObj from './kbn_securitysolution_list_utils.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_rules.mdx b/api_docs/kbn_securitysolution_rules.mdx index 2f31a4b28d29..6a00d13fb3b5 100644 --- a/api_docs/kbn_securitysolution_rules.mdx +++ b/api_docs/kbn_securitysolution_rules.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-rules title: "@kbn/securitysolution-rules" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-rules plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-rules'] --- import kbnSecuritysolutionRulesObj from './kbn_securitysolution_rules.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_t_grid.mdx b/api_docs/kbn_securitysolution_t_grid.mdx index 3f3c71961372..424f784abadf 100644 --- a/api_docs/kbn_securitysolution_t_grid.mdx +++ b/api_docs/kbn_securitysolution_t_grid.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-t-grid title: "@kbn/securitysolution-t-grid" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-t-grid plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-t-grid'] --- import kbnSecuritysolutionTGridObj from './kbn_securitysolution_t_grid.devdocs.json'; diff --git a/api_docs/kbn_securitysolution_utils.mdx b/api_docs/kbn_securitysolution_utils.mdx index ea4076ce67c9..d0c5d655632b 100644 --- a/api_docs/kbn_securitysolution_utils.mdx +++ b/api_docs/kbn_securitysolution_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-securitysolution-utils title: "@kbn/securitysolution-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/securitysolution-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/securitysolution-utils'] --- import kbnSecuritysolutionUtilsObj from './kbn_securitysolution_utils.devdocs.json'; diff --git a/api_docs/kbn_server_http_tools.mdx b/api_docs/kbn_server_http_tools.mdx index 7a7b86ad1a96..d26e7f2837e4 100644 --- a/api_docs/kbn_server_http_tools.mdx +++ b/api_docs/kbn_server_http_tools.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-server-http-tools title: "@kbn/server-http-tools" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/server-http-tools plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/server-http-tools'] --- import kbnServerHttpToolsObj from './kbn_server_http_tools.devdocs.json'; diff --git a/api_docs/kbn_server_route_repository.mdx b/api_docs/kbn_server_route_repository.mdx index 9cf9a758ea29..1a225d92b53a 100644 --- a/api_docs/kbn_server_route_repository.mdx +++ b/api_docs/kbn_server_route_repository.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-server-route-repository title: "@kbn/server-route-repository" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/server-route-repository plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/server-route-repository'] --- import kbnServerRouteRepositoryObj from './kbn_server_route_repository.devdocs.json'; diff --git a/api_docs/kbn_serverless_common_settings.mdx b/api_docs/kbn_serverless_common_settings.mdx index 95a21031b7fd..7fe380e0bf54 100644 --- a/api_docs/kbn_serverless_common_settings.mdx +++ b/api_docs/kbn_serverless_common_settings.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-serverless-common-settings title: "@kbn/serverless-common-settings" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/serverless-common-settings plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/serverless-common-settings'] --- import kbnServerlessCommonSettingsObj from './kbn_serverless_common_settings.devdocs.json'; diff --git a/api_docs/kbn_serverless_observability_settings.mdx b/api_docs/kbn_serverless_observability_settings.mdx index 797f3427db6c..023e2b19d862 100644 --- a/api_docs/kbn_serverless_observability_settings.mdx +++ b/api_docs/kbn_serverless_observability_settings.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-serverless-observability-settings title: "@kbn/serverless-observability-settings" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/serverless-observability-settings plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/serverless-observability-settings'] --- import kbnServerlessObservabilitySettingsObj from './kbn_serverless_observability_settings.devdocs.json'; diff --git a/api_docs/kbn_serverless_project_switcher.mdx b/api_docs/kbn_serverless_project_switcher.mdx index 718dea9a53db..3fa7c46d002e 100644 --- a/api_docs/kbn_serverless_project_switcher.mdx +++ b/api_docs/kbn_serverless_project_switcher.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-serverless-project-switcher title: "@kbn/serverless-project-switcher" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/serverless-project-switcher plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/serverless-project-switcher'] --- import kbnServerlessProjectSwitcherObj from './kbn_serverless_project_switcher.devdocs.json'; diff --git a/api_docs/kbn_serverless_search_settings.mdx b/api_docs/kbn_serverless_search_settings.mdx index 7e6c7590c2f3..5c9df1a51fd7 100644 --- a/api_docs/kbn_serverless_search_settings.mdx +++ b/api_docs/kbn_serverless_search_settings.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-serverless-search-settings title: "@kbn/serverless-search-settings" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/serverless-search-settings plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/serverless-search-settings'] --- import kbnServerlessSearchSettingsObj from './kbn_serverless_search_settings.devdocs.json'; diff --git a/api_docs/kbn_serverless_security_settings.mdx b/api_docs/kbn_serverless_security_settings.mdx index ac25cc3eba29..90764c414c67 100644 --- a/api_docs/kbn_serverless_security_settings.mdx +++ b/api_docs/kbn_serverless_security_settings.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-serverless-security-settings title: "@kbn/serverless-security-settings" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/serverless-security-settings plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/serverless-security-settings'] --- import kbnServerlessSecuritySettingsObj from './kbn_serverless_security_settings.devdocs.json'; diff --git a/api_docs/kbn_serverless_storybook_config.mdx b/api_docs/kbn_serverless_storybook_config.mdx index cf5d743e0eef..ee14bf49416d 100644 --- a/api_docs/kbn_serverless_storybook_config.mdx +++ b/api_docs/kbn_serverless_storybook_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-serverless-storybook-config title: "@kbn/serverless-storybook-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/serverless-storybook-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/serverless-storybook-config'] --- import kbnServerlessStorybookConfigObj from './kbn_serverless_storybook_config.devdocs.json'; diff --git a/api_docs/kbn_shared_svg.mdx b/api_docs/kbn_shared_svg.mdx index 1ca604e93848..3ca354d31f59 100644 --- a/api_docs/kbn_shared_svg.mdx +++ b/api_docs/kbn_shared_svg.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-svg title: "@kbn/shared-svg" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-svg plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-svg'] --- import kbnSharedSvgObj from './kbn_shared_svg.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_avatar_solution.mdx b/api_docs/kbn_shared_ux_avatar_solution.mdx index 6119443b4c4e..0c028e69275f 100644 --- a/api_docs/kbn_shared_ux_avatar_solution.mdx +++ b/api_docs/kbn_shared_ux_avatar_solution.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-avatar-solution title: "@kbn/shared-ux-avatar-solution" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-avatar-solution plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-avatar-solution'] --- import kbnSharedUxAvatarSolutionObj from './kbn_shared_ux_avatar_solution.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_button_exit_full_screen.mdx b/api_docs/kbn_shared_ux_button_exit_full_screen.mdx index 2db41b40a55b..adf957ecf75c 100644 --- a/api_docs/kbn_shared_ux_button_exit_full_screen.mdx +++ b/api_docs/kbn_shared_ux_button_exit_full_screen.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-button-exit-full-screen title: "@kbn/shared-ux-button-exit-full-screen" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-button-exit-full-screen plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-button-exit-full-screen'] --- import kbnSharedUxButtonExitFullScreenObj from './kbn_shared_ux_button_exit_full_screen.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_button_toolbar.mdx b/api_docs/kbn_shared_ux_button_toolbar.mdx index ba130d42ac4d..ec994d4e18b7 100644 --- a/api_docs/kbn_shared_ux_button_toolbar.mdx +++ b/api_docs/kbn_shared_ux_button_toolbar.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-button-toolbar title: "@kbn/shared-ux-button-toolbar" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-button-toolbar plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-button-toolbar'] --- import kbnSharedUxButtonToolbarObj from './kbn_shared_ux_button_toolbar.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_card_no_data.mdx b/api_docs/kbn_shared_ux_card_no_data.mdx index 679569f8e672..4724a4a6e465 100644 --- a/api_docs/kbn_shared_ux_card_no_data.mdx +++ b/api_docs/kbn_shared_ux_card_no_data.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-card-no-data title: "@kbn/shared-ux-card-no-data" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-card-no-data plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-card-no-data'] --- import kbnSharedUxCardNoDataObj from './kbn_shared_ux_card_no_data.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_card_no_data_mocks.mdx b/api_docs/kbn_shared_ux_card_no_data_mocks.mdx index 6111d140b1b4..2a59b198fc4a 100644 --- a/api_docs/kbn_shared_ux_card_no_data_mocks.mdx +++ b/api_docs/kbn_shared_ux_card_no_data_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-card-no-data-mocks title: "@kbn/shared-ux-card-no-data-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-card-no-data-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-card-no-data-mocks'] --- import kbnSharedUxCardNoDataMocksObj from './kbn_shared_ux_card_no_data_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_chrome_navigation.mdx b/api_docs/kbn_shared_ux_chrome_navigation.mdx index e2a786e5a59f..7adfd40f09b5 100644 --- a/api_docs/kbn_shared_ux_chrome_navigation.mdx +++ b/api_docs/kbn_shared_ux_chrome_navigation.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-chrome-navigation title: "@kbn/shared-ux-chrome-navigation" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-chrome-navigation plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-chrome-navigation'] --- import kbnSharedUxChromeNavigationObj from './kbn_shared_ux_chrome_navigation.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_error_boundary.mdx b/api_docs/kbn_shared_ux_error_boundary.mdx index 25fe4c0ed41b..442033533489 100644 --- a/api_docs/kbn_shared_ux_error_boundary.mdx +++ b/api_docs/kbn_shared_ux_error_boundary.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-error-boundary title: "@kbn/shared-ux-error-boundary" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-error-boundary plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-error-boundary'] --- import kbnSharedUxErrorBoundaryObj from './kbn_shared_ux_error_boundary.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_context.mdx b/api_docs/kbn_shared_ux_file_context.mdx index 6b42853105e3..a25cff2f66b5 100644 --- a/api_docs/kbn_shared_ux_file_context.mdx +++ b/api_docs/kbn_shared_ux_file_context.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-context title: "@kbn/shared-ux-file-context" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-context plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-context'] --- import kbnSharedUxFileContextObj from './kbn_shared_ux_file_context.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_image.mdx b/api_docs/kbn_shared_ux_file_image.mdx index d3cd9f9d0acb..7276b3a6cb97 100644 --- a/api_docs/kbn_shared_ux_file_image.mdx +++ b/api_docs/kbn_shared_ux_file_image.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-image title: "@kbn/shared-ux-file-image" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-image plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-image'] --- import kbnSharedUxFileImageObj from './kbn_shared_ux_file_image.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_image_mocks.mdx b/api_docs/kbn_shared_ux_file_image_mocks.mdx index 0c169631530d..576b46f0a128 100644 --- a/api_docs/kbn_shared_ux_file_image_mocks.mdx +++ b/api_docs/kbn_shared_ux_file_image_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-image-mocks title: "@kbn/shared-ux-file-image-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-image-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-image-mocks'] --- import kbnSharedUxFileImageMocksObj from './kbn_shared_ux_file_image_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_mocks.mdx b/api_docs/kbn_shared_ux_file_mocks.mdx index 2b0d670d59b7..affcb80b62ba 100644 --- a/api_docs/kbn_shared_ux_file_mocks.mdx +++ b/api_docs/kbn_shared_ux_file_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-mocks title: "@kbn/shared-ux-file-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-mocks'] --- import kbnSharedUxFileMocksObj from './kbn_shared_ux_file_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_picker.mdx b/api_docs/kbn_shared_ux_file_picker.mdx index 21a5cc86e884..c8d74eac32e1 100644 --- a/api_docs/kbn_shared_ux_file_picker.mdx +++ b/api_docs/kbn_shared_ux_file_picker.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-picker title: "@kbn/shared-ux-file-picker" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-picker plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-picker'] --- import kbnSharedUxFilePickerObj from './kbn_shared_ux_file_picker.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_types.mdx b/api_docs/kbn_shared_ux_file_types.mdx index e9b4e9b0e5ea..609605213abe 100644 --- a/api_docs/kbn_shared_ux_file_types.mdx +++ b/api_docs/kbn_shared_ux_file_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-types title: "@kbn/shared-ux-file-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-types'] --- import kbnSharedUxFileTypesObj from './kbn_shared_ux_file_types.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_upload.mdx b/api_docs/kbn_shared_ux_file_upload.mdx index 4c843ecc9c96..cdc4cc148af4 100644 --- a/api_docs/kbn_shared_ux_file_upload.mdx +++ b/api_docs/kbn_shared_ux_file_upload.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-upload title: "@kbn/shared-ux-file-upload" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-upload plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-upload'] --- import kbnSharedUxFileUploadObj from './kbn_shared_ux_file_upload.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_file_util.mdx b/api_docs/kbn_shared_ux_file_util.mdx index 0620f94bb753..c238beb9f087 100644 --- a/api_docs/kbn_shared_ux_file_util.mdx +++ b/api_docs/kbn_shared_ux_file_util.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-file-util title: "@kbn/shared-ux-file-util" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-file-util plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-file-util'] --- import kbnSharedUxFileUtilObj from './kbn_shared_ux_file_util.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_link_redirect_app.mdx b/api_docs/kbn_shared_ux_link_redirect_app.mdx index 934591ebeb06..e3b0fef1518b 100644 --- a/api_docs/kbn_shared_ux_link_redirect_app.mdx +++ b/api_docs/kbn_shared_ux_link_redirect_app.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-link-redirect-app title: "@kbn/shared-ux-link-redirect-app" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-link-redirect-app plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-link-redirect-app'] --- import kbnSharedUxLinkRedirectAppObj from './kbn_shared_ux_link_redirect_app.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_link_redirect_app_mocks.mdx b/api_docs/kbn_shared_ux_link_redirect_app_mocks.mdx index 93e896d2707a..79fe7bfe7722 100644 --- a/api_docs/kbn_shared_ux_link_redirect_app_mocks.mdx +++ b/api_docs/kbn_shared_ux_link_redirect_app_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-link-redirect-app-mocks title: "@kbn/shared-ux-link-redirect-app-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-link-redirect-app-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-link-redirect-app-mocks'] --- import kbnSharedUxLinkRedirectAppMocksObj from './kbn_shared_ux_link_redirect_app_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_markdown.mdx b/api_docs/kbn_shared_ux_markdown.mdx index 9121d40c5083..9d13ad8c7dc4 100644 --- a/api_docs/kbn_shared_ux_markdown.mdx +++ b/api_docs/kbn_shared_ux_markdown.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-markdown title: "@kbn/shared-ux-markdown" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-markdown plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-markdown'] --- import kbnSharedUxMarkdownObj from './kbn_shared_ux_markdown.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_markdown_mocks.mdx b/api_docs/kbn_shared_ux_markdown_mocks.mdx index 1c1dbce1f280..e5ef32d881ae 100644 --- a/api_docs/kbn_shared_ux_markdown_mocks.mdx +++ b/api_docs/kbn_shared_ux_markdown_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-markdown-mocks title: "@kbn/shared-ux-markdown-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-markdown-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-markdown-mocks'] --- import kbnSharedUxMarkdownMocksObj from './kbn_shared_ux_markdown_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_analytics_no_data.mdx b/api_docs/kbn_shared_ux_page_analytics_no_data.mdx index 6a7b09c9fb88..87bbd83be92f 100644 --- a/api_docs/kbn_shared_ux_page_analytics_no_data.mdx +++ b/api_docs/kbn_shared_ux_page_analytics_no_data.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-analytics-no-data title: "@kbn/shared-ux-page-analytics-no-data" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-analytics-no-data plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-analytics-no-data'] --- import kbnSharedUxPageAnalyticsNoDataObj from './kbn_shared_ux_page_analytics_no_data.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_analytics_no_data_mocks.mdx b/api_docs/kbn_shared_ux_page_analytics_no_data_mocks.mdx index fcf30f872a13..d5b24608c816 100644 --- a/api_docs/kbn_shared_ux_page_analytics_no_data_mocks.mdx +++ b/api_docs/kbn_shared_ux_page_analytics_no_data_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-analytics-no-data-mocks title: "@kbn/shared-ux-page-analytics-no-data-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-analytics-no-data-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-analytics-no-data-mocks'] --- import kbnSharedUxPageAnalyticsNoDataMocksObj from './kbn_shared_ux_page_analytics_no_data_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_kibana_no_data.mdx b/api_docs/kbn_shared_ux_page_kibana_no_data.mdx index 244bb2d7f8d3..01f745c3f715 100644 --- a/api_docs/kbn_shared_ux_page_kibana_no_data.mdx +++ b/api_docs/kbn_shared_ux_page_kibana_no_data.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-kibana-no-data title: "@kbn/shared-ux-page-kibana-no-data" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-kibana-no-data plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-kibana-no-data'] --- import kbnSharedUxPageKibanaNoDataObj from './kbn_shared_ux_page_kibana_no_data.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_kibana_no_data_mocks.mdx b/api_docs/kbn_shared_ux_page_kibana_no_data_mocks.mdx index 2d020aa24548..76c3d9c634b6 100644 --- a/api_docs/kbn_shared_ux_page_kibana_no_data_mocks.mdx +++ b/api_docs/kbn_shared_ux_page_kibana_no_data_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-kibana-no-data-mocks title: "@kbn/shared-ux-page-kibana-no-data-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-kibana-no-data-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-kibana-no-data-mocks'] --- import kbnSharedUxPageKibanaNoDataMocksObj from './kbn_shared_ux_page_kibana_no_data_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_kibana_template.mdx b/api_docs/kbn_shared_ux_page_kibana_template.mdx index 2a9cd7906d88..e9fe2259bfab 100644 --- a/api_docs/kbn_shared_ux_page_kibana_template.mdx +++ b/api_docs/kbn_shared_ux_page_kibana_template.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-kibana-template title: "@kbn/shared-ux-page-kibana-template" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-kibana-template plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-kibana-template'] --- import kbnSharedUxPageKibanaTemplateObj from './kbn_shared_ux_page_kibana_template.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_kibana_template_mocks.mdx b/api_docs/kbn_shared_ux_page_kibana_template_mocks.mdx index 7a9d358364fa..6f08c619612f 100644 --- a/api_docs/kbn_shared_ux_page_kibana_template_mocks.mdx +++ b/api_docs/kbn_shared_ux_page_kibana_template_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-kibana-template-mocks title: "@kbn/shared-ux-page-kibana-template-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-kibana-template-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-kibana-template-mocks'] --- import kbnSharedUxPageKibanaTemplateMocksObj from './kbn_shared_ux_page_kibana_template_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_no_data.mdx b/api_docs/kbn_shared_ux_page_no_data.mdx index 12da95163ac4..5e9e46ad65c7 100644 --- a/api_docs/kbn_shared_ux_page_no_data.mdx +++ b/api_docs/kbn_shared_ux_page_no_data.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-no-data title: "@kbn/shared-ux-page-no-data" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-no-data plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-no-data'] --- import kbnSharedUxPageNoDataObj from './kbn_shared_ux_page_no_data.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_no_data_config.mdx b/api_docs/kbn_shared_ux_page_no_data_config.mdx index 1479314b6208..62798a05c510 100644 --- a/api_docs/kbn_shared_ux_page_no_data_config.mdx +++ b/api_docs/kbn_shared_ux_page_no_data_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-no-data-config title: "@kbn/shared-ux-page-no-data-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-no-data-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-no-data-config'] --- import kbnSharedUxPageNoDataConfigObj from './kbn_shared_ux_page_no_data_config.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_no_data_config_mocks.mdx b/api_docs/kbn_shared_ux_page_no_data_config_mocks.mdx index b791a757444b..0fb02e8559a2 100644 --- a/api_docs/kbn_shared_ux_page_no_data_config_mocks.mdx +++ b/api_docs/kbn_shared_ux_page_no_data_config_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-no-data-config-mocks title: "@kbn/shared-ux-page-no-data-config-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-no-data-config-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-no-data-config-mocks'] --- import kbnSharedUxPageNoDataConfigMocksObj from './kbn_shared_ux_page_no_data_config_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_no_data_mocks.mdx b/api_docs/kbn_shared_ux_page_no_data_mocks.mdx index 85970986b124..62b476114215 100644 --- a/api_docs/kbn_shared_ux_page_no_data_mocks.mdx +++ b/api_docs/kbn_shared_ux_page_no_data_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-no-data-mocks title: "@kbn/shared-ux-page-no-data-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-no-data-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-no-data-mocks'] --- import kbnSharedUxPageNoDataMocksObj from './kbn_shared_ux_page_no_data_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_page_solution_nav.mdx b/api_docs/kbn_shared_ux_page_solution_nav.mdx index 878788b3d471..5f353f432c6d 100644 --- a/api_docs/kbn_shared_ux_page_solution_nav.mdx +++ b/api_docs/kbn_shared_ux_page_solution_nav.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-page-solution-nav title: "@kbn/shared-ux-page-solution-nav" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-page-solution-nav plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-page-solution-nav'] --- import kbnSharedUxPageSolutionNavObj from './kbn_shared_ux_page_solution_nav.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_prompt_no_data_views.mdx b/api_docs/kbn_shared_ux_prompt_no_data_views.mdx index bf52b96afb94..a9250e7f475a 100644 --- a/api_docs/kbn_shared_ux_prompt_no_data_views.mdx +++ b/api_docs/kbn_shared_ux_prompt_no_data_views.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-prompt-no-data-views title: "@kbn/shared-ux-prompt-no-data-views" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-prompt-no-data-views plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-prompt-no-data-views'] --- import kbnSharedUxPromptNoDataViewsObj from './kbn_shared_ux_prompt_no_data_views.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_prompt_no_data_views_mocks.mdx b/api_docs/kbn_shared_ux_prompt_no_data_views_mocks.mdx index 9814b2f735df..677ce3036983 100644 --- a/api_docs/kbn_shared_ux_prompt_no_data_views_mocks.mdx +++ b/api_docs/kbn_shared_ux_prompt_no_data_views_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-prompt-no-data-views-mocks title: "@kbn/shared-ux-prompt-no-data-views-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-prompt-no-data-views-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-prompt-no-data-views-mocks'] --- import kbnSharedUxPromptNoDataViewsMocksObj from './kbn_shared_ux_prompt_no_data_views_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_prompt_not_found.mdx b/api_docs/kbn_shared_ux_prompt_not_found.mdx index 387c655a3d65..fe7fdd18c8f3 100644 --- a/api_docs/kbn_shared_ux_prompt_not_found.mdx +++ b/api_docs/kbn_shared_ux_prompt_not_found.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-prompt-not-found title: "@kbn/shared-ux-prompt-not-found" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-prompt-not-found plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-prompt-not-found'] --- import kbnSharedUxPromptNotFoundObj from './kbn_shared_ux_prompt_not_found.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_router.mdx b/api_docs/kbn_shared_ux_router.mdx index d451948c7c7d..a918fd656791 100644 --- a/api_docs/kbn_shared_ux_router.mdx +++ b/api_docs/kbn_shared_ux_router.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-router title: "@kbn/shared-ux-router" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-router plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-router'] --- import kbnSharedUxRouterObj from './kbn_shared_ux_router.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_router_mocks.mdx b/api_docs/kbn_shared_ux_router_mocks.mdx index 67fdd58316ed..3f433e8353bd 100644 --- a/api_docs/kbn_shared_ux_router_mocks.mdx +++ b/api_docs/kbn_shared_ux_router_mocks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-router-mocks title: "@kbn/shared-ux-router-mocks" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-router-mocks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-router-mocks'] --- import kbnSharedUxRouterMocksObj from './kbn_shared_ux_router_mocks.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_storybook_config.mdx b/api_docs/kbn_shared_ux_storybook_config.mdx index f3856bfb82f0..e1d49975125a 100644 --- a/api_docs/kbn_shared_ux_storybook_config.mdx +++ b/api_docs/kbn_shared_ux_storybook_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-storybook-config title: "@kbn/shared-ux-storybook-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-storybook-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-storybook-config'] --- import kbnSharedUxStorybookConfigObj from './kbn_shared_ux_storybook_config.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_storybook_mock.mdx b/api_docs/kbn_shared_ux_storybook_mock.mdx index bd9938f285c6..b5362b262c52 100644 --- a/api_docs/kbn_shared_ux_storybook_mock.mdx +++ b/api_docs/kbn_shared_ux_storybook_mock.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-storybook-mock title: "@kbn/shared-ux-storybook-mock" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-storybook-mock plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-storybook-mock'] --- import kbnSharedUxStorybookMockObj from './kbn_shared_ux_storybook_mock.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_tabbed_modal.mdx b/api_docs/kbn_shared_ux_tabbed_modal.mdx index 72094fa7b49e..cc8522ad022f 100644 --- a/api_docs/kbn_shared_ux_tabbed_modal.mdx +++ b/api_docs/kbn_shared_ux_tabbed_modal.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-tabbed-modal title: "@kbn/shared-ux-tabbed-modal" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-tabbed-modal plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-tabbed-modal'] --- import kbnSharedUxTabbedModalObj from './kbn_shared_ux_tabbed_modal.devdocs.json'; diff --git a/api_docs/kbn_shared_ux_utility.mdx b/api_docs/kbn_shared_ux_utility.mdx index 40af05c12e0a..0abe79d90ca5 100644 --- a/api_docs/kbn_shared_ux_utility.mdx +++ b/api_docs/kbn_shared_ux_utility.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-shared-ux-utility title: "@kbn/shared-ux-utility" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/shared-ux-utility plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/shared-ux-utility'] --- import kbnSharedUxUtilityObj from './kbn_shared_ux_utility.devdocs.json'; diff --git a/api_docs/kbn_slo_schema.mdx b/api_docs/kbn_slo_schema.mdx index 0946fca909ec..ebd938fd31b2 100644 --- a/api_docs/kbn_slo_schema.mdx +++ b/api_docs/kbn_slo_schema.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-slo-schema title: "@kbn/slo-schema" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/slo-schema plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/slo-schema'] --- import kbnSloSchemaObj from './kbn_slo_schema.devdocs.json'; diff --git a/api_docs/kbn_some_dev_log.mdx b/api_docs/kbn_some_dev_log.mdx index bd3ac329d3ff..375566d76026 100644 --- a/api_docs/kbn_some_dev_log.mdx +++ b/api_docs/kbn_some_dev_log.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-some-dev-log title: "@kbn/some-dev-log" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/some-dev-log plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/some-dev-log'] --- import kbnSomeDevLogObj from './kbn_some_dev_log.devdocs.json'; diff --git a/api_docs/kbn_sort_predicates.mdx b/api_docs/kbn_sort_predicates.mdx index 01e9d3af0579..21265792e07e 100644 --- a/api_docs/kbn_sort_predicates.mdx +++ b/api_docs/kbn_sort_predicates.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-sort-predicates title: "@kbn/sort-predicates" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/sort-predicates plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/sort-predicates'] --- import kbnSortPredicatesObj from './kbn_sort_predicates.devdocs.json'; diff --git a/api_docs/kbn_std.mdx b/api_docs/kbn_std.mdx index bf3f5c395b67..b35afc4b8a8d 100644 --- a/api_docs/kbn_std.mdx +++ b/api_docs/kbn_std.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-std title: "@kbn/std" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/std plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/std'] --- import kbnStdObj from './kbn_std.devdocs.json'; diff --git a/api_docs/kbn_stdio_dev_helpers.mdx b/api_docs/kbn_stdio_dev_helpers.mdx index 86a829c82259..b5afd2e18e90 100644 --- a/api_docs/kbn_stdio_dev_helpers.mdx +++ b/api_docs/kbn_stdio_dev_helpers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-stdio-dev-helpers title: "@kbn/stdio-dev-helpers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/stdio-dev-helpers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/stdio-dev-helpers'] --- import kbnStdioDevHelpersObj from './kbn_stdio_dev_helpers.devdocs.json'; diff --git a/api_docs/kbn_storybook.mdx b/api_docs/kbn_storybook.mdx index 782653c3cc88..28645f226568 100644 --- a/api_docs/kbn_storybook.mdx +++ b/api_docs/kbn_storybook.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-storybook title: "@kbn/storybook" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/storybook plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/storybook'] --- import kbnStorybookObj from './kbn_storybook.devdocs.json'; diff --git a/api_docs/kbn_telemetry_tools.mdx b/api_docs/kbn_telemetry_tools.mdx index f751fe16f782..07b7aaa0e835 100644 --- a/api_docs/kbn_telemetry_tools.mdx +++ b/api_docs/kbn_telemetry_tools.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-telemetry-tools title: "@kbn/telemetry-tools" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/telemetry-tools plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/telemetry-tools'] --- import kbnTelemetryToolsObj from './kbn_telemetry_tools.devdocs.json'; diff --git a/api_docs/kbn_test.mdx b/api_docs/kbn_test.mdx index 8a34a0646390..448de6ac1349 100644 --- a/api_docs/kbn_test.mdx +++ b/api_docs/kbn_test.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-test title: "@kbn/test" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/test plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/test'] --- import kbnTestObj from './kbn_test.devdocs.json'; diff --git a/api_docs/kbn_test_eui_helpers.mdx b/api_docs/kbn_test_eui_helpers.mdx index 5d7337462dc5..d0a18a49c558 100644 --- a/api_docs/kbn_test_eui_helpers.mdx +++ b/api_docs/kbn_test_eui_helpers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-test-eui-helpers title: "@kbn/test-eui-helpers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/test-eui-helpers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/test-eui-helpers'] --- import kbnTestEuiHelpersObj from './kbn_test_eui_helpers.devdocs.json'; diff --git a/api_docs/kbn_test_jest_helpers.mdx b/api_docs/kbn_test_jest_helpers.mdx index 44e139b8f7eb..4a6a82ed8965 100644 --- a/api_docs/kbn_test_jest_helpers.mdx +++ b/api_docs/kbn_test_jest_helpers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-test-jest-helpers title: "@kbn/test-jest-helpers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/test-jest-helpers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/test-jest-helpers'] --- import kbnTestJestHelpersObj from './kbn_test_jest_helpers.devdocs.json'; diff --git a/api_docs/kbn_test_subj_selector.mdx b/api_docs/kbn_test_subj_selector.mdx index 344d706efc54..12e87ff06099 100644 --- a/api_docs/kbn_test_subj_selector.mdx +++ b/api_docs/kbn_test_subj_selector.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-test-subj-selector title: "@kbn/test-subj-selector" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/test-subj-selector plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/test-subj-selector'] --- import kbnTestSubjSelectorObj from './kbn_test_subj_selector.devdocs.json'; diff --git a/api_docs/kbn_text_based_editor.mdx b/api_docs/kbn_text_based_editor.mdx index 14c7ce6dccd4..334d43dd475a 100644 --- a/api_docs/kbn_text_based_editor.mdx +++ b/api_docs/kbn_text_based_editor.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-text-based-editor title: "@kbn/text-based-editor" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/text-based-editor plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/text-based-editor'] --- import kbnTextBasedEditorObj from './kbn_text_based_editor.devdocs.json'; diff --git a/api_docs/kbn_timerange.mdx b/api_docs/kbn_timerange.mdx index fcd0106988ed..9b6b25878d5c 100644 --- a/api_docs/kbn_timerange.mdx +++ b/api_docs/kbn_timerange.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-timerange title: "@kbn/timerange" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/timerange plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/timerange'] --- import kbnTimerangeObj from './kbn_timerange.devdocs.json'; diff --git a/api_docs/kbn_tooling_log.mdx b/api_docs/kbn_tooling_log.mdx index 9c9b94318c94..0240ee8f3650 100644 --- a/api_docs/kbn_tooling_log.mdx +++ b/api_docs/kbn_tooling_log.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-tooling-log title: "@kbn/tooling-log" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/tooling-log plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/tooling-log'] --- import kbnToolingLogObj from './kbn_tooling_log.devdocs.json'; diff --git a/api_docs/kbn_triggers_actions_ui_types.mdx b/api_docs/kbn_triggers_actions_ui_types.mdx index 79f93fb9a322..c595a5de6729 100644 --- a/api_docs/kbn_triggers_actions_ui_types.mdx +++ b/api_docs/kbn_triggers_actions_ui_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-triggers-actions-ui-types title: "@kbn/triggers-actions-ui-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/triggers-actions-ui-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/triggers-actions-ui-types'] --- import kbnTriggersActionsUiTypesObj from './kbn_triggers_actions_ui_types.devdocs.json'; diff --git a/api_docs/kbn_try_in_console.mdx b/api_docs/kbn_try_in_console.mdx index 6e2e5be02c22..baeb628c7b42 100644 --- a/api_docs/kbn_try_in_console.mdx +++ b/api_docs/kbn_try_in_console.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-try-in-console title: "@kbn/try-in-console" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/try-in-console plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/try-in-console'] --- import kbnTryInConsoleObj from './kbn_try_in_console.devdocs.json'; diff --git a/api_docs/kbn_ts_projects.mdx b/api_docs/kbn_ts_projects.mdx index ea3b165b75c0..f2f8984b6423 100644 --- a/api_docs/kbn_ts_projects.mdx +++ b/api_docs/kbn_ts_projects.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ts-projects title: "@kbn/ts-projects" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ts-projects plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ts-projects'] --- import kbnTsProjectsObj from './kbn_ts_projects.devdocs.json'; diff --git a/api_docs/kbn_typed_react_router_config.mdx b/api_docs/kbn_typed_react_router_config.mdx index 0326f70127ab..97cf3f5bfed8 100644 --- a/api_docs/kbn_typed_react_router_config.mdx +++ b/api_docs/kbn_typed_react_router_config.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-typed-react-router-config title: "@kbn/typed-react-router-config" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/typed-react-router-config plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/typed-react-router-config'] --- import kbnTypedReactRouterConfigObj from './kbn_typed_react_router_config.devdocs.json'; diff --git a/api_docs/kbn_ui_actions_browser.mdx b/api_docs/kbn_ui_actions_browser.mdx index dad900c07015..506836ede7fd 100644 --- a/api_docs/kbn_ui_actions_browser.mdx +++ b/api_docs/kbn_ui_actions_browser.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ui-actions-browser title: "@kbn/ui-actions-browser" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ui-actions-browser plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ui-actions-browser'] --- import kbnUiActionsBrowserObj from './kbn_ui_actions_browser.devdocs.json'; diff --git a/api_docs/kbn_ui_shared_deps_src.mdx b/api_docs/kbn_ui_shared_deps_src.mdx index 86001479d4c4..f9b114d5775e 100644 --- a/api_docs/kbn_ui_shared_deps_src.mdx +++ b/api_docs/kbn_ui_shared_deps_src.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ui-shared-deps-src title: "@kbn/ui-shared-deps-src" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ui-shared-deps-src plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ui-shared-deps-src'] --- import kbnUiSharedDepsSrcObj from './kbn_ui_shared_deps_src.devdocs.json'; diff --git a/api_docs/kbn_ui_theme.mdx b/api_docs/kbn_ui_theme.mdx index 4de3d8b71b21..c305850a1d83 100644 --- a/api_docs/kbn_ui_theme.mdx +++ b/api_docs/kbn_ui_theme.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-ui-theme title: "@kbn/ui-theme" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/ui-theme plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ui-theme'] --- import kbnUiThemeObj from './kbn_ui_theme.devdocs.json'; diff --git a/api_docs/kbn_unified_data_table.mdx b/api_docs/kbn_unified_data_table.mdx index 93cab30ac3d6..3668c93f7f02 100644 --- a/api_docs/kbn_unified_data_table.mdx +++ b/api_docs/kbn_unified_data_table.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-unified-data-table title: "@kbn/unified-data-table" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/unified-data-table plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/unified-data-table'] --- import kbnUnifiedDataTableObj from './kbn_unified_data_table.devdocs.json'; diff --git a/api_docs/kbn_unified_doc_viewer.mdx b/api_docs/kbn_unified_doc_viewer.mdx index c4efafa51763..d368739b28ec 100644 --- a/api_docs/kbn_unified_doc_viewer.mdx +++ b/api_docs/kbn_unified_doc_viewer.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-unified-doc-viewer title: "@kbn/unified-doc-viewer" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/unified-doc-viewer plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/unified-doc-viewer'] --- import kbnUnifiedDocViewerObj from './kbn_unified_doc_viewer.devdocs.json'; diff --git a/api_docs/kbn_unified_field_list.mdx b/api_docs/kbn_unified_field_list.mdx index ec7620203086..8905189feec0 100644 --- a/api_docs/kbn_unified_field_list.mdx +++ b/api_docs/kbn_unified_field_list.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-unified-field-list title: "@kbn/unified-field-list" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/unified-field-list plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/unified-field-list'] --- import kbnUnifiedFieldListObj from './kbn_unified_field_list.devdocs.json'; diff --git a/api_docs/kbn_unsaved_changes_badge.mdx b/api_docs/kbn_unsaved_changes_badge.mdx index 3d357ffa7fa4..b903bb956cd2 100644 --- a/api_docs/kbn_unsaved_changes_badge.mdx +++ b/api_docs/kbn_unsaved_changes_badge.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-unsaved-changes-badge title: "@kbn/unsaved-changes-badge" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/unsaved-changes-badge plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/unsaved-changes-badge'] --- import kbnUnsavedChangesBadgeObj from './kbn_unsaved_changes_badge.devdocs.json'; diff --git a/api_docs/kbn_unsaved_changes_prompt.mdx b/api_docs/kbn_unsaved_changes_prompt.mdx index f87859c4fd8c..7e5698023d2f 100644 --- a/api_docs/kbn_unsaved_changes_prompt.mdx +++ b/api_docs/kbn_unsaved_changes_prompt.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-unsaved-changes-prompt title: "@kbn/unsaved-changes-prompt" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/unsaved-changes-prompt plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/unsaved-changes-prompt'] --- import kbnUnsavedChangesPromptObj from './kbn_unsaved_changes_prompt.devdocs.json'; diff --git a/api_docs/kbn_use_tracked_promise.mdx b/api_docs/kbn_use_tracked_promise.mdx index 94d3b5983fd0..a571febf2237 100644 --- a/api_docs/kbn_use_tracked_promise.mdx +++ b/api_docs/kbn_use_tracked_promise.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-use-tracked-promise title: "@kbn/use-tracked-promise" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/use-tracked-promise plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/use-tracked-promise'] --- import kbnUseTrackedPromiseObj from './kbn_use_tracked_promise.devdocs.json'; diff --git a/api_docs/kbn_user_profile_components.mdx b/api_docs/kbn_user_profile_components.mdx index 197b95cf06ed..6a17df36f565 100644 --- a/api_docs/kbn_user_profile_components.mdx +++ b/api_docs/kbn_user_profile_components.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-user-profile-components title: "@kbn/user-profile-components" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/user-profile-components plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/user-profile-components'] --- import kbnUserProfileComponentsObj from './kbn_user_profile_components.devdocs.json'; diff --git a/api_docs/kbn_utility_types.mdx b/api_docs/kbn_utility_types.mdx index 80729564ca39..5ffdec9cf0bb 100644 --- a/api_docs/kbn_utility_types.mdx +++ b/api_docs/kbn_utility_types.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-utility-types title: "@kbn/utility-types" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/utility-types plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/utility-types'] --- import kbnUtilityTypesObj from './kbn_utility_types.devdocs.json'; diff --git a/api_docs/kbn_utility_types_jest.mdx b/api_docs/kbn_utility_types_jest.mdx index d45921393b4c..1854302ae52f 100644 --- a/api_docs/kbn_utility_types_jest.mdx +++ b/api_docs/kbn_utility_types_jest.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-utility-types-jest title: "@kbn/utility-types-jest" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/utility-types-jest plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/utility-types-jest'] --- import kbnUtilityTypesJestObj from './kbn_utility_types_jest.devdocs.json'; diff --git a/api_docs/kbn_utils.mdx b/api_docs/kbn_utils.mdx index d1f7ae6e0194..08a86a63ee2e 100644 --- a/api_docs/kbn_utils.mdx +++ b/api_docs/kbn_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-utils title: "@kbn/utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/utils'] --- import kbnUtilsObj from './kbn_utils.devdocs.json'; diff --git a/api_docs/kbn_visualization_ui_components.mdx b/api_docs/kbn_visualization_ui_components.mdx index 6f0fe4502da5..91dd35a796e2 100644 --- a/api_docs/kbn_visualization_ui_components.mdx +++ b/api_docs/kbn_visualization_ui_components.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-visualization-ui-components title: "@kbn/visualization-ui-components" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/visualization-ui-components plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/visualization-ui-components'] --- import kbnVisualizationUiComponentsObj from './kbn_visualization_ui_components.devdocs.json'; diff --git a/api_docs/kbn_visualization_utils.mdx b/api_docs/kbn_visualization_utils.mdx index 47f0a8700c42..dbb54a80b7f8 100644 --- a/api_docs/kbn_visualization_utils.mdx +++ b/api_docs/kbn_visualization_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-visualization-utils title: "@kbn/visualization-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/visualization-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/visualization-utils'] --- import kbnVisualizationUtilsObj from './kbn_visualization_utils.devdocs.json'; diff --git a/api_docs/kbn_xstate_utils.mdx b/api_docs/kbn_xstate_utils.mdx index 47b8fa79c93f..979805b0144e 100644 --- a/api_docs/kbn_xstate_utils.mdx +++ b/api_docs/kbn_xstate_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-xstate-utils title: "@kbn/xstate-utils" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/xstate-utils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/xstate-utils'] --- import kbnXstateUtilsObj from './kbn_xstate_utils.devdocs.json'; diff --git a/api_docs/kbn_yarn_lock_validator.mdx b/api_docs/kbn_yarn_lock_validator.mdx index 5595149de650..b1c3b0539048 100644 --- a/api_docs/kbn_yarn_lock_validator.mdx +++ b/api_docs/kbn_yarn_lock_validator.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-yarn-lock-validator title: "@kbn/yarn-lock-validator" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/yarn-lock-validator plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/yarn-lock-validator'] --- import kbnYarnLockValidatorObj from './kbn_yarn_lock_validator.devdocs.json'; diff --git a/api_docs/kbn_zod_helpers.mdx b/api_docs/kbn_zod_helpers.mdx index 657fed7eb2b1..f6be23d9593d 100644 --- a/api_docs/kbn_zod_helpers.mdx +++ b/api_docs/kbn_zod_helpers.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kbn-zod-helpers title: "@kbn/zod-helpers" image: https://source.unsplash.com/400x175/?github description: API docs for the @kbn/zod-helpers plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/zod-helpers'] --- import kbnZodHelpersObj from './kbn_zod_helpers.devdocs.json'; diff --git a/api_docs/kibana_overview.mdx b/api_docs/kibana_overview.mdx index 91980a037639..799026a521b5 100644 --- a/api_docs/kibana_overview.mdx +++ b/api_docs/kibana_overview.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kibanaOverview title: "kibanaOverview" image: https://source.unsplash.com/400x175/?github description: API docs for the kibanaOverview plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'kibanaOverview'] --- import kibanaOverviewObj from './kibana_overview.devdocs.json'; diff --git a/api_docs/kibana_react.mdx b/api_docs/kibana_react.mdx index 95c83742d018..d86b9b363179 100644 --- a/api_docs/kibana_react.mdx +++ b/api_docs/kibana_react.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kibanaReact title: "kibanaReact" image: https://source.unsplash.com/400x175/?github description: API docs for the kibanaReact plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'kibanaReact'] --- import kibanaReactObj from './kibana_react.devdocs.json'; diff --git a/api_docs/kibana_utils.mdx b/api_docs/kibana_utils.mdx index af99dea7c4cd..103779c04280 100644 --- a/api_docs/kibana_utils.mdx +++ b/api_docs/kibana_utils.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kibanaUtils title: "kibanaUtils" image: https://source.unsplash.com/400x175/?github description: API docs for the kibanaUtils plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'kibanaUtils'] --- import kibanaUtilsObj from './kibana_utils.devdocs.json'; diff --git a/api_docs/kubernetes_security.mdx b/api_docs/kubernetes_security.mdx index c87be433cc32..ad0727f0e3d7 100644 --- a/api_docs/kubernetes_security.mdx +++ b/api_docs/kubernetes_security.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/kubernetesSecurity title: "kubernetesSecurity" image: https://source.unsplash.com/400x175/?github description: API docs for the kubernetesSecurity plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'kubernetesSecurity'] --- import kubernetesSecurityObj from './kubernetes_security.devdocs.json'; diff --git a/api_docs/lens.mdx b/api_docs/lens.mdx index c6045a345834..a60ddd7a5b0b 100644 --- a/api_docs/lens.mdx +++ b/api_docs/lens.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/lens title: "lens" image: https://source.unsplash.com/400x175/?github description: API docs for the lens plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'lens'] --- import lensObj from './lens.devdocs.json'; diff --git a/api_docs/license_api_guard.mdx b/api_docs/license_api_guard.mdx index 9b3db06849cc..71e7f45ad5a2 100644 --- a/api_docs/license_api_guard.mdx +++ b/api_docs/license_api_guard.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/licenseApiGuard title: "licenseApiGuard" image: https://source.unsplash.com/400x175/?github description: API docs for the licenseApiGuard plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'licenseApiGuard'] --- import licenseApiGuardObj from './license_api_guard.devdocs.json'; diff --git a/api_docs/license_management.mdx b/api_docs/license_management.mdx index 380fa2c0af55..932ec13479be 100644 --- a/api_docs/license_management.mdx +++ b/api_docs/license_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/licenseManagement title: "licenseManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the licenseManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'licenseManagement'] --- import licenseManagementObj from './license_management.devdocs.json'; diff --git a/api_docs/licensing.mdx b/api_docs/licensing.mdx index 4d27f997a586..53b85119a88b 100644 --- a/api_docs/licensing.mdx +++ b/api_docs/licensing.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/licensing title: "licensing" image: https://source.unsplash.com/400x175/?github description: API docs for the licensing plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'licensing'] --- import licensingObj from './licensing.devdocs.json'; diff --git a/api_docs/links.mdx b/api_docs/links.mdx index 9281df5884fa..dd9983cca1f5 100644 --- a/api_docs/links.mdx +++ b/api_docs/links.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/links title: "links" image: https://source.unsplash.com/400x175/?github description: API docs for the links plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'links'] --- import linksObj from './links.devdocs.json'; diff --git a/api_docs/lists.mdx b/api_docs/lists.mdx index 39bd636d8f64..1fe215e6756d 100644 --- a/api_docs/lists.mdx +++ b/api_docs/lists.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/lists title: "lists" image: https://source.unsplash.com/400x175/?github description: API docs for the lists plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'lists'] --- import listsObj from './lists.devdocs.json'; diff --git a/api_docs/logs_data_access.devdocs.json b/api_docs/logs_data_access.devdocs.json index 89d5df7f6c93..c4f3b466d53a 100644 --- a/api_docs/logs_data_access.devdocs.json +++ b/api_docs/logs_data_access.devdocs.json @@ -124,7 +124,19 @@ "section": "def-server.LogsRatesServiceReturnType", "text": "LogsRatesServiceReturnType" }, - ">; }; }" + ">; getLogSourcesService: (request: ", + { + "pluginId": "@kbn/core-http-server", + "scope": "common", + "docId": "kibKbnCoreHttpServerPluginApi", + "section": "def-common.KibanaRequest", + "text": "KibanaRequest" + }, + ") => Promise<{ getLogSources: () => Promise<", + "LogSource", + "[]>; setLogSources: (sources: ", + "LogSource", + "[]) => Promise; }>; }; }" ], "path": "x-pack/plugins/observability_solution/logs_data_access/server/plugin.ts", "deprecated": false, diff --git a/api_docs/logs_data_access.mdx b/api_docs/logs_data_access.mdx index 4863659b23eb..76dc5414b4d9 100644 --- a/api_docs/logs_data_access.mdx +++ b/api_docs/logs_data_access.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/logsDataAccess title: "logsDataAccess" image: https://source.unsplash.com/400x175/?github description: API docs for the logsDataAccess plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'logsDataAccess'] --- import logsDataAccessObj from './logs_data_access.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 7 | 0 | 7 | 1 | +| 7 | 0 | 7 | 2 | ## Server diff --git a/api_docs/logs_explorer.mdx b/api_docs/logs_explorer.mdx index 98b02692c1e8..a49adadcbbcd 100644 --- a/api_docs/logs_explorer.mdx +++ b/api_docs/logs_explorer.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/logsExplorer title: "logsExplorer" image: https://source.unsplash.com/400x175/?github description: API docs for the logsExplorer plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'logsExplorer'] --- import logsExplorerObj from './logs_explorer.devdocs.json'; diff --git a/api_docs/logs_shared.mdx b/api_docs/logs_shared.mdx index b809bd0772d5..653a49f95abf 100644 --- a/api_docs/logs_shared.mdx +++ b/api_docs/logs_shared.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/logsShared title: "logsShared" image: https://source.unsplash.com/400x175/?github description: API docs for the logsShared plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'logsShared'] --- import logsSharedObj from './logs_shared.devdocs.json'; diff --git a/api_docs/management.mdx b/api_docs/management.mdx index 4ca87c41eb15..d7a37b9b7807 100644 --- a/api_docs/management.mdx +++ b/api_docs/management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/management title: "management" image: https://source.unsplash.com/400x175/?github description: API docs for the management plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'management'] --- import managementObj from './management.devdocs.json'; diff --git a/api_docs/maps.mdx b/api_docs/maps.mdx index 886d41c503e9..02a6ccf22b59 100644 --- a/api_docs/maps.mdx +++ b/api_docs/maps.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/maps title: "maps" image: https://source.unsplash.com/400x175/?github description: API docs for the maps plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'maps'] --- import mapsObj from './maps.devdocs.json'; diff --git a/api_docs/maps_ems.mdx b/api_docs/maps_ems.mdx index dfa6f89ed61e..88577ac6485f 100644 --- a/api_docs/maps_ems.mdx +++ b/api_docs/maps_ems.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/mapsEms title: "mapsEms" image: https://source.unsplash.com/400x175/?github description: API docs for the mapsEms plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'mapsEms'] --- import mapsEmsObj from './maps_ems.devdocs.json'; diff --git a/api_docs/metrics_data_access.mdx b/api_docs/metrics_data_access.mdx index 0111d8711ad8..54350b0924c4 100644 --- a/api_docs/metrics_data_access.mdx +++ b/api_docs/metrics_data_access.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/metricsDataAccess title: "metricsDataAccess" image: https://source.unsplash.com/400x175/?github description: API docs for the metricsDataAccess plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'metricsDataAccess'] --- import metricsDataAccessObj from './metrics_data_access.devdocs.json'; diff --git a/api_docs/ml.mdx b/api_docs/ml.mdx index 4bd30f438933..f493e07f7e0b 100644 --- a/api_docs/ml.mdx +++ b/api_docs/ml.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/ml title: "ml" image: https://source.unsplash.com/400x175/?github description: API docs for the ml plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'ml'] --- import mlObj from './ml.devdocs.json'; diff --git a/api_docs/mock_idp_plugin.mdx b/api_docs/mock_idp_plugin.mdx index 3c326666c035..15be7829533a 100644 --- a/api_docs/mock_idp_plugin.mdx +++ b/api_docs/mock_idp_plugin.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/mockIdpPlugin title: "mockIdpPlugin" image: https://source.unsplash.com/400x175/?github description: API docs for the mockIdpPlugin plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'mockIdpPlugin'] --- import mockIdpPluginObj from './mock_idp_plugin.devdocs.json'; diff --git a/api_docs/monitoring.mdx b/api_docs/monitoring.mdx index d3bc5e3339ca..4bc6d153851b 100644 --- a/api_docs/monitoring.mdx +++ b/api_docs/monitoring.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/monitoring title: "monitoring" image: https://source.unsplash.com/400x175/?github description: API docs for the monitoring plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'monitoring'] --- import monitoringObj from './monitoring.devdocs.json'; diff --git a/api_docs/monitoring_collection.mdx b/api_docs/monitoring_collection.mdx index f33e43b5051f..34d264e634be 100644 --- a/api_docs/monitoring_collection.mdx +++ b/api_docs/monitoring_collection.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/monitoringCollection title: "monitoringCollection" image: https://source.unsplash.com/400x175/?github description: API docs for the monitoringCollection plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'monitoringCollection'] --- import monitoringCollectionObj from './monitoring_collection.devdocs.json'; diff --git a/api_docs/navigation.mdx b/api_docs/navigation.mdx index dbfba40ddce3..0f1e7a6bb27b 100644 --- a/api_docs/navigation.mdx +++ b/api_docs/navigation.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/navigation title: "navigation" image: https://source.unsplash.com/400x175/?github description: API docs for the navigation plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'navigation'] --- import navigationObj from './navigation.devdocs.json'; diff --git a/api_docs/newsfeed.mdx b/api_docs/newsfeed.mdx index daf282d06ce4..4c67fab6d9f3 100644 --- a/api_docs/newsfeed.mdx +++ b/api_docs/newsfeed.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/newsfeed title: "newsfeed" image: https://source.unsplash.com/400x175/?github description: API docs for the newsfeed plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'newsfeed'] --- import newsfeedObj from './newsfeed.devdocs.json'; diff --git a/api_docs/no_data_page.mdx b/api_docs/no_data_page.mdx index 823155533595..924393777eaf 100644 --- a/api_docs/no_data_page.mdx +++ b/api_docs/no_data_page.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/noDataPage title: "noDataPage" image: https://source.unsplash.com/400x175/?github description: API docs for the noDataPage plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'noDataPage'] --- import noDataPageObj from './no_data_page.devdocs.json'; diff --git a/api_docs/notifications.mdx b/api_docs/notifications.mdx index 450baff38817..97b8a7c60b91 100644 --- a/api_docs/notifications.mdx +++ b/api_docs/notifications.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/notifications title: "notifications" image: https://source.unsplash.com/400x175/?github description: API docs for the notifications plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'notifications'] --- import notificationsObj from './notifications.devdocs.json'; diff --git a/api_docs/observability.mdx b/api_docs/observability.mdx index a42a48b8dfb9..83173879a2af 100644 --- a/api_docs/observability.mdx +++ b/api_docs/observability.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/observability title: "observability" image: https://source.unsplash.com/400x175/?github description: API docs for the observability plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'observability'] --- import observabilityObj from './observability.devdocs.json'; diff --git a/api_docs/observability_a_i_assistant.mdx b/api_docs/observability_a_i_assistant.mdx index 5dcc08040d7d..ad9735161e5d 100644 --- a/api_docs/observability_a_i_assistant.mdx +++ b/api_docs/observability_a_i_assistant.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/observabilityAIAssistant title: "observabilityAIAssistant" image: https://source.unsplash.com/400x175/?github description: API docs for the observabilityAIAssistant plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'observabilityAIAssistant'] --- import observabilityAIAssistantObj from './observability_a_i_assistant.devdocs.json'; diff --git a/api_docs/observability_a_i_assistant_app.mdx b/api_docs/observability_a_i_assistant_app.mdx index 88815e6c59e4..267f19dc38a0 100644 --- a/api_docs/observability_a_i_assistant_app.mdx +++ b/api_docs/observability_a_i_assistant_app.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/observabilityAIAssistantApp title: "observabilityAIAssistantApp" image: https://source.unsplash.com/400x175/?github description: API docs for the observabilityAIAssistantApp plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'observabilityAIAssistantApp'] --- import observabilityAIAssistantAppObj from './observability_a_i_assistant_app.devdocs.json'; diff --git a/api_docs/observability_ai_assistant_management.mdx b/api_docs/observability_ai_assistant_management.mdx index 54b073e62e69..cf3f45bb07ec 100644 --- a/api_docs/observability_ai_assistant_management.mdx +++ b/api_docs/observability_ai_assistant_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/observabilityAiAssistantManagement title: "observabilityAiAssistantManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the observabilityAiAssistantManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'observabilityAiAssistantManagement'] --- import observabilityAiAssistantManagementObj from './observability_ai_assistant_management.devdocs.json'; diff --git a/api_docs/observability_logs_explorer.mdx b/api_docs/observability_logs_explorer.mdx index 89e3ec978cb0..54c07b3d1221 100644 --- a/api_docs/observability_logs_explorer.mdx +++ b/api_docs/observability_logs_explorer.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/observabilityLogsExplorer title: "observabilityLogsExplorer" image: https://source.unsplash.com/400x175/?github description: API docs for the observabilityLogsExplorer plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'observabilityLogsExplorer'] --- import observabilityLogsExplorerObj from './observability_logs_explorer.devdocs.json'; diff --git a/api_docs/observability_onboarding.devdocs.json b/api_docs/observability_onboarding.devdocs.json index b5d8d41f255c..c3edfe2d3c38 100644 --- a/api_docs/observability_onboarding.devdocs.json +++ b/api_docs/observability_onboarding.devdocs.json @@ -4,6 +4,42 @@ "classes": [], "functions": [], "interfaces": [ + { + "parentPluginId": "observabilityOnboarding", + "id": "def-public.AppContext", + "type": "Interface", + "tags": [], + "label": "AppContext", + "description": [], + "path": "x-pack/plugins/observability_solution/observability_onboarding/public/index.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "observabilityOnboarding", + "id": "def-public.AppContext.isServerless", + "type": "boolean", + "tags": [], + "label": "isServerless", + "description": [], + "path": "x-pack/plugins/observability_solution/observability_onboarding/public/index.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "observabilityOnboarding", + "id": "def-public.AppContext.stackVersion", + "type": "string", + "tags": [], + "label": "stackVersion", + "description": [], + "path": "x-pack/plugins/observability_solution/observability_onboarding/public/index.ts", + "deprecated": false, + "trackAdoption": false + } + ], + "initialIsOpen": false + }, { "parentPluginId": "observabilityOnboarding", "id": "def-public.ConfigSchema", @@ -97,6 +133,66 @@ "deprecated": false, "trackAdoption": false }, + { + "parentPluginId": "observabilityOnboarding", + "id": "def-public.ObservabilityOnboardingAppServices.share", + "type": "CompoundType", + "tags": [], + "label": "share", + "description": [], + "signature": [ + "{ toggleShareContextMenu: (options: ", + { + "pluginId": "share", + "scope": "public", + "docId": "kibSharePluginApi", + "section": "def-public.ShowShareMenuOptions", + "text": "ShowShareMenuOptions" + }, + ") => void; } & { url: ", + { + "pluginId": "share", + "scope": "public", + "docId": "kibSharePluginApi", + "section": "def-public.BrowserUrlService", + "text": "BrowserUrlService" + }, + "; navigate(options: ", + "RedirectOptions", + "<", + { + "pluginId": "@kbn/utility-types", + "scope": "common", + "docId": "kibKbnUtilityTypesPluginApi", + "section": "def-common.SerializableRecord", + "text": "SerializableRecord" + }, + ">): void; }" + ], + "path": "x-pack/plugins/observability_solution/observability_onboarding/public/index.ts", + "deprecated": false, + "trackAdoption": false + }, + { + "parentPluginId": "observabilityOnboarding", + "id": "def-public.ObservabilityOnboardingAppServices.context", + "type": "Object", + "tags": [], + "label": "context", + "description": [], + "signature": [ + { + "pluginId": "observabilityOnboarding", + "scope": "public", + "docId": "kibObservabilityOnboardingPluginApi", + "section": "def-public.AppContext", + "text": "AppContext" + } + ], + "path": "x-pack/plugins/observability_solution/observability_onboarding/public/index.ts", + "deprecated": false, + "trackAdoption": false + }, { "parentPluginId": "observabilityOnboarding", "id": "def-public.ObservabilityOnboardingAppServices.config", diff --git a/api_docs/observability_onboarding.mdx b/api_docs/observability_onboarding.mdx index c7d8994f11ed..70021f1b7a28 100644 --- a/api_docs/observability_onboarding.mdx +++ b/api_docs/observability_onboarding.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/observabilityOnboarding title: "observabilityOnboarding" image: https://source.unsplash.com/400x175/?github description: API docs for the observabilityOnboarding plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'observabilityOnboarding'] --- import observabilityOnboardingObj from './observability_onboarding.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 16 | 0 | 16 | 0 | +| 21 | 0 | 21 | 0 | ## Client diff --git a/api_docs/observability_shared.mdx b/api_docs/observability_shared.mdx index b27eb876c6c7..4400441e6f61 100644 --- a/api_docs/observability_shared.mdx +++ b/api_docs/observability_shared.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/observabilityShared title: "observabilityShared" image: https://source.unsplash.com/400x175/?github description: API docs for the observabilityShared plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'observabilityShared'] --- import observabilitySharedObj from './observability_shared.devdocs.json'; diff --git a/api_docs/osquery.mdx b/api_docs/osquery.mdx index b4f9ddc9a064..92462fdb2d85 100644 --- a/api_docs/osquery.mdx +++ b/api_docs/osquery.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/osquery title: "osquery" image: https://source.unsplash.com/400x175/?github description: API docs for the osquery plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'osquery'] --- import osqueryObj from './osquery.devdocs.json'; diff --git a/api_docs/painless_lab.mdx b/api_docs/painless_lab.mdx index 5dc4277d865d..40fd96e82ee6 100644 --- a/api_docs/painless_lab.mdx +++ b/api_docs/painless_lab.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/painlessLab title: "painlessLab" image: https://source.unsplash.com/400x175/?github description: API docs for the painlessLab plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'painlessLab'] --- import painlessLabObj from './painless_lab.devdocs.json'; diff --git a/api_docs/plugin_directory.mdx b/api_docs/plugin_directory.mdx index 9d720b8c8b2f..fa1509d163ab 100644 --- a/api_docs/plugin_directory.mdx +++ b/api_docs/plugin_directory.mdx @@ -7,7 +7,7 @@ id: kibDevDocsPluginDirectory slug: /kibana-dev-docs/api-meta/plugin-api-directory title: Directory description: Directory of public APIs available through plugins or packages. -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana'] --- @@ -21,7 +21,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | API Count | Any Count | Missing comments | Missing exports | |--------------|----------|-----------------|--------| -| 49599 | 238 | 37841 | 1886 | +| 49662 | 238 | 37887 | 1890 | ## Plugin Directory @@ -30,8 +30,8 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 307 | 0 | 301 | 32 | | | [@elastic/appex-sharedux @elastic/kibana-management](https://github.com/orgs/elastic/teams/appex-sharedux ) | - | 2 | 0 | 2 | 0 | | | [@elastic/obs-knowledge-team](https://github.com/orgs/elastic/teams/obs-knowledge-team) | - | 4 | 0 | 4 | 1 | -| | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | AIOps plugin maintained by ML team. | 72 | 0 | 9 | 2 | -| | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 870 | 1 | 838 | 52 | +| | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | AIOps plugin maintained by ML team. | 74 | 0 | 9 | 2 | +| | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 871 | 1 | 839 | 52 | | | [@elastic/obs-ux-infra_services-team](https://github.com/orgs/elastic/teams/obs-ux-infra_services-team) | The user interface for Elastic APM | 29 | 0 | 29 | 123 | | | [@elastic/obs-knowledge-team](https://github.com/orgs/elastic/teams/obs-knowledge-team) | - | 9 | 0 | 9 | 0 | | | [@elastic/obs-knowledge-team](https://github.com/orgs/elastic/teams/obs-knowledge-team) | - | 2 | 0 | 2 | 0 | @@ -117,7 +117,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 4 | 0 | 4 | 0 | | inputControlVis | [@elastic/kibana-presentation](https://github.com/orgs/elastic/teams/kibana-presentation) | Adds Input Control visualization to Kibana | 0 | 0 | 0 | 0 | | | [@elastic/kibana-presentation](https://github.com/orgs/elastic/teams/kibana-presentation) | - | 127 | 2 | 100 | 4 | -| | [@elastic/security-solution](https://github.com/orgs/elastic/teams/security-solution) | Plugin implementing the Integration Assistant API and UI | 44 | 0 | 38 | 2 | +| | [@elastic/security-solution](https://github.com/orgs/elastic/teams/security-solution) | Plugin implementing the Integration Assistant API and UI | 47 | 0 | 40 | 3 | | | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | This plugin provides UI and APIs for the interactive setup mode. | 28 | 0 | 18 | 0 | | | [@elastic/obs-ai-assistant](https://github.com/orgs/elastic/teams/obs-ai-assistant) | - | 95 | 0 | 95 | 4 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 6 | 0 | 6 | 0 | @@ -131,7 +131,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 117 | 0 | 42 | 10 | | | [@elastic/kibana-presentation](https://github.com/orgs/elastic/teams/kibana-presentation) | A dashboard panel for creating links to dashboards or external links. | 58 | 0 | 58 | 6 | | | [@elastic/security-detection-engine](https://github.com/orgs/elastic/teams/security-detection-engine) | - | 226 | 0 | 97 | 52 | -| | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | - | 7 | 0 | 7 | 1 | +| | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | - | 7 | 0 | 7 | 2 | | | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | This plugin provides a LogsExplorer component using the Discover customization framework, offering several affordances specifically designed for log consumption. | 117 | 4 | 117 | 22 | | | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | Exposes the shared components and APIs to access and visualize logs. | 296 | 0 | 268 | 32 | | logstash | [@elastic/logstash](https://github.com/orgs/elastic/teams/logstash) | - | 0 | 0 | 0 | 0 | @@ -152,7 +152,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/obs-ai-assistant](https://github.com/orgs/elastic/teams/obs-ai-assistant) | - | 4 | 0 | 4 | 0 | | | [@elastic/obs-ai-assistant](https://github.com/orgs/elastic/teams/obs-ai-assistant) | - | 2 | 0 | 2 | 0 | | | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | This plugin exposes and registers observability log consumption features. | 19 | 0 | 19 | 1 | -| | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | - | 16 | 0 | 16 | 0 | +| | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | - | 21 | 0 | 21 | 0 | | | [@elastic/observability-ui](https://github.com/orgs/elastic/teams/observability-ui) | - | 355 | 1 | 350 | 22 | | | [@elastic/security-defend-workflows](https://github.com/orgs/elastic/teams/security-defend-workflows) | - | 23 | 0 | 23 | 7 | | | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 2 | 0 | 2 | 0 | @@ -176,10 +176,10 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/search-kibana](https://github.com/orgs/elastic/teams/search-kibana) | Plugin hosting shared features for connectors | 19 | 0 | 19 | 3 | | | [@elastic/search-kibana](https://github.com/orgs/elastic/teams/search-kibana) | - | 18 | 0 | 10 | 0 | | | [@elastic/search-kibana](https://github.com/orgs/elastic/teams/search-kibana) | - | 10 | 0 | 6 | 1 | -| | [@elastic/search-kibana](https://github.com/orgs/elastic/teams/search-kibana) | Plugin to provide access to and rendering of python notebooks for use in the persistent developer console. | 6 | 0 | 6 | 0 | +| | [@elastic/search-kibana](https://github.com/orgs/elastic/teams/search-kibana) | Plugin to provide access to and rendering of python notebooks for use in the persistent developer console. | 8 | 0 | 8 | 1 | | | [@elastic/search-kibana](https://github.com/orgs/elastic/teams/search-kibana) | - | 18 | 0 | 10 | 1 | | searchprofiler | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 0 | 0 | 0 | 0 | -| | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | This plugin provides authentication and authorization features, and exposes functionality to understand the capabilities of the currently authenticated user. | 411 | 0 | 204 | 1 | +| | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | This plugin provides authentication and authorization features, and exposes functionality to understand the capabilities of the currently authenticated user. | 415 | 0 | 206 | 1 | | | [@elastic/security-solution](https://github.com/orgs/elastic/teams/security-solution) | - | 191 | 0 | 121 | 37 | | | [@elastic/security-solution](https://github.com/orgs/elastic/teams/security-solution) | ESS customizations for Security Solution. | 6 | 0 | 6 | 0 | | | [@elastic/security-solution](https://github.com/orgs/elastic/teams/security-solution) | Serverless customizations for security. | 7 | 0 | 7 | 0 | @@ -201,7 +201,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 6 | 0 | 0 | 0 | | | [@elastic/kibana-esql](https://github.com/orgs/elastic/teams/kibana-esql) | - | 29 | 0 | 10 | 0 | | | [@elastic/security-threat-hunting-investigations](https://github.com/orgs/elastic/teams/security-threat-hunting-investigations) | Elastic threat intelligence helps you see if you are open to or have been subject to current or historical known threats | 30 | 0 | 14 | 4 | -| | [@elastic/security-threat-hunting-investigations](https://github.com/orgs/elastic/teams/security-threat-hunting-investigations) | - | 239 | 1 | 195 | 17 | +| | [@elastic/security-threat-hunting-investigations](https://github.com/orgs/elastic/teams/security-threat-hunting-investigations) | - | 238 | 1 | 194 | 18 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | This plugin provides access to the transforms features provided by Elastic. Transforms enable you to convert existing Elasticsearch indices into summarized indices, which provide opportunities for new insights and analytics. | 4 | 0 | 4 | 1 | | translations | [@elastic/kibana-localization](https://github.com/orgs/elastic/teams/kibana-localization) | - | 0 | 0 | 0 | 0 | | | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 588 | 1 | 562 | 52 | @@ -244,9 +244,9 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 27 | 3 | 27 | 0 | | | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 5 | 0 | 5 | 0 | | | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 23 | 0 | 22 | 0 | -| | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 193 | 0 | 190 | 0 | +| | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 194 | 0 | 191 | 0 | | | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 33 | 0 | 33 | 0 | -| | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 237 | 0 | 223 | 2 | +| | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 245 | 0 | 231 | 2 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 73 | 0 | 73 | 2 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 1 | 0 | 0 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 18 | 0 | 18 | 0 | @@ -272,7 +272,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-visualizations](https://github.com/orgs/elastic/teams/kibana-visualizations) | - | 217 | 0 | 180 | 9 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 79 | 0 | 50 | 9 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 24 | 0 | 24 | 0 | -| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 146 | 2 | 142 | 20 | +| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 149 | 2 | 143 | 20 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 10 | 0 | 8 | 4 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 8 | 0 | 8 | 0 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 3 | 0 | 3 | 0 | @@ -303,7 +303,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 6 | 0 | 6 | 0 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 207 | 0 | 100 | 0 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 3 | 0 | 3 | 0 | -| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 4 | 0 | 4 | 0 | +| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 6 | 0 | 1 | 0 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 8 | 0 | 8 | 0 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 5 | 0 | 5 | 0 | | | [@elastic/appex-sharedux](https://github.com/orgs/elastic/teams/appex-sharedux) | - | 4 | 0 | 4 | 0 | @@ -415,11 +415,11 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 36 | 0 | 6 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 10 | 0 | 3 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 8 | 0 | 8 | 0 | -| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 6 | 0 | 6 | 0 | +| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 8 | 0 | 8 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 20 | 0 | 6 | 0 | -| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 49 | 0 | 16 | 0 | +| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 52 | 0 | 16 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 16 | 0 | 16 | 0 | -| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 11 | 0 | 11 | 2 | +| | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 13 | 0 | 13 | 2 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 12 | 0 | 2 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 21 | 0 | 20 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 20 | 0 | 3 | 0 | @@ -487,9 +487,9 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 33 | 0 | 24 | 1 | | | [@elastic/security-threat-hunting-explore](https://github.com/orgs/elastic/teams/security-threat-hunting-explore) | - | 13 | 0 | 5 | 0 | | | [@elastic/obs-ux-logs-team](https://github.com/orgs/elastic/teams/obs-ux-logs-team) | - | 35 | 0 | 34 | 0 | -| | [@elastic/security-generative-ai](https://github.com/orgs/elastic/teams/security-generative-ai) | - | 166 | 0 | 139 | 9 | -| | [@elastic/security-generative-ai](https://github.com/orgs/elastic/teams/security-generative-ai) | - | 333 | 0 | 309 | 0 | -| | [@elastic/obs-knowledge-team](https://github.com/orgs/elastic/teams/obs-knowledge-team) | - | 20 | 0 | 20 | 0 | +| | [@elastic/security-generative-ai](https://github.com/orgs/elastic/teams/security-generative-ai) | - | 160 | 0 | 134 | 9 | +| | [@elastic/security-generative-ai](https://github.com/orgs/elastic/teams/security-generative-ai) | - | 362 | 0 | 336 | 0 | +| | [@elastic/obs-knowledge-team](https://github.com/orgs/elastic/teams/obs-knowledge-team) | - | 18 | 0 | 18 | 0 | | | [@elastic/kibana-operations](https://github.com/orgs/elastic/teams/kibana-operations) | - | 52 | 0 | 37 | 7 | | | [@elastic/kibana-operations](https://github.com/orgs/elastic/teams/kibana-operations) | - | 32 | 0 | 19 | 1 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 11 | 0 | 6 | 0 | @@ -498,7 +498,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-operations](https://github.com/orgs/elastic/teams/kibana-operations) | - | 2 | 0 | 1 | 0 | | | [@elastic/kibana-esql](https://github.com/orgs/elastic/teams/kibana-esql) | - | 99 | 1 | 96 | 11 | | | [@elastic/kibana-esql](https://github.com/orgs/elastic/teams/kibana-esql) | - | 53 | 0 | 51 | 0 | -| | [@elastic/kibana-esql](https://github.com/orgs/elastic/teams/kibana-esql) | - | 192 | 0 | 181 | 10 | +| | [@elastic/kibana-esql](https://github.com/orgs/elastic/teams/kibana-esql) | - | 193 | 0 | 182 | 10 | | | [@elastic/kibana-visualizations](https://github.com/orgs/elastic/teams/kibana-visualizations) | - | 39 | 0 | 39 | 0 | | | [@elastic/kibana-visualizations](https://github.com/orgs/elastic/teams/kibana-visualizations) | - | 52 | 0 | 52 | 1 | | | [@elastic/security-threat-hunting-investigations](https://github.com/orgs/elastic/teams/security-threat-hunting-investigations) | - | 39 | 0 | 14 | 1 | @@ -546,7 +546,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 23 | 0 | 7 | 0 | | | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 8 | 0 | 2 | 3 | | | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 45 | 0 | 0 | 0 | -| | [@elastic/appex-sharedux @elastic/kibana-management](https://github.com/orgs/elastic/teams/appex-sharedux ) | - | 138 | 0 | 136 | 0 | +| | [@elastic/appex-sharedux @elastic/kibana-management](https://github.com/orgs/elastic/teams/appex-sharedux ) | - | 139 | 0 | 137 | 0 | | | [@elastic/appex-sharedux @elastic/kibana-management](https://github.com/orgs/elastic/teams/appex-sharedux ) | - | 20 | 0 | 11 | 0 | | | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 88 | 0 | 10 | 0 | | | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 56 | 0 | 6 | 0 | @@ -570,7 +570,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 5 | 0 | 3 | 0 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 8 | 2 | 8 | 0 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 3 | 0 | 0 | 0 | -| | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 28 | 0 | 0 | 0 | +| | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 29 | 0 | 1 | 0 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 30 | 0 | 0 | 0 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 5 | 0 | 0 | 0 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 8 | 0 | 0 | 0 | @@ -640,7 +640,7 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | - | 66 | 0 | 63 | 0 | | | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | - | 35 | 0 | 25 | 0 | | | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | - | 7 | 0 | 7 | 0 | -| | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | - | 116 | 0 | 58 | 0 | +| | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | - | 118 | 0 | 59 | 0 | | | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | - | 51 | 0 | 25 | 0 | | | [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana-security) | - | 216 | 0 | 121 | 0 | | | [@elastic/security-threat-hunting-explore](https://github.com/orgs/elastic/teams/security-threat-hunting-explore) | - | 14 | 0 | 14 | 6 | diff --git a/api_docs/presentation_panel.mdx b/api_docs/presentation_panel.mdx index cd69f9851043..5a7792bf3ac7 100644 --- a/api_docs/presentation_panel.mdx +++ b/api_docs/presentation_panel.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/presentationPanel title: "presentationPanel" image: https://source.unsplash.com/400x175/?github description: API docs for the presentationPanel plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'presentationPanel'] --- import presentationPanelObj from './presentation_panel.devdocs.json'; diff --git a/api_docs/presentation_util.mdx b/api_docs/presentation_util.mdx index a8a293a42b8b..6dc693ba98a6 100644 --- a/api_docs/presentation_util.mdx +++ b/api_docs/presentation_util.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/presentationUtil title: "presentationUtil" image: https://source.unsplash.com/400x175/?github description: API docs for the presentationUtil plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'presentationUtil'] --- import presentationUtilObj from './presentation_util.devdocs.json'; diff --git a/api_docs/profiling.mdx b/api_docs/profiling.mdx index cd6cc0615757..3ba6679b7c97 100644 --- a/api_docs/profiling.mdx +++ b/api_docs/profiling.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/profiling title: "profiling" image: https://source.unsplash.com/400x175/?github description: API docs for the profiling plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'profiling'] --- import profilingObj from './profiling.devdocs.json'; diff --git a/api_docs/profiling_data_access.mdx b/api_docs/profiling_data_access.mdx index 7761fe64fc3e..352489dde176 100644 --- a/api_docs/profiling_data_access.mdx +++ b/api_docs/profiling_data_access.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/profilingDataAccess title: "profilingDataAccess" image: https://source.unsplash.com/400x175/?github description: API docs for the profilingDataAccess plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'profilingDataAccess'] --- import profilingDataAccessObj from './profiling_data_access.devdocs.json'; diff --git a/api_docs/remote_clusters.mdx b/api_docs/remote_clusters.mdx index bd8b9027e1ae..32f26431579f 100644 --- a/api_docs/remote_clusters.mdx +++ b/api_docs/remote_clusters.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/remoteClusters title: "remoteClusters" image: https://source.unsplash.com/400x175/?github description: API docs for the remoteClusters plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'remoteClusters'] --- import remoteClustersObj from './remote_clusters.devdocs.json'; diff --git a/api_docs/reporting.mdx b/api_docs/reporting.mdx index ac56271bfeca..d5c47f3f3ed2 100644 --- a/api_docs/reporting.mdx +++ b/api_docs/reporting.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/reporting title: "reporting" image: https://source.unsplash.com/400x175/?github description: API docs for the reporting plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'reporting'] --- import reportingObj from './reporting.devdocs.json'; diff --git a/api_docs/rollup.mdx b/api_docs/rollup.mdx index 917907f20dce..840ca64f21b2 100644 --- a/api_docs/rollup.mdx +++ b/api_docs/rollup.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/rollup title: "rollup" image: https://source.unsplash.com/400x175/?github description: API docs for the rollup plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'rollup'] --- import rollupObj from './rollup.devdocs.json'; diff --git a/api_docs/rule_registry.mdx b/api_docs/rule_registry.mdx index 87a25bfbfb59..d3415c598dfa 100644 --- a/api_docs/rule_registry.mdx +++ b/api_docs/rule_registry.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/ruleRegistry title: "ruleRegistry" image: https://source.unsplash.com/400x175/?github description: API docs for the ruleRegistry plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'ruleRegistry'] --- import ruleRegistryObj from './rule_registry.devdocs.json'; diff --git a/api_docs/runtime_fields.mdx b/api_docs/runtime_fields.mdx index caf64c3dcd11..463344a297ad 100644 --- a/api_docs/runtime_fields.mdx +++ b/api_docs/runtime_fields.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/runtimeFields title: "runtimeFields" image: https://source.unsplash.com/400x175/?github description: API docs for the runtimeFields plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'runtimeFields'] --- import runtimeFieldsObj from './runtime_fields.devdocs.json'; diff --git a/api_docs/saved_objects.mdx b/api_docs/saved_objects.mdx index f5a7b131e72a..8dd068cfc542 100644 --- a/api_docs/saved_objects.mdx +++ b/api_docs/saved_objects.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/savedObjects title: "savedObjects" image: https://source.unsplash.com/400x175/?github description: API docs for the savedObjects plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'savedObjects'] --- import savedObjectsObj from './saved_objects.devdocs.json'; diff --git a/api_docs/saved_objects_finder.mdx b/api_docs/saved_objects_finder.mdx index 76e2f2e186db..2761967fe271 100644 --- a/api_docs/saved_objects_finder.mdx +++ b/api_docs/saved_objects_finder.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/savedObjectsFinder title: "savedObjectsFinder" image: https://source.unsplash.com/400x175/?github description: API docs for the savedObjectsFinder plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'savedObjectsFinder'] --- import savedObjectsFinderObj from './saved_objects_finder.devdocs.json'; diff --git a/api_docs/saved_objects_management.mdx b/api_docs/saved_objects_management.mdx index 42be24524322..137f7993547a 100644 --- a/api_docs/saved_objects_management.mdx +++ b/api_docs/saved_objects_management.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/savedObjectsManagement title: "savedObjectsManagement" image: https://source.unsplash.com/400x175/?github description: API docs for the savedObjectsManagement plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'savedObjectsManagement'] --- import savedObjectsManagementObj from './saved_objects_management.devdocs.json'; diff --git a/api_docs/saved_objects_tagging.mdx b/api_docs/saved_objects_tagging.mdx index 0ed29db3eafd..6b2a13c1dafd 100644 --- a/api_docs/saved_objects_tagging.mdx +++ b/api_docs/saved_objects_tagging.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/savedObjectsTagging title: "savedObjectsTagging" image: https://source.unsplash.com/400x175/?github description: API docs for the savedObjectsTagging plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'savedObjectsTagging'] --- import savedObjectsTaggingObj from './saved_objects_tagging.devdocs.json'; diff --git a/api_docs/saved_objects_tagging_oss.mdx b/api_docs/saved_objects_tagging_oss.mdx index c5f6edec166e..d6996d0e6380 100644 --- a/api_docs/saved_objects_tagging_oss.mdx +++ b/api_docs/saved_objects_tagging_oss.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/savedObjectsTaggingOss title: "savedObjectsTaggingOss" image: https://source.unsplash.com/400x175/?github description: API docs for the savedObjectsTaggingOss plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'savedObjectsTaggingOss'] --- import savedObjectsTaggingOssObj from './saved_objects_tagging_oss.devdocs.json'; diff --git a/api_docs/saved_search.mdx b/api_docs/saved_search.mdx index 7af86dda64b2..0c11fb18b62a 100644 --- a/api_docs/saved_search.mdx +++ b/api_docs/saved_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/savedSearch title: "savedSearch" image: https://source.unsplash.com/400x175/?github description: API docs for the savedSearch plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'savedSearch'] --- import savedSearchObj from './saved_search.devdocs.json'; diff --git a/api_docs/screenshot_mode.mdx b/api_docs/screenshot_mode.mdx index c3d4fa6d2b4e..fdcdd5fdee9e 100644 --- a/api_docs/screenshot_mode.mdx +++ b/api_docs/screenshot_mode.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/screenshotMode title: "screenshotMode" image: https://source.unsplash.com/400x175/?github description: API docs for the screenshotMode plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'screenshotMode'] --- import screenshotModeObj from './screenshot_mode.devdocs.json'; diff --git a/api_docs/screenshotting.mdx b/api_docs/screenshotting.mdx index f9fef23f8dd5..e076dcbda583 100644 --- a/api_docs/screenshotting.mdx +++ b/api_docs/screenshotting.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/screenshotting title: "screenshotting" image: https://source.unsplash.com/400x175/?github description: API docs for the screenshotting plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'screenshotting'] --- import screenshottingObj from './screenshotting.devdocs.json'; diff --git a/api_docs/search_connectors.mdx b/api_docs/search_connectors.mdx index 0818f161227b..976df014fa7e 100644 --- a/api_docs/search_connectors.mdx +++ b/api_docs/search_connectors.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/searchConnectors title: "searchConnectors" image: https://source.unsplash.com/400x175/?github description: API docs for the searchConnectors plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'searchConnectors'] --- import searchConnectorsObj from './search_connectors.devdocs.json'; diff --git a/api_docs/search_homepage.mdx b/api_docs/search_homepage.mdx index c8342805d4f1..33c77dc2c223 100644 --- a/api_docs/search_homepage.mdx +++ b/api_docs/search_homepage.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/searchHomepage title: "searchHomepage" image: https://source.unsplash.com/400x175/?github description: API docs for the searchHomepage plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'searchHomepage'] --- import searchHomepageObj from './search_homepage.devdocs.json'; diff --git a/api_docs/search_inference_endpoints.mdx b/api_docs/search_inference_endpoints.mdx index 4bbed68cfe07..b44e1cc4185a 100644 --- a/api_docs/search_inference_endpoints.mdx +++ b/api_docs/search_inference_endpoints.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/searchInferenceEndpoints title: "searchInferenceEndpoints" image: https://source.unsplash.com/400x175/?github description: API docs for the searchInferenceEndpoints plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'searchInferenceEndpoints'] --- import searchInferenceEndpointsObj from './search_inference_endpoints.devdocs.json'; diff --git a/api_docs/search_notebooks.devdocs.json b/api_docs/search_notebooks.devdocs.json index 88ebf531e43e..ac5a01c9ceab 100644 --- a/api_docs/search_notebooks.devdocs.json +++ b/api_docs/search_notebooks.devdocs.json @@ -31,7 +31,42 @@ "path": "x-pack/plugins/search_notebooks/public/types.ts", "deprecated": false, "trackAdoption": false, - "children": [], + "children": [ + { + "parentPluginId": "searchNotebooks", + "id": "def-public.SearchNotebooksPluginStart.setNotebookList", + "type": "Function", + "tags": [], + "label": "setNotebookList", + "description": [], + "signature": [ + "(value: ", + "NotebookListValue", + ") => void" + ], + "path": "x-pack/plugins/search_notebooks/public/types.ts", + "deprecated": false, + "trackAdoption": false, + "children": [ + { + "parentPluginId": "searchNotebooks", + "id": "def-public.SearchNotebooksPluginStart.setNotebookList.$1", + "type": "CompoundType", + "tags": [], + "label": "value", + "description": [], + "signature": [ + "NotebookListValue" + ], + "path": "x-pack/plugins/search_notebooks/public/types.ts", + "deprecated": false, + "trackAdoption": false, + "isRequired": false + } + ], + "returnComment": [] + } + ], "lifecycle": "start", "initialIsOpen": true } diff --git a/api_docs/search_notebooks.mdx b/api_docs/search_notebooks.mdx index 4c4e6b5d826e..8098462573f3 100644 --- a/api_docs/search_notebooks.mdx +++ b/api_docs/search_notebooks.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/searchNotebooks title: "searchNotebooks" image: https://source.unsplash.com/400x175/?github description: API docs for the searchNotebooks plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'searchNotebooks'] --- import searchNotebooksObj from './search_notebooks.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/search-kibana](https://github.com/orgs/elastic/teams/search-ki | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 6 | 0 | 6 | 0 | +| 8 | 0 | 8 | 1 | ## Client diff --git a/api_docs/search_playground.mdx b/api_docs/search_playground.mdx index f6e94463de17..e2128ff79077 100644 --- a/api_docs/search_playground.mdx +++ b/api_docs/search_playground.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/searchPlayground title: "searchPlayground" image: https://source.unsplash.com/400x175/?github description: API docs for the searchPlayground plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'searchPlayground'] --- import searchPlaygroundObj from './search_playground.devdocs.json'; diff --git a/api_docs/security.devdocs.json b/api_docs/security.devdocs.json index c233a4b56956..65b7d3c7c482 100644 --- a/api_docs/security.devdocs.json +++ b/api_docs/security.devdocs.json @@ -352,6 +352,22 @@ "children": [], "returnComment": [] }, + { + "parentPluginId": "security", + "id": "def-public.SecurityLicense.getLicenseType", + "type": "Function", + "tags": [], + "label": "getLicenseType", + "description": [], + "signature": [ + "() => string | undefined" + ], + "path": "x-pack/packages/security/plugin_types_common/src/licensing/license.ts", + "deprecated": false, + "trackAdoption": false, + "children": [], + "returnComment": [] + }, { "parentPluginId": "security", "id": "def-public.SecurityLicense.getUnavailableReason", @@ -669,6 +685,19 @@ "path": "x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts", "deprecated": false, "trackAdoption": false + }, + { + "parentPluginId": "security", + "id": "def-public.SecurityLicenseFeatures.allowFips", + "type": "boolean", + "tags": [], + "label": "allowFips", + "description": [ + "\nIndicates whether we allow FIPS mode" + ], + "path": "x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts", + "deprecated": false, + "trackAdoption": false } ], "initialIsOpen": false @@ -5291,18 +5320,10 @@ "plugin": "encryptedSavedObjects", "path": "x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts" }, - { - "plugin": "actions", - "path": "x-pack/plugins/actions/server/plugin.ts" - }, { "plugin": "ml", "path": "x-pack/plugins/ml/server/routes/annotations.ts" }, - { - "plugin": "logstash", - "path": "x-pack/plugins/logstash/server/routes/pipeline/save.ts" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/request_context_factory.ts" @@ -5323,10 +5344,6 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts" }, - { - "plugin": "cloudChat", - "path": "x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts" @@ -5434,30 +5451,10 @@ "deprecated": true, "trackAdoption": false, "references": [ - { - "plugin": "actions", - "path": "x-pack/plugins/actions/server/lib/action_executor.ts" - }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/rules_client_factory.ts" - }, { "plugin": "alerting", "path": "x-pack/plugins/alerting/server/rules_client_factory.ts" }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/rules_client_factory.ts" - }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/rules_settings_client_factory.ts" - }, - { - "plugin": "alerting", - "path": "x-pack/plugins/alerting/server/maintenance_window_client_factory.ts" - }, { "plugin": "alerting", "path": "x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts" @@ -5470,10 +5467,6 @@ "plugin": "alerting", "path": "x-pack/plugins/alerting/server/plugin.ts" }, - { - "plugin": "cases", - "path": "x-pack/plugins/cases/server/client/factory.ts" - }, { "plugin": "observabilityAIAssistant", "path": "x-pack/plugins/observability_solution/observability_ai_assistant/server/service/index.ts" @@ -5494,10 +5487,6 @@ "plugin": "fleet", "path": "x-pack/plugins/fleet/server/routes/setup/handlers.ts" }, - { - "plugin": "cloudDefend", - "path": "x-pack/plugins/cloud_defend/server/routes/setup_routes.ts" - }, { "plugin": "cloudSecurityPosture", "path": "x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts" @@ -5510,10 +5499,6 @@ "plugin": "enterpriseSearch", "path": "x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts" }, - { - "plugin": "lists", - "path": "x-pack/plugins/lists/server/get_user.ts" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts" @@ -7023,6 +7008,22 @@ "children": [], "returnComment": [] }, + { + "parentPluginId": "security", + "id": "def-common.SecurityLicense.getLicenseType", + "type": "Function", + "tags": [], + "label": "getLicenseType", + "description": [], + "signature": [ + "() => string | undefined" + ], + "path": "x-pack/packages/security/plugin_types_common/src/licensing/license.ts", + "deprecated": false, + "trackAdoption": false, + "children": [], + "returnComment": [] + }, { "parentPluginId": "security", "id": "def-common.SecurityLicense.getUnavailableReason", @@ -7340,6 +7341,19 @@ "path": "x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts", "deprecated": false, "trackAdoption": false + }, + { + "parentPluginId": "security", + "id": "def-common.SecurityLicenseFeatures.allowFips", + "type": "boolean", + "tags": [], + "label": "allowFips", + "description": [ + "\nIndicates whether we allow FIPS mode" + ], + "path": "x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts", + "deprecated": false, + "trackAdoption": false } ], "initialIsOpen": false diff --git a/api_docs/security.mdx b/api_docs/security.mdx index ed9515dc1ac2..43bd8e5f9cf7 100644 --- a/api_docs/security.mdx +++ b/api_docs/security.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/security title: "security" image: https://source.unsplash.com/400x175/?github description: API docs for the security plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'security'] --- import securityObj from './security.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/kibana-security](https://github.com/orgs/elastic/teams/kibana- | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 411 | 0 | 204 | 1 | +| 415 | 0 | 206 | 1 | ## Client diff --git a/api_docs/security_solution.devdocs.json b/api_docs/security_solution.devdocs.json index 053f30a5fd66..e07a045ba772 100644 --- a/api_docs/security_solution.devdocs.json +++ b/api_docs/security_solution.devdocs.json @@ -390,7 +390,7 @@ "label": "data", "description": [], "signature": [ - "({ id: string; type: \"eql\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"eql\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; data_view_id?: string | undefined; filters?: unknown[] | undefined; event_category_override?: string | undefined; tiebreaker_field?: string | undefined; timestamp_field?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; } | { id: string; type: \"query\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; saved_id?: string | undefined; response_actions?: ({ params: { query?: string | undefined; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; queries?: { id: string; query: string; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; version?: string | undefined; platform?: string | undefined; removed?: boolean | undefined; snapshot?: boolean | undefined; }[] | undefined; pack_id?: string | undefined; saved_query_id?: string | undefined; timeout?: number | undefined; }; action_type_id: \".osquery\"; } | { params: { command: \"isolate\"; comment?: string | undefined; } | { config: { field: string; overwrite: boolean; }; command: \"kill-process\" | \"suspend-process\"; comment?: string | undefined; }; action_type_id: \".endpoint\"; })[] | undefined; } | { id: string; type: \"saved_query\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; saved_id: string; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; query?: string | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; response_actions?: ({ params: { query?: string | undefined; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; queries?: { id: string; query: string; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; version?: string | undefined; platform?: string | undefined; removed?: boolean | undefined; snapshot?: boolean | undefined; }[] | undefined; pack_id?: string | undefined; saved_query_id?: string | undefined; timeout?: number | undefined; }; action_type_id: \".osquery\"; } | { params: { command: \"isolate\"; comment?: string | undefined; } | { config: { field: string; overwrite: boolean; }; command: \"kill-process\" | \"suspend-process\"; comment?: string | undefined; }; action_type_id: \".endpoint\"; })[] | undefined; } | { id: string; type: \"threshold\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; threshold: { value: number; field: (string | string[]) & (string | string[] | undefined); cardinality?: { value: number; field: string; }[] | undefined; }; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { duration: { value: number; unit: \"m\" | \"h\" | \"s\"; }; } | undefined; saved_id?: string | undefined; } | { id: string; type: \"threat_match\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; threat_query: string; threat_mapping: { entries: { value: string; type: \"mapping\"; field: string; }[]; }[]; threat_index: string[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; saved_id?: string | undefined; threat_filters?: unknown[] | undefined; threat_indicator_path?: string | undefined; threat_language?: \"lucene\" | \"kuery\" | undefined; concurrent_searches?: number | undefined; items_per_search?: number | undefined; } | { id: string; type: \"machine_learning\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; anomaly_threshold: number; machine_learning_job_id: (string | string[]) & (string | string[] | undefined); meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; } | { id: string; type: \"new_terms\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; new_terms_fields: string[]; history_window_start: string; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; } | { id: string; type: \"esql\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"esql\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; })[]" + "({ id: string; type: \"eql\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"eql\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; data_view_id?: string | undefined; filters?: unknown[] | undefined; event_category_override?: string | undefined; tiebreaker_field?: string | undefined; timestamp_field?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; } | { id: string; type: \"query\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; saved_id?: string | undefined; response_actions?: ({ params: { query?: string | undefined; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; queries?: { id: string; query: string; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; version?: string | undefined; platform?: string | undefined; removed?: boolean | undefined; snapshot?: boolean | undefined; }[] | undefined; pack_id?: string | undefined; saved_query_id?: string | undefined; timeout?: number | undefined; }; action_type_id: \".osquery\"; } | { params: { command: \"isolate\"; comment?: string | undefined; } | { config: { field: string; overwrite: boolean; }; command: \"kill-process\" | \"suspend-process\"; comment?: string | undefined; }; action_type_id: \".endpoint\"; })[] | undefined; } | { id: string; type: \"saved_query\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; saved_id: string; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; query?: string | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; response_actions?: ({ params: { query?: string | undefined; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; queries?: { id: string; query: string; ecs_mapping?: Zod.objectOutputType<{}, Zod.ZodObject<{ field: Zod.ZodOptional; value: Zod.ZodOptional]>>; }, \"strip\", Zod.ZodTypeAny, { field?: string | undefined; value?: string | string[] | undefined; }, { field?: string | undefined; value?: string | string[] | undefined; }>, \"strip\"> | undefined; version?: string | undefined; platform?: string | undefined; removed?: boolean | undefined; snapshot?: boolean | undefined; }[] | undefined; pack_id?: string | undefined; saved_query_id?: string | undefined; timeout?: number | undefined; }; action_type_id: \".osquery\"; } | { params: { command: \"isolate\"; comment?: string | undefined; } | { config: { field: string; overwrite: boolean; }; command: \"kill-process\" | \"suspend-process\"; comment?: string | undefined; }; action_type_id: \".endpoint\"; })[] | undefined; } | { id: string; type: \"threshold\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; threshold: { value: number; field: (string | string[]) & (string | string[] | undefined); cardinality?: { value: number; field: string; }[] | undefined; }; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { duration: { value: number; unit: \"m\" | \"h\" | \"s\"; }; } | undefined; saved_id?: string | undefined; } | { id: string; type: \"threat_match\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; threat_query: string; threat_mapping: { entries: { value: string; type: \"mapping\"; field: string; }[]; }[]; threat_index: string[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; saved_id?: string | undefined; threat_filters?: unknown[] | undefined; threat_indicator_path?: string | undefined; threat_language?: \"lucene\" | \"kuery\" | undefined; concurrent_searches?: number | undefined; items_per_search?: number | undefined; } | { id: string; type: \"machine_learning\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; anomaly_threshold: number; machine_learning_job_id: (string | string[]) & (string | string[] | undefined); meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; } | { id: string; type: \"new_terms\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"kuery\" | \"lucene\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; new_terms_fields: string[]; history_window_start: string; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; index?: string[] | undefined; filters?: unknown[] | undefined; data_view_id?: string | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; } | { id: string; type: \"esql\"; version: number; name: string; actions: { params: {} & { [k: string]: unknown; }; id: string; group: string; action_type_id: string; uuid?: string | undefined; alerts_filter?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; frequency?: { throttle: string | null; notifyWhen: \"onActionGroupChange\" | \"onActiveAlert\" | \"onThrottleInterval\"; summary: boolean; } | undefined; }[]; tags: string[]; setup: string; enabled: boolean; revision: number; query: string; interval: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; description: string; risk_score: number; from: string; to: string; language: \"esql\"; created_at: string; created_by: string; updated_at: string; updated_by: string; references: string[]; author: string[]; immutable: boolean; rule_id: string; threat: { framework: string; tactic: { id: string; name: string; reference: string; }; technique?: { id: string; name: string; reference: string; subtechnique?: { id: string; name: string; reference: string; }[] | undefined; }[] | undefined; }[]; risk_score_mapping: { value: string; field: string; operator: \"equals\"; risk_score?: number | undefined; }[]; severity_mapping: { value: string; severity: \"medium\" | \"high\" | \"low\" | \"critical\"; field: string; operator: \"equals\"; }[]; exceptions_list: { id: string; type: \"endpoint\" | \"detection\" | \"rule_default\" | \"endpoint_trusted_apps\" | \"endpoint_events\" | \"endpoint_host_isolation_exceptions\" | \"endpoint_blocklists\"; list_id: string; namespace_type: \"single\" | \"agnostic\"; }[]; false_positives: string[]; max_signals: number; related_integrations: { version: string; package: string; integration?: string | undefined; }[]; required_fields: { type: string; name: string; ecs: boolean; }[]; meta?: Zod.objectOutputType<{}, Zod.ZodUnknown, \"strip\"> | undefined; namespace?: string | undefined; license?: string | undefined; throttle?: string | undefined; outcome?: \"exactMatch\" | \"aliasMatch\" | \"conflict\" | undefined; alias_target_id?: string | undefined; alias_purpose?: \"savedObjectConversion\" | \"savedObjectImport\" | undefined; note?: string | undefined; rule_name_override?: string | undefined; timestamp_override?: string | undefined; timestamp_override_fallback_disabled?: boolean | undefined; timeline_id?: string | undefined; timeline_title?: string | undefined; building_block_type?: string | undefined; output_index?: string | undefined; investigation_fields?: { field_names: string[]; } | undefined; rule_source?: { type: \"external\"; is_customized: boolean; } | { type: \"internal\"; } | undefined; execution_summary?: { last_execution: { message: string; date: string; status: \"running\" | \"succeeded\" | \"failed\" | \"going to run\" | \"partial failure\"; metrics: { total_search_duration_ms?: number | undefined; total_indexing_duration_ms?: number | undefined; total_enrichment_duration_ms?: number | undefined; execution_gap_duration_s?: number | undefined; }; status_order: number; }; } | undefined; alert_suppression?: { group_by: string[]; duration?: { value: number; unit: \"m\" | \"h\" | \"s\"; } | undefined; missing_fields_strategy?: \"doNotSuppress\" | \"suppress\" | undefined; } | undefined; })[]" ], "path": "x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts", "deprecated": false, @@ -485,7 +485,7 @@ "\nExperimental flag needed to enable the link" ], "signature": [ - "\"assistantKnowledgeBaseByDefault\" | \"assistantModelEvaluation\" | \"excludePoliciesInFilterEnabled\" | \"kubernetesEnabled\" | \"donutChartEmbeddablesEnabled\" | \"previewTelemetryUrlEnabled\" | \"extendedRuleExecutionLoggingEnabled\" | \"socTrendsEnabled\" | \"responseActionsEnabled\" | \"endpointResponseActionsEnabled\" | \"responseActionUploadEnabled\" | \"automatedProcessActionsEnabled\" | \"responseActionsSentinelOneV1Enabled\" | \"responseActionsSentinelOneV2Enabled\" | \"responseActionsSentinelOneGetFileEnabled\" | \"responseActionsCrowdstrikeManualHostIsolationEnabled\" | \"responseActionScanEnabled\" | \"alertsPageChartsEnabled\" | \"alertTypeEnabled\" | \"expandableFlyoutDisabled\" | \"securitySolutionNotesEnabled\" | \"entityAlertPreviewEnabled\" | \"newUserDetailsFlyoutManagedUser\" | \"riskScoringPersistence\" | \"riskScoringRoutesEnabled\" | \"esqlRulesDisabled\" | \"protectionUpdatesEnabled\" | \"disableTimelineSaveTour\" | \"alertSuppressionForEsqlRuleEnabled\" | \"riskEnginePrivilegesRouteEnabled\" | \"sentinelOneDataInAnalyzerEnabled\" | \"sentinelOneManualHostActionsEnabled\" | \"crowdstrikeDataInAnalyzerEnabled\" | \"jamfDataInAnalyzerEnabled\" | \"timelineEsqlTabDisabled\" | \"unifiedComponentsInTimelineEnabled\" | \"analyzerDatePickersAndSourcererDisabled\" | \"prebuiltRulesCustomizationEnabled\" | \"malwareOnWriteScanOptionAvailable\" | \"unifiedManifestEnabled\" | \"aiAssistantFlyoutMode\" | \"valueListItemsModalEnabled\" | \"bulkCustomHighlightedFieldsEnabled\" | \"manualRuleRunEnabled\" | \"filterProcessDescendantsForEventFiltersEnabled\" | undefined" + "\"assistantKnowledgeBaseByDefault\" | \"assistantModelEvaluation\" | \"excludePoliciesInFilterEnabled\" | \"kubernetesEnabled\" | \"donutChartEmbeddablesEnabled\" | \"previewTelemetryUrlEnabled\" | \"extendedRuleExecutionLoggingEnabled\" | \"socTrendsEnabled\" | \"responseActionsEnabled\" | \"endpointResponseActionsEnabled\" | \"responseActionUploadEnabled\" | \"automatedProcessActionsEnabled\" | \"responseActionsSentinelOneV1Enabled\" | \"responseActionsSentinelOneV2Enabled\" | \"responseActionsSentinelOneGetFileEnabled\" | \"responseActionsCrowdstrikeManualHostIsolationEnabled\" | \"responseActionScanEnabled\" | \"alertsPageChartsEnabled\" | \"alertTypeEnabled\" | \"expandableFlyoutDisabled\" | \"securitySolutionNotesEnabled\" | \"entityAlertPreviewEnabled\" | \"newUserDetailsFlyoutManagedUser\" | \"riskScoringPersistence\" | \"riskScoringRoutesEnabled\" | \"esqlRulesDisabled\" | \"protectionUpdatesEnabled\" | \"AIAssistantOnRuleCreationFormEnabled\" | \"disableTimelineSaveTour\" | \"alertSuppressionForEsqlRuleEnabled\" | \"riskEnginePrivilegesRouteEnabled\" | \"alertSuppressionForMachineLearningRuleEnabled\" | \"sentinelOneDataInAnalyzerEnabled\" | \"sentinelOneManualHostActionsEnabled\" | \"crowdstrikeDataInAnalyzerEnabled\" | \"jamfDataInAnalyzerEnabled\" | \"timelineEsqlTabDisabled\" | \"unifiedComponentsInTimelineEnabled\" | \"analyzerDatePickersAndSourcererDisabled\" | \"prebuiltRulesCustomizationEnabled\" | \"malwareOnWriteScanOptionAvailable\" | \"unifiedManifestEnabled\" | \"aiAssistantFlyoutMode\" | \"valueListItemsModalEnabled\" | \"bulkCustomHighlightedFieldsEnabled\" | \"manualRuleRunEnabled\" | \"filterProcessDescendantsForEventFiltersEnabled\" | undefined" ], "path": "x-pack/plugins/security_solution/public/common/links/types.ts", "deprecated": false, @@ -565,7 +565,7 @@ "\nExperimental flag needed to disable the link. Opposite of experimentalKey" ], "signature": [ - "\"assistantKnowledgeBaseByDefault\" | \"assistantModelEvaluation\" | \"excludePoliciesInFilterEnabled\" | \"kubernetesEnabled\" | \"donutChartEmbeddablesEnabled\" | \"previewTelemetryUrlEnabled\" | \"extendedRuleExecutionLoggingEnabled\" | \"socTrendsEnabled\" | \"responseActionsEnabled\" | \"endpointResponseActionsEnabled\" | \"responseActionUploadEnabled\" | \"automatedProcessActionsEnabled\" | \"responseActionsSentinelOneV1Enabled\" | \"responseActionsSentinelOneV2Enabled\" | \"responseActionsSentinelOneGetFileEnabled\" | \"responseActionsCrowdstrikeManualHostIsolationEnabled\" | \"responseActionScanEnabled\" | \"alertsPageChartsEnabled\" | \"alertTypeEnabled\" | \"expandableFlyoutDisabled\" | \"securitySolutionNotesEnabled\" | \"entityAlertPreviewEnabled\" | \"newUserDetailsFlyoutManagedUser\" | \"riskScoringPersistence\" | \"riskScoringRoutesEnabled\" | \"esqlRulesDisabled\" | \"protectionUpdatesEnabled\" | \"disableTimelineSaveTour\" | \"alertSuppressionForEsqlRuleEnabled\" | \"riskEnginePrivilegesRouteEnabled\" | \"sentinelOneDataInAnalyzerEnabled\" | \"sentinelOneManualHostActionsEnabled\" | \"crowdstrikeDataInAnalyzerEnabled\" | \"jamfDataInAnalyzerEnabled\" | \"timelineEsqlTabDisabled\" | \"unifiedComponentsInTimelineEnabled\" | \"analyzerDatePickersAndSourcererDisabled\" | \"prebuiltRulesCustomizationEnabled\" | \"malwareOnWriteScanOptionAvailable\" | \"unifiedManifestEnabled\" | \"aiAssistantFlyoutMode\" | \"valueListItemsModalEnabled\" | \"bulkCustomHighlightedFieldsEnabled\" | \"manualRuleRunEnabled\" | \"filterProcessDescendantsForEventFiltersEnabled\" | undefined" + "\"assistantKnowledgeBaseByDefault\" | \"assistantModelEvaluation\" | \"excludePoliciesInFilterEnabled\" | \"kubernetesEnabled\" | \"donutChartEmbeddablesEnabled\" | \"previewTelemetryUrlEnabled\" | \"extendedRuleExecutionLoggingEnabled\" | \"socTrendsEnabled\" | \"responseActionsEnabled\" | \"endpointResponseActionsEnabled\" | \"responseActionUploadEnabled\" | \"automatedProcessActionsEnabled\" | \"responseActionsSentinelOneV1Enabled\" | \"responseActionsSentinelOneV2Enabled\" | \"responseActionsSentinelOneGetFileEnabled\" | \"responseActionsCrowdstrikeManualHostIsolationEnabled\" | \"responseActionScanEnabled\" | \"alertsPageChartsEnabled\" | \"alertTypeEnabled\" | \"expandableFlyoutDisabled\" | \"securitySolutionNotesEnabled\" | \"entityAlertPreviewEnabled\" | \"newUserDetailsFlyoutManagedUser\" | \"riskScoringPersistence\" | \"riskScoringRoutesEnabled\" | \"esqlRulesDisabled\" | \"protectionUpdatesEnabled\" | \"AIAssistantOnRuleCreationFormEnabled\" | \"disableTimelineSaveTour\" | \"alertSuppressionForEsqlRuleEnabled\" | \"riskEnginePrivilegesRouteEnabled\" | \"alertSuppressionForMachineLearningRuleEnabled\" | \"sentinelOneDataInAnalyzerEnabled\" | \"sentinelOneManualHostActionsEnabled\" | \"crowdstrikeDataInAnalyzerEnabled\" | \"jamfDataInAnalyzerEnabled\" | \"timelineEsqlTabDisabled\" | \"unifiedComponentsInTimelineEnabled\" | \"analyzerDatePickersAndSourcererDisabled\" | \"prebuiltRulesCustomizationEnabled\" | \"malwareOnWriteScanOptionAvailable\" | \"unifiedManifestEnabled\" | \"aiAssistantFlyoutMode\" | \"valueListItemsModalEnabled\" | \"bulkCustomHighlightedFieldsEnabled\" | \"manualRuleRunEnabled\" | \"filterProcessDescendantsForEventFiltersEnabled\" | undefined" ], "path": "x-pack/plugins/security_solution/public/common/links/types.ts", "deprecated": false, @@ -1964,7 +1964,7 @@ "label": "experimentalFeatures", "description": [], "signature": [ - "{ readonly excludePoliciesInFilterEnabled: boolean; readonly kubernetesEnabled: boolean; readonly donutChartEmbeddablesEnabled: boolean; readonly previewTelemetryUrlEnabled: boolean; readonly extendedRuleExecutionLoggingEnabled: boolean; readonly socTrendsEnabled: boolean; readonly responseActionsEnabled: boolean; readonly endpointResponseActionsEnabled: boolean; readonly responseActionUploadEnabled: boolean; readonly automatedProcessActionsEnabled: boolean; readonly responseActionsSentinelOneV1Enabled: boolean; readonly responseActionsSentinelOneV2Enabled: boolean; readonly responseActionsSentinelOneGetFileEnabled: boolean; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: boolean; readonly responseActionScanEnabled: boolean; readonly alertsPageChartsEnabled: boolean; readonly alertTypeEnabled: boolean; readonly expandableFlyoutDisabled: boolean; readonly securitySolutionNotesEnabled: boolean; readonly entityAlertPreviewEnabled: boolean; readonly assistantModelEvaluation: boolean; readonly assistantKnowledgeBaseByDefault: boolean; readonly newUserDetailsFlyoutManagedUser: boolean; readonly riskScoringPersistence: boolean; readonly riskScoringRoutesEnabled: boolean; readonly esqlRulesDisabled: boolean; readonly protectionUpdatesEnabled: boolean; readonly disableTimelineSaveTour: boolean; readonly alertSuppressionForEsqlRuleEnabled: boolean; readonly riskEnginePrivilegesRouteEnabled: boolean; readonly sentinelOneDataInAnalyzerEnabled: boolean; readonly sentinelOneManualHostActionsEnabled: boolean; readonly crowdstrikeDataInAnalyzerEnabled: boolean; readonly jamfDataInAnalyzerEnabled: boolean; readonly timelineEsqlTabDisabled: boolean; readonly unifiedComponentsInTimelineEnabled: boolean; readonly analyzerDatePickersAndSourcererDisabled: boolean; readonly prebuiltRulesCustomizationEnabled: boolean; readonly malwareOnWriteScanOptionAvailable: boolean; readonly unifiedManifestEnabled: boolean; readonly aiAssistantFlyoutMode: boolean; readonly valueListItemsModalEnabled: boolean; readonly bulkCustomHighlightedFieldsEnabled: boolean; readonly manualRuleRunEnabled: boolean; readonly filterProcessDescendantsForEventFiltersEnabled: boolean; }" + "{ readonly excludePoliciesInFilterEnabled: boolean; readonly kubernetesEnabled: boolean; readonly donutChartEmbeddablesEnabled: boolean; readonly previewTelemetryUrlEnabled: boolean; readonly extendedRuleExecutionLoggingEnabled: boolean; readonly socTrendsEnabled: boolean; readonly responseActionsEnabled: boolean; readonly endpointResponseActionsEnabled: boolean; readonly responseActionUploadEnabled: boolean; readonly automatedProcessActionsEnabled: boolean; readonly responseActionsSentinelOneV1Enabled: boolean; readonly responseActionsSentinelOneV2Enabled: boolean; readonly responseActionsSentinelOneGetFileEnabled: boolean; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: boolean; readonly responseActionScanEnabled: boolean; readonly alertsPageChartsEnabled: boolean; readonly alertTypeEnabled: boolean; readonly expandableFlyoutDisabled: boolean; readonly securitySolutionNotesEnabled: boolean; readonly entityAlertPreviewEnabled: boolean; readonly assistantModelEvaluation: boolean; readonly assistantKnowledgeBaseByDefault: boolean; readonly newUserDetailsFlyoutManagedUser: boolean; readonly riskScoringPersistence: boolean; readonly riskScoringRoutesEnabled: boolean; readonly esqlRulesDisabled: boolean; readonly protectionUpdatesEnabled: boolean; readonly AIAssistantOnRuleCreationFormEnabled: boolean; readonly disableTimelineSaveTour: boolean; readonly alertSuppressionForEsqlRuleEnabled: boolean; readonly riskEnginePrivilegesRouteEnabled: boolean; readonly alertSuppressionForMachineLearningRuleEnabled: boolean; readonly sentinelOneDataInAnalyzerEnabled: boolean; readonly sentinelOneManualHostActionsEnabled: boolean; readonly crowdstrikeDataInAnalyzerEnabled: boolean; readonly jamfDataInAnalyzerEnabled: boolean; readonly timelineEsqlTabDisabled: boolean; readonly unifiedComponentsInTimelineEnabled: boolean; readonly analyzerDatePickersAndSourcererDisabled: boolean; readonly prebuiltRulesCustomizationEnabled: boolean; readonly malwareOnWriteScanOptionAvailable: boolean; readonly unifiedManifestEnabled: boolean; readonly aiAssistantFlyoutMode: boolean; readonly valueListItemsModalEnabled: boolean; readonly bulkCustomHighlightedFieldsEnabled: boolean; readonly manualRuleRunEnabled: boolean; readonly filterProcessDescendantsForEventFiltersEnabled: boolean; }" ], "path": "x-pack/plugins/security_solution/public/types.ts", "deprecated": false, @@ -3071,7 +3071,7 @@ "\nThe security solution generic experimental features" ], "signature": [ - "{ readonly excludePoliciesInFilterEnabled: boolean; readonly kubernetesEnabled: boolean; readonly donutChartEmbeddablesEnabled: boolean; readonly previewTelemetryUrlEnabled: boolean; readonly extendedRuleExecutionLoggingEnabled: boolean; readonly socTrendsEnabled: boolean; readonly responseActionsEnabled: boolean; readonly endpointResponseActionsEnabled: boolean; readonly responseActionUploadEnabled: boolean; readonly automatedProcessActionsEnabled: boolean; readonly responseActionsSentinelOneV1Enabled: boolean; readonly responseActionsSentinelOneV2Enabled: boolean; readonly responseActionsSentinelOneGetFileEnabled: boolean; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: boolean; readonly responseActionScanEnabled: boolean; readonly alertsPageChartsEnabled: boolean; readonly alertTypeEnabled: boolean; readonly expandableFlyoutDisabled: boolean; readonly securitySolutionNotesEnabled: boolean; readonly entityAlertPreviewEnabled: boolean; readonly assistantModelEvaluation: boolean; readonly assistantKnowledgeBaseByDefault: boolean; readonly newUserDetailsFlyoutManagedUser: boolean; readonly riskScoringPersistence: boolean; readonly riskScoringRoutesEnabled: boolean; readonly esqlRulesDisabled: boolean; readonly protectionUpdatesEnabled: boolean; readonly disableTimelineSaveTour: boolean; readonly alertSuppressionForEsqlRuleEnabled: boolean; readonly riskEnginePrivilegesRouteEnabled: boolean; readonly sentinelOneDataInAnalyzerEnabled: boolean; readonly sentinelOneManualHostActionsEnabled: boolean; readonly crowdstrikeDataInAnalyzerEnabled: boolean; readonly jamfDataInAnalyzerEnabled: boolean; readonly timelineEsqlTabDisabled: boolean; readonly unifiedComponentsInTimelineEnabled: boolean; readonly analyzerDatePickersAndSourcererDisabled: boolean; readonly prebuiltRulesCustomizationEnabled: boolean; readonly malwareOnWriteScanOptionAvailable: boolean; readonly unifiedManifestEnabled: boolean; readonly aiAssistantFlyoutMode: boolean; readonly valueListItemsModalEnabled: boolean; readonly bulkCustomHighlightedFieldsEnabled: boolean; readonly manualRuleRunEnabled: boolean; readonly filterProcessDescendantsForEventFiltersEnabled: boolean; }" + "{ readonly excludePoliciesInFilterEnabled: boolean; readonly kubernetesEnabled: boolean; readonly donutChartEmbeddablesEnabled: boolean; readonly previewTelemetryUrlEnabled: boolean; readonly extendedRuleExecutionLoggingEnabled: boolean; readonly socTrendsEnabled: boolean; readonly responseActionsEnabled: boolean; readonly endpointResponseActionsEnabled: boolean; readonly responseActionUploadEnabled: boolean; readonly automatedProcessActionsEnabled: boolean; readonly responseActionsSentinelOneV1Enabled: boolean; readonly responseActionsSentinelOneV2Enabled: boolean; readonly responseActionsSentinelOneGetFileEnabled: boolean; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: boolean; readonly responseActionScanEnabled: boolean; readonly alertsPageChartsEnabled: boolean; readonly alertTypeEnabled: boolean; readonly expandableFlyoutDisabled: boolean; readonly securitySolutionNotesEnabled: boolean; readonly entityAlertPreviewEnabled: boolean; readonly assistantModelEvaluation: boolean; readonly assistantKnowledgeBaseByDefault: boolean; readonly newUserDetailsFlyoutManagedUser: boolean; readonly riskScoringPersistence: boolean; readonly riskScoringRoutesEnabled: boolean; readonly esqlRulesDisabled: boolean; readonly protectionUpdatesEnabled: boolean; readonly AIAssistantOnRuleCreationFormEnabled: boolean; readonly disableTimelineSaveTour: boolean; readonly alertSuppressionForEsqlRuleEnabled: boolean; readonly riskEnginePrivilegesRouteEnabled: boolean; readonly alertSuppressionForMachineLearningRuleEnabled: boolean; readonly sentinelOneDataInAnalyzerEnabled: boolean; readonly sentinelOneManualHostActionsEnabled: boolean; readonly crowdstrikeDataInAnalyzerEnabled: boolean; readonly jamfDataInAnalyzerEnabled: boolean; readonly timelineEsqlTabDisabled: boolean; readonly unifiedComponentsInTimelineEnabled: boolean; readonly analyzerDatePickersAndSourcererDisabled: boolean; readonly prebuiltRulesCustomizationEnabled: boolean; readonly malwareOnWriteScanOptionAvailable: boolean; readonly unifiedManifestEnabled: boolean; readonly aiAssistantFlyoutMode: boolean; readonly valueListItemsModalEnabled: boolean; readonly bulkCustomHighlightedFieldsEnabled: boolean; readonly manualRuleRunEnabled: boolean; readonly filterProcessDescendantsForEventFiltersEnabled: boolean; }" ], "path": "x-pack/plugins/security_solution/server/plugin_contract.ts", "deprecated": false, @@ -3247,7 +3247,7 @@ "label": "ExperimentalFeatures", "description": [], "signature": [ - "{ readonly excludePoliciesInFilterEnabled: boolean; readonly kubernetesEnabled: boolean; readonly donutChartEmbeddablesEnabled: boolean; readonly previewTelemetryUrlEnabled: boolean; readonly extendedRuleExecutionLoggingEnabled: boolean; readonly socTrendsEnabled: boolean; readonly responseActionsEnabled: boolean; readonly endpointResponseActionsEnabled: boolean; readonly responseActionUploadEnabled: boolean; readonly automatedProcessActionsEnabled: boolean; readonly responseActionsSentinelOneV1Enabled: boolean; readonly responseActionsSentinelOneV2Enabled: boolean; readonly responseActionsSentinelOneGetFileEnabled: boolean; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: boolean; readonly responseActionScanEnabled: boolean; readonly alertsPageChartsEnabled: boolean; readonly alertTypeEnabled: boolean; readonly expandableFlyoutDisabled: boolean; readonly securitySolutionNotesEnabled: boolean; readonly entityAlertPreviewEnabled: boolean; readonly assistantModelEvaluation: boolean; readonly assistantKnowledgeBaseByDefault: boolean; readonly newUserDetailsFlyoutManagedUser: boolean; readonly riskScoringPersistence: boolean; readonly riskScoringRoutesEnabled: boolean; readonly esqlRulesDisabled: boolean; readonly protectionUpdatesEnabled: boolean; readonly disableTimelineSaveTour: boolean; readonly alertSuppressionForEsqlRuleEnabled: boolean; readonly riskEnginePrivilegesRouteEnabled: boolean; readonly sentinelOneDataInAnalyzerEnabled: boolean; readonly sentinelOneManualHostActionsEnabled: boolean; readonly crowdstrikeDataInAnalyzerEnabled: boolean; readonly jamfDataInAnalyzerEnabled: boolean; readonly timelineEsqlTabDisabled: boolean; readonly unifiedComponentsInTimelineEnabled: boolean; readonly analyzerDatePickersAndSourcererDisabled: boolean; readonly prebuiltRulesCustomizationEnabled: boolean; readonly malwareOnWriteScanOptionAvailable: boolean; readonly unifiedManifestEnabled: boolean; readonly aiAssistantFlyoutMode: boolean; readonly valueListItemsModalEnabled: boolean; readonly bulkCustomHighlightedFieldsEnabled: boolean; readonly manualRuleRunEnabled: boolean; readonly filterProcessDescendantsForEventFiltersEnabled: boolean; }" + "{ readonly excludePoliciesInFilterEnabled: boolean; readonly kubernetesEnabled: boolean; readonly donutChartEmbeddablesEnabled: boolean; readonly previewTelemetryUrlEnabled: boolean; readonly extendedRuleExecutionLoggingEnabled: boolean; readonly socTrendsEnabled: boolean; readonly responseActionsEnabled: boolean; readonly endpointResponseActionsEnabled: boolean; readonly responseActionUploadEnabled: boolean; readonly automatedProcessActionsEnabled: boolean; readonly responseActionsSentinelOneV1Enabled: boolean; readonly responseActionsSentinelOneV2Enabled: boolean; readonly responseActionsSentinelOneGetFileEnabled: boolean; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: boolean; readonly responseActionScanEnabled: boolean; readonly alertsPageChartsEnabled: boolean; readonly alertTypeEnabled: boolean; readonly expandableFlyoutDisabled: boolean; readonly securitySolutionNotesEnabled: boolean; readonly entityAlertPreviewEnabled: boolean; readonly assistantModelEvaluation: boolean; readonly assistantKnowledgeBaseByDefault: boolean; readonly newUserDetailsFlyoutManagedUser: boolean; readonly riskScoringPersistence: boolean; readonly riskScoringRoutesEnabled: boolean; readonly esqlRulesDisabled: boolean; readonly protectionUpdatesEnabled: boolean; readonly AIAssistantOnRuleCreationFormEnabled: boolean; readonly disableTimelineSaveTour: boolean; readonly alertSuppressionForEsqlRuleEnabled: boolean; readonly riskEnginePrivilegesRouteEnabled: boolean; readonly alertSuppressionForMachineLearningRuleEnabled: boolean; readonly sentinelOneDataInAnalyzerEnabled: boolean; readonly sentinelOneManualHostActionsEnabled: boolean; readonly crowdstrikeDataInAnalyzerEnabled: boolean; readonly jamfDataInAnalyzerEnabled: boolean; readonly timelineEsqlTabDisabled: boolean; readonly unifiedComponentsInTimelineEnabled: boolean; readonly analyzerDatePickersAndSourcererDisabled: boolean; readonly prebuiltRulesCustomizationEnabled: boolean; readonly malwareOnWriteScanOptionAvailable: boolean; readonly unifiedManifestEnabled: boolean; readonly aiAssistantFlyoutMode: boolean; readonly valueListItemsModalEnabled: boolean; readonly bulkCustomHighlightedFieldsEnabled: boolean; readonly manualRuleRunEnabled: boolean; readonly filterProcessDescendantsForEventFiltersEnabled: boolean; }" ], "path": "x-pack/plugins/security_solution/common/experimental_features.ts", "deprecated": false, @@ -3313,7 +3313,7 @@ "\nA list of allowed values that can be used in `xpack.securitySolution.enableExperimental`.\nThis object is then used to validate and parse the value entered." ], "signature": [ - "{ readonly excludePoliciesInFilterEnabled: false; readonly kubernetesEnabled: true; readonly donutChartEmbeddablesEnabled: false; readonly previewTelemetryUrlEnabled: false; readonly extendedRuleExecutionLoggingEnabled: false; readonly socTrendsEnabled: false; readonly responseActionsEnabled: true; readonly endpointResponseActionsEnabled: true; readonly responseActionUploadEnabled: true; readonly automatedProcessActionsEnabled: true; readonly responseActionsSentinelOneV1Enabled: true; readonly responseActionsSentinelOneV2Enabled: true; readonly responseActionsSentinelOneGetFileEnabled: true; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: true; readonly responseActionScanEnabled: false; readonly alertsPageChartsEnabled: true; readonly alertTypeEnabled: false; readonly expandableFlyoutDisabled: false; readonly securitySolutionNotesEnabled: false; readonly entityAlertPreviewEnabled: false; readonly assistantModelEvaluation: false; readonly assistantKnowledgeBaseByDefault: false; readonly newUserDetailsFlyoutManagedUser: false; readonly riskScoringPersistence: true; readonly riskScoringRoutesEnabled: true; readonly esqlRulesDisabled: false; readonly protectionUpdatesEnabled: true; readonly disableTimelineSaveTour: false; readonly alertSuppressionForEsqlRuleEnabled: false; readonly riskEnginePrivilegesRouteEnabled: true; readonly sentinelOneDataInAnalyzerEnabled: true; readonly sentinelOneManualHostActionsEnabled: true; readonly crowdstrikeDataInAnalyzerEnabled: true; readonly jamfDataInAnalyzerEnabled: false; readonly timelineEsqlTabDisabled: false; readonly unifiedComponentsInTimelineEnabled: false; readonly analyzerDatePickersAndSourcererDisabled: false; readonly prebuiltRulesCustomizationEnabled: false; readonly malwareOnWriteScanOptionAvailable: true; readonly unifiedManifestEnabled: false; readonly aiAssistantFlyoutMode: true; readonly valueListItemsModalEnabled: true; readonly bulkCustomHighlightedFieldsEnabled: false; readonly manualRuleRunEnabled: false; readonly filterProcessDescendantsForEventFiltersEnabled: false; }" + "{ readonly excludePoliciesInFilterEnabled: false; readonly kubernetesEnabled: true; readonly donutChartEmbeddablesEnabled: false; readonly previewTelemetryUrlEnabled: false; readonly extendedRuleExecutionLoggingEnabled: false; readonly socTrendsEnabled: false; readonly responseActionsEnabled: true; readonly endpointResponseActionsEnabled: true; readonly responseActionUploadEnabled: true; readonly automatedProcessActionsEnabled: true; readonly responseActionsSentinelOneV1Enabled: true; readonly responseActionsSentinelOneV2Enabled: true; readonly responseActionsSentinelOneGetFileEnabled: true; readonly responseActionsCrowdstrikeManualHostIsolationEnabled: true; readonly responseActionScanEnabled: false; readonly alertsPageChartsEnabled: true; readonly alertTypeEnabled: false; readonly expandableFlyoutDisabled: false; readonly securitySolutionNotesEnabled: false; readonly entityAlertPreviewEnabled: false; readonly assistantModelEvaluation: false; readonly assistantKnowledgeBaseByDefault: false; readonly newUserDetailsFlyoutManagedUser: false; readonly riskScoringPersistence: true; readonly riskScoringRoutesEnabled: true; readonly esqlRulesDisabled: false; readonly protectionUpdatesEnabled: true; readonly AIAssistantOnRuleCreationFormEnabled: false; readonly disableTimelineSaveTour: false; readonly alertSuppressionForEsqlRuleEnabled: false; readonly riskEnginePrivilegesRouteEnabled: true; readonly alertSuppressionForMachineLearningRuleEnabled: false; readonly sentinelOneDataInAnalyzerEnabled: true; readonly sentinelOneManualHostActionsEnabled: true; readonly crowdstrikeDataInAnalyzerEnabled: true; readonly jamfDataInAnalyzerEnabled: false; readonly timelineEsqlTabDisabled: false; readonly unifiedComponentsInTimelineEnabled: false; readonly analyzerDatePickersAndSourcererDisabled: false; readonly prebuiltRulesCustomizationEnabled: false; readonly malwareOnWriteScanOptionAvailable: true; readonly unifiedManifestEnabled: true; readonly aiAssistantFlyoutMode: true; readonly valueListItemsModalEnabled: true; readonly bulkCustomHighlightedFieldsEnabled: false; readonly manualRuleRunEnabled: false; readonly filterProcessDescendantsForEventFiltersEnabled: false; }" ], "path": "x-pack/plugins/security_solution/common/experimental_features.ts", "deprecated": false, diff --git a/api_docs/security_solution.mdx b/api_docs/security_solution.mdx index 5007fffad601..fe91f2f4d81f 100644 --- a/api_docs/security_solution.mdx +++ b/api_docs/security_solution.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/securitySolution title: "securitySolution" image: https://source.unsplash.com/400x175/?github description: API docs for the securitySolution plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'securitySolution'] --- import securitySolutionObj from './security_solution.devdocs.json'; diff --git a/api_docs/security_solution_ess.mdx b/api_docs/security_solution_ess.mdx index 77ff6a11af11..62590f6edbb0 100644 --- a/api_docs/security_solution_ess.mdx +++ b/api_docs/security_solution_ess.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/securitySolutionEss title: "securitySolutionEss" image: https://source.unsplash.com/400x175/?github description: API docs for the securitySolutionEss plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'securitySolutionEss'] --- import securitySolutionEssObj from './security_solution_ess.devdocs.json'; diff --git a/api_docs/security_solution_serverless.mdx b/api_docs/security_solution_serverless.mdx index b18c2570fb57..92590b2dd088 100644 --- a/api_docs/security_solution_serverless.mdx +++ b/api_docs/security_solution_serverless.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/securitySolutionServerless title: "securitySolutionServerless" image: https://source.unsplash.com/400x175/?github description: API docs for the securitySolutionServerless plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'securitySolutionServerless'] --- import securitySolutionServerlessObj from './security_solution_serverless.devdocs.json'; diff --git a/api_docs/serverless.mdx b/api_docs/serverless.mdx index e1110a135fc9..dae6ab27d0f5 100644 --- a/api_docs/serverless.mdx +++ b/api_docs/serverless.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/serverless title: "serverless" image: https://source.unsplash.com/400x175/?github description: API docs for the serverless plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'serverless'] --- import serverlessObj from './serverless.devdocs.json'; diff --git a/api_docs/serverless_observability.mdx b/api_docs/serverless_observability.mdx index caf04a27d719..7452b0349dca 100644 --- a/api_docs/serverless_observability.mdx +++ b/api_docs/serverless_observability.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/serverlessObservability title: "serverlessObservability" image: https://source.unsplash.com/400x175/?github description: API docs for the serverlessObservability plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'serverlessObservability'] --- import serverlessObservabilityObj from './serverless_observability.devdocs.json'; diff --git a/api_docs/serverless_search.mdx b/api_docs/serverless_search.mdx index 9cd3f37c0cfe..f65f7938a0ea 100644 --- a/api_docs/serverless_search.mdx +++ b/api_docs/serverless_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/serverlessSearch title: "serverlessSearch" image: https://source.unsplash.com/400x175/?github description: API docs for the serverlessSearch plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'serverlessSearch'] --- import serverlessSearchObj from './serverless_search.devdocs.json'; diff --git a/api_docs/session_view.mdx b/api_docs/session_view.mdx index b6f627631255..886ce136a936 100644 --- a/api_docs/session_view.mdx +++ b/api_docs/session_view.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/sessionView title: "sessionView" image: https://source.unsplash.com/400x175/?github description: API docs for the sessionView plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'sessionView'] --- import sessionViewObj from './session_view.devdocs.json'; diff --git a/api_docs/share.mdx b/api_docs/share.mdx index 88ff01770374..99d016576c26 100644 --- a/api_docs/share.mdx +++ b/api_docs/share.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/share title: "share" image: https://source.unsplash.com/400x175/?github description: API docs for the share plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'share'] --- import shareObj from './share.devdocs.json'; diff --git a/api_docs/slo.mdx b/api_docs/slo.mdx index cd59d5620871..bcdff116e9e0 100644 --- a/api_docs/slo.mdx +++ b/api_docs/slo.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/slo title: "slo" image: https://source.unsplash.com/400x175/?github description: API docs for the slo plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'slo'] --- import sloObj from './slo.devdocs.json'; diff --git a/api_docs/snapshot_restore.mdx b/api_docs/snapshot_restore.mdx index affacb458655..1e66ffb9619c 100644 --- a/api_docs/snapshot_restore.mdx +++ b/api_docs/snapshot_restore.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/snapshotRestore title: "snapshotRestore" image: https://source.unsplash.com/400x175/?github description: API docs for the snapshotRestore plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'snapshotRestore'] --- import snapshotRestoreObj from './snapshot_restore.devdocs.json'; diff --git a/api_docs/spaces.mdx b/api_docs/spaces.mdx index 2cb49e0bf0e8..3a4797b07f39 100644 --- a/api_docs/spaces.mdx +++ b/api_docs/spaces.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/spaces title: "spaces" image: https://source.unsplash.com/400x175/?github description: API docs for the spaces plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'spaces'] --- import spacesObj from './spaces.devdocs.json'; diff --git a/api_docs/stack_alerts.mdx b/api_docs/stack_alerts.mdx index f8034f8456e1..8b24bcddc2ec 100644 --- a/api_docs/stack_alerts.mdx +++ b/api_docs/stack_alerts.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/stackAlerts title: "stackAlerts" image: https://source.unsplash.com/400x175/?github description: API docs for the stackAlerts plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'stackAlerts'] --- import stackAlertsObj from './stack_alerts.devdocs.json'; diff --git a/api_docs/stack_connectors.mdx b/api_docs/stack_connectors.mdx index 058741677c2b..fba2360abee8 100644 --- a/api_docs/stack_connectors.mdx +++ b/api_docs/stack_connectors.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/stackConnectors title: "stackConnectors" image: https://source.unsplash.com/400x175/?github description: API docs for the stackConnectors plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'stackConnectors'] --- import stackConnectorsObj from './stack_connectors.devdocs.json'; diff --git a/api_docs/task_manager.mdx b/api_docs/task_manager.mdx index 16045ed88a48..4227742b695b 100644 --- a/api_docs/task_manager.mdx +++ b/api_docs/task_manager.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/taskManager title: "taskManager" image: https://source.unsplash.com/400x175/?github description: API docs for the taskManager plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'taskManager'] --- import taskManagerObj from './task_manager.devdocs.json'; diff --git a/api_docs/telemetry.mdx b/api_docs/telemetry.mdx index fda09b9d20ac..89da3bb82a6f 100644 --- a/api_docs/telemetry.mdx +++ b/api_docs/telemetry.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/telemetry title: "telemetry" image: https://source.unsplash.com/400x175/?github description: API docs for the telemetry plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'telemetry'] --- import telemetryObj from './telemetry.devdocs.json'; diff --git a/api_docs/telemetry_collection_manager.mdx b/api_docs/telemetry_collection_manager.mdx index adfc60c6a27b..d8103e454c54 100644 --- a/api_docs/telemetry_collection_manager.mdx +++ b/api_docs/telemetry_collection_manager.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/telemetryCollectionManager title: "telemetryCollectionManager" image: https://source.unsplash.com/400x175/?github description: API docs for the telemetryCollectionManager plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'telemetryCollectionManager'] --- import telemetryCollectionManagerObj from './telemetry_collection_manager.devdocs.json'; diff --git a/api_docs/telemetry_collection_xpack.mdx b/api_docs/telemetry_collection_xpack.mdx index dae72600287b..04a3769d5516 100644 --- a/api_docs/telemetry_collection_xpack.mdx +++ b/api_docs/telemetry_collection_xpack.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/telemetryCollectionXpack title: "telemetryCollectionXpack" image: https://source.unsplash.com/400x175/?github description: API docs for the telemetryCollectionXpack plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'telemetryCollectionXpack'] --- import telemetryCollectionXpackObj from './telemetry_collection_xpack.devdocs.json'; diff --git a/api_docs/telemetry_management_section.mdx b/api_docs/telemetry_management_section.mdx index 5f46ded1ee25..8f1b0edcb7d7 100644 --- a/api_docs/telemetry_management_section.mdx +++ b/api_docs/telemetry_management_section.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/telemetryManagementSection title: "telemetryManagementSection" image: https://source.unsplash.com/400x175/?github description: API docs for the telemetryManagementSection plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'telemetryManagementSection'] --- import telemetryManagementSectionObj from './telemetry_management_section.devdocs.json'; diff --git a/api_docs/text_based_languages.mdx b/api_docs/text_based_languages.mdx index 4b3290d4a48c..6cbe9d47406a 100644 --- a/api_docs/text_based_languages.mdx +++ b/api_docs/text_based_languages.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/textBasedLanguages title: "textBasedLanguages" image: https://source.unsplash.com/400x175/?github description: API docs for the textBasedLanguages plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'textBasedLanguages'] --- import textBasedLanguagesObj from './text_based_languages.devdocs.json'; diff --git a/api_docs/threat_intelligence.mdx b/api_docs/threat_intelligence.mdx index e680c9f8e901..deedae402bb3 100644 --- a/api_docs/threat_intelligence.mdx +++ b/api_docs/threat_intelligence.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/threatIntelligence title: "threatIntelligence" image: https://source.unsplash.com/400x175/?github description: API docs for the threatIntelligence plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'threatIntelligence'] --- import threatIntelligenceObj from './threat_intelligence.devdocs.json'; diff --git a/api_docs/timelines.devdocs.json b/api_docs/timelines.devdocs.json index c978c041d986..475865401710 100644 --- a/api_docs/timelines.devdocs.json +++ b/api_docs/timelines.devdocs.json @@ -1481,68 +1481,60 @@ "trackAdoption": false, "references": [ { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts" + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" }, { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" }, { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" }, { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" }, { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" }, { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx" + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx" + "path": "x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx" + "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx" + "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" }, { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts" }, { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx" }, { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx" }, { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx" }, { "plugin": "securitySolution", @@ -1592,14 +1584,6 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/common/components/event_details/types.ts" }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx" @@ -1638,11 +1622,15 @@ }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/threatmatch_input/index.tsx" + "path": "x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_config.ts" + }, + { + "plugin": "securitySolution", + "path": "x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_config.ts" }, { "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/threatmatch_input/index.tsx" + "path": "x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_config.ts" }, { "plugin": "securitySolution", @@ -1685,28 +1673,6 @@ "deprecated": false, "trackAdoption": false }, - { - "parentPluginId": "timelines", - "id": "def-common.BrowserField.fields", - "type": "Object", - "tags": [], - "label": "fields", - "description": [], - "signature": [ - "{ [x: string]: Partial<", - { - "pluginId": "timelines", - "scope": "common", - "docId": "kibTimelinesPluginApi", - "section": "def-common.BrowserField", - "text": "BrowserField" - }, - ">; }" - ], - "path": "x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts", - "deprecated": false, - "trackAdoption": false - }, { "parentPluginId": "timelines", "id": "def-common.BrowserField.format", @@ -3921,20 +3887,42 @@ "label": "BrowserFields", "description": [], "signature": [ - "{ [x: string]: Partial<", - { - "pluginId": "timelines", - "scope": "common", - "docId": "kibTimelinesPluginApi", - "section": "def-common.BrowserField", - "text": "BrowserField" - }, - ">; }" + "{ [x: string]: ", + "FieldCategory", + "; }" ], "path": "x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts", "deprecated": true, "trackAdoption": false, "references": [ + { + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + }, + { + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + }, + { + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + }, + { + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" + }, + { + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/index.tsx" + }, + { + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/index.tsx" + }, + { + "plugin": "@kbn/securitysolution-data-table", + "path": "x-pack/packages/security-solution/data_table/components/data_table/index.tsx" + }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts" @@ -4083,34 +4071,6 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/common/containers/source/mock.ts" }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/column_headers/helpers.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/index.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/index.tsx" - }, - { - "plugin": "@kbn/securitysolution-data-table", - "path": "x-pack/packages/security-solution/data_table/components/data_table/index.tsx" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/sourcerer/store/model.ts" @@ -4251,14 +4211,6 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx" }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.tsx" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.tsx" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx" @@ -4399,14 +4351,6 @@ "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx" }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx" - }, - { - "plugin": "securitySolution", - "path": "x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx" - }, { "plugin": "securitySolution", "path": "x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/utils.ts" diff --git a/api_docs/timelines.mdx b/api_docs/timelines.mdx index b4fea5417910..c825115a0446 100644 --- a/api_docs/timelines.mdx +++ b/api_docs/timelines.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/timelines title: "timelines" image: https://source.unsplash.com/400x175/?github description: API docs for the timelines plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'timelines'] --- import timelinesObj from './timelines.devdocs.json'; @@ -21,7 +21,7 @@ Contact [@elastic/security-threat-hunting-investigations](https://github.com/org | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 239 | 1 | 195 | 17 | +| 238 | 1 | 194 | 18 | ## Client diff --git a/api_docs/transform.mdx b/api_docs/transform.mdx index 5ddf405ec22c..ef145aff8fa5 100644 --- a/api_docs/transform.mdx +++ b/api_docs/transform.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/transform title: "transform" image: https://source.unsplash.com/400x175/?github description: API docs for the transform plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'transform'] --- import transformObj from './transform.devdocs.json'; diff --git a/api_docs/triggers_actions_ui.devdocs.json b/api_docs/triggers_actions_ui.devdocs.json index e8e1b4e82078..ad45f50762eb 100644 --- a/api_docs/triggers_actions_ui.devdocs.json +++ b/api_docs/triggers_actions_ui.devdocs.json @@ -2399,8 +2399,8 @@ "pluginId": "@kbn/alerts-ui-shared", "scope": "common", "docId": "kibKbnAlertsUiSharedPluginApi", - "section": "def-common.RuleFormErrors", - "text": "RuleFormErrors" + "section": "def-common.RuleFormParamsErrors", + "text": "RuleFormParamsErrors" } ], "path": "packages/kbn-alerts-ui-shared/src/common/types/action_types.ts", diff --git a/api_docs/triggers_actions_ui.mdx b/api_docs/triggers_actions_ui.mdx index 522cebb767b6..d5d9c27ec210 100644 --- a/api_docs/triggers_actions_ui.mdx +++ b/api_docs/triggers_actions_ui.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/triggersActionsUi title: "triggersActionsUi" image: https://source.unsplash.com/400x175/?github description: API docs for the triggersActionsUi plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'triggersActionsUi'] --- import triggersActionsUiObj from './triggers_actions_ui.devdocs.json'; diff --git a/api_docs/ui_actions.mdx b/api_docs/ui_actions.mdx index 0b1324c05e62..49df36a5eb09 100644 --- a/api_docs/ui_actions.mdx +++ b/api_docs/ui_actions.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/uiActions title: "uiActions" image: https://source.unsplash.com/400x175/?github description: API docs for the uiActions plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'uiActions'] --- import uiActionsObj from './ui_actions.devdocs.json'; diff --git a/api_docs/ui_actions_enhanced.mdx b/api_docs/ui_actions_enhanced.mdx index 0c070052e0a0..7f6ce8da09f7 100644 --- a/api_docs/ui_actions_enhanced.mdx +++ b/api_docs/ui_actions_enhanced.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/uiActionsEnhanced title: "uiActionsEnhanced" image: https://source.unsplash.com/400x175/?github description: API docs for the uiActionsEnhanced plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'uiActionsEnhanced'] --- import uiActionsEnhancedObj from './ui_actions_enhanced.devdocs.json'; diff --git a/api_docs/unified_doc_viewer.mdx b/api_docs/unified_doc_viewer.mdx index 4b52d84a0e9b..055a63e701ac 100644 --- a/api_docs/unified_doc_viewer.mdx +++ b/api_docs/unified_doc_viewer.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/unifiedDocViewer title: "unifiedDocViewer" image: https://source.unsplash.com/400x175/?github description: API docs for the unifiedDocViewer plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'unifiedDocViewer'] --- import unifiedDocViewerObj from './unified_doc_viewer.devdocs.json'; diff --git a/api_docs/unified_histogram.mdx b/api_docs/unified_histogram.mdx index 198e8212d4c2..49022db07ed3 100644 --- a/api_docs/unified_histogram.mdx +++ b/api_docs/unified_histogram.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/unifiedHistogram title: "unifiedHistogram" image: https://source.unsplash.com/400x175/?github description: API docs for the unifiedHistogram plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'unifiedHistogram'] --- import unifiedHistogramObj from './unified_histogram.devdocs.json'; diff --git a/api_docs/unified_search.mdx b/api_docs/unified_search.mdx index cbfd1410066f..4b831a83772e 100644 --- a/api_docs/unified_search.mdx +++ b/api_docs/unified_search.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/unifiedSearch title: "unifiedSearch" image: https://source.unsplash.com/400x175/?github description: API docs for the unifiedSearch plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'unifiedSearch'] --- import unifiedSearchObj from './unified_search.devdocs.json'; diff --git a/api_docs/unified_search_autocomplete.mdx b/api_docs/unified_search_autocomplete.mdx index b3819f3571e5..adb9e989ae7f 100644 --- a/api_docs/unified_search_autocomplete.mdx +++ b/api_docs/unified_search_autocomplete.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/unifiedSearch-autocomplete title: "unifiedSearch.autocomplete" image: https://source.unsplash.com/400x175/?github description: API docs for the unifiedSearch.autocomplete plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'unifiedSearch.autocomplete'] --- import unifiedSearchAutocompleteObj from './unified_search_autocomplete.devdocs.json'; diff --git a/api_docs/uptime.mdx b/api_docs/uptime.mdx index 8a752745794c..83988e94008c 100644 --- a/api_docs/uptime.mdx +++ b/api_docs/uptime.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/uptime title: "uptime" image: https://source.unsplash.com/400x175/?github description: API docs for the uptime plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'uptime'] --- import uptimeObj from './uptime.devdocs.json'; diff --git a/api_docs/url_forwarding.mdx b/api_docs/url_forwarding.mdx index 3f280b345272..3fd6abfac031 100644 --- a/api_docs/url_forwarding.mdx +++ b/api_docs/url_forwarding.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/urlForwarding title: "urlForwarding" image: https://source.unsplash.com/400x175/?github description: API docs for the urlForwarding plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'urlForwarding'] --- import urlForwardingObj from './url_forwarding.devdocs.json'; diff --git a/api_docs/usage_collection.mdx b/api_docs/usage_collection.mdx index 66352f35fc39..38be51dc80a4 100644 --- a/api_docs/usage_collection.mdx +++ b/api_docs/usage_collection.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/usageCollection title: "usageCollection" image: https://source.unsplash.com/400x175/?github description: API docs for the usageCollection plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'usageCollection'] --- import usageCollectionObj from './usage_collection.devdocs.json'; diff --git a/api_docs/ux.mdx b/api_docs/ux.mdx index c40e8f770cb0..8195e0e09fef 100644 --- a/api_docs/ux.mdx +++ b/api_docs/ux.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/ux title: "ux" image: https://source.unsplash.com/400x175/?github description: API docs for the ux plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'ux'] --- import uxObj from './ux.devdocs.json'; diff --git a/api_docs/vis_default_editor.mdx b/api_docs/vis_default_editor.mdx index 279c791440c5..d62810e3d1ea 100644 --- a/api_docs/vis_default_editor.mdx +++ b/api_docs/vis_default_editor.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visDefaultEditor title: "visDefaultEditor" image: https://source.unsplash.com/400x175/?github description: API docs for the visDefaultEditor plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visDefaultEditor'] --- import visDefaultEditorObj from './vis_default_editor.devdocs.json'; diff --git a/api_docs/vis_type_gauge.mdx b/api_docs/vis_type_gauge.mdx index eec263e92564..fe5ece28da25 100644 --- a/api_docs/vis_type_gauge.mdx +++ b/api_docs/vis_type_gauge.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeGauge title: "visTypeGauge" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeGauge plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeGauge'] --- import visTypeGaugeObj from './vis_type_gauge.devdocs.json'; diff --git a/api_docs/vis_type_heatmap.mdx b/api_docs/vis_type_heatmap.mdx index 1b0e630249aa..eddbcbce54a5 100644 --- a/api_docs/vis_type_heatmap.mdx +++ b/api_docs/vis_type_heatmap.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeHeatmap title: "visTypeHeatmap" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeHeatmap plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeHeatmap'] --- import visTypeHeatmapObj from './vis_type_heatmap.devdocs.json'; diff --git a/api_docs/vis_type_pie.mdx b/api_docs/vis_type_pie.mdx index 0da4e352abd1..c99cbfa2402a 100644 --- a/api_docs/vis_type_pie.mdx +++ b/api_docs/vis_type_pie.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypePie title: "visTypePie" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypePie plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypePie'] --- import visTypePieObj from './vis_type_pie.devdocs.json'; diff --git a/api_docs/vis_type_table.mdx b/api_docs/vis_type_table.mdx index 9fd42e7d54ae..49170451c94b 100644 --- a/api_docs/vis_type_table.mdx +++ b/api_docs/vis_type_table.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeTable title: "visTypeTable" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeTable plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeTable'] --- import visTypeTableObj from './vis_type_table.devdocs.json'; diff --git a/api_docs/vis_type_timelion.mdx b/api_docs/vis_type_timelion.mdx index c2b348061950..88d15e166981 100644 --- a/api_docs/vis_type_timelion.mdx +++ b/api_docs/vis_type_timelion.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeTimelion title: "visTypeTimelion" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeTimelion plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeTimelion'] --- import visTypeTimelionObj from './vis_type_timelion.devdocs.json'; diff --git a/api_docs/vis_type_timeseries.mdx b/api_docs/vis_type_timeseries.mdx index 8d1646fa2db2..4bd4a29b1bd8 100644 --- a/api_docs/vis_type_timeseries.mdx +++ b/api_docs/vis_type_timeseries.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeTimeseries title: "visTypeTimeseries" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeTimeseries plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeTimeseries'] --- import visTypeTimeseriesObj from './vis_type_timeseries.devdocs.json'; diff --git a/api_docs/vis_type_vega.mdx b/api_docs/vis_type_vega.mdx index 70f151f1bf97..fbc1e6b50794 100644 --- a/api_docs/vis_type_vega.mdx +++ b/api_docs/vis_type_vega.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeVega title: "visTypeVega" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeVega plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeVega'] --- import visTypeVegaObj from './vis_type_vega.devdocs.json'; diff --git a/api_docs/vis_type_vislib.mdx b/api_docs/vis_type_vislib.mdx index a7c8edde513e..e7a413a6b90a 100644 --- a/api_docs/vis_type_vislib.mdx +++ b/api_docs/vis_type_vislib.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeVislib title: "visTypeVislib" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeVislib plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeVislib'] --- import visTypeVislibObj from './vis_type_vislib.devdocs.json'; diff --git a/api_docs/vis_type_xy.mdx b/api_docs/vis_type_xy.mdx index 024d8722dd41..6217e915e866 100644 --- a/api_docs/vis_type_xy.mdx +++ b/api_docs/vis_type_xy.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visTypeXy title: "visTypeXy" image: https://source.unsplash.com/400x175/?github description: API docs for the visTypeXy plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visTypeXy'] --- import visTypeXyObj from './vis_type_xy.devdocs.json'; diff --git a/api_docs/visualizations.mdx b/api_docs/visualizations.mdx index 50642eca73c9..81c0a563a381 100644 --- a/api_docs/visualizations.mdx +++ b/api_docs/visualizations.mdx @@ -8,7 +8,7 @@ slug: /kibana-dev-docs/api/visualizations title: "visualizations" image: https://source.unsplash.com/400x175/?github description: API docs for the visualizations plugin -date: 2024-06-29 +date: 2024-07-04 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'visualizations'] --- import visualizationsObj from './visualizations.devdocs.json'; diff --git a/docs/developer/best-practices/stability.asciidoc b/docs/developer/best-practices/stability.asciidoc index 29be86f58317..9287e1df6b4f 100644 --- a/docs/developer/best-practices/stability.asciidoc +++ b/docs/developer/best-practices/stability.asciidoc @@ -29,7 +29,7 @@ access. *** We need to make sure security is set up in a specific way for non-standard {kib} indices. (create their own custom roles) * {kib} running behind a reverse proxy or load balancer, without sticky -sessions. (we have had many discuss/SDH tickets around this) +sessions. * If a proxy/loadbalancer is running between ES and {kib} [discrete] @@ -78,4 +78,4 @@ Does the feature work efficiently on the list of supported browsers? * Does the feature affect old indices or saved objects? * Has the feature been tested with {kib} aliases? * Read/Write privileges of the indices before and after the -upgrade? \ No newline at end of file +upgrade? diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index cb096ec12715..6df6c8f3c95e 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -85,6 +85,9 @@ enforce even rudimentary CSP rules, though {kib} is still accessible. This configuration is effectively ignored when <> is enabled. *Default: `true`* +`permissionsPolicy.report_to:`:: +Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy[Permissions Policy `report-to` directive]. + [[elasticsearch-maxSockets]] `elasticsearch.maxSockets`:: The maximum number of sockets that can be used for communications with {es}. *Default: `Infinity`* @@ -424,6 +427,12 @@ Refer to the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissio directives, values, and text format. To disable, set to `null`. *Default:* `camera=(), display-capture=(), fullscreen=(self), geolocation=(), microphone=(), web-share=()` +[[server-securityResponseHeaders-permissionsPolicyReportOnly]] `server.securityResponseHeaders.permissionsPolicyReportOnly`:: +experimental[] Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy[`Permissions-Policy-Report-Only`] header +is used in all responses to the client from the {kib} server, and specifies what value is used. Allowed values are any text value or `null`. +Refer to the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy[`Permissions-Policy` documentation] for defined +directives, values, and text format. + [[server-securityResponseHeaders-disableEmbedding]]`server.securityResponseHeaders.disableEmbedding`:: Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy[`Content-Security-Policy`] and https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options[`X-Frame-Options`] headers are configured to disable embedding diff --git a/docs/user/security/fips-140-2.asciidoc b/docs/user/security/fips-140-2.asciidoc new file mode 100644 index 000000000000..2b4b195f38b0 --- /dev/null +++ b/docs/user/security/fips-140-2.asciidoc @@ -0,0 +1,63 @@ +[[xpack-security-fips-140-2]] +=== FIPS 140-2 + +experimental::[] + +The Federal Information Processing Standard (FIPS) Publication 140-2, (FIPS PUB 140-2), +titled "Security Requirements for Cryptographic Modules" is a U.S. government computer security standard +used to approve cryptographic modules. + +{kib} offers a FIPS 140-2 compliant mode and as such can run in a Node.js environment configured with a FIPS +140-2 compliant OpenSSL3 provider. + +To run {kib} in FIPS mode, you must have the appropriate {subscriptions}[subscription]. + +[IMPORTANT] +============================================================================ +The Node bundled with {kib} is not configured for FIPS 140-2. You must configure a FIPS 140-2 compliant OpenSSL3 +provider. Consult the Node.js documentation to learn how to configure your environment. +============================================================================ + +For {kib}, adherence to FIPS 140-2 is ensured by: + +* Using FIPS approved / NIST recommended cryptographic algorithms. + +* Delegating the implementation of these cryptographic algorithms to a NIST validated cryptographic module +(available via Node.js configured with an OpenSSL3 provider). + +* Allowing the configuration of {kib} in a FIPS 140-2 compliant manner, as documented below. + +==== Configuring {kib} for FIPS 140-2 + +Apart from setting `xpack.security.experimental.fipsMode.enabled` to `true` in your {kib} config, a number of security related +settings need to be reviewed and configured in order to run {kib} successfully in a FIPS 140-2 compliant Node.js +environment. + +===== Kibana keystore + +FIPS 140-2 (via NIST Special Publication 800-132) dictates that encryption keys should at least have an effective +strength of 112 bits. As such, the Kibana keystore that stores the application’s secure settings needs to be +password protected with a password that satisfies this requirement. This means that the password needs to be 14 bytes +long which is equivalent to a 14 character ASCII encoded password, or a 7 character UTF-8 encoded password. + +For more information on how to set this password, refer to the <>. + +===== TLS keystore and keys + +Keystores can be used in a number of General TLS settings in order to conveniently store key and trust material. +PKCS#12 keystores cannot be used in a FIPS 140-2 compliant Node.js environment. Avoid using these types of keystores. +Your FIPS 140-2 provider may provide a compliant keystore implementation that can be used, or you can use PEM encoded +files. To use PEM encoded key material, you can use the relevant `\*.key` and `*.certificate` configuration options, +and for trust material you can use `*.certificate_authorities`. + +As an example, avoid PKCS#12 specific settings such as: + +* `server.ssl.keystore.path` +* `server.ssl.truststore.path` +* `elasticsearch.ssl.keystore.path` +* `elasticsearch.ssl.truststore.path` + +===== Limitations + +Configuring {kib} to run in FIPS mode is still considered to be experimental. Not all features are guaranteed to +function as expected. diff --git a/docs/user/security/index.asciidoc b/docs/user/security/index.asciidoc index f4678700d5e7..906aee3d76d5 100644 --- a/docs/user/security/index.asciidoc +++ b/docs/user/security/index.asciidoc @@ -46,3 +46,4 @@ include::authorization/index.asciidoc[] include::authorization/kibana-privileges.asciidoc[] include::api-keys/index.asciidoc[] include::role-mappings/index.asciidoc[] +include::fips-140-2.asciidoc[] diff --git a/examples/controls_example/public/app/app.tsx b/examples/controls_example/public/app/app.tsx index aad5dc38df16..d6d8df443c34 100644 --- a/examples/controls_example/public/app/app.tsx +++ b/examples/controls_example/public/app/app.tsx @@ -16,6 +16,7 @@ import { EuiTab, EuiTabs, } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; import React, { useState } from 'react'; import ReactDOM from 'react-dom'; @@ -47,35 +48,37 @@ const App = ({ } return ( - - - - - - + + + - - onSelectedTabChanged(CONTROLS_REFACTOR_TEST)} - isSelected={CONTROLS_REFACTOR_TEST === selectedTabId} - > - Register a new React control - - onSelectedTabChanged(CONTROLS_AS_A_BUILDING_BLOCK)} - isSelected={CONTROLS_AS_A_BUILDING_BLOCK === selectedTabId} - > - Controls as a building block - - + + + + + + onSelectedTabChanged(CONTROLS_REFACTOR_TEST)} + isSelected={CONTROLS_REFACTOR_TEST === selectedTabId} + > + Register a new React control + + onSelectedTabChanged(CONTROLS_AS_A_BUILDING_BLOCK)} + isSelected={CONTROLS_AS_A_BUILDING_BLOCK === selectedTabId} + > + Controls as a building block + + - + - {renderTabContent()} - - - - + {renderTabContent()} + + + + + ); }; diff --git a/examples/controls_example/public/app/react_control_example.tsx b/examples/controls_example/public/app/react_control_example.tsx index 0c84d557ec31..391f41e0ac81 100644 --- a/examples/controls_example/public/app/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example.tsx @@ -6,14 +6,16 @@ * Side Public License, v 1. */ +import React, { useEffect, useMemo, useState } from 'react'; +import { BehaviorSubject, combineLatest } from 'rxjs'; + import { EuiButton, EuiButtonGroup, + EuiCallOut, EuiCodeBlock, - EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiLoadingSpinner, EuiSpacer, EuiSuperDatePicker, OnTimeChangeProps, @@ -23,23 +25,18 @@ import { CoreStart } from '@kbn/core/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public'; import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; -import { combineCompatibleChildrenApis, PresentationContainer } from '@kbn/presentation-containers'; +import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { apiPublishesDataLoading, HasUniqueId, PublishesDataLoading, - PublishesUnifiedSearch, - PublishesViewMode, useBatchedPublishingSubjects, - useStateFromPublishingSubject, ViewMode as ViewModeType, } from '@kbn/presentation-publishing'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import React, { useEffect, useMemo, useState } from 'react'; -import useAsync from 'react-use/lib/useAsync'; -import useMount from 'react-use/lib/useMount'; -import { BehaviorSubject } from 'rxjs'; + import { ControlGroupApi } from '../react_controls/control_group/types'; +import { RANGE_SLIDER_CONTROL_TYPE } from '../react_controls/data_controls/range_slider/types'; import { SEARCH_CONTROL_TYPE } from '../react_controls/data_controls/search_control/types'; import { TIMESLIDER_CONTROL_TYPE } from '../react_controls/timeslider_control/types'; @@ -57,6 +54,7 @@ const toggleViewButtons = [ ]; const searchControlId = 'searchControl1'; +const rangeSliderControlId = 'rangeSliderControl1'; const timesliderControlId = 'timesliderControl1'; const controlGroupPanels = { [searchControlId]: { @@ -74,6 +72,20 @@ const controlGroupPanels = { enhancements: {}, }, }, + [rangeSliderControlId]: { + type: RANGE_SLIDER_CONTROL_TYPE, + order: 0, + grow: true, + width: 'medium', + explicitInput: { + id: rangeSliderControlId, + fieldName: 'bytes', + title: 'Bytes', + grow: true, + width: 'medium', + enhancements: {}, + }, + }, [timesliderControlId]: { type: TIMESLIDER_CONTROL_TYPE, order: 0, @@ -87,18 +99,7 @@ const controlGroupPanels = { }, }; -/** - * I am mocking the dashboard API so that the data table embeddble responds to changes to the - * data view publishing subject from the control group - */ -type MockedDashboardApi = PresentationContainer & - PublishesDataLoading & - PublishesViewMode & - PublishesUnifiedSearch & { - publishFilters: (newFilters: Filter[] | undefined) => void; - setViewMode: (newViewMode: ViewMode) => void; - setChild: (child: HasUniqueId) => void; - }; +const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247'; export const ReactControlExample = ({ core, @@ -110,6 +111,15 @@ export const ReactControlExample = ({ const dataLoading$ = useMemo(() => { return new BehaviorSubject(false); }, []); + const controlGroupFilters$ = useMemo(() => { + return new BehaviorSubject(undefined); + }, []); + const filters$ = useMemo(() => { + return new BehaviorSubject(undefined); + }, []); + const unifiedSearchFilters$ = useMemo(() => { + return new BehaviorSubject(undefined); + }, []); const timeRange$ = useMemo(() => { return new BehaviorSubject({ from: 'now-24h', @@ -119,29 +129,34 @@ export const ReactControlExample = ({ const timeslice$ = useMemo(() => { return new BehaviorSubject<[number, number] | undefined>(undefined); }, []); - const [dataLoading, timeRange] = useBatchedPublishingSubjects(dataLoading$, timeRange$); + const viewMode$ = useMemo(() => { + return new BehaviorSubject(ViewMode.EDIT as ViewModeType); + }, []); + const [dataLoading, timeRange, viewMode] = useBatchedPublishingSubjects( + dataLoading$, + timeRange$, + viewMode$ + ); - const [dashboardApi, setDashboardApi] = useState(undefined); const [controlGroupApi, setControlGroupApi] = useState(undefined); - const viewModeSelected = useStateFromPublishingSubject(dashboardApi?.viewMode); + const [dataViewNotFound, setDataViewNotFound] = useState(false); - useMount(() => { - const viewMode = new BehaviorSubject(ViewMode.EDIT as ViewModeType); - const filters$ = new BehaviorSubject([]); + const dashboardApi = useMemo(() => { const query$ = new BehaviorSubject(undefined); const children$ = new BehaviorSubject<{ [key: string]: unknown }>({}); - setDashboardApi({ + return { dataLoading: dataLoading$, - viewMode, + unifiedSearchFilters$, + viewMode: viewMode$, filters$, query$, timeRange$, timeslice$, children$, - publishFilters: (newFilters) => filters$.next(newFilters), - setViewMode: (newViewMode) => viewMode.next(newViewMode), - setChild: (child) => children$.next({ ...children$.getValue(), [child.uuid]: child }), + publishFilters: (newFilters: Filter[] | undefined) => filters$.next(newFilters), + setChild: (child: HasUniqueId) => + children$.next({ ...children$.getValue(), [child.uuid]: child }), removePanel: () => {}, replacePanel: () => { return Promise.resolve(''); @@ -152,8 +167,9 @@ export const ReactControlExample = ({ addNewPanel: () => { return Promise.resolve(undefined); }, - }); - }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { const subscription = combineCompatibleChildrenApis( @@ -174,26 +190,31 @@ export const ReactControlExample = ({ }; }, [dashboardApi, dataLoading$]); - // TODO: Maybe remove `useAsync` - see https://github.com/elastic/kibana/pull/182842#discussion_r1624909709 - const { - loading, - value: dataViews, - error, - } = useAsync(async () => { - return await dataViewsService.find('kibana_sample_data_logs'); + useEffect(() => { + let ignore = false; + dataViewsService.get(WEB_LOGS_DATA_VIEW_ID).catch(() => { + if (!ignore) { + setDataViewNotFound(true); + } + }); + + return () => { + ignore = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!controlGroupApi) return; const subscription = controlGroupApi.filters$.subscribe((controlGroupFilters) => { - if (dashboardApi) dashboardApi.publishFilters(controlGroupFilters); + controlGroupFilters$.next(controlGroupFilters); }); return () => { subscription.unsubscribe(); }; - }, [dashboardApi, controlGroupApi]); + }, [controlGroupFilters$, controlGroupApi]); useEffect(() => { if (!controlGroupApi) return; @@ -207,20 +228,28 @@ export const ReactControlExample = ({ }; }, [controlGroupApi, timeslice$]); - if (error || (!dataViews?.[0]?.id && !loading)) - return ( - There was an error!} - body={

{error ? error.message : 'Please add at least one data view.'}

} - /> + useEffect(() => { + const subscription = combineLatest([controlGroupFilters$, unifiedSearchFilters$]).subscribe( + ([controlGroupFilters, unifiedSearchFilters]) => { + filters$.next([...(controlGroupFilters ?? []), ...(unifiedSearchFilters ?? [])]); + } ); - return loading ? ( - - ) : ( + return () => { + subscription.unsubscribe(); + }; + }, [controlGroupFilters$, filters$, unifiedSearchFilters$]); + + return ( <> + {dataViewNotFound && ( + <> + +

{`Install "Sample web logs" to run example`}

+
+ + + )} { - dashboardApi?.setViewMode(value); + viewMode$.next(value); }} /> @@ -297,9 +326,14 @@ export const ReactControlExample = ({ } as object, references: [ { - name: `controlGroup_${searchControlId}:searchControlDataView`, + name: `controlGroup_${searchControlId}:${SEARCH_CONTROL_TYPE}DataView`, + type: 'index-pattern', + id: WEB_LOGS_DATA_VIEW_ID, + }, + { + name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL_TYPE}DataView`, type: 'index-pattern', - id: dataViews?.[0].id!, + id: WEB_LOGS_DATA_VIEW_ID, }, ], }), diff --git a/examples/controls_example/public/plugin.tsx b/examples/controls_example/public/plugin.tsx index c3a43e8907b3..64f6686e92c8 100644 --- a/examples/controls_example/public/plugin.tsx +++ b/examples/controls_example/public/plugin.tsx @@ -17,6 +17,7 @@ import { PLUGIN_ID } from './constants'; import img from './control_group_image.png'; import { EditControlAction } from './react_controls/actions/edit_control_action'; import { registerControlFactory } from './react_controls/control_factory_registry'; +import { RANGE_SLIDER_CONTROL_TYPE } from './react_controls/data_controls/range_slider/types'; import { SEARCH_CONTROL_TYPE } from './react_controls/data_controls/search_control/types'; import { TIMESLIDER_CONTROL_TYPE } from './react_controls/timeslider_control/types'; @@ -49,6 +50,19 @@ export class ControlsExamplePlugin }); }); + registerControlFactory(RANGE_SLIDER_CONTROL_TYPE, async () => { + const [{ getRangesliderControlFactory }, [coreStart, depsStart]] = await Promise.all([ + import('./react_controls/data_controls/range_slider/get_range_slider_control_factory'), + core.getStartServices(), + ]); + + return getRangesliderControlFactory({ + core: coreStart, + data: depsStart.data, + dataViews: depsStart.data.dataViews, + }); + }); + registerControlFactory(SEARCH_CONTROL_TYPE, async () => { const [{ getSearchControlFactory: getSearchEmbeddableFactory }, [coreStart, depsStart]] = await Promise.all([ diff --git a/examples/controls_example/public/react_controls/constants.ts b/examples/controls_example/public/react_controls/constants.ts new file mode 100644 index 000000000000..0773dd7ea907 --- /dev/null +++ b/examples/controls_example/public/react_controls/constants.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const MIN_POPOVER_WIDTH = 300; diff --git a/examples/controls_example/public/react_controls/control_error_component.tsx b/examples/controls_example/public/react_controls/control_error_component.tsx index eea1709db648..bcc30192e02f 100644 --- a/examples/controls_example/public/react_controls/control_error_component.tsx +++ b/examples/controls_example/public/react_controls/control_error_component.tsx @@ -9,7 +9,7 @@ import React, { useState } from 'react'; import { EuiButtonEmpty, EuiPopover } from '@elastic/eui'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; +import { FormattedMessage } from '@kbn/i18n-react'; import { Markdown } from '@kbn/shared-ux-markdown'; /** TODO: This file is duplicated from the controls plugin to avoid exporting it */ @@ -24,13 +24,15 @@ export const ControlError = ({ error }: ControlErrorProps) => { const popoverButton = ( setPopoverOpen((open) => !open)} - className={'errorEmbeddableCompact__button'} + className="errorEmbeddableCompact__button controlErrorButton" textProps={{ className: 'errorEmbeddableCompact__text' }} + contentProps={{ className: 'controlErrorButton--content' }} > { ); return ( - - setPopoverOpen(false)} - > - - {errorMessage} - - - + setPopoverOpen(false)} + > + + {errorMessage} + + ); }; diff --git a/examples/controls_example/public/react_controls/control_group/data_control_fetch.ts b/examples/controls_example/public/react_controls/control_group/data_control_fetch.ts new file mode 100644 index 000000000000..cf68a2bf0df7 --- /dev/null +++ b/examples/controls_example/public/react_controls/control_group/data_control_fetch.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ParentIgnoreSettings } from '@kbn/controls-plugin/public'; +import { Filter } from '@kbn/es-query'; +import { PublishesUnifiedSearch, PublishingSubject } from '@kbn/presentation-publishing'; +import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload'; +import { BehaviorSubject, debounceTime, map, merge, Observable, switchMap } from 'rxjs'; +import { DataControlFetchContext } from './types'; + +export function dataControlFetch$( + ignoreParentSettings$: PublishingSubject, + parentApi: Partial & { + unifiedSearchFilters$?: PublishingSubject; + } +): Observable { + return ignoreParentSettings$.pipe( + switchMap((parentIgnoreSettings) => { + const observables: Array> = []; + // Subscribe to parentApi.unifiedSearchFilters$ instead of parentApi.filters$ + // to avoid passing control group filters back into control group + if (!parentIgnoreSettings?.ignoreFilters && parentApi.unifiedSearchFilters$) { + observables.push(parentApi.unifiedSearchFilters$); + } + if (!parentIgnoreSettings?.ignoreQuery && parentApi.query$) { + observables.push(parentApi.query$); + } + if (!parentIgnoreSettings?.ignoreTimerange && parentApi.timeRange$) { + observables.push(parentApi.timeRange$); + if (parentApi.timeslice$) { + observables.push(parentApi.timeslice$); + } + } + if (apiPublishesReload(parentApi)) { + observables.push(parentApi.reload$); + } + return observables.length ? merge(...observables) : new BehaviorSubject(undefined); + }), + debounceTime(0), + map(() => { + const parentIgnoreSettings = ignoreParentSettings$.value; + return { + unifiedSearchFilters: + parentIgnoreSettings?.ignoreFilters || !parentApi.unifiedSearchFilters$ + ? undefined + : parentApi.unifiedSearchFilters$.value, + query: + parentIgnoreSettings?.ignoreQuery || !parentApi.query$ + ? undefined + : parentApi.query$.value, + timeRange: + parentIgnoreSettings?.ignoreTimerange || !parentApi.timeRange$ + ? undefined + : parentApi.timeslice$?.value + ? { + from: new Date(parentApi.timeslice$?.value[0]).toISOString(), + to: new Date(parentApi.timeslice$?.value[1]).toISOString(), + mode: 'absolute' as 'absolute', + } + : (parentApi as PublishesUnifiedSearch).timeRange$.value, + }; + }) + ); +} diff --git a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx index 669361ad040f..8d69d99b8d19 100644 --- a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx +++ b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx @@ -47,6 +47,7 @@ import { ControlGroupSerializedState, ControlGroupUnsavedChanges, } from './types'; +import { dataControlFetch$ } from './data_control_fetch'; export const getControlGroupEmbeddableFactory = (services: { core: CoreStart; @@ -67,7 +68,7 @@ export const getControlGroupEmbeddableFactory = (services: { labelPosition, chainingSystem, autoApplySelections, - ignoreParentSettings: initialParentSettings, + ignoreParentSettings, } = initialState; const autoApplySelections$ = new BehaviorSubject(autoApplySelections); @@ -76,8 +77,8 @@ export const getControlGroupEmbeddableFactory = (services: { const filters$ = new BehaviorSubject([]); const dataViews = new BehaviorSubject(undefined); const chainingSystem$ = new BehaviorSubject(chainingSystem); - const ignoreParentSettings = new BehaviorSubject( - initialParentSettings + const ignoreParentSettings$ = new BehaviorSubject( + ignoreParentSettings ); const grow = new BehaviorSubject( defaultControlGrow === undefined ? DEFAULT_CONTROL_GROW : defaultControlGrow @@ -114,6 +115,8 @@ export const getControlGroupEmbeddableFactory = (services: { .sort((a, b) => (a.order > b.order ? 1 : -1)) ); const api = setApi({ + dataControlFetch$: dataControlFetch$(ignoreParentSettings$, parentApi ? parentApi : {}), + ignoreParentSettings$, autoApplySelections$, unsavedChanges, resetUnsavedChanges: () => { @@ -134,7 +137,7 @@ export const getControlGroupEmbeddableFactory = (services: { chainingSystem: chainingSystem$, labelPosition: labelPosition$, autoApplySelections: autoApplySelections$, - ignoreParentSettings, + ignoreParentSettings: ignoreParentSettings$, }, { core: services.core } ); @@ -155,7 +158,7 @@ export const getControlGroupEmbeddableFactory = (services: { labelPosition: labelPosition$.getValue(), chainingSystem: chainingSystem$.getValue(), autoApplySelections: autoApplySelections$.getValue(), - ignoreParentSettings: ignoreParentSettings.getValue(), + ignoreParentSettings: ignoreParentSettings$.getValue(), } ); }, @@ -227,7 +230,7 @@ export const getControlGroupEmbeddableFactory = (services: { return { api, - Component: (props, test) => { + Component: () => { const controlsInOrder = useStateFromPublishingSubject(controlOrder); useEffect(() => { diff --git a/examples/controls_example/public/react_controls/control_group/types.ts b/examples/controls_example/public/react_controls/control_group/types.ts index 413d72a96702..a275e48e5394 100644 --- a/examples/controls_example/public/react_controls/control_group/types.ts +++ b/examples/controls_example/public/react_controls/control_group/types.ts @@ -10,7 +10,7 @@ import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_ import { ParentIgnoreSettings } from '@kbn/controls-plugin/public'; import { ControlStyle, ControlWidth } from '@kbn/controls-plugin/public/types'; import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; -import { Filter } from '@kbn/es-query'; +import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers'; import { HasEditCapabilities, @@ -23,6 +23,7 @@ import { PublishingSubject, } from '@kbn/presentation-publishing'; import { PublishesDataViews } from '@kbn/presentation-publishing/interfaces/publishes_data_views'; +import { Observable } from 'rxjs'; import { DefaultControlState, PublishesControlDisplaySettings } from '../types'; /** The control display settings published by the control group are the "default" */ @@ -42,6 +43,12 @@ export type ControlGroupUnsavedChanges = Omit< export type ControlPanelState = DefaultControlState & { type: string; order: number }; +export interface DataControlFetchContext { + unifiedSearchFilters?: Filter[] | undefined; + query?: Query | AggregateQuery | undefined; + timeRange?: TimeRange | undefined; +} + export type ControlGroupApi = PresentationContainer & DefaultEmbeddableApi & PublishesFilters & @@ -54,6 +61,8 @@ export type ControlGroupApi = PresentationContainer & PublishesTimeslice & Partial> & { autoApplySelections$: PublishingSubject; + dataControlFetch$: Observable; + ignoreParentSettings$: PublishingSubject; }; export interface ControlGroupRuntimeState { diff --git a/examples/controls_example/public/react_controls/control_panel.scss b/examples/controls_example/public/react_controls/control_panel.scss new file mode 100644 index 000000000000..bd347ac124d4 --- /dev/null +++ b/examples/controls_example/public/react_controls/control_panel.scss @@ -0,0 +1,36 @@ +.controlPanel { + width: 100%; + max-inline-size: 100% !important; + height: calc($euiButtonHeight - 2px); + box-shadow: none !important; + background-color: $euiFormBackgroundColor !important; + + border-radius: 0 $euiBorderRadius $euiBorderRadius 0 !important; + &--roundedBorders { + border-radius: $euiBorderRadius !important; + } + + &--label { + @include euiTextTruncate; + max-width: 40%; + background-color: transparent; + border-radius: $euiBorderRadius; + + margin-left: 0 !important; + padding-left: 0 !important; + } + + &--hideComponent { + display: none; + } + + .controlErrorButton { + width: 100%; + border-radius: 0 $euiBorderRadius $euiBorderRadius 0 !important; + + &--content { + justify-content: left; + padding-left: $euiSizeM; + } + } +} \ No newline at end of file diff --git a/examples/controls_example/public/react_controls/control_panel.tsx b/examples/controls_example/public/react_controls/control_panel.tsx index 7dd8df359674..a427b3ed1801 100644 --- a/examples/controls_example/public/react_controls/control_panel.tsx +++ b/examples/controls_example/public/react_controls/control_panel.tsx @@ -10,7 +10,6 @@ import classNames from 'classnames'; import React, { useState } from 'react'; import { EuiFlexItem, EuiFormControlLayout, EuiFormLabel, EuiFormRow, EuiIcon } from '@elastic/eui'; -import { css } from '@emotion/react'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { @@ -19,15 +18,24 @@ import { useBatchedOptionalPublishingSubjects, } from '@kbn/presentation-publishing'; import { FloatingActions } from '@kbn/presentation-util-plugin/public'; -import { euiThemeVars } from '@kbn/ui-theme'; import { ControlError } from './control_error_component'; import { ControlPanelProps, DefaultControlApi } from './types'; +import './control_panel.scss'; + /** * TODO: Handle dragging */ -const DragHandle = ({ isEditable, controlTitle }: { isEditable: boolean; controlTitle?: string }) => +const DragHandle = ({ + isEditable, + controlTitle, + hideEmptyDragHandle, +}: { + isEditable: boolean; + controlTitle?: string; + hideEmptyDragHandle: boolean; +}) => isEditable ? ( - ) : null; + ) : hideEmptyDragHandle ? null : ( + + ); export const ControlPanel = ({ Component, @@ -115,63 +125,49 @@ export const ControlPanel = - {blockingError ? ( - - - - ) : ( - + + {api?.CustomPrependComponent ? ( - ) : usingTwoLineLayout ? ( - - ) : ( - <> - {' '} - - {panelTitle || defaultPanelTitle} - - - ) - } - > + ) : usingTwoLineLayout ? null : ( + + {panelTitle || defaultPanelTitle} + + )} + + } + > + <> + {blockingError && ( + + )} { if (newApi && !api) setApi(newApi); }} /> - - )} + + diff --git a/examples/controls_example/public/react_controls/control_renderer.tsx b/examples/controls_example/public/react_controls/control_renderer.tsx index 1629673e64f7..feea0269ee88 100644 --- a/examples/controls_example/public/react_controls/control_renderer.tsx +++ b/examples/controls_example/public/react_controls/control_renderer.tsx @@ -10,7 +10,6 @@ import React, { useImperativeHandle, useMemo } from 'react'; import { BehaviorSubject } from 'rxjs'; import { v4 as generateId } from 'uuid'; -import { SerializedStyles } from '@emotion/react'; import { StateComparators } from '@kbn/presentation-publishing'; import { getControlFactory } from './control_factory_registry'; @@ -68,7 +67,7 @@ export const ControlRenderer = < parentApi ); - return React.forwardRef((props, ref) => { + return React.forwardRef((props, ref) => { // expose the api into the imperative handle useImperativeHandle(ref, () => api, []); return ; diff --git a/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx b/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx index 9f0db921b077..c35462b66ecb 100644 --- a/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx +++ b/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx @@ -30,7 +30,15 @@ import { EuiTitle, EuiToolTip, } from '@elastic/eui'; +import { + ControlWidth, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_WIDTH, +} from '@kbn/controls-plugin/common'; +import { CONTROL_WIDTH_OPTIONS } from '@kbn/controls-plugin/public'; +import { DataControlFieldRegistry } from '@kbn/controls-plugin/public/types'; import { DataViewField } from '@kbn/data-views-plugin/common'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { LazyDataViewPicker, @@ -38,13 +46,6 @@ import { withSuspense, } from '@kbn/presentation-util-plugin/public'; -import { - ControlWidth, - DEFAULT_CONTROL_GROW, - DEFAULT_CONTROL_WIDTH, -} from '@kbn/controls-plugin/common'; -import { CONTROL_WIDTH_OPTIONS } from '@kbn/controls-plugin/public'; -import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { getAllControlTypes, getControlFactory } from '../control_factory_registry'; import { ControlGroupApi } from '../control_group/types'; import { ControlStateManager } from '../types'; @@ -69,6 +70,65 @@ export interface ControlEditorProps< const FieldPicker = withSuspense(LazyFieldPicker, null); const DataViewPicker = withSuspense(LazyDataViewPicker, null); +const CompatibleControlTypesComponent = ({ + fieldRegistry, + selectedFieldName, + selectedControlType, + setSelectedControlType, +}: { + fieldRegistry?: DataControlFieldRegistry; + selectedFieldName: string; + selectedControlType?: string; + setSelectedControlType: (type: string) => void; +}) => { + const dataControlFactories = useMemo(() => { + return getAllControlTypes() + .map((type) => getControlFactory(type)) + .filter((factory) => { + return isDataControlFactory(factory); + }); + }, []); + + return ( + + {dataControlFactories.map((factory) => { + const disabled = + fieldRegistry && selectedFieldName + ? !fieldRegistry[selectedFieldName]?.compatibleControlTypes.includes(factory.type) + : true; + const keyPadMenuItem = ( + setSelectedControlType(factory.type)} + label={factory.getDisplayName()} + > + + + ); + + return disabled ? ( + + {keyPadMenuItem} + + ) : ( + keyPadMenuItem + ); + })} + + ); +}; + export const DataControlEditor = ({ controlId, controlType, @@ -139,57 +199,6 @@ export const DataControlEditor = ({ ); }, [selectedFieldName, setControlEditorValid, selectedDataView, selectedControlType]); - const dataControlFactories = useMemo(() => { - return getAllControlTypes() - .map((type) => getControlFactory(type)) - .filter((factory) => { - return isDataControlFactory(factory); - }); - }, []); - - const CompatibleControlTypesComponent = useMemo(() => { - return ( - - {dataControlFactories.map((factory) => { - const disabled = - fieldRegistry && selectedFieldName - ? !fieldRegistry[selectedFieldName]?.compatibleControlTypes.includes(factory.type) - : true; - const keyPadMenuItem = ( - setSelectedControlType(factory.type)} - label={factory.getDisplayName()} - > - - - ); - - return disabled ? ( - - {keyPadMenuItem} - - ) : ( - keyPadMenuItem - ); - })} - - ); - }, [selectedFieldName, fieldRegistry, selectedControlType, controlType, dataControlFactories]); - const CustomSettingsComponent = useMemo(() => { if (!selectedControlType || !selectedFieldName || !fieldRegistry) return; @@ -254,6 +263,7 @@ export const DataControlEditor = ({ selectedDataViewId={selectedDataViewId} onChangeDataViewId={(newDataViewId) => { stateManager.dataViewId.next(newDataViewId); + setSelectedControlType(undefined); }} trigger={{ label: @@ -300,7 +310,15 @@ export const DataControlEditor = ({ - {CompatibleControlTypesComponent} + {/* wrapping in `div` so that focus gets passed properly to the form row */} +
+ +
string; + isInvalid: boolean; + isLoading: boolean; + max: number | undefined; + min: number | undefined; + onChange: (value: RangeValue | undefined) => void; + step: number | undefined; + value: RangeValue | undefined; + uuid: string; +} + +export const RangeSliderControl: FC = ({ + fieldFormatter, + isInvalid, + isLoading, + max, + min, + onChange, + step, + value, + uuid, + ...rest +}: Props) => { + const rangeSliderRef = useRef(null); + + const [displayedValue, setDisplayedValue] = useState(value ?? ['', '']); + const debouncedOnChange = useMemo( + () => + debounce((newRange: RangeValue) => { + onChange(newRange); + }, 750), + [onChange] + ); + + /** + * This will recalculate the displayed min/max of the range slider to allow for selections smaller + * than the `min` and larger than the `max` + */ + const [displayedMin, displayedMax] = useMemo((): [number, number] => { + if (min === undefined || max === undefined) return [-Infinity, Infinity]; + const selectedValue = value ?? ['', '']; + const [selectedMin, selectedMax] = [ + selectedValue[0] === '' ? min : parseFloat(selectedValue[0]), + selectedValue[1] === '' ? max : parseFloat(selectedValue[1]), + ]; + + if (!step) return [Math.min(selectedMin, min), Math.max(selectedMax, max ?? Infinity)]; + + const minTick = Math.floor(Math.min(selectedMin, min) / step) * step; + const maxTick = Math.ceil(Math.max(selectedMax, max) / step) * step; + + return [Math.min(selectedMin, min, minTick), Math.max(selectedMax, max ?? Infinity, maxTick)]; + }, [min, max, value, step]); + + /** + * The following `useEffect` ensures that the changes to the value that come from the embeddable (for example, + * from the `reset` button on the dashboard or via chaining) are reflected in the displayed value + */ + useEffect(() => { + setDisplayedValue(value ?? ['', '']); + }, [value]); + + const ticks: EuiRangeTick[] = useMemo(() => { + return [ + { + value: displayedMin ?? -Infinity, + label: fieldFormatter ? fieldFormatter(String(displayedMin)) : displayedMin, + }, + { + value: displayedMax ?? Infinity, + label: fieldFormatter ? fieldFormatter(String(displayedMax)) : displayedMax, + }, + ]; + }, [displayedMin, displayedMax, fieldFormatter]); + + const levels = useMemo(() => { + if (!step || min === undefined || max === undefined) { + return [ + { + min: min ?? -Infinity, + max: max ?? Infinity, + color: 'success', + }, + ]; + } + + const roundedMin = Math.floor(min / step) * step; + const roundedMax = Math.ceil(max / step) * step; + + return [ + { + min: roundedMin, + max: roundedMax, + color: 'success', + }, + ]; + }, [step, min, max]); + + const disablePopover = useMemo( + () => + isLoading || + displayedMin === -Infinity || + displayedMax === Infinity || + displayedMin === displayedMax, + [isLoading, displayedMin, displayedMax] + ); + + const getCommonInputProps = useCallback( + ({ + inputValue, + testSubj, + placeholder, + }: { + inputValue: string; + testSubj: string; + placeholder: string; + }) => { + return { + isInvalid: undefined, // disabling this prop to handle our own validation styling + placeholder, + readOnly: false, // overwrites `canOpenPopover` to ensure that the inputs are always clickable + className: `rangeSliderAnchor__fieldNumber ${ + isInvalid + ? 'rangeSliderAnchor__fieldNumber--invalid' + : 'rangeSliderAnchor__fieldNumber--valid' + }`, + 'data-test-subj': `rangeSlider__${testSubj}`, + value: inputValue === placeholder ? '' : inputValue, + title: !isInvalid && step ? '' : undefined, // overwrites native number input validation error when the value falls between two steps + }; + }, + [isInvalid, step] + ); + + const minInputProps = useMemo(() => { + return getCommonInputProps({ + inputValue: displayedValue[0], + testSubj: 'lowerBoundFieldNumber', + placeholder: String(min ?? -Infinity), + }); + }, [getCommonInputProps, min, displayedValue]); + + const maxInputProps = useMemo(() => { + return getCommonInputProps({ + inputValue: displayedValue[1], + testSubj: 'upperBoundFieldNumber', + placeholder: String(max ?? Infinity), + }); + }, [getCommonInputProps, max, displayedValue]); + + return ( + + + + + + + ) : undefined + } + onMouseUp={() => { + // when the pin is dropped (on mouse up), cancel any pending debounced changes and force the change + // in value to happen instantly (which, in turn, will re-calculate the min/max for the slider due to + // the `useEffect` above. + debouncedOnChange.cancel(); + onChange(displayedValue); + }} + readOnly={disablePopover} + showInput={'inputWithPopover'} + data-test-subj="rangeSlider__slider" + minInputProps={minInputProps} + maxInputProps={maxInputProps} + value={[displayedValue[0] || displayedMin, displayedValue[1] || displayedMax]} + onChange={([minSelection, maxSelection]: [number | string, number | string]) => { + setDisplayedValue([String(minSelection), String(maxSelection)]); + debouncedOnChange([String(minSelection), String(maxSelection)]); + }} + /> + + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx new file mode 100644 index 000000000000..98ce7619dda9 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { estypes } from '@elastic/elasticsearch'; +import { TimeRange } from '@kbn/es-query'; +import { BehaviorSubject, first, of, skip } from 'rxjs'; +import { render, waitFor } from '@testing-library/react'; +import { coreMock } from '@kbn/core/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { ControlGroupApi, DataControlFetchContext } from '../../control_group/types'; +import { getRangesliderControlFactory } from './get_range_slider_control_factory'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { ControlApiRegistration } from '../../types'; +import { RangesliderControlApi, RangesliderControlState } from './types'; +import { StateComparators } from '@kbn/presentation-publishing'; + +const DEFAULT_TOTAL_RESULTS = 20; +const DEFAULT_MIN = 0; +const DEFAULT_MAX = 1000; + +describe('RangesliderControlApi', () => { + const uuid = 'myControl1'; + const dashboardApi = { + timeRange$: new BehaviorSubject(undefined), + }; + const controlGroupApi = { + dataControlFetch$: new BehaviorSubject({}), + ignoreParentSettings$: new BehaviorSubject(undefined), + parentApi: dashboardApi, + } as unknown as ControlGroupApi; + const dataStartServiceMock = dataPluginMock.createStartContract(); + let totalResults = DEFAULT_TOTAL_RESULTS; + let min: estypes.AggregationsSingleMetricAggregateBase['value'] = DEFAULT_MIN; + let max: estypes.AggregationsSingleMetricAggregateBase['value'] = DEFAULT_MAX; + dataStartServiceMock.search.searchSource.create = jest.fn().mockImplementation(() => { + let isAggsRequest = false; + return { + setField: (key: string) => { + if (key === 'aggs') { + isAggsRequest = true; + } + }, + fetch$: () => { + return isAggsRequest + ? of({ + rawResponse: { aggregations: { minAgg: { value: min }, maxAgg: { value: max } } }, + }) + : of({ + rawResponse: { hits: { total: { value: totalResults } } }, + }); + }, + }; + }); + const mockDataViews = dataViewPluginMocks.createStartContract(); + // @ts-ignore + mockDataViews.get = async (id: string): Promise => { + if (id !== 'myDataViewId') { + throw new Error(`Simulated error: no data view found for id ${id}`); + } + return { + id, + getFieldByName: (fieldName: string) => { + return [ + { + displayName: 'My field name', + name: 'myFieldName', + type: 'string', + }, + ].find((field) => fieldName === field.name); + }, + getFormatterForField: () => { + return { + getConverterFor: () => { + return (value: string) => `${value} myUnits`; + }, + }; + }, + } as unknown as DataView; + }; + const factory = getRangesliderControlFactory({ + core: coreMock.createStart(), + data: dataStartServiceMock, + dataViews: mockDataViews, + }); + + beforeEach(() => { + totalResults = DEFAULT_TOTAL_RESULTS; + min = DEFAULT_MIN; + max = DEFAULT_MAX; + }); + + function buildApiMock( + api: ControlApiRegistration, + nextComparitors: StateComparators + ) { + return { + ...api, + uuid, + parentApi: controlGroupApi, + unsavedChanges: new BehaviorSubject | undefined>(undefined), + resetUnsavedChanges: () => {}, + type: factory.type, + }; + } + + describe('filters$', () => { + test('should not set filters$ when value is not provided', (done) => { + const { api } = factory.buildControl( + { + dataViewId: 'myDataView', + fieldName: 'myFieldName', + }, + buildApiMock, + uuid, + controlGroupApi + ); + api.filters$.pipe(skip(1), first()).subscribe((filter) => { + expect(filter).toBeUndefined(); + done(); + }); + }); + + test('should set filters$ when value is provided', (done) => { + const { api } = factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + value: ['5', '10'], + }, + buildApiMock, + uuid, + controlGroupApi + ); + api.filters$.pipe(skip(1), first()).subscribe((filter) => { + expect(filter).toEqual([ + { + meta: { + field: 'myFieldName', + index: 'myDataViewId', + key: 'myFieldName', + params: { + gte: 5, + lte: 10, + }, + type: 'range', + }, + query: { + range: { + myFieldName: { + gte: 5, + lte: 10, + }, + }, + }, + }, + ]); + done(); + }); + }); + }); + + describe('selected range has no results', () => { + test('should display invalid state', async () => { + totalResults = 0; // simulate no results by returning hits total of zero + min = null; // simulate no results by returning min aggregation value of null + max = null; // simulate no results by returning max aggregation value of null + const { Component } = factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + value: ['5', '10'], + }, + buildApiMock, + uuid, + controlGroupApi + ); + const { findByTestId } = render(); + await waitFor(async () => { + await findByTestId('range-slider-control-invalid-append-myControl1'); + }); + }); + }); + + describe('min max', () => { + test('bounds inputs should display min and max placeholders when there is no selected range', async () => { + const { Component } = factory.buildControl( + { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + }, + buildApiMock, + uuid, + controlGroupApi + ); + const { findByTestId } = render(); + await waitFor(async () => { + const minInput = await findByTestId('rangeSlider__lowerBoundFieldNumber'); + expect(minInput).toHaveAttribute('placeholder', String(DEFAULT_MIN)); + const maxInput = await findByTestId('rangeSlider__upperBoundFieldNumber'); + expect(maxInput).toHaveAttribute('placeholder', String(DEFAULT_MAX)); + }); + }); + }); +}); diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx new file mode 100644 index 000000000000..79670226c666 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -0,0 +1,264 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useMemo } from 'react'; +import deepEqual from 'react-fast-compare'; +import { BehaviorSubject, combineLatest, distinctUntilChanged, map, skip } from 'rxjs'; +import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import { + useBatchedPublishingSubjects, + useStateFromPublishingSubject, +} from '@kbn/presentation-publishing'; +import { buildRangeFilter, Filter, RangeFilterParams } from '@kbn/es-query'; +import { initializeDataControl } from '../initialize_data_control'; +import { DataControlFactory } from '../types'; +import { + RangesliderControlApi, + RangesliderControlState, + RangeValue, + RANGE_SLIDER_CONTROL_TYPE, + Services, +} from './types'; +import { RangeSliderStrings } from './range_slider_strings'; +import { RangeSliderControl } from './components/range_slider_control'; +import { minMax$ } from './min_max'; +import { hasNoResults$ } from './has_no_results'; + +export const getRangesliderControlFactory = ( + services: Services +): DataControlFactory => { + return { + type: RANGE_SLIDER_CONTROL_TYPE, + getIconType: () => 'controlsHorizontal', + getDisplayName: RangeSliderStrings.control.getDisplayName, + isFieldCompatible: (field) => { + return field.aggregatable && field.type === 'number'; + }, + CustomOptionsComponent: ({ stateManager, setControlEditorValid }) => { + const step = useStateFromPublishingSubject(stateManager.step); + + return ( + <> + + { + const newStep = event.target.valueAsNumber; + stateManager.step.next(newStep); + setControlEditorValid(newStep > 0); + }} + min={0} + isInvalid={step === undefined || step <= 0} + data-test-subj="rangeSliderControl__stepAdditionalSetting" + /> + + + ); + }, + buildControl: (initialState, buildApi, uuid, controlGroupApi) => { + const loadingMinMax$ = new BehaviorSubject(false); + const loadingHasNoResults$ = new BehaviorSubject(false); + const dataLoading$ = new BehaviorSubject(undefined); + const step$ = new BehaviorSubject(initialState.step); + const value$ = new BehaviorSubject(initialState.value); + function setValue(nextValue: RangeValue | undefined) { + value$.next(nextValue); + } + + const dataControl = initializeDataControl>( + uuid, + RANGE_SLIDER_CONTROL_TYPE, + initialState, + { + step: step$, + value: value$, + }, + controlGroupApi, + services + ); + + const api = buildApi( + { + ...dataControl.api, + dataLoading: dataLoading$, + getTypeDisplayName: RangeSliderStrings.control.getDisplayName, + serializeState: () => { + const { rawState: dataControlState, references } = dataControl.serialize(); + return { + rawState: { + ...dataControlState, + step: step$.getValue(), + value: value$.getValue(), + }, + references, // does not have any references other than those provided by the data control serializer + }; + }, + clearSelections: () => { + value$.next(undefined); + }, + }, + { + ...dataControl.comparators, + step: [step$, (nextStep: number | undefined) => step$.next(nextStep)], + value: [value$, setValue], + } + ); + + const dataLoadingSubscription = combineLatest([loadingMinMax$, loadingHasNoResults$]) + .pipe( + map((values) => { + return values.some((value) => { + return value; + }); + }) + ) + .subscribe((isLoading) => { + dataLoading$.next(isLoading); + }); + + // Clear state when the field changes + const fieldChangedSubscription = combineLatest([ + dataControl.stateManager.fieldName, + dataControl.stateManager.dataViewId, + ]) + .pipe( + distinctUntilChanged(deepEqual), + skip(1) // skip first filter output because it will have been applied in initialize + ) + .subscribe(() => { + step$.next(1); + value$.next(undefined); + }); + + const max$ = new BehaviorSubject(undefined); + const min$ = new BehaviorSubject(undefined); + const minMaxSubscription = minMax$({ + data: services.data, + dataControlFetch$: controlGroupApi.dataControlFetch$, + dataViews$: dataControl.api.dataViews, + fieldName$: dataControl.stateManager.fieldName, + setIsLoading: (isLoading: boolean) => { + // clear previous loading error on next loading start + if (isLoading && dataControl.api.blockingError.value) { + dataControl.api.setBlockingError(undefined); + } + loadingMinMax$.next(isLoading); + }, + }).subscribe( + ({ + error, + min, + max, + }: { + error?: Error; + min: number | undefined; + max: number | undefined; + }) => { + if (error) { + dataControl.api.setBlockingError(error); + } + max$.next(max); + min$.next(min); + } + ); + + const outputFilterSubscription = combineLatest([ + dataControl.api.dataViews, + dataControl.stateManager.fieldName, + value$, + ]).subscribe(([dataViews, fieldName, value]) => { + const dataView = dataViews?.[0]; + const dataViewField = + dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; + const gte = parseFloat(value?.[0] ?? ''); + const lte = parseFloat(value?.[1] ?? ''); + + let rangeFilter: Filter | undefined; + if (value && dataView && dataViewField && !isNaN(gte) && !isNaN(lte)) { + const params = { + gte, + lte, + } as RangeFilterParams; + + rangeFilter = buildRangeFilter(dataViewField, params, dataView); + rangeFilter.meta.key = fieldName; + rangeFilter.meta.type = 'range'; + rangeFilter.meta.params = params; + } + api.setOutputFilter(rangeFilter); + }); + + const selectionHasNoResults$ = new BehaviorSubject(false); + const hasNotResultsSubscription = hasNoResults$({ + data: services.data, + dataViews$: dataControl.api.dataViews, + filters$: dataControl.api.filters$, + ignoreParentSettings$: controlGroupApi.ignoreParentSettings$, + dataControlFetch$: controlGroupApi.dataControlFetch$, + setIsLoading: (isLoading: boolean) => { + loadingHasNoResults$.next(isLoading); + }, + }).subscribe((hasNoResults) => { + selectionHasNoResults$.next(hasNoResults); + }); + + return { + api, + Component: (controlPanelClassNames) => { + const [dataLoading, dataViews, fieldName, max, min, selectionHasNotResults, step, value] = + useBatchedPublishingSubjects( + dataLoading$, + dataControl.api.dataViews, + dataControl.stateManager.fieldName, + max$, + min$, + selectionHasNoResults$, + step$, + value$ + ); + + useEffect(() => { + return () => { + dataLoadingSubscription.unsubscribe(); + fieldChangedSubscription.unsubscribe(); + hasNotResultsSubscription.unsubscribe(); + minMaxSubscription.unsubscribe(); + outputFilterSubscription.unsubscribe(); + }; + }, []); + + const fieldFormatter = useMemo(() => { + const dataView = dataViews?.[0]; + if (!dataView) { + return undefined; + } + const fieldSpec = dataView.getFieldByName(fieldName); + return fieldSpec + ? dataView.getFormatterForField(fieldSpec).getConverterFor('text') + : undefined; + }, [dataViews, fieldName]); + + return ( + + ); + }, + }; + }, + }; +}; diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/has_no_results.ts b/examples/controls_example/public/react_controls/data_controls/range_slider/has_no_results.ts new file mode 100644 index 000000000000..e3567cf3b013 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/has_no_results.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { estypes } from '@elastic/elasticsearch'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import { PublishesDataViews } from '@kbn/presentation-publishing'; +import { combineLatest, lastValueFrom, switchMap, tap } from 'rxjs'; +import { ControlGroupApi } from '../../control_group/types'; +import { DataControlApi } from '../types'; + +export function hasNoResults$({ + data, + dataControlFetch$, + dataViews$, + filters$, + ignoreParentSettings$, + setIsLoading, +}: { + data: DataPublicPluginStart; + dataControlFetch$: ControlGroupApi['dataControlFetch$']; + dataViews$?: PublishesDataViews['dataViews']; + filters$: DataControlApi['filters$']; + ignoreParentSettings$: ControlGroupApi['ignoreParentSettings$']; + setIsLoading: (isLoading: boolean) => void; +}) { + let prevRequestAbortController: AbortController | undefined; + return combineLatest([filters$, ignoreParentSettings$, dataControlFetch$]).pipe( + tap(() => { + if (prevRequestAbortController) { + prevRequestAbortController.abort(); + prevRequestAbortController = undefined; + } + }), + switchMap(async ([filters, ignoreParentSettings, dataControlFetchContext]) => { + const dataView = dataViews$?.value?.[0]; + const rangeFilter = filters?.[0]; + if (!dataView || !rangeFilter || ignoreParentSettings?.ignoreValidations) { + return false; + } + + try { + setIsLoading(true); + const abortController = new AbortController(); + prevRequestAbortController = abortController; + return await hasNoResults({ + abortSignal: abortController.signal, + data, + dataView, + rangeFilter, + ...dataControlFetchContext, + }); + } catch (error) { + // Ignore error, validation is not required for control to function properly + return false; + } + }), + tap(() => { + setIsLoading(false); + }) + ); +} + +async function hasNoResults({ + abortSignal, + data, + dataView, + unifiedSearchFilters, + query, + rangeFilter, + timeRange, +}: { + abortSignal: AbortSignal; + data: DataPublicPluginStart; + dataView: DataView; + unifiedSearchFilters?: Filter[]; + query?: Query | AggregateQuery; + rangeFilter: Filter; + timeRange?: TimeRange; +}): Promise { + const searchSource = await data.search.searchSource.create(); + searchSource.setField('size', 0); + searchSource.setField('index', dataView); + // Tracking total hits accurately has a performance cost + // Setting 'trackTotalHits' to 1 since we just want to know + // "has no results" or "has results" vs the actual count + searchSource.setField('trackTotalHits', 1); + + const allFilters = unifiedSearchFilters ? unifiedSearchFilters : []; + allFilters.push(rangeFilter); + if (timeRange) { + const timeFilter = data.query.timefilter.timefilter.createFilter(dataView, timeRange); + if (timeFilter) allFilters.push(timeFilter); + } + if (allFilters.length) { + searchSource.setField('filter', allFilters); + } + + if (query) { + searchSource.setField('query', query); + } + + const resp = await lastValueFrom( + searchSource.fetch$({ + abortSignal, + legacyHitsTotal: false, + }) + ); + const count = (resp?.rawResponse?.hits?.total as estypes.SearchTotalHits)?.value ?? 0; + return count === 0; +} diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/min_max.ts b/examples/controls_example/public/react_controls/data_controls/range_slider/min_max.ts new file mode 100644 index 000000000000..a35fb8667eaa --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/min_max.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { estypes } from '@elastic/elasticsearch'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; +import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import { PublishesDataViews, PublishingSubject } from '@kbn/presentation-publishing'; +import { combineLatest, lastValueFrom, switchMap, tap } from 'rxjs'; +import { ControlGroupApi } from '../../control_group/types'; + +export function minMax$({ + data, + dataControlFetch$, + dataViews$, + fieldName$, + setIsLoading, +}: { + data: DataPublicPluginStart; + dataControlFetch$: ControlGroupApi['dataControlFetch$']; + dataViews$: PublishesDataViews['dataViews']; + fieldName$: PublishingSubject; + setIsLoading: (isLoading: boolean) => void; +}) { + let prevRequestAbortController: AbortController | undefined; + return combineLatest([dataViews$, fieldName$, dataControlFetch$]).pipe( + tap(() => { + if (prevRequestAbortController) { + prevRequestAbortController.abort(); + prevRequestAbortController = undefined; + } + }), + switchMap(async ([dataViews, fieldName, dataControlFetchContext]) => { + const dataView = dataViews?.[0]; + const dataViewField = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; + if (!dataView || !dataViewField) { + return { max: undefined, min: undefined }; + } + + try { + setIsLoading(true); + const abortController = new AbortController(); + prevRequestAbortController = abortController; + return await getMinMax({ + abortSignal: abortController.signal, + data, + dataView, + field: dataViewField, + ...dataControlFetchContext, + }); + } catch (error) { + return { error, max: undefined, min: undefined }; + } + }), + tap(() => { + setIsLoading(false); + }) + ); +} + +export async function getMinMax({ + abortSignal, + data, + dataView, + field, + unifiedSearchFilters, + query, + timeRange, +}: { + abortSignal: AbortSignal; + data: DataPublicPluginStart; + dataView: DataView; + field: DataViewField; + unifiedSearchFilters?: Filter[]; + query?: Query | AggregateQuery; + timeRange?: TimeRange; +}): Promise<{ min: number | undefined; max: number | undefined }> { + const searchSource = await data.search.searchSource.create(); + searchSource.setField('size', 0); + searchSource.setField('index', dataView); + + const allFilters = unifiedSearchFilters ? unifiedSearchFilters : []; + if (timeRange) { + const timeFilter = data.query.timefilter.timefilter.createFilter(dataView, timeRange); + if (timeFilter) allFilters.push(timeFilter); + } + if (allFilters.length) { + searchSource.setField('filter', allFilters); + } + + if (query) { + searchSource.setField('query', query); + } + + const aggBody: any = {}; + if (field.scripted) { + aggBody.script = { + source: field.script, + lang: field.lang, + }; + } else { + aggBody.field = field.name; + } + + const aggs = { + maxAgg: { + max: aggBody, + }, + minAgg: { + min: aggBody, + }, + }; + searchSource.setField('aggs', aggs); + + const resp = await lastValueFrom(searchSource.fetch$({ abortSignal })); + return { + min: + (resp.rawResponse?.aggregations?.minAgg as estypes.AggregationsSingleMetricAggregateBase) + ?.value ?? undefined, + max: + (resp.rawResponse?.aggregations?.maxAgg as estypes.AggregationsSingleMetricAggregateBase) + ?.value ?? undefined, + }; +} diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/range_slider_strings.ts b/examples/controls_example/public/react_controls/data_controls/range_slider/range_slider_strings.ts new file mode 100644 index 000000000000..cdf64fee21fd --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/range_slider_strings.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const RangeSliderStrings = { + control: { + getDisplayName: () => + i18n.translate('controlsExamples.rangeSliderControl.displayName', { + defaultMessage: 'Range slider', + }), + getInvalidSelectionWarningLabel: () => + i18n.translate('controlsExamples.rangeSlider.control.invalidSelectionWarningLabel', { + defaultMessage: 'Selected range returns no results.', + }), + }, + editor: { + getStepTitle: () => + i18n.translate('controlsExamples.rangeSlider.editor.stepSizeTitle', { + defaultMessage: 'Step size', + }), + }, + popover: { + getNoAvailableDataHelpText: () => + i18n.translate('controlsExamples.rangeSlider.popover.noAvailableDataHelpText', { + defaultMessage: 'There is no data to display. Adjust the time range and filters.', + }), + }, +}; diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/types.ts b/examples/controls_example/public/react_controls/data_controls/range_slider/types.ts new file mode 100644 index 000000000000..2eb7f70d348e --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/types.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreStart } from '@kbn/core/public'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataControlApi, DefaultDataControlState } from '../types'; + +export const RANGE_SLIDER_CONTROL_TYPE = 'rangeSlider'; + +export type RangeValue = [string, string]; + +export interface RangesliderControlState extends DefaultDataControlState { + value?: RangeValue; + step?: number; +} + +export type RangesliderControlApi = DataControlApi; + +export interface Services { + core: CoreStart; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; +} diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx index 35f6d01f442d..dcb8161842ec 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx @@ -185,10 +185,10 @@ export const getSearchControlFactory = ({ return { api, /** - * The `conrolStyleProps` prop is necessary because it contains the props from the generic + * The `controlPanelClassNamess` prop is necessary because it contains the class names from the generic * ControlPanel that are necessary for styling */ - Component: (conrolStyleProps) => { + Component: (controlPanelClassNames) => { const currentSearch = useStateFromPublishingSubject(searchString); useEffect(() => { @@ -202,7 +202,7 @@ export const getSearchControlFactory = ({ return ( & // control titles cannot be hidden HasEditCapabilities & - CanClearSelections & PublishesDataViews & PublishesFilters & { setOutputFilter: (filter: Filter | undefined) => void; // a control should only ever output a **single** filter diff --git a/examples/controls_example/public/react_controls/timeslider_control/components/index.scss b/examples/controls_example/public/react_controls/timeslider_control/components/index.scss index 3c7478c097bf..9fb6510b1a93 100644 --- a/examples/controls_example/public/react_controls/timeslider_control/components/index.scss +++ b/examples/controls_example/public/react_controls/timeslider_control/components/index.scss @@ -1,23 +1,19 @@ - -.timeSlider__popoverOverride { - width: 100%; - max-inline-size: 100% !important; -} - .timeSlider-playToggle:enabled { background-color: $euiColorPrimary !important; } +.timeSlider-prependButton { + background-color: transparent !important; +} + .timeSlider__anchor { width: 100%; height: 100%; box-shadow: none; overflow: hidden; - @include euiFormControlSideBorderRadius($euiFormControlBorderRadius, $side: 'right', $internal: true); .euiText { - background-color: $euiFormBackgroundColor !important; - // background-color: transparent !important; TODO revert to this rule once control group provides background color + background-color: transparent !important; &:hover { text-decoration: underline; diff --git a/examples/controls_example/public/react_controls/timeslider_control/components/time_slider_prepend.tsx b/examples/controls_example/public/react_controls/timeslider_control/components/time_slider_prepend.tsx index 14927317f9e7..decc50bb8c38 100644 --- a/examples/controls_example/public/react_controls/timeslider_control/components/time_slider_prepend.tsx +++ b/examples/controls_example/public/react_controls/timeslider_control/components/time_slider_prepend.tsx @@ -6,13 +6,12 @@ * Side Public License, v 1. */ -import { EuiButtonIcon } from '@elastic/eui'; -import React, { FC, useCallback, useState } from 'react'; -import { Observable, Subscription } from 'rxjs'; -import { first } from 'rxjs'; +import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; import { ViewMode } from '@kbn/presentation-publishing'; -import { TimeSliderStrings } from './time_slider_strings'; +import React, { FC, useCallback, useState } from 'react'; +import { first, Observable, Subscription } from 'rxjs'; import { PlayButton } from './play_button'; +import { TimeSliderStrings } from './time_slider_strings'; interface Props { onNext: () => void; @@ -66,35 +65,43 @@ export const TimeSliderPrepend: FC = (props: Props) => { }, [props, subscription, timeoutId]); return ( -
- { - onPause(); - props.onPrevious(); - }} - iconType="framePrevious" - color="text" - aria-label={TimeSliderStrings.control.getPreviousButtonAriaLabel()} - data-test-subj="timeSlider-previousTimeWindow" - /> - - { - onPause(); - props.onNext(); - }} - iconType="frameNext" - color="text" - aria-label={TimeSliderStrings.control.getNextButtonAriaLabel()} - data-test-subj="timeSlider-nextTimeWindow" - /> -
+ <> + + { + onPause(); + props.onPrevious(); + }} + iconType="framePrevious" + color="text" + className={'timeSlider-prependButton'} + aria-label={TimeSliderStrings.control.getPreviousButtonAriaLabel()} + data-test-subj="timeSlider-previousTimeWindow" + /> + + + + + + { + onPause(); + props.onNext(); + }} + iconType="frameNext" + color="text" + className={'timeSlider-prependButton'} + aria-label={TimeSliderStrings.control.getNextButtonAriaLabel()} + data-test-subj="timeSlider-nextTimeWindow" + /> + + ); }; diff --git a/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx b/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx index c264eaf175ff..b0c7a8e2164f 100644 --- a/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx @@ -53,6 +53,14 @@ export const getTimesliderControlFactory = ( const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } = initTimeRangeSubscription(controlGroupApi, services); const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined); + const isAnchored$ = new BehaviorSubject(initialState.isAnchored); + const isPopoverOpen$ = new BehaviorSubject(false); + + const timeRangePercentage = initTimeRangePercentage( + initialState, + syncTimesliceWithTimeRangePercentage + ); + function syncTimesliceWithTimeRangePercentage( startPercentage: number | undefined, endPercentage: number | undefined @@ -73,18 +81,16 @@ export const getTimesliderControlFactory = ( ]); setSelectedRange(to - from); } - const timeRangePercentage = initTimeRangePercentage( - initialState, - syncTimesliceWithTimeRangePercentage - ); + function setTimeslice(timeslice?: Timeslice) { timeRangePercentage.setTimeRangePercentage(timeslice, timeRangeMeta$.value); timeslice$.next(timeslice); } - const isAnchored$ = new BehaviorSubject(initialState.isAnchored); + function setIsAnchored(isAnchored: boolean | undefined) { isAnchored$.next(isAnchored); } + let selectedRange: number | undefined; function setSelectedRange(nextSelectedRange?: number) { selectedRange = @@ -176,10 +182,6 @@ export const getTimesliderControlFactory = ( setTimeslice([from, Math.min(to, timeRangeMax)]); } - const isPopoverOpen$ = new BehaviorSubject(false); - function setIsPopoverOpen(value: boolean) { - isPopoverOpen$.next(value); - } const viewModeSubject = getViewModeSubject(controlGroupApi) ?? new BehaviorSubject('view' as ViewMode); @@ -217,6 +219,9 @@ export const getTimesliderControlFactory = ( references: [], }; }, + clearSelections: () => { + setTimeslice(undefined); + }, CustomPrependComponent: () => { const [autoApplySelections, viewMode] = useBatchedPublishingSubjects( controlGroupApi.autoApplySelections$, @@ -229,7 +234,7 @@ export const getTimesliderControlFactory = ( onPrevious={onPrevious} viewMode={viewMode} disablePlayButton={!autoApplySelections} - setIsPopoverOpen={setIsPopoverOpen} + setIsPopoverOpen={(value) => isPopoverOpen$.next(value)} waitForControlOutputConsumersToLoad$={waitForDashboardPanelsToLoad$} /> ); @@ -253,7 +258,7 @@ export const getTimesliderControlFactory = ( return { api, - Component: (controlStyleProps) => { + Component: (controlPanelClassNames) => { const [isAnchored, isPopoverOpen, timeRangeMeta, timeslice] = useBatchedPublishingSubjects(isAnchored$, isPopoverOpen$, timeRangeMeta$, timeslice$); @@ -273,13 +278,12 @@ export const getTimesliderControlFactory = ( return ( { - setIsPopoverOpen(!isPopoverOpen); + isPopoverOpen$.next(!isPopoverOpen); }} formatDate={formatDate} from={from} @@ -287,7 +291,7 @@ export const getTimesliderControlFactory = ( /> } isOpen={isPopoverOpen} - closePopover={() => setIsPopoverOpen(false)} + closePopover={() => isPopoverOpen$.next(false)} panelPaddingSize="s" > & + CanClearSelections & HasType & HasUniqueId & HasSerializableState & HasParentApi & { + /** TODO: Make these non-public as part of https://github.com/elastic/kibana/issues/174961 */ setDataLoading: (loading: boolean) => void; setBlockingError: (error: Error | undefined) => void; }; @@ -90,7 +91,7 @@ export type ControlStateManager = { export interface ControlPanelProps< ApiType extends DefaultControlApi = DefaultControlApi, - PropsType extends {} = { css: SerializedStyles } + PropsType extends {} = { className: string } > { Component: PanelCompatibleComponent; } diff --git a/examples/controls_example/tsconfig.json b/examples/controls_example/tsconfig.json index 5af09dd368b5..76ff8d8a75f0 100644 --- a/examples/controls_example/tsconfig.json +++ b/examples/controls_example/tsconfig.json @@ -31,7 +31,6 @@ "@kbn/react-kibana-mount", "@kbn/content-management-utils", "@kbn/presentation-util-plugin", - "@kbn/ui-theme", "@kbn/core-lifecycle-browser", "@kbn/presentation-panel-plugin", "@kbn/datemath", diff --git a/package.json b/package.json index dfbfcd6bec3a..e4e89ddceb85 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "@elastic/apm-rum": "^5.16.1", "@elastic/apm-rum-core": "^5.21.0", "@elastic/apm-rum-react": "^2.0.3", - "@elastic/charts": "66.0.4", + "@elastic/charts": "66.0.5", "@elastic/datemath": "5.0.3", "@elastic/ecs": "^8.11.1", "@elastic/elasticsearch": "^8.14.0", diff --git a/packages/core/config/core-config-server-internal/index.ts b/packages/core/config/core-config-server-internal/index.ts index 734888dba84e..a0bf42df31d6 100644 --- a/packages/core/config/core-config-server-internal/index.ts +++ b/packages/core/config/core-config-server-internal/index.ts @@ -7,4 +7,7 @@ */ export { coreDeprecationProvider } from './src/deprecation'; -export { ensureValidConfiguration } from './src/ensure_valid_configuration'; +export { + ensureValidConfiguration, + type EnsureValidConfigurationParameters, +} from './src/ensure_valid_configuration'; diff --git a/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.test.ts b/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.test.ts index c2ed9523c505..ddfed9489def 100644 --- a/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.test.ts +++ b/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.test.ts @@ -22,19 +22,19 @@ describe('ensureValidConfiguration', () => { }); it('returns normally when there is no unused keys and when the config validates', async () => { - await expect(ensureValidConfiguration(configService as any)).resolves.toBeUndefined(); + await expect(ensureValidConfiguration(configService)).resolves.toBeUndefined(); - expect(configService.validate).toHaveBeenCalledWith(undefined); + expect(configService.validate).toHaveBeenCalledWith({ logDeprecations: true }); }); it('forwards parameters to the `validate` method', async () => { await expect( - ensureValidConfiguration(configService as any, { logDeprecations: false }) + ensureValidConfiguration(configService, { logDeprecations: false }) ).resolves.toBeUndefined(); expect(configService.validate).toHaveBeenCalledWith({ logDeprecations: false }); await expect( - ensureValidConfiguration(configService as any, { logDeprecations: true }) + ensureValidConfiguration(configService, { logDeprecations: true }) ).resolves.toBeUndefined(); expect(configService.validate).toHaveBeenCalledWith({ logDeprecations: true }); }); @@ -44,7 +44,7 @@ describe('ensureValidConfiguration', () => { throw new Error('some message'); }); - await expect(ensureValidConfiguration(configService as any)).rejects.toMatchInlineSnapshot( + await expect(ensureValidConfiguration(configService)).rejects.toMatchInlineSnapshot( `[Error: some message]` ); }); @@ -57,7 +57,7 @@ describe('ensureValidConfiguration', () => { }); try { - await ensureValidConfiguration(configService as any); + await ensureValidConfiguration(configService); } catch (e) { expect(e).toBeInstanceOf(CriticalError); expect(e.processExitCode).toEqual(78); @@ -67,18 +67,26 @@ describe('ensureValidConfiguration', () => { it('throws when there are some unused keys', async () => { configService.getUnusedPaths.mockResolvedValue(['some.key', 'some.other.key']); - await expect(ensureValidConfiguration(configService as any)).rejects.toMatchInlineSnapshot( + await expect(ensureValidConfiguration(configService)).rejects.toMatchInlineSnapshot( `[Error: Unknown configuration key(s): "some.key", "some.other.key". Check for spelling errors and ensure that expected plugins are installed.]` ); }); + it('does not throw when there are some unused keys and skipUnusedConfigKeysCheck: true', async () => { + configService.getUnusedPaths.mockResolvedValue(['some.key', 'some.other.key']); + + await expect( + ensureValidConfiguration(configService, { logDeprecations: true, stripUnknownKeys: true }) + ).resolves.toBeUndefined(); + }); + it('throws a `CriticalError` with the correct processExitCode value', async () => { expect.assertions(2); configService.getUnusedPaths.mockResolvedValue(['some.key', 'some.other.key']); try { - await ensureValidConfiguration(configService as any); + await ensureValidConfiguration(configService); } catch (e) { expect(e).toBeInstanceOf(CriticalError); expect(e.processExitCode).toEqual(64); @@ -88,13 +96,13 @@ describe('ensureValidConfiguration', () => { it('does not throw when all unused keys are included in the ignored paths', async () => { configService.getUnusedPaths.mockResolvedValue(['dev.someDevKey', 'elastic.apm.enabled']); - await expect(ensureValidConfiguration(configService as any)).resolves.toBeUndefined(); + await expect(ensureValidConfiguration(configService)).resolves.toBeUndefined(); }); it('throws when only some keys are included in the ignored paths', async () => { configService.getUnusedPaths.mockResolvedValue(['dev.someDevKey', 'some.key']); - await expect(ensureValidConfiguration(configService as any)).rejects.toMatchInlineSnapshot( + await expect(ensureValidConfiguration(configService)).rejects.toMatchInlineSnapshot( `[Error: Unknown configuration key(s): "some.key". Check for spelling errors and ensure that expected plugins are installed.]` ); }); diff --git a/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts b/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts index ddf4573b8de2..2da6731b6d63 100644 --- a/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts +++ b/packages/core/config/core-config-server-internal/src/ensure_valid_configuration.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ConfigService, ConfigValidateParameters } from '@kbn/config'; +import type { IConfigService, ConfigValidateParameters } from '@kbn/config'; import { CriticalError } from '@kbn/core-base-server-internal'; const ignoredPaths = ['dev.', 'elastic.apm.']; @@ -14,12 +14,32 @@ const ignoredPaths = ['dev.', 'elastic.apm.']; const invalidConfigExitCode = 78; const legacyInvalidConfigExitCode = 64; +/** + * Parameters for the helper {@link ensureValidConfiguration} + * + * @private + */ +export interface EnsureValidConfigurationParameters extends ConfigValidateParameters { + /** + * Set to `true` to ignore any unknown keys and discard them from the final validated config object. + */ + stripUnknownKeys?: boolean; +} + +/** + * Validate the entire Kibana configuration object, including the detection of extra keys. + * @param configService The {@link IConfigService} instance that has the raw configuration preloaded. + * @param params {@link EnsureValidConfigurationParameters | Options} to enable/disable extra edge-cases. + * + * @private + */ export async function ensureValidConfiguration( - configService: ConfigService, - params?: ConfigValidateParameters + configService: IConfigService, + params: EnsureValidConfigurationParameters = { logDeprecations: true } ) { + const { stripUnknownKeys, ...validateParams } = params; try { - await configService.validate(params); + await configService.validate(validateParams); } catch (e) { throw new CriticalError(e.message, 'InvalidConfig', invalidConfigExitCode, e); } @@ -29,7 +49,7 @@ export async function ensureValidConfiguration( return !ignoredPaths.some((ignoredPath) => unusedPath.startsWith(ignoredPath)); }); - if (unusedConfigKeys.length > 0) { + if (unusedConfigKeys.length > 0 && !stripUnknownKeys) { const message = `Unknown configuration key(s): ${unusedConfigKeys .map((key) => `"${key}"`) .join(', ')}. Check for spelling errors and ensure that expected plugins are installed.`; diff --git a/packages/core/http/core-http-server-internal/index.ts b/packages/core/http/core-http-server-internal/index.ts index b9d4edd8a862..cf9fe65afdb1 100644 --- a/packages/core/http/core-http-server-internal/index.ts +++ b/packages/core/http/core-http-server-internal/index.ts @@ -27,4 +27,8 @@ export { type ExternalUrlConfigType, } from './src/external_url'; +export type { PermissionsPolicyConfigType } from './src/permissions_policy'; + +export { permissionsPolicyConfig } from './src/permissions_policy'; + export { createCookieSessionStorageFactory } from './src/cookie_session_storage'; diff --git a/packages/core/http/core-http-server-internal/src/http_config.test.ts b/packages/core/http/core-http-server-internal/src/http_config.test.ts index 97da37fe703b..45efca0b3376 100644 --- a/packages/core/http/core-http-server-internal/src/http_config.test.ts +++ b/packages/core/http/core-http-server-internal/src/http_config.test.ts @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid'; import { config, HttpConfig } from './http_config'; import { cspConfig } from './csp'; +import { permissionsPolicyConfig } from './permissions_policy'; import { ExternalUrlConfig } from './external_url'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost', '0.0.0.0']; @@ -654,7 +655,13 @@ describe('HttpConfig', () => { }, }); const rawCspConfig = cspConfig.schema.validate({}); - const httpConfig = new HttpConfig(rawConfig, rawCspConfig, ExternalUrlConfig.DEFAULT); + const rawPermissionsPolicyConfig = permissionsPolicyConfig.schema.validate({}); + const httpConfig = new HttpConfig( + rawConfig, + rawCspConfig, + ExternalUrlConfig.DEFAULT, + rawPermissionsPolicyConfig + ); expect(httpConfig.customResponseHeaders).toEqual({ string: 'string', @@ -668,7 +675,13 @@ describe('HttpConfig', () => { it('defaults restrictInternalApis to false', () => { const rawConfig = config.schema.validate({}, {}); const rawCspConfig = cspConfig.schema.validate({}); - const httpConfig = new HttpConfig(rawConfig, rawCspConfig, ExternalUrlConfig.DEFAULT); + const rawPermissionsPolicyConfig = permissionsPolicyConfig.schema.validate({}); + const httpConfig = new HttpConfig( + rawConfig, + rawCspConfig, + ExternalUrlConfig.DEFAULT, + rawPermissionsPolicyConfig + ); expect(httpConfig.restrictInternalApis).toBe(false); }); }); diff --git a/packages/core/http/core-http-server-internal/src/http_config.ts b/packages/core/http/core-http-server-internal/src/http_config.ts index 746420fad810..30fee577b9e1 100644 --- a/packages/core/http/core-http-server-internal/src/http_config.ts +++ b/packages/core/http/core-http-server-internal/src/http_config.ts @@ -23,6 +23,7 @@ import { securityResponseHeadersSchema, } from './security_response_headers_config'; import { CdnConfig } from './cdn_config'; +import { PermissionsPolicyConfigType } from './permissions_policy'; const SECOND = 1000; @@ -343,14 +344,16 @@ export class HttpConfig implements IHttpConfig { constructor( rawHttpConfig: HttpConfigType, rawCspConfig: CspConfigType, - rawExternalUrlConfig: ExternalUrlConfig + rawExternalUrlConfig: ExternalUrlConfig, + rawPermissionsPolicyConfig: PermissionsPolicyConfigType ) { this.autoListen = rawHttpConfig.autoListen; this.host = rawHttpConfig.host; this.port = rawHttpConfig.port; this.cors = rawHttpConfig.cors; const { securityResponseHeaders, disableEmbedding } = parseRawSecurityResponseHeadersConfig( - rawHttpConfig.securityResponseHeaders + rawHttpConfig.securityResponseHeaders, + rawPermissionsPolicyConfig ); this.securityResponseHeaders = securityResponseHeaders; this.customResponseHeaders = Object.entries(rawHttpConfig.customResponseHeaders ?? {}).reduce( diff --git a/packages/core/http/core-http-server-internal/src/http_service.test.ts b/packages/core/http/core-http-server-internal/src/http_service.test.ts index e5ef15594b83..9867e97e0dfd 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.test.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.test.ts @@ -23,6 +23,7 @@ import { HttpService } from './http_service'; import { HttpConfigType, config } from './http_config'; import { cspConfig } from './csp'; import { externalUrlConfig, ExternalUrlConfig } from './external_url'; +import { permissionsPolicyConfig } from './permissions_policy'; const logger = loggingSystemMock.create(); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); @@ -42,6 +43,7 @@ const createConfigService = (value: Partial = {}) => { configService.setSchema(config.path, config.schema); configService.setSchema(cspConfig.path, cspConfig.schema); configService.setSchema(externalUrlConfig.path, externalUrlConfig.schema); + configService.setSchema(permissionsPolicyConfig.path, permissionsPolicyConfig.schema); return configService; }; const contextPreboot = contextServiceMock.createPrebootContract(); 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 7e2168d397fe..05e5f48e5666 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 @@ -29,6 +29,7 @@ import type { import { Router, RouterOptions } from '@kbn/core-http-router-server-internal'; import { CspConfigType, cspConfig } from './csp'; +import { PermissionsPolicyConfigType, permissionsPolicyConfig } from './permissions_policy'; import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; @@ -76,7 +77,13 @@ export class HttpService configService.atPath(httpConfig.path, { ignoreUnchanged: false }), configService.atPath(cspConfig.path), configService.atPath(externalUrlConfig.path), - ]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); + configService.atPath(permissionsPolicyConfig.path), + ]).pipe( + map( + ([http, csp, externalUrl, permissionsPolicy]) => + new HttpConfig(http, csp, externalUrl, permissionsPolicy) + ) + ); const shutdownTimeout$ = this.config$.pipe(map(({ shutdownTimeout }) => shutdownTimeout)); this.prebootServer = new HttpServer(coreContext, 'Preboot', shutdownTimeout$); this.httpServer = new HttpServer(coreContext, 'Kibana', shutdownTimeout$); diff --git a/packages/core/http/core-http-server-internal/src/permissions_policy/config.ts b/packages/core/http/core-http-server-internal/src/permissions_policy/config.ts new file mode 100644 index 000000000000..f9bfe44807be --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/permissions_policy/config.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { TypeOf, schema } from '@kbn/config-schema'; +import type { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; + +const configSchema = schema.object({ + report_to: schema.arrayOf(schema.string(), { + defaultValue: [], + }), +}); + +/** + * @internal + */ +export type PermissionsPolicyConfigType = TypeOf; + +export const permissionsPolicyConfig: ServiceConfigDescriptor = { + path: 'permissionsPolicy', + schema: configSchema, +}; diff --git a/packages/core/http/core-http-server-internal/src/permissions_policy/index.ts b/packages/core/http/core-http-server-internal/src/permissions_policy/index.ts new file mode 100644 index 000000000000..a7e4a0057c88 --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/permissions_policy/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export { permissionsPolicyConfig } from './config'; +export type { PermissionsPolicyConfigType } from './config'; diff --git a/packages/core/http/core-http-server-internal/src/security_response_headers_config.test.ts b/packages/core/http/core-http-server-internal/src/security_response_headers_config.test.ts index d4a0e1e384ef..4eea3469688c 100644 --- a/packages/core/http/core-http-server-internal/src/security_response_headers_config.test.ts +++ b/packages/core/http/core-http-server-internal/src/security_response_headers_config.test.ts @@ -14,7 +14,7 @@ import { describe('parseRawSecurityResponseHeadersConfig', () => { it('returns default values', () => { const config = schema.validate({}); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.disableEmbedding).toBe(false); expect(result.securityResponseHeaders).toMatchInlineSnapshot(` Object { @@ -30,7 +30,7 @@ describe('parseRawSecurityResponseHeadersConfig', () => { it('a custom value results in the expected Strict-Transport-Security header', () => { const strictTransportSecurity = 'max-age=31536000; includeSubDomains'; const config = schema.validate({ strictTransportSecurity }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Strict-Transport-Security']).toEqual( strictTransportSecurity ); @@ -38,7 +38,7 @@ describe('parseRawSecurityResponseHeadersConfig', () => { it('a null value removes the Strict-Transport-Security header', () => { const config = schema.validate({ strictTransportSecurity: null }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Strict-Transport-Security']).toBeUndefined(); }); }); @@ -47,13 +47,13 @@ describe('parseRawSecurityResponseHeadersConfig', () => { it('a custom value results in the expected X-Content-Type-Options header', () => { const xContentTypeOptions = 'nosniff'; // there is no other valid value to test with const config = schema.validate({ xContentTypeOptions }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['X-Content-Type-Options']).toEqual(xContentTypeOptions); }); it('a null value removes the X-Content-Type-Options header', () => { const config = schema.validate({ xContentTypeOptions: null }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['X-Content-Type-Options']).toBeUndefined(); }); }); @@ -62,13 +62,13 @@ describe('parseRawSecurityResponseHeadersConfig', () => { it('a custom value results in the expected Referrer-Policy header', () => { const referrerPolicy = 'strict-origin-when-cross-origin'; const config = schema.validate({ referrerPolicy }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Referrer-Policy']).toEqual(referrerPolicy); }); it('a null value removes the Referrer-Policy header', () => { const config = schema.validate({ referrerPolicy: null }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Referrer-Policy']).toBeUndefined(); }); }); @@ -77,21 +77,45 @@ describe('parseRawSecurityResponseHeadersConfig', () => { it('a custom value results in the expected Permissions-Policy header', () => { const permissionsPolicy = 'display-capture=(self)'; const config = schema.validate({ permissionsPolicy }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Permissions-Policy']).toEqual(permissionsPolicy); }); it('a null value removes the Permissions-Policy header', () => { const config = schema.validate({ permissionsPolicy: null }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Permissions-Policy']).toBeUndefined(); }); + + it('includes report-to directive if it is provided', () => { + const config = schema.validate({ permissionsPolicy: 'display-capture=(self)' }); + const result = parse(config, { report_to: ['violations-endpoint'] }); + expect(result.securityResponseHeaders['Permissions-Policy']).toEqual( + 'display-capture=(self);report-to=violations-endpoint' + ); + }); + }); + + describe('permissionsPolicyReportOnly', () => { + it('a custom value results in the expected Permissions-Policy-Report-Only header', () => { + const config = schema.validate({ permissionsPolicyReportOnly: 'display-capture=(self)' }); + const result = parse(config, { report_to: ['violations-endpoint'] }); + expect(result.securityResponseHeaders['Permissions-Policy-Report-Only']).toEqual( + 'display-capture=(self);report-to=violations-endpoint' + ); + }); + + it('includes Permissions-Policy-Report-Only only if report-to directive is set', () => { + const config = schema.validate({ permissionsPolicy: 'display-capture=(self)' }); + const result = parse(config, { report_to: [] }); + expect(result.securityResponseHeaders['Permissions-Policy-Report-Only']).toBeUndefined(); + }); }); describe('disableEmbedding', () => { it('a true value results in the expected X-Frame-Options header and expected disableEmbedding result value', () => { const config = schema.validate({ disableEmbedding: true }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['X-Frame-Options']).toMatchInlineSnapshot( `"SAMEORIGIN"` ); @@ -103,7 +127,7 @@ describe('parseRawSecurityResponseHeadersConfig', () => { it('a custom value results in the expected Cross-Origin-Opener-Policy header', () => { const crossOriginOpenerPolicy = 'same-origin-allow-popups'; const config = schema.validate({ crossOriginOpenerPolicy }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Cross-Origin-Opener-Policy']).toEqual( crossOriginOpenerPolicy ); @@ -111,7 +135,7 @@ describe('parseRawSecurityResponseHeadersConfig', () => { it('a null value removes the Cross-Origin-Opener-Policy header', () => { const config = schema.validate({ crossOriginOpenerPolicy: null }); - const result = parse(config); + const result = parse(config, { report_to: [] }); expect(result.securityResponseHeaders['Cross-Origin-Opener-Policy']).toBeUndefined(); }); }); diff --git a/packages/core/http/core-http-server-internal/src/security_response_headers_config.ts b/packages/core/http/core-http-server-internal/src/security_response_headers_config.ts index 7eb6f6e31d57..b62c8aa1ee5d 100644 --- a/packages/core/http/core-http-server-internal/src/security_response_headers_config.ts +++ b/packages/core/http/core-http-server-internal/src/security_response_headers_config.ts @@ -7,6 +7,7 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import { PermissionsPolicyConfigType } from './permissions_policy'; export const securityResponseHeadersSchema = schema.object({ strictTransportSecurity: schema.oneOf([schema.string(), schema.literal(null)], { @@ -38,6 +39,7 @@ export const securityResponseHeadersSchema = schema.object({ defaultValue: 'camera=(), display-capture=(), fullscreen=(self), geolocation=(), microphone=(), web-share=()', }), + permissionsPolicyReportOnly: schema.maybe(schema.oneOf([schema.string(), schema.literal(null)])), disableEmbedding: schema.boolean({ defaultValue: false }), // is used to control X-Frame-Options and CSP headers crossOriginOpenerPolicy: schema.oneOf( // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy @@ -58,7 +60,8 @@ export const securityResponseHeadersSchema = schema.object({ * @internal */ export function parseRawSecurityResponseHeadersConfig( - raw: TypeOf + raw: TypeOf, + rawPermissionsPolicyConfig: PermissionsPolicyConfigType ) { const securityResponseHeaders: Record = {}; const { disableEmbedding } = raw; @@ -72,9 +75,21 @@ export function parseRawSecurityResponseHeadersConfig( if (raw.referrerPolicy) { securityResponseHeaders['Referrer-Policy'] = raw.referrerPolicy; } + + const reportTo = rawPermissionsPolicyConfig.report_to.length + ? `;report-to=${rawPermissionsPolicyConfig.report_to}` + : ''; + if (raw.permissionsPolicy) { - securityResponseHeaders['Permissions-Policy'] = raw.permissionsPolicy; + securityResponseHeaders['Permissions-Policy'] = `${raw.permissionsPolicy}${reportTo}`; } + + if (raw.permissionsPolicyReportOnly && reportTo) { + securityResponseHeaders[ + 'Permissions-Policy-Report-Only' + ] = `${raw.permissionsPolicyReportOnly}${reportTo}`; + } + if (raw.crossOriginOpenerPolicy) { securityResponseHeaders['Cross-Origin-Opener-Policy'] = raw.crossOriginOpenerPolicy; } diff --git a/packages/core/http/core-http-server-mocks/src/test_utils.ts b/packages/core/http/core-http-server-mocks/src/test_utils.ts index 552306cefbab..e82fc4117099 100644 --- a/packages/core/http/core-http-server-mocks/src/test_utils.ts +++ b/packages/core/http/core-http-server-mocks/src/test_utils.ts @@ -91,6 +91,11 @@ export const createConfigService = ({ ...csp, }); } + if (path === 'permissionsPolicy') { + return new BehaviorSubject({ + report_to: [], + }); + } throw new Error(`Unexpected config path: ${path}`); }); return configService; diff --git a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts index f1469fa57ced..70551c1e2750 100644 --- a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts +++ b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts @@ -284,6 +284,7 @@ export function createPluginSetupContext({ }, security: { registerSecurityDelegate: (api) => deps.security.registerSecurityDelegate(api), + fips: deps.security.fips, }, userProfile: { registerUserProfileDelegate: (delegate) => diff --git a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/__snapshots__/render_template.test.ts.snap b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/__snapshots__/render_template.test.ts.snap index f7e28eebd1a6..af1808d9a201 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/__snapshots__/render_template.test.ts.snap +++ b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/__snapshots__/render_template.test.ts.snap @@ -52,15 +52,38 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { // make subsequent calls to failure() noop failure = function () {}; - var err = document.createElement('h1'); - err.style['color'] = 'white'; - err.style['font-family'] = 'monospace'; - err.style['text-align'] = 'center'; - err.style['background'] = '#F44336'; - err.style['padding'] = '25px'; - err.innerText = document.querySelector('[data-error-message]').dataset.errorMessage; - - document.body.innerHTML = err.outerHTML; + var errorTitle = document.querySelector('[data-error-message-title]').dataset.errorMessageTitle; + var errorText = document.querySelector('[data-error-message-text]').dataset.errorMessageText; + var errorReload = document.querySelector('[data-error-message-reload]').dataset.errorMessageReload; + + var err = document.createElement('div'); + err.style.textAlign = 'center'; + err.style.padding = '120px 20px'; + err.style.fontFamily = 'Inter, BlinkMacSystemFont, Helvetica, Arial, sans-serif'; + + var errorTitleEl = document.createElement('h1'); + errorTitleEl.innerText = errorTitle; + errorTitleEl.style.margin = '20px'; + + var errorTextEl = document.createElement('p'); + errorTextEl.innerText = errorText; + errorTextEl.style.margin = '20px'; + + var errorReloadEl = document.createElement('button'); + errorReloadEl.innerText = errorReload; + errorReloadEl.onclick = function () { + location.reload(); + }; + errorReloadEl.setAttribute('style', + 'cursor: pointer; padding-inline: 12px; block-size: 40px; font-size: 1rem; line-height: 1.4286rem; border-radius: 6px; min-inline-size: 112px; color: rgb(255, 255, 255); background-color: rgb(0, 119, 204); outline-color: rgb(0, 0, 0); border:none' + ); + + err.appendChild(errorTitleEl); + err.appendChild(errorTextEl); + err.appendChild(errorReloadEl); + + document.body.innerHTML = ''; + document.body.appendChild(err); } var stylesheetTarget = document.querySelector('head meta[name=\\"add-styles-here\\"]') diff --git a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/render_template.ts b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/render_template.ts index 996aacd5e3ed..fbb7a4290bf1 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/render_template.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/render_template.ts @@ -68,15 +68,38 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { // make subsequent calls to failure() noop failure = function () {}; - var err = document.createElement('h1'); - err.style['color'] = 'white'; - err.style['font-family'] = 'monospace'; - err.style['text-align'] = 'center'; - err.style['background'] = '#F44336'; - err.style['padding'] = '25px'; - err.innerText = document.querySelector('[data-error-message]').dataset.errorMessage; - - document.body.innerHTML = err.outerHTML; + var errorTitle = document.querySelector('[data-error-message-title]').dataset.errorMessageTitle; + var errorText = document.querySelector('[data-error-message-text]').dataset.errorMessageText; + var errorReload = document.querySelector('[data-error-message-reload]').dataset.errorMessageReload; + + var err = document.createElement('div'); + err.style.textAlign = 'center'; + err.style.padding = '120px 20px'; + err.style.fontFamily = 'Inter, BlinkMacSystemFont, Helvetica, Arial, sans-serif'; + + var errorTitleEl = document.createElement('h1'); + errorTitleEl.innerText = errorTitle; + errorTitleEl.style.margin = '20px'; + + var errorTextEl = document.createElement('p'); + errorTextEl.innerText = errorText; + errorTextEl.style.margin = '20px'; + + var errorReloadEl = document.createElement('button'); + errorReloadEl.innerText = errorReload; + errorReloadEl.onclick = function () { + location.reload(); + }; + errorReloadEl.setAttribute('style', + 'cursor: pointer; padding-inline: 12px; block-size: 40px; font-size: 1rem; line-height: 1.4286rem; border-radius: 6px; min-inline-size: 112px; color: rgb(255, 255, 255); background-color: rgb(0, 119, 204); outline-color: rgb(0, 0, 0); border:none' + ); + + err.appendChild(errorTitleEl); + err.appendChild(errorTextEl); + err.appendChild(errorReloadEl); + + document.body.innerHTML = ''; + document.body.appendChild(err); } var stylesheetTarget = document.querySelector('head meta[name="add-styles-here"]') diff --git a/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx b/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx index 358cd267f653..f1612e4adbe5 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/views/template.tsx @@ -80,9 +80,15 @@ export const Template: FunctionComponent = ({ {logo}
{i18n('core.ui.welcomeMessage', { diff --git a/packages/core/root/core-root-server-internal/src/register_service_config.ts b/packages/core/root/core-root-server-internal/src/register_service_config.ts index be67627720b3..3398c8e7b506 100644 --- a/packages/core/root/core-root-server-internal/src/register_service_config.ts +++ b/packages/core/root/core-root-server-internal/src/register_service_config.ts @@ -14,7 +14,12 @@ import { coreDeprecationProvider } from '@kbn/core-config-server-internal'; import { nodeConfig } from '@kbn/core-node-server-internal'; import { pidConfig } from '@kbn/core-environment-server-internal'; import { executionContextConfig } from '@kbn/core-execution-context-server-internal'; -import { config as httpConfig, cspConfig, externalUrlConfig } from '@kbn/core-http-server-internal'; +import { + config as httpConfig, + cspConfig, + externalUrlConfig, + permissionsPolicyConfig, +} from '@kbn/core-http-server-internal'; import { config as elasticsearchConfig } from '@kbn/core-elasticsearch-server-internal'; import { config as coreAppConfig } from '@kbn/core-apps-server-internal'; import { opsConfig } from '@kbn/core-metrics-server-internal'; @@ -56,6 +61,7 @@ export function registerServiceConfig(configService: ConfigService) { serverlessConfig, statusConfig, uiSettingsConfig, + permissionsPolicyConfig, ]; configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); diff --git a/packages/core/root/core-root-server-internal/src/server.ts b/packages/core/root/core-root-server-internal/src/server.ts index 5613a70fce97..d380bffd8b35 100644 --- a/packages/core/root/core-root-server-internal/src/server.ts +++ b/packages/core/root/core-root-server-internal/src/server.ts @@ -256,7 +256,7 @@ export class Server { const environmentSetup = this.environment.setup(); // Configuration could have changed after preboot. - await ensureValidConfiguration(this.configService); + await this.ensureValidConfiguration(); const { uiPlugins, pluginPaths, pluginTree } = this.discoveredPlugins!.standard; const contextServiceSetup = this.context.setup({ @@ -486,6 +486,27 @@ export class Server { this.userProfile.stop(); } + private async ensureValidConfiguration() { + try { + await ensureValidConfiguration(this.configService); + } catch (validationError) { + if (this.env.packageInfo.buildFlavor !== 'serverless') { + throw validationError; + } + // When running on serverless, we may allow unknown keys, but stripping them from the final config object. + this.configService.setGlobalStripUnknownKeys(true); + await ensureValidConfiguration(this.configService, { + logDeprecations: true, + stripUnknownKeys: true, + }); + this.log + .get('config-validation') + .error( + `Strict config validation failed! Extra unknown keys removed in Serverless-compatible mode. Original error: ${validationError}` + ); + } + } + private registerCoreContext(coreSetup: InternalCoreSetup) { coreSetup.http.registerRouteHandlerContext( coreId, diff --git a/packages/core/security/core-security-browser-mocks/src/security_service.mock.ts b/packages/core/security/core-security-browser-mocks/src/security_service.mock.ts index 9fea0a680817..feda2ade4f9d 100644 --- a/packages/core/security/core-security-browser-mocks/src/security_service.mock.ts +++ b/packages/core/security/core-security-browser-mocks/src/security_service.mock.ts @@ -11,6 +11,7 @@ import type { InternalSecurityServiceSetup, InternalSecurityServiceStart, } from '@kbn/core-security-browser-internal'; +import { mockAuthenticatedUser, MockAuthenticatedUserProps } from '@kbn/core-security-common/mocks'; const createSetupMock = () => { const mock: jest.Mocked = { @@ -64,4 +65,6 @@ export const securityServiceMock = { createStart: createStartMock, createInternalSetup: createInternalSetupMock, createInternalStart: createInternalStartMock, + createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => + mockAuthenticatedUser(props), }; diff --git a/packages/core/security/core-security-browser-mocks/tsconfig.json b/packages/core/security/core-security-browser-mocks/tsconfig.json index 7e98cd5bed84..363e26375db2 100644 --- a/packages/core/security/core-security-browser-mocks/tsconfig.json +++ b/packages/core/security/core-security-browser-mocks/tsconfig.json @@ -18,5 +18,6 @@ "kbn_references": [ "@kbn/core-security-browser", "@kbn/core-security-browser-internal", + "@kbn/core-security-common", ] } diff --git a/packages/core/security/core-security-server-internal/src/fips/fips.test.ts b/packages/core/security/core-security-server-internal/src/fips/fips.test.ts new file mode 100644 index 000000000000..65f95aa7da69 --- /dev/null +++ b/packages/core/security/core-security-server-internal/src/fips/fips.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const mockGetFipsFn = jest.fn(); +jest.mock('crypto', () => ({ + randomBytes: jest.fn(), + constants: jest.requireActual('crypto').constants, + get getFips() { + return mockGetFipsFn; + }, +})); + +import { SecurityServiceConfigType } from '../utils'; +import { isFipsEnabled, checkFipsConfig } from './fips'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; + +describe('fips', () => { + let config: SecurityServiceConfigType; + describe('#isFipsEnabled', () => { + it('should return `true` if config.experimental.fipsMode.enabled is `true`', () => { + config = { experimental: { fipsMode: { enabled: true } } }; + + expect(isFipsEnabled(config)).toBe(true); + }); + + it('should return `false` if config.experimental.fipsMode.enabled is `false`', () => { + config = { experimental: { fipsMode: { enabled: false } } }; + + expect(isFipsEnabled(config)).toBe(false); + }); + + it('should return `false` if config.experimental.fipsMode.enabled is `undefined`', () => { + expect(isFipsEnabled(config)).toBe(false); + }); + }); + + describe('checkFipsConfig', () => { + let mockExit: jest.SpyInstance; + + beforeAll(() => { + mockExit = jest.spyOn(process, 'exit').mockImplementation((exitCode) => { + throw new Error(`Fake Exit: ${exitCode}`); + }); + }); + + afterAll(() => { + mockExit.mockRestore(); + }); + + it('should log an error message if FIPS mode is misconfigured - xpack.security.experimental.fipsMode.enabled true, Nodejs FIPS mode false', async () => { + config = { experimental: { fipsMode: { enabled: true } } }; + const logger = loggingSystemMock.create().get(); + try { + checkFipsConfig(config, logger); + } catch (e) { + expect(mockExit).toHaveBeenNthCalledWith(1, 78); + } + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "Configuration mismatch error. xpack.security.experimental.fipsMode.enabled is set to true and the configured Node.js environment has FIPS disabled", + ], + ] + `); + }); + + it('should log an error message if FIPS mode is misconfigured - xpack.security.experimental.fipsMode.enabled false, Nodejs FIPS mode true', async () => { + mockGetFipsFn.mockImplementationOnce(() => { + return 1; + }); + + config = { experimental: { fipsMode: { enabled: false } } }; + const logger = loggingSystemMock.create().get(); + + try { + checkFipsConfig(config, logger); + } catch (e) { + expect(mockExit).toHaveBeenNthCalledWith(1, 78); + } + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "Configuration mismatch error. xpack.security.experimental.fipsMode.enabled is set to false and the configured Node.js environment has FIPS enabled", + ], + ] + `); + }); + + it('should log an info message if FIPS mode is properly configured - xpack.security.experimental.fipsMode.enabled true, Nodejs FIPS mode true', async () => { + mockGetFipsFn.mockImplementationOnce(() => { + return 1; + }); + + config = { experimental: { fipsMode: { enabled: true } } }; + const logger = loggingSystemMock.create().get(); + + try { + checkFipsConfig(config, logger); + } catch (e) { + logger.error('Should not throw error!'); + } + + expect(loggingSystemMock.collect(logger).info).toMatchInlineSnapshot(` + Array [ + Array [ + "Kibana is running in FIPS mode.", + ], + ] + `); + }); + }); +}); diff --git a/packages/core/security/core-security-server-internal/src/fips/fips.ts b/packages/core/security/core-security-server-internal/src/fips/fips.ts new file mode 100644 index 000000000000..2b48fb68ff60 --- /dev/null +++ b/packages/core/security/core-security-server-internal/src/fips/fips.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Logger } from '@kbn/logging'; +import { getFips } from 'crypto'; +import { SecurityServiceConfigType } from '../utils'; + +export function isFipsEnabled(config: SecurityServiceConfigType): boolean { + return config?.experimental?.fipsMode?.enabled ?? false; +} + +export function checkFipsConfig(config: SecurityServiceConfigType, logger: Logger) { + const isFipsConfigEnabled = isFipsEnabled(config); + const isNodeRunningWithFipsEnabled = getFips() === 1; + + // Check if FIPS is enabled in either setting + if (isFipsConfigEnabled || isNodeRunningWithFipsEnabled) { + // FIPS must be enabled on both or log and error an exit Kibana + if (isFipsConfigEnabled !== isNodeRunningWithFipsEnabled) { + logger.error( + `Configuration mismatch error. xpack.security.experimental.fipsMode.enabled is set to ${isFipsConfigEnabled} and the configured Node.js environment has FIPS ${ + isNodeRunningWithFipsEnabled ? 'enabled' : 'disabled' + }` + ); + process.exit(78); + } else { + logger.info('Kibana is running in FIPS mode.'); + } + } +} diff --git a/packages/core/security/core-security-server-internal/src/security_service.test.ts b/packages/core/security/core-security-server-internal/src/security_service.test.ts index 4f5ae5e86cba..5fb6b46f6dc6 100644 --- a/packages/core/security/core-security-server-internal/src/security_service.test.ts +++ b/packages/core/security/core-security-server-internal/src/security_service.test.ts @@ -45,6 +45,16 @@ describe('SecurityService', () => { ); }); }); + + describe('#fips', () => { + describe('#isEnabled', () => { + it('should return boolean', () => { + const { fips } = service.setup(); + + expect(fips.isEnabled()).toBe(false); + }); + }); + }); }); describe('#start', () => { diff --git a/packages/core/security/core-security-server-internal/src/security_service.ts b/packages/core/security/core-security-server-internal/src/security_service.ts index 826019f773b9..215e7ef37628 100644 --- a/packages/core/security/core-security-server-internal/src/security_service.ts +++ b/packages/core/security/core-security-server-internal/src/security_service.ts @@ -9,23 +9,49 @@ import type { Logger } from '@kbn/logging'; import type { CoreContext, CoreService } from '@kbn/core-base-server-internal'; import type { CoreSecurityDelegateContract } from '@kbn/core-security-server'; +import { Observable, Subscription } from 'rxjs'; +import { Config } from '@kbn/config'; +import { isFipsEnabled, checkFipsConfig } from './fips/fips'; import type { InternalSecurityServiceSetup, InternalSecurityServiceStart, } from './internal_contracts'; -import { getDefaultSecurityImplementation, convertSecurityApi } from './utils'; +import { + getDefaultSecurityImplementation, + convertSecurityApi, + SecurityServiceConfigType, +} from './utils'; export class SecurityService implements CoreService { private readonly log: Logger; private securityApi?: CoreSecurityDelegateContract; + private config$: Observable; + private configSubscription?: Subscription; + private config: Config | undefined; + private readonly getConfig = () => { + if (!this.config) { + throw new Error('Config is not available.'); + } + return this.config; + }; constructor(coreContext: CoreContext) { this.log = coreContext.logger.get('security-service'); + + this.config$ = coreContext.configService.getConfig$(); + this.configSubscription = this.config$.subscribe((config) => { + this.config = config; + }); } public setup(): InternalSecurityServiceSetup { + const config = this.getConfig(); + const securityConfig: SecurityServiceConfigType = config.get(['xpack', 'security']); + + checkFipsConfig(securityConfig, this.log); + return { registerSecurityDelegate: (api) => { if (this.securityApi) { @@ -33,6 +59,9 @@ export class SecurityService } this.securityApi = api; }, + fips: { + isEnabled: () => isFipsEnabled(securityConfig), + }, }; } @@ -44,5 +73,10 @@ export class SecurityService return convertSecurityApi(apiContract); } - public stop() {} + public stop() { + if (this.configSubscription) { + this.configSubscription.unsubscribe(); + this.configSubscription = undefined; + } + } } diff --git a/packages/core/security/core-security-server-internal/src/utils/index.ts b/packages/core/security/core-security-server-internal/src/utils/index.ts index e43884f204ec..6ce85739b44f 100644 --- a/packages/core/security/core-security-server-internal/src/utils/index.ts +++ b/packages/core/security/core-security-server-internal/src/utils/index.ts @@ -8,3 +8,11 @@ export { convertSecurityApi } from './convert_security_api'; export { getDefaultSecurityImplementation } from './default_implementation'; + +export interface SecurityServiceConfigType { + experimental?: { + fipsMode?: { + enabled: boolean; + }; + }; +} diff --git a/packages/core/security/core-security-server-internal/tsconfig.json b/packages/core/security/core-security-server-internal/tsconfig.json index ad66b66deeee..e1812dc77cf4 100644 --- a/packages/core/security/core-security-server-internal/tsconfig.json +++ b/packages/core/security/core-security-server-internal/tsconfig.json @@ -20,5 +20,7 @@ "@kbn/core-http-server", "@kbn/logging-mocks", "@kbn/core-base-server-mocks", + "@kbn/config", + "@kbn/core-logging-server-mocks", ] } diff --git a/packages/core/security/core-security-server-mocks/src/security_service.mock.ts b/packages/core/security/core-security-server-mocks/src/security_service.mock.ts index 8c28db943149..86a39af3b16d 100644 --- a/packages/core/security/core-security-server-mocks/src/security_service.mock.ts +++ b/packages/core/security/core-security-server-mocks/src/security_service.mock.ts @@ -17,10 +17,12 @@ import type { } from '@kbn/core-security-server-internal'; import { apiKeysMock } from './api_keys.mock'; import { auditServiceMock, type MockedAuditService } from './audit.mock'; +import { mockAuthenticatedUser, MockAuthenticatedUserProps } from '@kbn/core-security-common/mocks'; const createSetupMock = () => { const mock: jest.Mocked = { registerSecurityDelegate: jest.fn(), + fips: { isEnabled: jest.fn() }, }; return mock; @@ -45,6 +47,7 @@ const createStartMock = (): SecurityStartMock => { const createInternalSetupMock = () => { const mock: jest.Mocked = { registerSecurityDelegate: jest.fn(), + fips: { isEnabled: jest.fn() }, }; return mock; @@ -107,4 +110,6 @@ export const securityServiceMock = { createInternalSetup: createInternalSetupMock, createInternalStart: createInternalStartMock, createRequestHandlerContext: createRequestHandlerContextMock, + createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => + mockAuthenticatedUser(props), }; diff --git a/packages/core/security/core-security-server-mocks/tsconfig.json b/packages/core/security/core-security-server-mocks/tsconfig.json index d0f6bbeb972d..9c170d484ca9 100644 --- a/packages/core/security/core-security-server-mocks/tsconfig.json +++ b/packages/core/security/core-security-server-mocks/tsconfig.json @@ -16,6 +16,7 @@ "kbn_references": [ "@kbn/core-security-server", "@kbn/core-security-server-internal", - "@kbn/core-http-server" + "@kbn/core-http-server", + "@kbn/core-security-common", ] } diff --git a/packages/core/security/core-security-server/index.ts b/packages/core/security/core-security-server/index.ts index 41d0c6ec99f9..b5dd091c7b87 100644 --- a/packages/core/security/core-security-server/index.ts +++ b/packages/core/security/core-security-server/index.ts @@ -48,3 +48,4 @@ export type { export type { KibanaPrivilegesType, ElasticsearchPrivilegesType } from './src/roles'; export { isCreateRestAPIKeyParams } from './src/authentication/api_keys'; +export type { CoreFipsService } from './src/fips'; diff --git a/packages/core/security/core-security-server/src/contracts.ts b/packages/core/security/core-security-server/src/contracts.ts index ed25737823f7..d2bf7d97e947 100644 --- a/packages/core/security/core-security-server/src/contracts.ts +++ b/packages/core/security/core-security-server/src/contracts.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { CoreFipsService } from './fips'; import type { CoreAuthenticationService } from './authc'; import type { CoreSecurityDelegateContract } from './api_provider'; import type { CoreAuditService } from './audit'; @@ -21,6 +22,11 @@ export interface SecurityServiceSetup { * @remark this should **exclusively** be used by the security plugin. */ registerSecurityDelegate(api: CoreSecurityDelegateContract): void; + + /** + * The {@link CoreFipsService | FIPS service} + */ + fips: CoreFipsService; } /** diff --git a/packages/core/security/core-security-server/src/fips.ts b/packages/core/security/core-security-server/src/fips.ts new file mode 100644 index 000000000000..239903caba3b --- /dev/null +++ b/packages/core/security/core-security-server/src/fips.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Core's FIPS service + * + * @public + */ +export interface CoreFipsService { + /** + * Check if Kibana is configured to run in FIPS mode + */ + isEnabled(): boolean; +} diff --git a/packages/kbn-alerting-types/circuit_breaker_message_header.ts b/packages/kbn-alerting-types/circuit_breaker_message_header.ts new file mode 100644 index 000000000000..c88a76ff9ef7 --- /dev/null +++ b/packages/kbn-alerting-types/circuit_breaker_message_header.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const errorMessageHeader = 'Error validating circuit breaker'; diff --git a/packages/kbn-alerting-types/index.ts b/packages/kbn-alerting-types/index.ts index 1b82df61427e..c54289d2ecc6 100644 --- a/packages/kbn-alerting-types/index.ts +++ b/packages/kbn-alerting-types/index.ts @@ -15,3 +15,4 @@ export * from './r_rule_types'; export * from './rule_types'; export * from './alerting_framework_health_types'; export * from './action_variable'; +export * from './circuit_breaker_message_header'; diff --git a/packages/kbn-alerting-types/rule_types.ts b/packages/kbn-alerting-types/rule_types.ts index c0cb500740cb..cc960592ae20 100644 --- a/packages/kbn-alerting-types/rule_types.ts +++ b/packages/kbn-alerting-types/rule_types.ts @@ -237,7 +237,7 @@ export interface Rule { revision: number; running?: boolean | null; viewInAppRelativeUrl?: string; - alertDelay?: AlertDelay; + alertDelay?: AlertDelay | null; } export type SanitizedRule = Omit< diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts index 261641d2c18a..9baae0267d22 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/transform_create_rule_body.ts @@ -11,7 +11,7 @@ import { CreateRuleBody } from './types'; export const transformCreateRuleBody: RewriteResponseCase = ({ ruleTypeId, - actions, + actions = [], alertDelay, ...res }): any => ({ diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts index dfea6fb77de3..e40059d5e686 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/create_rule/types.ts @@ -14,10 +14,10 @@ export interface CreateRuleBody enabled: Rule['enabled']; consumer: Rule['consumer']; tags: Rule['tags']; - throttle?: Rule['throttle']; params: Rule['params']; schedule: Rule['schedule']; actions: Rule['actions']; + throttle?: Rule['throttle']; notifyWhen?: Rule['notifyWhen']; alertDelay?: Rule['alertDelay']; } diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/fetch_ui_health_status/types.ts b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_ui_health_status/types.ts index bf0ee678d894..3fe902a11583 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/fetch_ui_health_status/types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_ui_health_status/types.ts @@ -13,3 +13,12 @@ export interface UiHealthCheck { export interface UiHealthCheckResponse { isAlertsAvailable: boolean; } + +export const healthCheckErrors = { + ALERTS_ERROR: 'alertsError', + ENCRYPTION_ERROR: 'encryptionError', + API_KEYS_DISABLED_ERROR: 'apiKeysDisabledError', + API_KEYS_AND_ENCRYPTION_ERROR: 'apiKeysAndEncryptionError', +} as const; + +export type HealthCheckErrors = typeof healthCheckErrors[keyof typeof healthCheckErrors]; diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/index.ts b/packages/kbn-alerts-ui-shared/src/common/apis/index.ts new file mode 100644 index 000000000000..8d7e06d6d6f4 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/apis/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './fetch_ui_health_status'; +export * from './fetch_alerting_framework_health'; +export * from './fetch_ui_config'; +export * from './create_rule'; +export * from './update_rule'; +export * from './resolve_rule'; diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts index f0493a1c164f..959cf5c7cdd3 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts @@ -10,7 +10,7 @@ import { RewriteResponseCase } from '@kbn/actions-types'; import { UpdateRuleBody } from './types'; export const transformUpdateRuleBody: RewriteResponseCase = ({ - actions, + actions = [], alertDelay, ...res }): any => ({ diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts index bca55de97dc2..dac91fa2076f 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/types.ts @@ -12,9 +12,9 @@ export interface UpdateRuleBody name: Rule['name']; tags: Rule['tags']; schedule: Rule['schedule']; - throttle?: Rule['throttle']; params: Rule['params']; actions: Rule['actions']; + throttle?: Rule['throttle']; notifyWhen?: Rule['notifyWhen']; alertDelay?: Rule['alertDelay']; } diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts index 64f9bf686ba2..28a0c39c998d 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.test.ts @@ -118,7 +118,7 @@ describe('updateRule', () => { Array [ "/api/alerting/rule/12%2F3", Object { - "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[{\\"group\\":\\"default\\",\\"id\\":\\"2\\",\\"params\\":{},\\"frequency\\":{\\"notify_when\\":\\"onActionGroupChange\\",\\"throttle\\":null,\\"summary\\":false},\\"use_alert_data_for_template\\":false},{\\"id\\":\\".test-system-action\\",\\"params\\":{}}],\\"alert_delay\\":{\\"active\\":10}}", + "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"alert_delay\\":{\\"active\\":10}}", }, ] `); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts index bae2fc52f518..7d9dbf71211e 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/update_rule.ts @@ -21,6 +21,14 @@ export const UPDATE_FIELDS: Array = [ 'schedule', 'params', 'alertDelay', +]; + +export const UPDATE_FIELDS_WITH_ACTIONS: Array = [ + 'name', + 'tags', + 'schedule', + 'params', + 'alertDelay', 'actions', ]; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts index 322d466b715a..dc59bf57610b 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts @@ -8,6 +8,7 @@ export * from './use_load_rule_types_query'; export * from './use_load_ui_config'; +export * from './use_health_check'; export * from './use_load_ui_health'; export * from './use_load_alerting_framework_health'; export * from './use_create_rule'; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_health_check.test.tsx b/packages/kbn-alerts-ui-shared/src/common/hooks/use_health_check.test.tsx new file mode 100644 index 000000000000..b46a44d928a7 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_health_check.test.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks/dom'; +import { waitFor } from '@testing-library/react'; +import type { HttpStart } from '@kbn/core-http-browser'; + +import { useHealthCheck } from './use_health_check'; +import { healthCheckErrors } from '../apis'; + +jest.mock('../apis/fetch_ui_health_status/fetch_ui_health_status', () => ({ + fetchUiHealthStatus: jest.fn(), +})); + +jest.mock('../apis/fetch_alerting_framework_health/fetch_alerting_framework_health', () => ({ + fetchAlertingFrameworkHealth: jest.fn(), +})); + +const { fetchUiHealthStatus } = jest.requireMock( + '../apis/fetch_ui_health_status/fetch_ui_health_status' +); +const { fetchAlertingFrameworkHealth } = jest.requireMock( + '../apis/fetch_alerting_framework_health/fetch_alerting_framework_health' +); + +const queryClient = new QueryClient(); + +const wrapper = ({ children }: { children: Node }) => ( + {children} +); + +const httpMock = jest.fn(); + +describe('useHealthCheck', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return null if there are no errors', async () => { + fetchUiHealthStatus.mockResolvedValueOnce({ + isRulesAvailable: true, + }); + + fetchAlertingFrameworkHealth.mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + }); + + const { result } = renderHook( + () => { + return useHealthCheck({ + http: httpMock as unknown as HttpStart, + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isLoading).toEqual(false); + }); + + expect(result.current.error).toBeNull(); + }); + + test('should return alerts error if rules are not available', async () => { + fetchUiHealthStatus.mockResolvedValueOnce({ + isRulesAvailable: false, + }); + + fetchAlertingFrameworkHealth.mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + }); + + const { result } = renderHook( + () => { + return useHealthCheck({ + http: httpMock as unknown as HttpStart, + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isLoading).toEqual(false); + }); + + expect(result.current.error).toEqual(healthCheckErrors.ALERTS_ERROR); + }); + + test('should return API keys encryption error if not secure or has no encryption key', async () => { + fetchUiHealthStatus.mockResolvedValueOnce({ + isRulesAvailable: true, + }); + + fetchAlertingFrameworkHealth.mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: false, + }); + + const { result } = renderHook( + () => { + return useHealthCheck({ + http: httpMock as unknown as HttpStart, + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isLoading).toEqual(false); + }); + + expect(result.current.error).toEqual(healthCheckErrors.API_KEYS_AND_ENCRYPTION_ERROR); + }); + + test('should return encryption error if has no encryption key', async () => { + fetchUiHealthStatus.mockResolvedValueOnce({ + isRulesAvailable: true, + }); + + fetchAlertingFrameworkHealth.mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: false, + }); + + const { result } = renderHook( + () => { + return useHealthCheck({ + http: httpMock as unknown as HttpStart, + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isLoading).toEqual(false); + }); + + expect(result.current.error).toEqual(healthCheckErrors.ENCRYPTION_ERROR); + }); + + test('should return API keys disabled error is API keys are disabled', async () => { + fetchUiHealthStatus.mockResolvedValueOnce({ + isRulesAvailable: true, + }); + + fetchAlertingFrameworkHealth.mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: true, + }); + + const { result } = renderHook( + () => { + return useHealthCheck({ + http: httpMock as unknown as HttpStart, + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isLoading).toEqual(false); + }); + + expect(result.current.error).toEqual(healthCheckErrors.API_KEYS_DISABLED_ERROR); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_health_check.tsx b/packages/kbn-alerts-ui-shared/src/common/hooks/use_health_check.tsx new file mode 100644 index 000000000000..fd3355b38f2e --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_health_check.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useMemo } from 'react'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { useLoadAlertingFrameworkHealth } from './use_load_alerting_framework_health'; +import { useLoadUiHealth } from './use_load_ui_health'; +import { healthCheckErrors, HealthCheckErrors } from '../apis'; + +export interface UseHealthCheckProps { + http: HttpStart; +} + +export interface UseHealthCheckResult { + isLoading: boolean; + healthCheckError: HealthCheckErrors | null; +} + +export interface HealthStatus { + isRulesAvailable: boolean; + isSufficientlySecure: boolean; + hasPermanentEncryptionKey: boolean; +} + +export const useHealthCheck = (props: UseHealthCheckProps) => { + const { http } = props; + + const { + data: uiHealth, + isLoading: isLoadingUiHealth, + isInitialLoading: isInitialLoadingUiHealth, + } = useLoadUiHealth({ http }); + + const { + data: alertingFrameworkHealth, + isLoading: isLoadingAlertingFrameworkHealth, + isInitialLoading: isInitialLoadingAlertingFrameworkHealth, + } = useLoadAlertingFrameworkHealth({ http }); + + const isLoading = useMemo(() => { + return isLoadingUiHealth || isLoadingAlertingFrameworkHealth; + }, [isLoadingUiHealth, isLoadingAlertingFrameworkHealth]); + + const isInitialLoading = useMemo(() => { + return isInitialLoadingUiHealth || isInitialLoadingAlertingFrameworkHealth; + }, [isInitialLoadingUiHealth, isInitialLoadingAlertingFrameworkHealth]); + + const alertingHealth: HealthStatus | null = useMemo(() => { + if (isLoading || isInitialLoading || !uiHealth || !alertingFrameworkHealth) { + return null; + } + if (!uiHealth.isRulesAvailable) { + return { + ...uiHealth, + isSufficientlySecure: false, + hasPermanentEncryptionKey: false, + }; + } + return { + ...uiHealth, + isSufficientlySecure: alertingFrameworkHealth.isSufficientlySecure, + hasPermanentEncryptionKey: alertingFrameworkHealth.hasPermanentEncryptionKey, + }; + }, [isLoading, isInitialLoading, uiHealth, alertingFrameworkHealth]); + + const error = useMemo(() => { + const { + isRulesAvailable, + isSufficientlySecure = false, + hasPermanentEncryptionKey = false, + } = alertingHealth || {}; + + if (isLoading || isInitialLoading || !alertingHealth) { + return null; + } + if (isSufficientlySecure && hasPermanentEncryptionKey) { + return null; + } + if (!isRulesAvailable) { + return healthCheckErrors.ALERTS_ERROR; + } + if (!isSufficientlySecure && !hasPermanentEncryptionKey) { + return healthCheckErrors.API_KEYS_AND_ENCRYPTION_ERROR; + } + if (!hasPermanentEncryptionKey) { + return healthCheckErrors.ENCRYPTION_ERROR; + } + return healthCheckErrors.API_KEYS_DISABLED_ERROR; + }, [isLoading, isInitialLoading, alertingHealth]); + + return { + isLoading, + isInitialLoading, + error, + }; +}; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts index c89837cee6f6..ef95fcb81b6e 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_types_query.ts @@ -108,7 +108,7 @@ export const useLoadRuleTypesQuery = ({ return { ruleTypesState: { - initialLoad: isLoading || isInitialLoading, + isInitialLoad: isInitialLoading, isLoading: isLoading || isFetching, data: filteredIndex, error, diff --git a/packages/kbn-alerts-ui-shared/src/common/types/action_types.ts b/packages/kbn-alerts-ui-shared/src/common/types/action_types.ts index 9fd39aebfc27..a377ca33b6fa 100644 --- a/packages/kbn-alerts-ui-shared/src/common/types/action_types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/types/action_types.ts @@ -11,7 +11,7 @@ import type { RuleActionParam, ActionVariable } from '@kbn/alerting-types'; import { IconType, RecursivePartial } from '@elastic/eui'; import { PublicMethodsOf } from '@kbn/utility-types'; import { TypeRegistry } from '../type_registry'; -import { RuleFormErrors } from '.'; +import { RuleFormParamsErrors } from './rule_types'; export interface GenericValidationResult { errors: Record, string[] | unknown>; @@ -77,7 +77,7 @@ export interface ActionParamsProps { actionParams: Partial; index: number; editAction: (key: string, value: RuleActionParam, index: number) => void; - errors: RuleFormErrors; + errors: RuleFormParamsErrors; ruleTypeId?: string; messageVariables?: ActionVariable[]; defaultMessage?: string; diff --git a/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts b/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts index 26c854d95b00..c68345cd96c6 100644 --- a/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts @@ -30,8 +30,18 @@ export type RuleTypeIndexWithDescriptions = Map export type RuleTypeParams = Record; -export interface RuleFormErrors { - [key: string]: string | string[] | RuleFormErrors; +export interface RuleFormBaseErrors { + name?: string[]; + interval?: string[]; + consumer?: string[]; + ruleTypeId?: string[]; + actionConnectors?: string[]; + alertDelay?: string[]; + tags?: string[]; +} + +export interface RuleFormParamsErrors { + [key: string]: string | string[] | RuleFormParamsErrors; } export interface MinimumScheduleInterval { @@ -81,7 +91,7 @@ export interface RuleTypeParamsExpressionProps< value: SanitizedRule[Prop] | null ) => void; onChangeMetaData: (metadata: MetaData) => void; - errors: RuleFormErrors; + errors: RuleFormParamsErrors; defaultActionGroupId: string; actionGroups: Array>; metadata?: MetaData; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts b/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts new file mode 100644 index 000000000000..fb3235e73df4 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + ES_QUERY_ID, + OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + ML_ANOMALY_DETECTION_RULE_TYPE_ID, + RuleCreationValidConsumer, + AlertConsumers, +} from '@kbn/rule-data-utils'; +import { RuleFormData } from './types'; + +export const DEFAULT_RULE_INTERVAL = '1m'; + +export const ALERTING_FEATURE_ID = 'alerts'; + +export const GET_DEFAULT_FORM_DATA = ({ + ruleTypeId, + name, + consumer, + schedule, +}: { + ruleTypeId: RuleFormData['ruleTypeId']; + name: RuleFormData['name']; + consumer: RuleFormData['consumer']; + schedule?: RuleFormData['schedule']; +}) => { + return { + tags: [], + params: {}, + schedule: schedule || { + interval: DEFAULT_RULE_INTERVAL, + }, + consumer, + ruleTypeId, + name, + }; +}; + +export const MULTI_CONSUMER_RULE_TYPE_IDS = [ + OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + ES_QUERY_ID, + ML_ANOMALY_DETECTION_RULE_TYPE_ID, +]; + +export const DEFAULT_VALID_CONSUMERS: RuleCreationValidConsumer[] = [ + AlertConsumers.LOGS, + AlertConsumers.INFRASTRUCTURE, + 'stackAlerts', + 'alerts', +]; + +export const createRuleRoute = '/rule/create/:ruleTypeId' as const; +export const editRuleRoute = '/rule/edit/:id' as const; 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 new file mode 100644 index 000000000000..e2725c1db54c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { EuiLoadingElastic } from '@elastic/eui'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import type { RuleFormData, RuleFormPlugins } from './types'; +import { ALERTING_FEATURE_ID, DEFAULT_VALID_CONSUMERS, GET_DEFAULT_FORM_DATA } from './constants'; +import { RuleFormStateProvider } from './rule_form_state'; +import { useCreateRule } from '../common/hooks'; +import { RulePage } from './rule_page'; +import { + RuleFormCircuitBreakerError, + RuleFormErrorPromptWrapper, + RuleFormHealthCheckError, + RuleFormRuleTypeError, +} from './rule_form_errors'; +import { useLoadDependencies } from './hooks/use_load_dependencies'; +import { + getInitialConsumer, + getInitialMultiConsumer, + getInitialSchedule, + parseRuleCircuitBreakerErrorMessage, +} from './utils'; +import { RULE_CREATE_SUCCESS_TEXT, RULE_CREATE_ERROR_TEXT } from './translations'; + +export interface CreateRuleFormProps { + ruleTypeId: string; + plugins: RuleFormPlugins; + consumer?: string; + multiConsumerSelection?: RuleCreationValidConsumer | null; + hideInterval?: boolean; + validConsumers?: RuleCreationValidConsumer[]; + filteredRuleTypes?: string[]; + shouldUseRuleProducer?: boolean; + returnUrl: string; +} + +export const CreateRuleForm = (props: CreateRuleFormProps) => { + const { + ruleTypeId, + plugins, + consumer = ALERTING_FEATURE_ID, + multiConsumerSelection, + validConsumers = DEFAULT_VALID_CONSUMERS, + filteredRuleTypes = [], + shouldUseRuleProducer = false, + returnUrl, + } = props; + + const { http, docLinks, notification, ruleTypeRegistry, i18n, theme } = plugins; + const { toasts } = notification; + + const { mutate, isLoading: isSaving } = useCreateRule({ + http, + onSuccess: ({ name }) => { + toasts.addSuccess(RULE_CREATE_SUCCESS_TEXT(name)); + }, + onError: (error) => { + const message = parseRuleCircuitBreakerErrorMessage( + error.body?.message || RULE_CREATE_ERROR_TEXT + ); + toasts.addDanger({ + title: message.summary, + ...(message.details && { + text: toMountPoint( + {message.details}, + { i18n, theme } + ), + }), + }); + }, + }); + + const { isInitialLoading, ruleType, ruleTypeModel, uiConfig, healthCheckError } = + useLoadDependencies({ + http, + toasts: notification.toasts, + ruleTypeRegistry, + ruleTypeId, + consumer, + validConsumers, + filteredRuleTypes, + }); + + const onSave = useCallback( + (newFormData: RuleFormData) => { + mutate({ + formData: { + name: newFormData.name, + ruleTypeId: newFormData.ruleTypeId!, + enabled: true, + consumer: newFormData.consumer, + tags: newFormData.tags, + params: newFormData.params, + schedule: newFormData.schedule, + // TODO: Will add actions in the actions PR + actions: [], + notifyWhen: newFormData.notifyWhen, + alertDelay: newFormData.alertDelay, + }, + }); + }, + [mutate] + ); + + if (isInitialLoading) { + return ( + + + + ); + } + + if (!ruleType || !ruleTypeModel) { + return ( + + + + ); + } + + if (healthCheckError) { + return ( + + + + ); + } + + return ( +
+ + + +
+ ); +}; 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 new file mode 100644 index 000000000000..c5ce99b21cc3 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { EuiLoadingElastic } from '@elastic/eui'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import type { RuleFormData, RuleFormPlugins } from './types'; +import { RuleFormStateProvider } from './rule_form_state'; +import { useUpdateRule } from '../common/hooks'; +import { RulePage } from './rule_page'; +import { RuleFormHealthCheckError } from './rule_form_errors/rule_form_health_check_error'; +import { useLoadDependencies } from './hooks/use_load_dependencies'; +import { + RuleFormCircuitBreakerError, + RuleFormErrorPromptWrapper, + RuleFormResolveRuleError, + RuleFormRuleTypeError, +} from './rule_form_errors'; +import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations'; +import { parseRuleCircuitBreakerErrorMessage } from './utils'; + +export interface EditRuleFormProps { + id: string; + plugins: RuleFormPlugins; + returnUrl: string; +} + +export const EditRuleForm = (props: EditRuleFormProps) => { + const { id, plugins, returnUrl } = props; + const { http, notification, docLinks, ruleTypeRegistry, i18n, theme } = plugins; + const { toasts } = notification; + + const { mutate, isLoading: isSaving } = useUpdateRule({ + http, + onSuccess: ({ name }) => { + toasts.addSuccess(RULE_EDIT_SUCCESS_TEXT(name)); + }, + onError: (error) => { + const message = parseRuleCircuitBreakerErrorMessage( + error.body?.message || RULE_EDIT_ERROR_TEXT + ); + toasts.addDanger({ + title: message.summary, + ...(message.details && { + text: toMountPoint( + {message.details}, + { i18n, theme } + ), + }), + }); + }, + }); + + const { isInitialLoading, ruleType, ruleTypeModel, uiConfig, healthCheckError, fetchedFormData } = + useLoadDependencies({ + http, + toasts: notification.toasts, + ruleTypeRegistry, + id, + }); + + const onSave = useCallback( + (newFormData: RuleFormData) => { + mutate({ + id, + formData: { + name: newFormData.name, + tags: newFormData.tags, + schedule: newFormData.schedule, + params: newFormData.params, + // TODO: Will add actions in the actions PR + actions: [], + notifyWhen: newFormData.notifyWhen, + alertDelay: newFormData.alertDelay, + }, + }); + }, + [id, mutate] + ); + + if (isInitialLoading) { + return ( + + + + ); + } + + if (!ruleType || !ruleTypeModel) { + return ( + + + + ); + } + + if (!fetchedFormData) { + return ( + + + + ); + } + + if (healthCheckError) { + return ( + + + + ); + } + + return ( +
+ + + +
+ ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/index.ts new file mode 100644 index 000000000000..49e2882aaede --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './use_rule_form_dispatch'; +export * from './use_rule_form_state'; 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 new file mode 100644 index 000000000000..abafebd94774 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx @@ -0,0 +1,317 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks/dom'; +import { waitFor } from '@testing-library/react'; +import type { HttpStart } from '@kbn/core-http-browser'; +import type { ToastsStart } from '@kbn/core-notifications-browser'; + +import { useLoadDependencies } from './use_load_dependencies'; +import { RuleTypeRegistryContract } from '../../common'; + +jest.mock('../../common/hooks/use_load_ui_config', () => ({ + useLoadUiConfig: jest.fn(), +})); + +jest.mock('../../common/hooks/use_health_check', () => ({ + useHealthCheck: jest.fn(), +})); + +jest.mock('../../common/hooks/use_resolve_rule', () => ({ + useResolveRule: jest.fn(), +})); + +jest.mock('../../common/hooks/use_load_rule_types_query', () => ({ + useLoadRuleTypesQuery: jest.fn(), +})); + +jest.mock('../utils/get_authorized_rule_types', () => ({ + getAvailableRuleTypes: 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'); +const { useLoadRuleTypesQuery } = jest.requireMock('../../common/hooks/use_load_rule_types_query'); +const { getAvailableRuleTypes } = jest.requireMock('../utils/get_authorized_rule_types'); + +const uiConfigMock = { + isUsingSecurity: true, + minimumScheduleInterval: { + value: '1m', + enforce: true, + }, +}; + +const ruleMock = { + params: {}, + consumer: 'stackAlerts', + schedule: { interval: '1m' }, + tags: [], + name: 'test', + enabled: true, + throttle: null, + ruleTypeId: '.index-threshold', + actions: [], + notifyWhen: 'onActionGroupChange', + alertDelay: { + active: 10, + }, +}; + +useLoadUiConfig.mockReturnValue({ + isLoading: false, + isInitialLoading: false, + data: uiConfigMock, +}); + +useHealthCheck.mockReturnValue({ + isLoading: false, + isInitialLoading: false, + error: null, +}); + +useResolveRule.mockReturnValue({ + isLoading: false, + isInitialLoading: false, + data: ruleMock, +}); + +const indexThresholdRuleType = { + enabledInLicense: true, + recoveryActionGroup: { + id: 'recovered', + name: 'Recovered', + }, + actionGroups: [], + defaultActionGroupId: 'threshold met', + minimumLicenseRequired: 'basic', + authorizedConsumers: { + stackAlerts: { + read: true, + all: true, + }, + }, + ruleTaskTimeout: '5m', + doesSetRecoveryContext: true, + hasAlertsMappings: true, + hasFieldsForAAD: false, + id: '.index-threshold', + name: 'Index threshold', + category: 'management', + producer: 'stackAlerts', + alerts: {}, + is_exportable: true, +}; + +const indexThresholdRuleTypeModel = { + id: '.index-threshold', + description: 'Alert when an aggregated query meets the threshold.', + iconClass: 'alert', + ruleParamsExpression: () =>
, + defaultActionMessage: + 'Rule {{rule.name}} is active for group {{context.group}}:\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}\n- Timestamp: {{context.date}}', + requiresAppContext: false, +}; + +const ruleTypeIndex = new Map(); + +ruleTypeIndex.set('.index-threshold', indexThresholdRuleType); + +useLoadRuleTypesQuery.mockReturnValue({ + ruleTypesState: { + isLoading: false, + isInitialLoading: false, + data: ruleTypeIndex, + }, +}); + +getAvailableRuleTypes.mockReturnValue([ + { + ruleType: indexThresholdRuleType, + ruleTypeModel: indexThresholdRuleTypeModel, + }, +]); + +const queryClient = new QueryClient(); + +const wrapper = ({ children }: { children: Node }) => ( + {children} +); + +const httpMock = jest.fn(); +const toastsMock = jest.fn(); + +const ruleTypeRegistryMock: RuleTypeRegistryContract = { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), +}; + +describe('useLoadDependencies', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('loads all rule form dependencies', async () => { + const { result } = renderHook( + () => { + return useLoadDependencies({ + http: httpMock as unknown as HttpStart, + toasts: toastsMock as unknown as ToastsStart, + ruleTypeRegistry: ruleTypeRegistryMock, + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isLoading).toEqual(false); + }); + + expect(result.current).toEqual({ + isLoading: false, + isInitialLoading: false, + ruleType: indexThresholdRuleType, + ruleTypeModel: indexThresholdRuleTypeModel, + uiConfig: uiConfigMock, + healthCheckError: null, + fetchedFormData: ruleMock, + }); + }); + + test('should call useLoadRuleTypesQuery with fitlered rule types', async () => { + const { result } = renderHook( + () => { + return useLoadDependencies({ + http: httpMock as unknown as HttpStart, + toasts: toastsMock as unknown as ToastsStart, + ruleTypeRegistry: ruleTypeRegistryMock, + filteredRuleTypes: ['test-rule-type'], + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(useLoadRuleTypesQuery).toBeCalledWith({ + http: httpMock, + toasts: toastsMock, + filteredRuleTypes: ['test-rule-type'], + }); + }); + + test('should call getAvailableRuleTypes with the correct params', async () => { + const { result } = renderHook( + () => { + return useLoadDependencies({ + http: httpMock as unknown as HttpStart, + toasts: toastsMock as unknown as ToastsStart, + ruleTypeRegistry: ruleTypeRegistryMock, + validConsumers: ['stackAlerts', 'logs'], + consumer: 'logs', + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(getAvailableRuleTypes).toBeCalledWith({ + consumer: 'logs', + ruleTypeRegistry: ruleTypeRegistryMock, + ruleTypes: [indexThresholdRuleType], + validConsumers: ['stackAlerts', 'logs'], + }); + }); + + test('should call resolve rule with the correct params', async () => { + const { result } = renderHook( + () => { + return useLoadDependencies({ + http: httpMock as unknown as HttpStart, + toasts: toastsMock as unknown as ToastsStart, + ruleTypeRegistry: ruleTypeRegistryMock, + id: 'test-rule-id', + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(useResolveRule).toBeCalledWith({ + http: httpMock, + id: 'test-rule-id', + }); + }); + + test('should use the ruleTypeId passed in if creating a rule', async () => { + useResolveRule.mockReturnValue({ + isLoading: false, + isInitialLoading: false, + data: null, + }); + + const { result } = renderHook( + () => { + return useLoadDependencies({ + http: httpMock as unknown as HttpStart, + toasts: toastsMock as unknown as ToastsStart, + ruleTypeRegistry: ruleTypeRegistryMock, + ruleTypeId: '.index-threshold', + consumer: 'stackAlerts', + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(result.current.ruleType).toEqual(indexThresholdRuleType); + }); + + test('should not use ruleTypeId if it is editing a rule', async () => { + useResolveRule.mockReturnValue({ + isLoading: false, + isInitialLoading: false, + data: null, + }); + + const { result } = renderHook( + () => { + return useLoadDependencies({ + http: httpMock as unknown as HttpStart, + toasts: toastsMock as unknown as ToastsStart, + ruleTypeRegistry: ruleTypeRegistryMock, + id: 'rule-id', + consumer: 'stackAlerts', + }); + }, + { wrapper } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(result.current.ruleType).toBeFalsy(); + }); +}); 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 new file mode 100644 index 000000000000..1432a4f2a4d1 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { HttpStart } from '@kbn/core-http-browser'; +import type { ToastsStart } from '@kbn/core-notifications-browser'; +import { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { useMemo } from 'react'; +import { + useHealthCheck, + useLoadRuleTypesQuery, + useLoadUiConfig, + useResolveRule, +} from '../../common/hooks'; +import { getAvailableRuleTypes } from '../utils'; +import { RuleTypeRegistryContract } from '../../common'; + +export interface UseLoadDependencies { + http: HttpStart; + toasts: ToastsStart; + ruleTypeRegistry: RuleTypeRegistryContract; + consumer?: string; + id?: string; + ruleTypeId?: string; + validConsumers?: RuleCreationValidConsumer[]; + filteredRuleTypes?: string[]; +} + +export const useLoadDependencies = (props: UseLoadDependencies) => { + const { + http, + toasts, + ruleTypeRegistry, + consumer, + validConsumers, + id, + ruleTypeId, + filteredRuleTypes = [], + } = props; + + const { + data: uiConfig, + isLoading: isLoadingUiConfig, + isInitialLoading: isInitialLoadingUiConfig, + } = useLoadUiConfig({ http }); + + const { + error: healthCheckError, + isLoading: isLoadingHealthCheck, + isInitialLoading: isInitialLoadingHealthCheck, + } = useHealthCheck({ http }); + + const { + data: fetchedFormData, + isLoading: isLoadingRule, + isInitialLoading: isInitialLoadingRule, + } = useResolveRule({ http, id }); + + const { + ruleTypesState: { + data: ruleTypeIndex, + isLoading: isLoadingRuleTypes, + isInitialLoad: isInitialLoadingRuleTypes, + }, + } = useLoadRuleTypesQuery({ + http, + toasts, + filteredRuleTypes, + }); + + const computedRuleTypeId = useMemo(() => { + return fetchedFormData?.ruleTypeId || ruleTypeId; + }, [fetchedFormData, ruleTypeId]); + + const authorizedRuleTypeItems = useMemo(() => { + const computedConsumer = consumer || fetchedFormData?.consumer; + if (!computedConsumer) { + return []; + } + return getAvailableRuleTypes({ + consumer: computedConsumer, + ruleTypes: [...ruleTypeIndex.values()], + ruleTypeRegistry, + validConsumers, + }); + }, [consumer, ruleTypeIndex, ruleTypeRegistry, validConsumers, fetchedFormData]); + + const [ruleType, ruleTypeModel] = useMemo(() => { + const item = authorizedRuleTypeItems.find(({ ruleType: rt }) => { + return rt.id === computedRuleTypeId; + }); + + return [item?.ruleType, item?.ruleTypeModel]; + }, [authorizedRuleTypeItems, computedRuleTypeId]); + + const isLoading = useMemo(() => { + if (id === undefined) { + return isLoadingUiConfig || isLoadingHealthCheck || isLoadingRuleTypes; + } + return isLoadingUiConfig || isLoadingHealthCheck || isLoadingRule || isLoadingRuleTypes; + }, [id, isLoadingUiConfig, isLoadingHealthCheck, isLoadingRule, isLoadingRuleTypes]); + + const isInitialLoading = useMemo(() => { + if (id === undefined) { + return isInitialLoadingUiConfig || isInitialLoadingHealthCheck || isInitialLoadingRuleTypes; + } + return ( + isInitialLoadingUiConfig || + isInitialLoadingHealthCheck || + isInitialLoadingRule || + isInitialLoadingRuleTypes + ); + }, [ + id, + isInitialLoadingUiConfig, + isInitialLoadingHealthCheck, + isInitialLoadingRule, + isInitialLoadingRuleTypes, + ]); + + return { + isLoading, + isInitialLoading: !!isInitialLoading, + ruleType, + ruleTypeModel, + uiConfig, + healthCheckError, + fetchedFormData, + }; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_rule_form_dispatch.ts b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_rule_form_dispatch.ts new file mode 100644 index 000000000000..be2bef9a63f6 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_rule_form_dispatch.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useContext } from 'react'; +import { RuleFormReducerContext } from '../rule_form_state/rule_form_state_context'; + +export const useRuleFormDispatch = () => { + return useContext(RuleFormReducerContext); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_rule_form_state.ts b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_rule_form_state.ts new file mode 100644 index 000000000000..d9770609a9ae --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_rule_form_state.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useContext } from 'react'; +import { RuleFormStateContext } from '../rule_form_state/rule_form_state_context'; + +export const useRuleFormState = () => { + return useContext(RuleFormStateContext); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/index.ts index 3751b1848d23..842efff5d4fe 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/index.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/index.ts @@ -9,5 +9,8 @@ export * from './rule_definition'; export * from './rule_actions'; export * from './rule_details'; +export * from './rule_page'; +export * from './rule_form'; export * from './utils'; export * from './types'; +export * from './constants'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx index 0613fa616de2..c7b228bcbec2 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx @@ -12,45 +12,56 @@ import { RuleAlertDelay } from './rule_alert_delay'; const mockOnChange = jest.fn(); +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); + +const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); + describe('RuleAlertDelay', () => { + beforeEach(() => { + useRuleFormState.mockReturnValue({ + formData: { + alertDelay: { + active: 5, + }, + }, + }); + useRuleFormDispatch.mockReturnValue(mockOnChange); + }); + afterEach(() => { jest.resetAllMocks(); }); test('Renders correctly', () => { - render( - - ); - + render(); expect(screen.getByTestId('alertDelay')).toBeInTheDocument(); }); test('Should handle input change', () => { - render( - - ); - + render(); fireEvent.change(screen.getByTestId('alertDelayInput'), { target: { value: '3', }, }); - expect(mockOnChange).toHaveBeenCalledWith('alertDelay', { active: 3 }); + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setAlertDelay', + payload: { active: 3 }, + }); }); test('Should only allow integers as inputs', async () => { - render(); + useRuleFormState.mockReturnValue({ + formData: { + alertDelay: null, + }, + }); + + render(); ['-', '+', 'e', 'E', '.', 'a', '01'].forEach((char) => { fireEvent.change(screen.getByTestId('alertDelayInput'), { @@ -63,36 +74,33 @@ describe('RuleAlertDelay', () => { }); test('Should call onChange with null if empty string is typed', () => { - render( - - ); - + render(); fireEvent.change(screen.getByTestId('alertDelayInput'), { target: { value: '', }, }); - expect(mockOnChange).toHaveBeenCalledWith('alertDelay', null); + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setAlertDelay', + payload: null, + }); }); test('Should display error when input is invalid', () => { - render( - - ); + }, + }, + baseErrors: { + alertDelay: 'Alert delay must be greater than 1.', + }, + }); + + render(); + expect(screen.getByTestId('alertDelayInput')).toBeInvalid(); expect(screen.getByText('Alert delay must be greater than 1.')).toBeInTheDocument(); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx index 7d2a226d073b..623fbfaaae5d 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx @@ -8,32 +8,43 @@ import React, { useCallback } from 'react'; import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; -import { ALERT_DELAY_TITLE_PREFIX, ALERT_DELAY_TITLE_SUFFIX } from '../translations'; -import { RuleFormErrors, Rule, RuleTypeParams } from '../../common'; +import { + ALERT_DELAY_TITLE_PREFIX, + ALERT_DELAY_TITLE_SUFFIX, + ALERT_DELAY_TITLE, +} from '../translations'; +import { useRuleFormState, useRuleFormDispatch } from '../hooks'; const INTEGER_REGEX = /^[1-9][0-9]*$/; const INVALID_KEYS = ['-', '+', '.', 'e', 'E']; -export interface RuleAlertDelayProps { - alertDelay?: Rule['alertDelay'] | null; - errors?: RuleFormErrors; - onChange: (property: string, value: unknown) => void; -} +export const RuleAlertDelay = () => { + const { formData, baseErrors } = useRuleFormState(); -export const RuleAlertDelay = (props: RuleAlertDelayProps) => { - const { alertDelay, errors = {}, onChange } = props; + const dispatch = useRuleFormDispatch(); + + const { alertDelay } = formData; const onAlertDelayChange = useCallback( (e: React.ChangeEvent) => { - const value = e.target.value.trim(); + if (!e.target.validity.valid) { + return; + } + const value = e.target.value; if (value === '') { - onChange('alertDelay', null); + dispatch({ + type: 'setAlertDelay', + payload: null, + }); } else if (INTEGER_REGEX.test(value)) { const parsedValue = parseInt(value, 10); - onChange('alertDelay', { active: parsedValue }); + dispatch({ + type: 'setAlertDelay', + payload: { active: parsedValue }, + }); } }, - [onChange] + [dispatch] ); const onKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -45,8 +56,9 @@ export const RuleAlertDelay = (props: RuleAlertDelayProps) => { return ( 0} - error={errors.alertDelay} + label={ALERT_DELAY_TITLE} + isInvalid={!!baseErrors?.alertDelay?.length} + error={baseErrors?.alertDelay} data-test-subj="alertDelay" display="rowCompressed" > @@ -57,7 +69,7 @@ export const RuleAlertDelay = (props: RuleAlertDelayProps) => { name="alertDelay" data-test-subj="alertDelayInput" prepend={[ALERT_DELAY_TITLE_PREFIX]} - isInvalid={errors.alertDelay?.length > 0} + isInvalid={!!baseErrors?.alertDelay?.length} append={ALERT_DELAY_TITLE_SUFFIX} onChange={onAlertDelayChange} onKeyDown={onKeyDown} diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx index 1a1a577d5d68..26bf94744f6b 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx @@ -14,78 +14,72 @@ import { RuleConsumerSelection } from './rule_consumer_selection'; const mockOnChange = jest.fn(); const mockConsumers: RuleCreationValidConsumer[] = ['logs', 'infrastructure', 'stackAlerts']; +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); + +const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); + describe('RuleConsumerSelection', () => { - test('Renders correctly', () => { - render( - - ); + beforeEach(() => { + useRuleFormState.mockReturnValue({ + multiConsumerSelection: 'stackAlerts', + }); + useRuleFormDispatch.mockReturnValue(mockOnChange); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + test('Renders correctly', () => { + render(); expect(screen.getByTestId('ruleConsumerSelection')).toBeInTheDocument(); }); test('Should default to the selected consumer', () => { - render( - - ); - + render(); expect(screen.getByTestId('comboBoxSearchInput')).toHaveValue('Stack Rules'); }); it('Should not display the initial selected consumer if it is not a selectable option', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + multiConsumerSelection: 'logs', + }); + render(); expect(screen.getByTestId('comboBoxSearchInput')).toHaveValue(''); }); it('should display nothing if there is only 1 consumer to select', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + multiConsumerSelection: null, + }); + render(); expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); }); it('should be able to select logs and call onChange', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + multiConsumerSelection: null, + }); + render(); fireEvent.click(screen.getByTestId('comboBoxToggleListButton')); fireEvent.click(screen.getByTestId('ruleConsumerSelectionOption-logs')); - expect(mockOnChange).toHaveBeenLastCalledWith('consumer', 'logs'); + expect(mockOnChange).toHaveBeenLastCalledWith({ + type: 'setMultiConsumer', + payload: 'logs', + }); }); it('should be able to show errors when there is one', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + multiConsumerSelection: null, + baseErrors: { consumer: ['Scope is required'] }, + }); + render(); expect(screen.queryAllByText('Scope is required')).toHaveLength(1); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx index e8bc0993734d..d22a296162a5 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx @@ -9,8 +9,13 @@ import React, { useMemo, useCallback } from 'react'; import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui'; import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils'; -import { FEATURE_NAME_MAP, CONSUMER_SELECT_COMBO_BOX_TITLE } from '../translations'; -import { RuleFormErrors } from '../../common'; +import { + CONSUMER_SELECT_TITLE, + FEATURE_NAME_MAP, + CONSUMER_SELECT_COMBO_BOX_TITLE, +} from '../translations'; +import { useRuleFormState, useRuleFormDispatch } from '../hooks'; +import { getValidatedMultiConsumer } from '../utils'; export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [ AlertConsumers.LOGS, @@ -20,10 +25,7 @@ export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [ ]; export interface RuleConsumerSelectionProps { - consumers: RuleCreationValidConsumer[]; - selectedConsumer?: RuleCreationValidConsumer | null; - errors?: RuleFormErrors; - onChange: (property: string, value: unknown) => void; + validConsumers: RuleCreationValidConsumer[]; } const SINGLE_SELECTION = { asPlainText: true }; @@ -31,26 +33,24 @@ const SINGLE_SELECTION = { asPlainText: true }; type ComboBoxOption = EuiComboBoxOptionOption; export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => { - const { consumers, selectedConsumer, errors = {}, onChange } = props; + const { validConsumers } = props; - const isInvalid = (errors.consumer?.length || 0) > 0; + const { multiConsumerSelection, baseErrors } = useRuleFormState(); + + const dispatch = useRuleFormDispatch(); const validatedSelectedConsumer = useMemo(() => { - if ( - selectedConsumer && - consumers.includes(selectedConsumer) && - FEATURE_NAME_MAP[selectedConsumer] - ) { - return selectedConsumer; - } - return null; - }, [selectedConsumer, consumers]); + return getValidatedMultiConsumer({ + multiConsumerSelection, + validConsumers, + }); + }, [multiConsumerSelection, validConsumers]); const selectedOptions = useMemo(() => { if (validatedSelectedConsumer) { return [ { - value: validatedSelectedConsumer, + value: validatedSelectedConsumer as RuleCreationValidConsumer, label: FEATURE_NAME_MAP[validatedSelectedConsumer], }, ]; @@ -59,7 +59,7 @@ export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => { }, [validatedSelectedConsumer]); const formattedSelectOptions = useMemo(() => { - return consumers + return validConsumers .reduce((result, consumer) => { if (FEATURE_NAME_MAP[consumer]) { result.push({ @@ -71,29 +71,36 @@ export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => { return result; }, []) .sort((a, b) => a.value!.localeCompare(b.value!)); - }, [consumers]); + }, [validConsumers]); const onConsumerChange = useCallback( (selected: ComboBoxOption[]) => { if (selected.length > 0) { const newSelectedConsumer = selected[0]; - onChange('consumer', newSelectedConsumer.value); + dispatch({ + type: 'setMultiConsumer', + payload: newSelectedConsumer.value!, + }); } else { - onChange('consumer', null); + dispatch({ + type: 'setMultiConsumer', + payload: 'alerts', + }); } }, - [onChange] + [dispatch] ); - if (consumers.length <= 1 || consumers.includes(AlertConsumers.OBSERVABILITY)) { + if (validConsumers.length <= 1 || validConsumers.includes(AlertConsumers.OBSERVABILITY)) { return null; } return ( ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); const ruleType = { id: '.es-query', @@ -40,6 +44,8 @@ const ruleType = { authorizedConsumers: { alerting: { read: true, all: true }, test: { read: true, all: true }, + stackAlerts: { read: true, all: true }, + logs: { read: true, all: true }, }, actionVariables: { params: [], @@ -60,7 +66,7 @@ const ruleModel: RuleTypeModel = { requiresAppContext: false, }; -const requiredPlugins = { +const plugins = { charts: {} as ChartsPluginSetup, data: {} as DataPublicPluginStart, dataViews: {} as DataViewsPublicPluginStart, @@ -68,152 +74,156 @@ const requiredPlugins = { docLinks: {} as DocLinksStart, }; +const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); + const mockOnChange = jest.fn(); describe('Rule Definition', () => { + beforeEach(() => { + useRuleFormDispatch.mockReturnValue(mockOnChange); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + test('Renders correctly', () => { - render( - - ); + 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.getByTestId('ruleDefinition')).toBeInTheDocument(); expect(screen.getByTestId('ruleSchedule')).toBeInTheDocument(); expect(screen.getByTestId('ruleConsumerSelection')).toBeInTheDocument(); expect(screen.getByTestId('ruleDefinitionHeaderDocsLink')).toBeInTheDocument(); + expect(screen.getByTestId('alertDelay')).not.toBeVisible(); - expect(screen.getByText(ALERT_DELAY_TITLE)).not.toBeVisible(); expect(screen.getByText('Expression')).toBeInTheDocument(); }); test('Hides doc link if not provided', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: { + ...ruleModel, + documentationUrl: null, + }, + }); + render(); expect(screen.queryByTestId('ruleDefinitionHeaderDocsLink')).not.toBeInTheDocument(); }); test('Hides consumer selection if canShowConsumerSelection is false', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + }); + + render(); expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); }); test('Can toggle advanced options', async () => { - render( - - ); + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + }); + + render(); fireEvent.click(screen.getByTestId('advancedOptionsAccordionButton')); - expect(screen.getByText(ALERT_DELAY_TITLE)).toBeVisible(); + expect(screen.getByTestId('alertDelay')).toBeVisible(); }); test('Calls onChange when inputs are modified', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + }); + + render(); fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), { target: { value: '10', }, }); - expect(mockOnChange).toHaveBeenCalledWith('interval', '10m'); + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setSchedule', + payload: { + interval: '10m', + }, + }); }); }); 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 08f74ba8fc46..104d698b343c 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 @@ -16,100 +16,63 @@ import { EuiText, EuiLink, EuiDescribedFormGroup, + EuiIconTip, EuiAccordion, EuiPanel, EuiSpacer, EuiErrorBoundary, - EuiIconTip, } from '@elastic/eui'; -import { - RuleCreationValidConsumer, - ES_QUERY_ID, - OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, - ML_ANOMALY_DETECTION_RULE_TYPE_ID, -} from '@kbn/rule-data-utils'; -import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; -import type { DocLinksStart } from '@kbn/core-doc-links-browser'; -import type { RuleType } from '@kbn/triggers-actions-ui-types'; -import type { - RuleTypeModel, - RuleFormErrors, - MinimumScheduleInterval, - Rule, - RuleTypeParams, -} from '../../common'; import { DOC_LINK_TITLE, LOADING_RULE_TYPE_PARAMS_TITLE, SCHEDULE_TITLE, SCHEDULE_DESCRIPTION_TEXT, + SCHEDULE_TOOLTIP_TEXT, ALERT_DELAY_TITLE, SCOPE_TITLE, SCOPE_DESCRIPTION_TEXT, ADVANCED_OPTIONS_TITLE, ALERT_DELAY_DESCRIPTION_TEXT, - SCHEDULE_TOOLTIP_TEXT, ALERT_DELAY_HELP_TEXT, } from '../translations'; import { RuleAlertDelay } from './rule_alert_delay'; import { RuleConsumerSelection } from './rule_consumer_selection'; import { RuleSchedule } from './rule_schedule'; +import { useRuleFormState, useRuleFormDispatch } from '../hooks'; +import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; +import { getAuthorizedConsumers } from '../utils'; -const MULTI_CONSUMER_RULE_TYPE_IDS = [ - OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, - ES_QUERY_ID, - ML_ANOMALY_DETECTION_RULE_TYPE_ID, -]; - -interface RuleDefinitionProps { - requiredPlugins: { - charts: ChartsPluginSetup; - data: DataPublicPluginStart; - dataViews: DataViewsPublicPluginStart; - unifiedSearch: UnifiedSearchPublicPluginStart; - docLinks: DocLinksStart; - }; - formValues: { - id?: Rule['id']; - params: Rule['params']; - schedule: Rule['schedule']; - alertDelay?: Rule['alertDelay']; - notifyWhen?: Rule['notifyWhen']; - consumer?: Rule['consumer']; - }; - minimumScheduleInterval?: MinimumScheduleInterval; - errors?: RuleFormErrors; - canShowConsumerSelection?: boolean; - authorizedConsumers?: RuleCreationValidConsumer[]; - selectedRuleTypeModel: RuleTypeModel; - selectedRuleType: RuleType; - validConsumers?: RuleCreationValidConsumer[]; - onChange: (property: string, value: unknown) => void; -} - -export const RuleDefinition = (props: RuleDefinitionProps) => { +export const RuleDefinition = () => { const { - requiredPlugins, - formValues, - errors = {}, - canShowConsumerSelection = false, - authorizedConsumers = [], - selectedRuleTypeModel, + id, + formData, + plugins, + paramsErrors, + metadata, selectedRuleType, - minimumScheduleInterval, - onChange, - } = props; + selectedRuleTypeModel, + validConsumers, + canShowConsumerSelection = false, + } = useRuleFormState(); + + const dispatch = useRuleFormDispatch(); - const { charts, data, dataViews, unifiedSearch, docLinks } = requiredPlugins; + const { charts, data, dataViews, unifiedSearch, docLinks } = plugins; - const { id, params, schedule, alertDelay, notifyWhen, consumer = 'alerts' } = formValues; + const { params, schedule, notifyWhen } = formData; - const [metadata, setMetadata] = useState>(); const [isAdvancedOptionsVisible, setIsAdvancedOptionsVisible] = useState(false); + const authorizedConsumers = useMemo(() => { + if (!validConsumers?.length) { + return []; + } + return getAuthorizedConsumers({ + ruleType: selectedRuleType, + validConsumers, + }); + }, [selectedRuleType, validConsumers]); + const shouldShowConsumerSelect = useMemo(() => { if (!canShowConsumerSelection) { return false; @@ -132,21 +95,40 @@ export const RuleDefinition = (props: RuleDefinitionProps) => { return documentationUrl; }, [selectedRuleTypeModel, docLinks]); + const onChangeMetaData = useCallback( + (newMetadata) => { + dispatch({ + type: 'setMetadata', + payload: newMetadata, + }); + }, + [dispatch] + ); + const onSetRuleParams = useCallback( (property: string, value: unknown) => { - onChange('params', { - ...params, - [property]: value, + dispatch({ + type: 'setParamsProperty', + payload: { + property, + value, + }, }); }, - [onChange, params] + [dispatch] ); - const onSetRule = useCallback( + const onSetRuleProperty = useCallback( (property: string, value: unknown) => { - onChange(property, value); + dispatch({ + type: 'setRuleProperty', + payload: { + property, + value, + }, + }); }, - [onChange] + [dispatch] ); return ( @@ -197,9 +179,9 @@ export const RuleDefinition = (props: RuleDefinitionProps) => { ruleInterval={schedule.interval} ruleThrottle={''} alertNotifyWhen={notifyWhen || 'onActionGroupChange'} - errors={errors} + errors={paramsErrors || {}} setRuleParams={onSetRuleParams} - setRuleProperty={onSetRule} + setRuleProperty={onSetRuleProperty} defaultActionGroupId={selectedRuleType.defaultActionGroupId} actionGroups={selectedRuleType.actionGroups} metadata={metadata} @@ -207,7 +189,7 @@ export const RuleDefinition = (props: RuleDefinitionProps) => { data={data} dataViews={dataViews} unifiedSearch={unifiedSearch} - onChangeMetaData={setMetadata} + onChangeMetaData={onChangeMetaData} /> @@ -232,12 +214,7 @@ export const RuleDefinition = (props: RuleDefinitionProps) => { } > - + {shouldShowConsumerSelect && ( { title={

{SCOPE_TITLE}

} description={

{SCOPE_DESCRIPTION_TEXT}

} > - +
)} @@ -286,7 +258,7 @@ export const RuleDefinition = (props: RuleDefinitionProps) => { } > - + diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.test.tsx index e0f2f3c9ab5a..d545bea54281 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.test.tsx @@ -13,37 +13,86 @@ import { RuleSchedule } from './rule_schedule'; const mockOnChange = jest.fn(); +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); + +const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); + describe('RuleSchedule', () => { + beforeEach(() => { + useRuleFormDispatch.mockReturnValue(mockOnChange); + }); + afterEach(() => { jest.resetAllMocks(); }); test('Renders correctly', () => { - render(); + useRuleFormState.mockReturnValue({ + formData: { + schedule: { + interval: '5m', + }, + }, + }); + render(); expect(screen.getByTestId('ruleSchedule')).toBeInTheDocument(); }); test('Should allow interval number to be changed', () => { - render(); + useRuleFormState.mockReturnValue({ + formData: { + schedule: { + interval: '5m', + }, + }, + }); + render(); fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), { target: { value: '10', }, }); - expect(mockOnChange).toHaveBeenCalledWith('interval', '10m'); + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setSchedule', + payload: { + interval: '10m', + }, + }); }); test('Should allow interval unit to be changed', () => { - render(); + useRuleFormState.mockReturnValue({ + formData: { + schedule: { + interval: '5m', + }, + }, + }); + render(); userEvent.selectOptions(screen.getByTestId('ruleScheduleUnitInput'), 'hours'); - expect(mockOnChange).toHaveBeenCalledWith('interval', '5h'); + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setSchedule', + payload: { + interval: '5h', + }, + }); }); test('Should only allow integers as inputs', async () => { - render(); + useRuleFormState.mockReturnValue({ + formData: { + schedule: { + interval: '5m', + }, + }, + }); + render(); ['-', '+', 'e', 'E', '.', 'a', '01'].forEach((char) => { fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), { @@ -56,30 +105,35 @@ describe('RuleSchedule', () => { }); test('Should display error properly', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + formData: { + schedule: { + interval: '5m', + }, + }, + baseErrors: { + interval: 'something went wrong!', + }, + }); + render(); expect(screen.getByText('something went wrong!')).toBeInTheDocument(); + expect(screen.getByTestId('ruleScheduleNumberInput')).toBeInvalid(); }); test('Should enforce minimum schedule interval', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + formData: { + schedule: { + interval: '30s', + }, + }, + minimumScheduleInterval: { + enforce: true, + value: '1m', + }, + }); + render(); expect(screen.getByText('Interval must be at least 1 minute.')).toBeInTheDocument(); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx index 8bdadc28cee4..72468dc00454 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { EuiFlexItem, EuiFormRow, EuiFlexGroup, EuiSelect, EuiFieldNumber } from '@elastic/eui'; import { parseDuration, @@ -15,12 +15,13 @@ import { getDurationNumberInItsUnit, } from '../utils/parse_duration'; import { getTimeOptions } from '../utils/get_time_options'; -import { MinimumScheduleInterval, RuleFormErrors } from '../../common'; import { SCHEDULE_TITLE_PREFIX, INTERVAL_MINIMUM_TEXT, INTERVAL_WARNING_TEXT, } from '../translations'; +import { useRuleFormState, useRuleFormDispatch } from '../hooks'; +import { MinimumScheduleInterval } from '../../common'; const INTEGER_REGEX = /^[1-9][0-9]*$/; const INVALID_KEYS = ['-', '+', '.', 'e', 'E']; @@ -47,44 +48,64 @@ const getHelpTextForInterval = ( } }; -export interface RuleScheduleProps { - interval: string; - minimumScheduleInterval?: MinimumScheduleInterval; - errors?: RuleFormErrors; - onChange: (property: string, value: unknown) => void; -} +export const RuleSchedule = () => { + const { formData, baseErrors, minimumScheduleInterval } = useRuleFormState(); -export const RuleSchedule = (props: RuleScheduleProps) => { - const { interval, minimumScheduleInterval, errors = {}, onChange } = props; + const dispatch = useRuleFormDispatch(); - const hasIntervalError = errors.interval?.length > 0; + const { + schedule: { interval }, + } = formData; - const intervalNumber = getDurationNumberInItsUnit(interval); + const hasIntervalError = useMemo(() => { + return !!baseErrors?.interval?.length; + }, [baseErrors]); - const intervalUnit = getDurationUnitValue(interval); + const intervalNumber = useMemo(() => { + return getDurationNumberInItsUnit(interval ?? 1); + }, [interval]); - // No help text if there is an error - const helpText = - minimumScheduleInterval && !hasIntervalError - ? getHelpTextForInterval(interval, minimumScheduleInterval) - : ''; + const intervalUnit = useMemo(() => { + return getDurationUnitValue(interval); + }, [interval]); + + const helpText = useMemo( + () => + minimumScheduleInterval && !hasIntervalError // No help text if there is an error + ? getHelpTextForInterval(interval, minimumScheduleInterval) + : '', + [interval, minimumScheduleInterval, hasIntervalError] + ); const onIntervalNumberChange = useCallback( (e: React.ChangeEvent) => { + if (!e.target.validity.valid) { + return; + } const value = e.target.value.trim(); if (INTEGER_REGEX.test(value)) { const parsedValue = parseInt(value, 10); - onChange('interval', `${parsedValue}${intervalUnit}`); + dispatch({ + type: 'setSchedule', + payload: { + interval: `${parsedValue}${intervalUnit}`, + }, + }); } }, - [intervalUnit, onChange] + [intervalUnit, dispatch] ); const onIntervalUnitChange = useCallback( (e: React.ChangeEvent) => { - onChange('interval', `${intervalNumber}${e.target.value}`); + dispatch({ + type: 'setSchedule', + payload: { + interval: `${intervalNumber}${e.target.value}`, + }, + }); }, - [intervalNumber, onChange] + [intervalNumber, dispatch] ); const onKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -99,15 +120,15 @@ export const RuleSchedule = (props: RuleScheduleProps) => { data-test-subj="ruleSchedule" display="rowCompressed" helpText={helpText} - isInvalid={errors.interval?.length > 0} - error={errors.interval} + isInvalid={hasIntervalError} + error={baseErrors?.interval} > 0} + isInvalid={hasIntervalError} value={intervalNumber} name="interval" data-test-subj="ruleScheduleNumberInput" diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.test.tsx index 58b327af94b4..5b2430c4dc27 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.test.tsx @@ -13,65 +13,66 @@ import { RuleDetails } from './rule_details'; const mockOnChange = jest.fn(); +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); + +const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); + describe('RuleDetails', () => { + beforeEach(() => { + useRuleFormState.mockReturnValue({ + formData: { + name: 'test', + tags: [], + }, + }); + useRuleFormDispatch.mockReturnValue(mockOnChange); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + test('Renders correctly', () => { - render( - - ); + render(); expect(screen.getByTestId('ruleDetails')).toBeInTheDocument(); }); test('Should allow name to be changed', () => { - render( - - ); + render(); fireEvent.change(screen.getByTestId('ruleDetailsNameInput'), { target: { value: 'hello' } }); - expect(mockOnChange).toHaveBeenCalledWith('name', 'hello'); + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setName', + payload: 'hello', + }); }); test('Should allow tags to be changed', () => { - render( - - ); + render(); userEvent.type(screen.getByTestId('comboBoxInput'), 'tag{enter}'); - expect(mockOnChange).toHaveBeenCalledWith('tags', ['tag']); + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setTags', + payload: ['tag'], + }); }); test('Should display error', () => { - render( - - ); + useRuleFormState.mockReturnValue({ + formData: { + name: 'test', + tags: [], + }, + baseErrors: { + name: 'name is invalid', + tags: 'tags is invalid', + }, + }); + render(); expect(screen.getByText('name is invalid')).toBeInTheDocument(); expect(screen.getByText('tags is invalid')).toBeInTheDocument(); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.tsx index 30af5dfa16ed..9b5cd6aeffc2 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_details/rule_details.tsx @@ -15,27 +15,21 @@ import { EuiComboBoxOptionOption, EuiText, } from '@elastic/eui'; -import type { RuleFormErrors, Rule, RuleTypeParams } from '../../common'; import { RULE_DETAILS_TITLE, RULE_DETAILS_DESCRIPTION, RULE_NAME_INPUT_TITLE, RULE_TAG_INPUT_TITLE, + RULE_TAG_PLACEHOLDER, } from '../translations'; +import { useRuleFormState, useRuleFormDispatch } from '../hooks'; -export interface RuleDetailsProps { - formValues: { - tags?: Rule['tags']; - name: Rule['name']; - }; - errors?: RuleFormErrors; - onChange: (property: string, value: unknown) => void; -} +export const RuleDetails = () => { + const { formData, baseErrors } = useRuleFormState(); -export const RuleDetails = (props: RuleDetailsProps) => { - const { formValues, errors = {}, onChange } = props; + const dispatch = useRuleFormDispatch(); - const { tags = [], name } = formValues; + const { tags = [], name } = formData; const tagsOptions = useMemo(() => { return tags.map((tag: string) => ({ label: tag })); @@ -43,40 +37,49 @@ export const RuleDetails = (props: RuleDetailsProps) => { const onNameChange = useCallback( (e: React.ChangeEvent) => { - onChange('name', e.target.value); + dispatch({ + type: 'setName', + payload: e.target.value, + }); }, - [onChange] + [dispatch] ); const onAddTag = useCallback( (searchValue: string) => { - onChange('tags', tags.concat([searchValue])); + dispatch({ + type: 'setTags', + payload: tags.concat([searchValue]), + }); }, - [onChange, tags] + [dispatch, tags] ); const onSetTag = useCallback( (options: Array>) => { - onChange( - 'tags', - options.map((selectedOption) => selectedOption.label) - ); + dispatch({ + type: 'setTags', + payload: options.map((selectedOption) => selectedOption.label), + }); }, - [onChange] + [dispatch] ); const onBlur = useCallback(() => { if (!tags) { - onChange('tags', []); + dispatch({ + type: 'setTags', + payload: [], + }); } - }, [onChange, tags]); + }, [dispatch, tags]); return ( {RULE_DETAILS_TITLE}} description={ - +

{RULE_DETAILS_DESCRIPTION}

} @@ -85,12 +88,13 @@ export const RuleDetails = (props: RuleDetailsProps) => { 0} - error={errors.name} + isInvalid={!!baseErrors?.name?.length} + error={baseErrors?.name} > @@ -98,12 +102,13 @@ export const RuleDetails = (props: RuleDetailsProps) => { 0} - error={errors.tags} + isInvalid={!!baseErrors?.tags?.length} + error={baseErrors?.tags} > { + const { plugins, returnUrl } = props; + const { id, ruleTypeId } = useParams<{ + id?: string; + ruleTypeId?: string; + }>(); + + const ruleFormComponent = useMemo(() => { + if (id) { + return ; + } + if (ruleTypeId) { + return ; + } + return ( + {RULE_FORM_ROUTE_PARAMS_ERROR_TITLE}} + > + +

{RULE_FORM_ROUTE_PARAMS_ERROR_TEXT}

+
+
+ ); + }, [id, ruleTypeId, plugins, returnUrl]); + + return {ruleFormComponent}; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/index.ts new file mode 100644 index 000000000000..70ee9d6a8929 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './rule_form_health_check_error'; +export * from './rule_form_circuit_breaker_error'; +export * from './rule_form_resolve_rule_error'; +export * from './rule_form_rule_type_error'; +export * from './rule_form_error_prompt_wrapper'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_circuit_breaker_error.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_circuit_breaker_error.test.tsx new file mode 100644 index 000000000000..37639340e036 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_circuit_breaker_error.test.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { RuleFormCircuitBreakerError } from './rule_form_circuit_breaker_error'; +import { CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT } from '../translations'; + +describe('RuleFormCircuitBreakerError', () => { + test('renders correctly', () => { + render(); + + expect(screen.getByText(CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT)).toBeInTheDocument(); + }); + + test('can toggle details', () => { + render( + +
child component
+
+ ); + + fireEvent.click(screen.getByTestId('ruleFormCircuitBreakerErrorToggleButton')); + + expect(screen.getByText('child component')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_circuit_breaker_error.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_circuit_breaker_error.tsx new file mode 100644 index 000000000000..80e2cf8686de --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_circuit_breaker_error.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useCallback, FC } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { + CIRCUIT_BREAKER_HIDE_FULL_ERROR_TEXT, + CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT, +} from '../translations'; + +export const RuleFormCircuitBreakerError: FC<{}> = ({ children }) => { + const [showDetails, setShowDetails] = useState(false); + + const onToggleShowDetails = useCallback(() => { + setShowDetails((prev) => !prev); + }, []); + + return ( + <> + {showDetails && ( + <> + {children} + + + )} + + + + {showDetails + ? CIRCUIT_BREAKER_HIDE_FULL_ERROR_TEXT + : CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT} + + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_error_prompt_wrapper.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_error_prompt_wrapper.tsx new file mode 100644 index 000000000000..b7342f74d4eb --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_error_prompt_wrapper.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useEuiBackgroundColorCSS, EuiPageTemplate } from '@elastic/eui'; + +interface RuleFormErrorPromptWrapperProps { + hasBorder?: boolean; + hasShadow?: boolean; +} + +export const RuleFormErrorPromptWrapper: React.FC = ({ + children, + hasBorder, + hasShadow, +}) => { + const styles = useEuiBackgroundColorCSS().transparent; + return ( + + + {children} + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_health_check_error.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_health_check_error.test.tsx new file mode 100644 index 000000000000..dc7aa851288c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_health_check_error.test.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { DocLinksStart } from '@kbn/core-doc-links-browser'; +import { render, screen } from '@testing-library/react'; +import { RuleFormHealthCheckError } from './rule_form_health_check_error'; +import { HealthCheckErrors, healthCheckErrors } from '../../common/apis'; +import { + HEALTH_CHECK_ALERTS_ERROR_TEXT, + HEALTH_CHECK_ENCRYPTION_ERROR_TEXT, + HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT, + HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT, + HEALTH_CHECK_ALERTS_ERROR_TITLE, + HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE, + HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE, + HEALTH_CHECK_ENCRYPTION_ERROR_TITLE, +} from '../translations'; + +const docLinksMock = { + links: { + alerting: { + generalSettings: 'generalSettings', + setupPrerequisites: 'setupPrerequisites', + }, + security: { + elasticsearchEnableApiKeys: 'elasticsearchEnableApiKeys', + }, + }, +} as DocLinksStart; + +describe('ruleFormHealthCheckError', () => { + test('renders correctly', () => { + render( + + ); + + expect(screen.getByTestId('ruleFormHealthCheckError')).toBeInTheDocument(); + }); + + test('renders alerts error', () => { + render( + + ); + expect(screen.getByText(HEALTH_CHECK_ALERTS_ERROR_TITLE)).toBeInTheDocument(); + expect(screen.getByText(HEALTH_CHECK_ALERTS_ERROR_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute( + 'href', + 'generalSettings' + ); + }); + + test('renders encryption error', () => { + render( + + ); + + expect(screen.getByText(HEALTH_CHECK_ENCRYPTION_ERROR_TEXT)).toBeInTheDocument(); + expect(screen.getByText(HEALTH_CHECK_ENCRYPTION_ERROR_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute( + 'href', + 'generalSettings' + ); + }); + + test('renders API keys and encryption error', () => { + render( + + ); + + expect(screen.getByText(HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT)).toBeInTheDocument(); + expect(screen.getByText(HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute( + 'href', + 'setupPrerequisites' + ); + }); + + test('renders API keys disabled error', () => { + render( + + ); + + expect(screen.getByText(HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT)).toBeInTheDocument(); + expect(screen.getByText(HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute( + 'href', + 'elasticsearchEnableApiKeys' + ); + }); + + test('should not render if unknown error is passed in', () => { + render( + + ); + + expect(screen.queryByTestId('ruleFormHealthCheckError')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_health_check_error.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_health_check_error.tsx new file mode 100644 index 000000000000..e02e984a7158 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_health_check_error.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo } from 'react'; +import { EuiEmptyPrompt, EuiLink, EuiText } from '@elastic/eui'; +import type { DocLinksStart } from '@kbn/core-doc-links-browser'; +import { HealthCheckErrors, healthCheckErrors } from '../../common/apis'; + +import { + HEALTH_CHECK_ALERTS_ERROR_TITLE, + HEALTH_CHECK_ALERTS_ERROR_TEXT, + HEALTH_CHECK_ENCRYPTION_ERROR_TITLE, + HEALTH_CHECK_ENCRYPTION_ERROR_TEXT, + HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE, + HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT, + HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE, + HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT, + HEALTH_CHECK_ACTION_TEXT, +} from '../translations'; + +export interface RuleFormHealthCheckErrorProps { + error: HealthCheckErrors; + docLinks: DocLinksStart; +} + +export const RuleFormHealthCheckError = (props: RuleFormHealthCheckErrorProps) => { + const { error, docLinks } = props; + + const errorState = useMemo(() => { + if (error === healthCheckErrors.ALERTS_ERROR) { + return { + errorTitle: HEALTH_CHECK_ALERTS_ERROR_TITLE, + errorBodyText: HEALTH_CHECK_ALERTS_ERROR_TEXT, + errorDocLink: docLinks.links.alerting.generalSettings, + }; + } + if (error === healthCheckErrors.ENCRYPTION_ERROR) { + return { + errorTitle: HEALTH_CHECK_ENCRYPTION_ERROR_TITLE, + errorBodyText: HEALTH_CHECK_ENCRYPTION_ERROR_TEXT, + errorDocLink: docLinks.links.alerting.generalSettings, + }; + } + if (error === healthCheckErrors.API_KEYS_AND_ENCRYPTION_ERROR) { + return { + errorTitle: HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE, + errorBodyText: HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT, + errorDocLink: docLinks.links.alerting.setupPrerequisites, + }; + } + if (error === healthCheckErrors.API_KEYS_DISABLED_ERROR) { + return { + errorTitle: HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE, + errorBodyText: HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT, + errorDocLink: docLinks.links.security.elasticsearchEnableApiKeys, + }; + } + }, [error, docLinks]); + + if (!errorState) { + return null; + } + + return ( + +

{errorState.errorTitle}

+
+ } + body={ +
+

+ {errorState.errorBodyText}  + + {HEALTH_CHECK_ACTION_TEXT} + +

+
+ } + /> + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_resolve_rule_error.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_resolve_rule_error.tsx new file mode 100644 index 000000000000..83dbd70109a0 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_resolve_rule_error.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import { + RULE_FORM_RULE_NOT_FOUND_ERROR_TITLE, + RULE_FORM_RULE_NOT_FOUND_ERROR_TEXT, +} from '../translations'; + +export const RuleFormResolveRuleError = () => { + return ( + +

{RULE_FORM_RULE_NOT_FOUND_ERROR_TITLE}

+ + } + body={ + +

{RULE_FORM_RULE_NOT_FOUND_ERROR_TEXT}

+
+ } + /> + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_rule_type_error.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_rule_type_error.tsx new file mode 100644 index 000000000000..f89ef35ffe02 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_rule_type_error.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import { + RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TITLE, + RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TEXT, +} from '../translations'; + +export const RuleFormRuleTypeError = () => { + return ( + +

{RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TITLE}

+ + } + body={ + +

{RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TEXT}

+
+ } + /> + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/index.ts new file mode 100644 index 000000000000..93aa6aa06ebf --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './rule_form_state_context'; +export * from './rule_form_state_provider'; +export * from './rule_form_state_reducer'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_context.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_context.tsx new file mode 100644 index 000000000000..365aa1199ce6 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_context.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createContext } from 'react'; +import type { RuleFormState } from '../types'; +import type { RuleFormStateReducerAction } from './rule_form_state_reducer'; + +export const RuleFormStateContext = createContext({} as RuleFormState); + +export const RuleFormReducerContext = createContext>( + () => {} +); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_provider.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_provider.tsx new file mode 100644 index 000000000000..cf9b781ead6e --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_provider.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useReducer } from 'react'; +import { RuleFormState } from '../types'; +import { RuleFormStateContext, RuleFormReducerContext } from './rule_form_state_context'; +import { ruleFormStateReducer } from './rule_form_state_reducer'; +import { validateRuleBase, validateRuleParams } from '../validation'; + +export interface RuleFormStateProviderProps { + initialRuleFormState: RuleFormState; +} + +export const RuleFormStateProvider: React.FC = (props) => { + const { children, initialRuleFormState } = props; + const { + formData, + selectedRuleTypeModel: ruleTypeModel, + minimumScheduleInterval, + } = initialRuleFormState; + + const [ruleFormState, dispatch] = useReducer(ruleFormStateReducer, { + ...initialRuleFormState, + baseErrors: validateRuleBase({ + formData, + minimumScheduleInterval, + }), + paramsErrors: validateRuleParams({ + formData, + ruleTypeModel, + }), + }); + return ( + + {children} + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx new file mode 100644 index 000000000000..38d8c7e3e5e9 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx @@ -0,0 +1,347 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useReducer } from 'react'; +import { act, renderHook } from '@testing-library/react-hooks/dom'; +import { ruleFormStateReducer } from './rule_form_state_reducer'; +import { RuleFormState } from '../types'; + +jest.mock('../validation/validate_form', () => ({ + validateRuleBase: jest.fn(), + validateRuleParams: jest.fn(), +})); + +const { validateRuleBase, validateRuleParams } = jest.requireMock('../validation/validate_form'); + +validateRuleBase.mockReturnValue({}); +validateRuleParams.mockReturnValue({}); + +const indexThresholdRuleType = { + enabledInLicense: true, + recoveryActionGroup: { + id: 'recovered', + name: 'Recovered', + }, + actionGroups: [], + defaultActionGroupId: 'threshold met', + minimumLicenseRequired: 'basic', + authorizedConsumers: { + stackAlerts: { + read: true, + all: true, + }, + }, + ruleTaskTimeout: '5m', + doesSetRecoveryContext: true, + hasAlertsMappings: true, + hasFieldsForAAD: false, + id: '.index-threshold', + name: 'Index threshold', + category: 'management', + producer: 'stackAlerts', + alerts: {}, + is_exportable: true, +} as unknown as RuleFormState['selectedRuleType']; + +const indexThresholdRuleTypeModel = { + id: '.index-threshold', + description: 'Alert when an aggregated query meets the threshold.', + iconClass: 'alert', + ruleParamsExpression: () =>
, + defaultActionMessage: + 'Rule {{rule.name}} is active for group {{context.group}}:\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}\n- Timestamp: {{context.date}}', + requiresAppContext: false, +} as unknown as RuleFormState['selectedRuleTypeModel']; + +const initialState: RuleFormState = { + formData: { + name: 'test-rule', + tags: [], + params: { + paramsValue: 'value-1', + }, + schedule: { interval: '5m' }, + consumer: 'stackAlerts', + notifyWhen: 'onActionGroupChange', + }, + plugins: {} as unknown as RuleFormState['plugins'], + selectedRuleType: indexThresholdRuleType, + selectedRuleTypeModel: indexThresholdRuleTypeModel, + multiConsumerSelection: 'stackAlerts', +}; + +describe('ruleFormStateReducer', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should initialize properly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + expect(result.current[0]).toEqual(initialState); + }); + + test('setRule works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + const updatedRule = { + name: 'test-rule-updated', + tags: ['tag'], + params: { + test: 'hello', + }, + schedule: { interval: '2m' }, + consumer: 'logs', + }; + + act(() => { + dispatch({ + type: 'setRule', + payload: updatedRule, + }); + }); + + expect(result.current[0].formData).toEqual(updatedRule); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setRuleProperty works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setRuleProperty', + payload: { + property: 'name', + value: 'test-rule-name-updated', + }, + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + name: 'test-rule-name-updated', + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setName works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setName', + payload: 'test-rule-name-updated', + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + name: 'test-rule-name-updated', + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setTags works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setTags', + payload: ['tag1', 'tag2'], + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + tags: ['tag1', 'tag2'], + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setParams works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setParams', + payload: { + anotherParamsValue: 'value-2', + }, + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + params: { + anotherParamsValue: 'value-2', + }, + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setParamsProperty works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setParamsProperty', + payload: { + property: 'anotherParamsValue', + value: 'value-2', + }, + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + params: { + ...initialState.formData.params, + anotherParamsValue: 'value-2', + }, + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setSchedule works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setSchedule', + payload: { interval: '10m' }, + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + schedule: { interval: '10m' }, + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setAlertDelay works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setAlertDelay', + payload: { active: 5 }, + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + alertDelay: { active: 5 }, + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setNotifyWhen works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setNotifyWhen', + payload: 'onActiveAlert', + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + notifyWhen: 'onActiveAlert', + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setConsumer works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setConsumer', + payload: 'logs', + }); + }); + + expect(result.current[0].formData).toEqual({ + ...initialState.formData, + consumer: 'logs', + }); + expect(validateRuleBase).toHaveBeenCalled(); + expect(validateRuleParams).toHaveBeenCalled(); + }); + + test('setMultiConsumer works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setMultiConsumer', + payload: 'logs', + }); + }); + + expect(result.current[0].multiConsumerSelection).toEqual('logs'); + expect(validateRuleBase).not.toHaveBeenCalled(); + expect(validateRuleParams).not.toHaveBeenCalled(); + }); + + test('setMetadata works correctly', () => { + const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState)); + + const dispatch = result.current[1]; + + act(() => { + dispatch({ + type: 'setMetadata', + payload: { + value1: 'value1', + value2: 'value2', + }, + }); + }); + + expect(result.current[0].metadata).toEqual({ + value1: 'value1', + value2: 'value2', + }); + expect(validateRuleBase).not.toHaveBeenCalled(); + expect(validateRuleParams).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts new file mode 100644 index 000000000000..727a6078aedb --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RuleFormData, RuleFormState } from '../types'; +import { validateRuleBase, validateRuleParams } from '../validation'; + +export type RuleFormStateReducerAction = + | { + type: 'setRule'; + payload: RuleFormData; + } + | { + type: 'setRuleProperty'; + payload: { + property: string; + value: unknown; + }; + } + | { + type: 'setName'; + payload: RuleFormData['name']; + } + | { + type: 'setTags'; + payload: RuleFormData['tags']; + } + | { + type: 'setParams'; + payload: RuleFormData['params']; + } + | { + type: 'setParamsProperty'; + payload: { + property: string; + value: unknown; + }; + } + | { + type: 'setSchedule'; + payload: RuleFormData['schedule']; + } + | { + type: 'setAlertDelay'; + payload: RuleFormData['alertDelay']; + } + | { + type: 'setNotifyWhen'; + payload: RuleFormData['notifyWhen']; + } + | { + type: 'setConsumer'; + payload: RuleFormData['consumer']; + } + | { + type: 'setMultiConsumer'; + payload: RuleFormState['multiConsumerSelection']; + } + | { + type: 'setMetadata'; + payload: Record; + }; + +const getUpdateWithValidation = + (ruleFormState: RuleFormState) => + (updater: () => RuleFormData): RuleFormState => { + const { minimumScheduleInterval, selectedRuleTypeModel, multiConsumerSelection } = + ruleFormState; + + const formData = updater(); + + const formDataWithMultiConsumer = { + ...formData, + ...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}), + }; + + return { + ...ruleFormState, + formData, + baseErrors: validateRuleBase({ + formData: formDataWithMultiConsumer, + minimumScheduleInterval, + }), + paramsErrors: validateRuleParams({ + formData: formDataWithMultiConsumer, + ruleTypeModel: selectedRuleTypeModel, + }), + }; + }; + +export const ruleFormStateReducer = ( + ruleFormState: RuleFormState, + action: RuleFormStateReducerAction +): RuleFormState => { + const { formData } = ruleFormState; + const updateWithValidation = getUpdateWithValidation(ruleFormState); + + switch (action.type) { + case 'setRule': { + const { payload } = action; + return updateWithValidation(() => payload); + } + case 'setRuleProperty': { + const { + payload: { property, value }, + } = action; + return updateWithValidation(() => ({ + ...ruleFormState.formData, + [property]: value, + })); + } + case 'setName': { + const { payload } = action; + return updateWithValidation(() => ({ + ...formData, + name: payload, + })); + } + case 'setTags': { + const { payload } = action; + return updateWithValidation(() => ({ + ...formData, + tags: payload, + })); + } + case 'setParams': { + const { payload } = action; + return updateWithValidation(() => ({ + ...formData, + params: payload, + })); + } + case 'setParamsProperty': { + const { + payload: { property, value }, + } = action; + return updateWithValidation(() => ({ + ...formData, + params: { + ...formData.params, + [property]: value, + }, + })); + } + case 'setSchedule': { + const { payload } = action; + return updateWithValidation(() => ({ + ...formData, + schedule: payload, + })); + } + case 'setAlertDelay': { + const { payload } = action; + return updateWithValidation(() => ({ + ...formData, + alertDelay: payload, + })); + } + case 'setNotifyWhen': { + const { payload } = action; + return updateWithValidation(() => ({ + ...formData, + notifyWhen: payload, + })); + } + case 'setConsumer': { + const { payload } = action; + return updateWithValidation(() => ({ + ...formData, + consumer: payload, + })); + } + case 'setMultiConsumer': { + const { payload } = action; + return { + ...ruleFormState, + multiConsumerSelection: payload, + }; + } + case 'setMetadata': { + const { payload } = action; + return { + ...ruleFormState, + metadata: payload, + }; + } + default: { + return ruleFormState; + } + } +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/index.ts new file mode 100644 index 000000000000..6bd6f350a560 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './rule_page'; +export * from './rule_page_name_input'; +export * from './rule_page_footer'; +export * from './rule_page_confirm_create_rule'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx new file mode 100644 index 000000000000..f57aa8f09102 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { RulePage } from './rule_page'; +import { + RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + RULE_FORM_PAGE_RULE_DETAILS_TITLE, +} from '../translations'; +import { RuleFormData } from '../types'; + +jest.mock('../rule_definition', () => ({ + RuleDefinition: () =>
, +})); + +jest.mock('../rule_actions', () => ({ + RuleActions: () =>
, +})); + +jest.mock('../rule_details', () => ({ + RuleDetails: () =>
, +})); + +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); + +const { useRuleFormState } = jest.requireMock('../hooks'); + +const navigateToUrl = jest.fn(); + +const formDataMock: RuleFormData = { + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'alert.executionStatus.lastExecutionDate', + }, + consumer: 'stackAlerts', + schedule: { interval: '1m' }, + tags: [], + name: 'test', + notifyWhen: 'onActionGroupChange', + alertDelay: { + active: 10, + }, +}; + +useRuleFormState.mockReturnValue({ + plugins: { + application: { + navigateToUrl, + }, + }, + baseErrors: {}, + paramsErrors: {}, + multiConsumerSelection: 'logs', + formData: formDataMock, +}); + +const onSave = jest.fn(); +const returnUrl = 'management'; + +describe('rulePage', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders correctly', () => { + render(); + + expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_DETAILS_TITLE)).toBeInTheDocument(); + }); + + test('should call onSave when save button is pressed', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageFooterSaveButton')); + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + + expect(onSave).toHaveBeenCalledWith({ + ...formDataMock, + consumer: 'logs', + }); + }); + + test('should call onCancel when the cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageFooterCancelButton')); + expect(navigateToUrl).toHaveBeenCalledWith('management'); + }); + + test('should call onCancel when the return button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageReturnButton')); + expect(navigateToUrl).toHaveBeenCalledWith('management'); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx new file mode 100644 index 000000000000..8b8b97e39a3c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiPageTemplate, + EuiHorizontalRule, + EuiSpacer, + EuiSteps, + EuiStepsProps, + useEuiBackgroundColorCSS, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { + RuleDefinition, + RuleActions, + RuleDetails, + RulePageNameInput, + RulePageFooter, + RuleFormData, +} from '..'; +import { useRuleFormState } from '../hooks'; +import { + RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + RULE_FORM_PAGE_RULE_DETAILS_TITLE, + RULE_FORM_RETURN_TITLE, +} from '../translations'; + +export interface RulePageProps { + isEdit?: boolean; + isSaving?: boolean; + returnUrl: string; + onSave: (formData: RuleFormData) => void; +} + +export const RulePage = (props: RulePageProps) => { + const { isEdit = false, isSaving = false, returnUrl, onSave } = props; + + const { + plugins: { application }, + formData, + multiConsumerSelection, + } = useRuleFormState(); + + const styles = useEuiBackgroundColorCSS().transparent; + + const onCancel = useCallback(() => { + application.navigateToUrl(returnUrl); + }, [application, returnUrl]); + + const onSaveInternal = useCallback(() => { + onSave({ + ...formData, + ...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}), + }); + }, [onSave, formData, multiConsumerSelection]); + + const steps: EuiStepsProps['steps'] = useMemo(() => { + return [ + { + title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + children: , + }, + { + title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + children: ( + <> + {}} /> + + + + ), + }, + { + title: RULE_FORM_PAGE_RULE_DETAILS_TITLE, + children: ( + <> + + + + + ), + }, + ]; + }, []); + + return ( + + + + + + {RULE_FORM_RETURN_TITLE} + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_confirm_create_rule.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_confirm_create_rule.test.tsx new file mode 100644 index 000000000000..9563a6a22855 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_confirm_create_rule.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { RulePageConfirmCreateRule } from './rule_page_confirm_create_rule'; +import { + CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT, + CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT, + CONFIRM_RULE_SAVE_MESSAGE_TEXT, +} from '../translations'; + +const onConfirmMock = jest.fn(); +const onCancelMock = jest.fn(); + +describe('rulePageConfirmCreateRule', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders correctly', () => { + render(); + + expect(screen.getByTestId('rulePageConfirmCreateRule')).toBeInTheDocument(); + expect(screen.getByText(CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT)).toBeInTheDocument(); + expect(screen.getByText(CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT)).toBeInTheDocument(); + expect(screen.getByText(CONFIRM_RULE_SAVE_MESSAGE_TEXT)).toBeInTheDocument(); + }); + + test('can confirm rule creation', () => { + render(); + + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + expect(onConfirmMock).toHaveBeenCalled(); + }); + + test('can cancel rule creation', () => { + render(); + + fireEvent.click(screen.getByTestId('confirmModalCancelButton')); + expect(onCancelMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_confirm_create_rule.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_confirm_create_rule.tsx new file mode 100644 index 000000000000..2436eb10aa6c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_confirm_create_rule.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiConfirmModal, EuiText } from '@elastic/eui'; +import { + CONFIRMATION_RULE_SAVE_TITLE, + CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT, + CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT, + CONFIRM_RULE_SAVE_MESSAGE_TEXT, +} from '../translations'; + +export interface RulePageConfirmCreateRuleProps { + onCancel: () => void; + onConfirm: () => void; +} + +export const RulePageConfirmCreateRule = (props: RulePageConfirmCreateRuleProps) => { + const { onCancel, onConfirm } = props; + + return ( + + +

{CONFIRM_RULE_SAVE_MESSAGE_TEXT}

+
+
+ ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx new file mode 100644 index 000000000000..56edf2bacb15 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { RulePageFooter } from './rule_page_footer'; +import { + RULE_PAGE_FOOTER_CANCEL_TEXT, + RULE_PAGE_FOOTER_CREATE_TEXT, + RULE_PAGE_FOOTER_SAVE_TEXT, + RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT, +} from '../translations'; + +jest.mock('../validation/validate_form', () => ({ + hasRuleErrors: jest.fn(), +})); + +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), +})); + +const { hasRuleErrors } = jest.requireMock('../validation/validate_form'); +const { useRuleFormState } = jest.requireMock('../hooks'); + +const onSave = jest.fn(); +const onCancel = jest.fn(); + +hasRuleErrors.mockReturnValue(false); +useRuleFormState.mockReturnValue({ + baseErrors: {}, + paramsErrors: {}, +}); + +describe('rulePageFooter', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders create footer correctly', () => { + render(); + + expect(screen.getByText(RULE_PAGE_FOOTER_CANCEL_TEXT)).toBeInTheDocument(); + expect(screen.getByText(RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT)).toBeInTheDocument(); + expect(screen.getByText(RULE_PAGE_FOOTER_CREATE_TEXT)).toBeInTheDocument(); + }); + + test('renders edit footer correctly', () => { + render(); + + expect(screen.getByText(RULE_PAGE_FOOTER_CANCEL_TEXT)).toBeInTheDocument(); + expect(screen.getByText(RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT)).toBeInTheDocument(); + expect(screen.getByText(RULE_PAGE_FOOTER_SAVE_TEXT)).toBeInTheDocument(); + }); + + test('should open show request modal when the button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageFooterShowRequestButton')); + expect(screen.getByTestId('rulePageShowRequestModal')).toBeInTheDocument(); + }); + + test('should show create rule confirmation', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageFooterSaveButton')); + expect(screen.getByTestId('rulePageConfirmCreateRule')).toBeInTheDocument(); + }); + + test('should show call onSave if clicking rule confirmation', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageFooterSaveButton')); + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + expect(onSave).toHaveBeenCalled(); + }); + + test('should cancel when the cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageFooterCancelButton')); + expect(onCancel).toHaveBeenCalled(); + }); + + test('should disable buttons when saving', () => { + render(); + + expect(screen.getByTestId('rulePageFooterCancelButton')).toBeDisabled(); + expect(screen.getByTestId('rulePageFooterShowRequestButton')).toBeDisabled(); + expect(screen.getByTestId('rulePageFooterSaveButton')).toBeDisabled(); + }); + + test('should disable save and show request buttons when there is an error', () => { + hasRuleErrors.mockReturnValue(true); + render(); + + expect(screen.getByTestId('rulePageFooterShowRequestButton')).toBeDisabled(); + expect(screen.getByTestId('rulePageFooterSaveButton')).toBeDisabled(); + expect(screen.getByTestId('rulePageFooterCancelButton')).not.toBeDisabled(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx new file mode 100644 index 000000000000..82f4d8fa1559 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { + RULE_PAGE_FOOTER_CANCEL_TEXT, + RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT, + RULE_PAGE_FOOTER_CREATE_TEXT, + RULE_PAGE_FOOTER_SAVE_TEXT, +} from '../translations'; +import { useRuleFormState } from '../hooks'; +import { hasRuleErrors } from '../validation'; +import { RulePageShowRequestModal } from './rule_page_show_request_modal'; +import { RulePageConfirmCreateRule } from './rule_page_confirm_create_rule'; + +export interface RulePageFooterProps { + isEdit?: boolean; + isSaving?: boolean; + onCancel: () => void; + onSave: () => void; +} + +export const RulePageFooter = (props: RulePageFooterProps) => { + const [showRequestModal, setShowRequestModal] = useState(false); + const [showCreateConfirmation, setShowCreateConfirmation] = useState(false); + + const { isEdit = false, isSaving = false, onCancel, onSave } = props; + + const { baseErrors, paramsErrors } = useRuleFormState(); + + const hasErrors = useMemo(() => { + return hasRuleErrors({ + baseErrors: baseErrors || {}, + paramsErrors: paramsErrors || {}, + }); + }, [baseErrors, paramsErrors]); + + const saveButtonText = useMemo(() => { + if (isEdit) { + return RULE_PAGE_FOOTER_SAVE_TEXT; + } + return RULE_PAGE_FOOTER_CREATE_TEXT; + }, [isEdit]); + + const onOpenShowRequestModalClick = useCallback(() => { + setShowRequestModal(true); + }, []); + + const onCloseShowRequestModalClick = useCallback(() => { + setShowRequestModal(false); + }, []); + + const onSaveClick = useCallback(() => { + if (isEdit) { + onSave(); + } else { + setShowCreateConfirmation(true); + } + }, [isEdit, onSave]); + + const onCreateConfirmClick = useCallback(() => { + setShowCreateConfirmation(false); + onSave(); + }, [onSave]); + + const onCreateCancelClick = useCallback(() => { + setShowCreateConfirmation(false); + }, []); + + return ( + <> + + + + {RULE_PAGE_FOOTER_CANCEL_TEXT} + + + + + + + {RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT} + + + + + {saveButtonText} + + + + + + {showRequestModal && ( + + )} + {showCreateConfirmation && ( + + )} + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_name_input.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_name_input.test.tsx new file mode 100644 index 000000000000..01cfe884075a --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_name_input.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { RulePageNameInput } from './rule_page_name_input'; + +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); + +const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); + +const dispatch = jest.fn(); + +useRuleFormState.mockReturnValue({ + formData: { + name: 'test-name', + }, +}); + +useRuleFormDispatch.mockReturnValue(dispatch); + +describe('rulePageNameInput', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders correctly', () => { + render(); + + expect(screen.getByText('test-name')).toBeInTheDocument(); + }); + + test('should become an input if the edit button is pressed', () => { + render(); + + fireEvent.click(screen.getByTestId('rulePageNameInputButton')); + + fireEvent.change(screen.getByTestId('rulePageNameInputField'), { + target: { + value: 'hello', + }, + }); + + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'setName', + payload: 'hello', + }); + }); + + test('should be invalid if there is an error', () => { + useRuleFormState.mockReturnValue({ + formData: { + name: '', + }, + baseErrors: { + name: ['Invalid name'], + }, + }); + + render(); + + fireEvent.click(screen.getByTestId('rulePageNameInputButton')); + + expect(screen.getByTestId('rulePageNameInputField')).toBeInvalid(); + expect(screen.getByText('Invalid name')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_name_input.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_name_input.tsx new file mode 100644 index 000000000000..f1278add5737 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_name_input.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { + EuiTitle, + EuiFieldText, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + useEuiTheme, + EuiFormRow, +} from '@elastic/eui'; +import { + RULE_NAME_ARIA_LABEL_TEXT, + RULE_NAME_INPUT_TITLE, + RULE_NAME_INPUT_BUTTON_ARIA_LABEL, +} from '../translations'; +import { useRuleFormState, useRuleFormDispatch } from '../hooks'; + +export const RulePageNameInput = () => { + const [isEditing, setIsEditing] = useState(false); + + const { formData, baseErrors } = useRuleFormState(); + + const { name } = formData; + + const dispatch = useRuleFormDispatch(); + + const { euiTheme } = useEuiTheme(); + + const isNameInvalid = useMemo(() => { + return !!baseErrors?.name?.length; + }, [baseErrors]); + + const inputStyles: React.CSSProperties = useMemo(() => { + return { + fontSize: 'inherit', + fontWeight: 'inherit', + lineHeight: 'inherit', + padding: 'inherit', + boxShadow: 'none', + backgroundColor: euiTheme.colors.lightestShade, + }; + }, [euiTheme]); + + const buttonStyles: React.CSSProperties = useMemo(() => { + return { + padding: 'inherit', + }; + }, []); + + const onInputChange = useCallback( + (e: React.ChangeEvent) => { + dispatch({ + type: 'setName', + payload: e.target.value, + }); + }, + [dispatch] + ); + + const onEdit = useCallback(() => { + setIsEditing(true); + }, []); + + const onCancelEdit = useCallback(() => { + if (isNameInvalid) { + return; + } + setIsEditing(false); + }, [isNameInvalid]); + + const onkeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isNameInvalid) { + return; + } + if (e.key === 'Enter' || e.key === 'Escape') { + setIsEditing(false); + } + }, + [isNameInvalid] + ); + + if (isEditing) { + return ( + + + + +

+ +

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

{name}

+
+
+ ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.test.tsx new file mode 100644 index 000000000000..eb74be908e1b --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.test.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { RulePageShowRequestModal } from './rule_page_show_request_modal'; +import { RuleFormData } from '../types'; + +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), +})); + +const { useRuleFormState } = jest.requireMock('../hooks'); + +const formData: RuleFormData = { + params: { + searchType: 'esQuery', + timeWindowSize: 5, + timeWindowUnit: 'm', + threshold: [1000], + thresholdComparator: '>', + size: 100, + esQuery: '{\n "query":{\n "match_all" : {}\n }\n }', + aggType: 'count', + groupBy: 'all', + termSize: 5, + excludeHitsFromPreviousRun: false, + sourceFields: [], + index: ['.kibana'], + timeField: 'created_at', + }, + consumer: 'stackAlerts', + ruleTypeId: '.es-query', + schedule: { interval: '1m' }, + tags: ['test'], + name: 'test', +}; + +const onCloseMock = jest.fn(); + +describe('rulePageShowRequestModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders create request correctly', async () => { + useRuleFormState.mockReturnValue({ formData, multiConsumerSelection: 'logs' }); + + render(); + + expect(screen.getByTestId('modalHeaderTitle').textContent).toBe('Create alerting rule request'); + expect(screen.getByTestId('modalSubtitle').textContent).toBe( + 'This Kibana request will create this rule.' + ); + expect(screen.getByTestId('modalRequestCodeBlock').textContent).toMatchInlineSnapshot(` + "POST kbn:/api/alerting/rule + { + \\"params\\": { + \\"searchType\\": \\"esQuery\\", + \\"timeWindowSize\\": 5, + \\"timeWindowUnit\\": \\"m\\", + \\"threshold\\": [ + 1000 + ], + \\"thresholdComparator\\": \\">\\", + \\"size\\": 100, + \\"esQuery\\": \\"{\\\\n \\\\\\"query\\\\\\":{\\\\n \\\\\\"match_all\\\\\\" : {}\\\\n }\\\\n }\\", + \\"aggType\\": \\"count\\", + \\"groupBy\\": \\"all\\", + \\"termSize\\": 5, + \\"excludeHitsFromPreviousRun\\": false, + \\"sourceFields\\": [], + \\"index\\": [ + \\".kibana\\" + ], + \\"timeField\\": \\"created_at\\" + }, + \\"consumer\\": \\"logs\\", + \\"schedule\\": { + \\"interval\\": \\"1m\\" + }, + \\"tags\\": [ + \\"test\\" + ], + \\"name\\": \\"test\\", + \\"rule_type_id\\": \\".es-query\\", + \\"actions\\": [] + }" + `); + }); + + test('renders edit request correctly', async () => { + useRuleFormState.mockReturnValue({ + formData, + multiConsumerSelection: 'logs', + id: 'test-id', + }); + + render(); + + expect(screen.getByTestId('modalHeaderTitle').textContent).toBe('Edit alerting rule request'); + expect(screen.getByTestId('modalSubtitle').textContent).toBe( + 'This Kibana request will edit this rule.' + ); + expect(screen.getByTestId('modalRequestCodeBlock').textContent).toMatchInlineSnapshot(` + "PUT kbn:/api/alerting/rule/test-id + { + \\"name\\": \\"test\\", + \\"tags\\": [ + \\"test\\" + ], + \\"schedule\\": { + \\"interval\\": \\"1m\\" + }, + \\"params\\": { + \\"searchType\\": \\"esQuery\\", + \\"timeWindowSize\\": 5, + \\"timeWindowUnit\\": \\"m\\", + \\"threshold\\": [ + 1000 + ], + \\"thresholdComparator\\": \\">\\", + \\"size\\": 100, + \\"esQuery\\": \\"{\\\\n \\\\\\"query\\\\\\":{\\\\n \\\\\\"match_all\\\\\\" : {}\\\\n }\\\\n }\\", + \\"aggType\\": \\"count\\", + \\"groupBy\\": \\"all\\", + \\"termSize\\": 5, + \\"excludeHitsFromPreviousRun\\": false, + \\"sourceFields\\": [], + \\"index\\": [ + \\".kibana\\" + ], + \\"timeField\\": \\"created_at\\" + }, + \\"actions\\": [] + }" + `); + }); + + test('can close modal', () => { + useRuleFormState.mockReturnValue({ + formData, + multiConsumerSelection: 'logs', + id: 'test-id', + }); + + render(); + fireEvent.click(screen.getByLabelText('Closes this modal window')); + expect(onCloseMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.tsx new file mode 100644 index 000000000000..9d405e9aa920 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo } from 'react'; +import { pick, omit } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiCodeBlock, + EuiText, + EuiTextColor, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { BASE_ALERTING_API_PATH } from '../../common/constants'; +import { RuleFormData } from '../types'; +import { + CreateRuleBody, + UPDATE_FIELDS, + UpdateRuleBody, + transformCreateRuleBody, + transformUpdateRuleBody, +} from '../../common/apis'; +import { useRuleFormState } from '../hooks'; + +const stringifyBodyRequest = ({ + formData, + isEdit, +}: { + formData: RuleFormData; + isEdit: boolean; +}): string => { + try { + const request = isEdit + ? transformUpdateRuleBody(pick(formData, UPDATE_FIELDS) as UpdateRuleBody) + : transformCreateRuleBody(omit(formData, 'id') as CreateRuleBody); + return JSON.stringify(request, null, 2); + } catch { + return SHOW_REQUEST_MODAL_ERROR; + } +}; + +export interface RulePageShowRequestModalProps { + onClose: () => void; + isEdit?: boolean; +} + +export const RulePageShowRequestModal = (props: RulePageShowRequestModalProps) => { + const { onClose, isEdit = false } = props; + + const { formData, id, multiConsumerSelection } = useRuleFormState(); + + const formattedRequest = useMemo(() => { + return stringifyBodyRequest({ + formData: { + ...formData, + ...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}), + }, + isEdit, + }); + }, [formData, isEdit, multiConsumerSelection]); + + return ( + + + + + + {SHOW_REQUEST_MODAL_TITLE(isEdit)} + + + + +

+ {SHOW_REQUEST_MODAL_SUBTITLE(isEdit)} +

+
+
+
+
+ + + {`${isEdit ? 'PUT' : 'POST'} kbn:${BASE_ALERTING_API_PATH}/rule${ + isEdit ? `/${id}` : '' + }\n${formattedRequest}`} + + +
+ ); +}; + +const SHOW_REQUEST_MODAL_EDIT = i18n.translate( + 'alertsUIShared.ruleForm.showRequestModal.subheadingTitleEdit', + { + defaultMessage: 'edit', + } +); + +const SHOW_REQUEST_MODAL_CREATE = i18n.translate( + 'alertsUIShared.ruleForm.showRequestModal.subheadingTitleCreate', + { + defaultMessage: 'create', + } +); + +const SHOW_REQUEST_MODAL_SUBTITLE = (edit: boolean) => + i18n.translate('alertsUIShared.ruleForm.showRequestModal.subheadingTitle', { + defaultMessage: 'This Kibana request will {requestType} this rule.', + values: { requestType: edit ? SHOW_REQUEST_MODAL_EDIT : SHOW_REQUEST_MODAL_CREATE }, + }); + +const SHOW_REQUEST_MODAL_TITLE_EDIT = i18n.translate( + 'alertsUIShared.ruleForm.showRequestModal.headerTitleEdit', + { + defaultMessage: 'Edit', + } +); + +const SHOW_REQUEST_MODAL_TITLE_CREATE = i18n.translate( + 'alertsUIShared.ruleForm.showRequestModal.headerTitleCreate', + { + defaultMessage: 'Create', + } +); + +const SHOW_REQUEST_MODAL_TITLE = (edit: boolean) => + i18n.translate('alertsUIShared.ruleForm.showRequestModal.headerTitle', { + defaultMessage: '{requestType} alerting rule request', + values: { + requestType: edit ? SHOW_REQUEST_MODAL_TITLE_EDIT : SHOW_REQUEST_MODAL_TITLE_CREATE, + }, + }); + +const SHOW_REQUEST_MODAL_ERROR = i18n.translate( + 'alertsUIShared.ruleForm.showRequestModal.somethingWentWrongDescription', + { + defaultMessage: 'Sorry about that, something went wrong.', + } +); 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 d9c86b60e7d9..326c384dca52 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts @@ -217,9 +217,253 @@ export const RULE_NAME_INPUT_TITLE = i18n.translate( } ); +export const RULE_NAME_INPUT_BUTTON_ARIA_LABEL = i18n.translate( + 'alertsUIShared.ruleForm.ruleDetails.ruleNameInputButtonAriaLabel', + { + defaultMessage: 'Save rule name', + } +); + export const RULE_TAG_INPUT_TITLE = i18n.translate( 'alertsUIShared.ruleForm.ruleDetails.ruleTagsInputTitle', { defaultMessage: 'Tags', } ); + +export const RULE_TAG_PLACEHOLDER = i18n.translate( + 'alertsUIShared.ruleForm.ruleDetails.ruleTagsPlaceholder', + { + defaultMessage: 'Add tags', + } +); + +export const RULE_NAME_ARIA_LABEL_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.rulePage.ruleNameAriaLabelText', + { + defaultMessage: 'Edit rule name', + } +); + +export const RULE_PAGE_FOOTER_CANCEL_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.rulePageFooter.cancelText', + { + defaultMessage: 'Cancel', + } +); + +export const RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.rulePageFooter.showRequestText', + { + defaultMessage: 'Show request', + } +); + +export const RULE_PAGE_FOOTER_CREATE_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.rulePageFooter.createText', + { + defaultMessage: 'Create rule', + } +); + +export const RULE_PAGE_FOOTER_SAVE_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.rulePageFooter.saveText', + { + defaultMessage: 'Save rule', + } +); + +export const HEALTH_CHECK_ALERTS_ERROR_TITLE = i18n.translate( + 'alertsUIShared.healthCheck.alertsErrorTitle', + { + defaultMessage: 'You must enable Alerting and Actions', + } +); + +export const HEALTH_CHECK_ALERTS_ERROR_TEXT = i18n.translate( + 'alertsUIShared.healthCheck.alertsErrorText', + { + defaultMessage: 'To create a rule, you must enable the alerting and actions plugins.', + } +); + +export const HEALTH_CHECK_ENCRYPTION_ERROR_TITLE = i18n.translate( + 'alertsUIShared.healthCheck.encryptionErrorTitle', + { + defaultMessage: 'Additional setup required', + } +); + +export const HEALTH_CHECK_ENCRYPTION_ERROR_TEXT = i18n.translate( + 'alertsUIShared.healthCheck.encryptionErrorText', + { + defaultMessage: 'You must configure an encryption key to use Alerting.', + } +); + +export const HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE = i18n.translate( + 'alertsUIShared.healthCheck.healthCheck.apiKeysAndEncryptionErrorTitle', + { + defaultMessage: 'Additional setup required', + } +); + +export const HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT = i18n.translate( + 'alertsUIShared.healthCheck.apiKeysAndEncryptionErrorText', + { + defaultMessage: 'You must enable API keys and configure an encryption key to use Alerting.', + } +); + +export const HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE = i18n.translate( + 'alertsUIShared.healthCheck.apiKeysDisabledErrorTitle', + { + defaultMessage: 'Additional setup required', + } +); + +export const HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT = i18n.translate( + 'alertsUIShared.healthCheck.apiKeysDisabledErrorText', + { + defaultMessage: 'You must enable API keys to use Alerting.', + } +); + +export const HEALTH_CHECK_ACTION_TEXT = i18n.translate('alertsUIShared.healthCheck.actionText', { + defaultMessage: 'Learn more.', +}); + +export const RULE_FORM_ROUTE_PARAMS_ERROR_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.routeParamsErrorTitle', + { + defaultMessage: 'Unable to load rule form.', + } +); + +export const RULE_FORM_ROUTE_PARAMS_ERROR_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.routeParamsErrorText', + { + defaultMessage: 'There was an error loading the rule form. Please ensure the route is correct.', + } +); + +export const RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleTypeNotFoundErrorTitle', + { + defaultMessage: 'Unable to load rule type.', + } +); + +export const RULE_FORM_RULE_NOT_FOUND_ERROR_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleNotFoundErrorTitle', + { + defaultMessage: 'Unable to load rule', + } +); + +export const RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.ruleTypeNotFoundErrorText', + { + defaultMessage: + 'There was an error loading the rule type. Please ensure you have access to the rule type selected.', + } +); + +export const RULE_FORM_RULE_NOT_FOUND_ERROR_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.ruleNotFoundErrorText', + { + defaultMessage: + 'There was an error loading the rule. Please ensure you have access to the rule selected.', + } +); + +export const RULE_CREATE_SUCCESS_TEXT = (ruleName: string) => + i18n.translate('alertsUIShared.ruleForm.createSuccessText', { + defaultMessage: 'Created rule "{ruleName}"', + values: { + ruleName, + }, + }); + +export const RULE_CREATE_ERROR_TEXT = i18n.translate('alertsUIShared.ruleForm.createErrorText', { + defaultMessage: 'Cannot create rule.', +}); + +export const RULE_EDIT_ERROR_TEXT = i18n.translate('alertsUIShared.ruleForm.editErrorText', { + defaultMessage: 'Cannot update rule.', +}); + +export const RULE_EDIT_SUCCESS_TEXT = (ruleName: string) => + i18n.translate('alertsUIShared.ruleForm.editSuccessText', { + defaultMessage: 'Updated "{ruleName}"', + values: { + ruleName, + }, + }); + +export const CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.circuitBreakerSeeFullErrorText', + { + defaultMessage: 'See full error', + } +); + +export const CIRCUIT_BREAKER_HIDE_FULL_ERROR_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.circuitBreakerHideFullErrorText', + { + defaultMessage: 'Hide full error', + } +); + +export const CONFIRMATION_RULE_SAVE_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.confirmRuleSaveTitle', + { + defaultMessage: 'Save rule with no actions?', + } +); + +export const CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.confirmRuleSaveConfirmButtonText', + { + defaultMessage: 'Save rule', + } +); + +export const CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.confirmRuleSaveCancelButtonText', + { + defaultMessage: 'Cancel', + } +); + +export const CONFIRM_RULE_SAVE_MESSAGE_TEXT = i18n.translate( + 'alertsUIShared.ruleForm.confirmRuleSaveMessageText', + { + defaultMessage: 'You can add an action at anytime.', + } +); + +export const RULE_FORM_PAGE_RULE_DEFINITION_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinitionTitle', + { + defaultMessage: 'Rule definition', + } +); + +export const RULE_FORM_PAGE_RULE_ACTIONS_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleActionsTitle', + { + defaultMessage: 'Actions', + } +); + +export const RULE_FORM_PAGE_RULE_DETAILS_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDetailsTitle', + { + defaultMessage: 'Rule details', + } +); + +export const RULE_FORM_RETURN_TITLE = i18n.translate('alertsUIShared.ruleForm.returnTitle', { + defaultMessage: 'Return', +}); 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 dff86d5ce61f..9b2d5bfac281 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts @@ -6,7 +6,27 @@ * Side Public License, v 1. */ -import { Rule, RuleTypeParams } from '../common'; +import type { DocLinksStart } from '@kbn/core-doc-links-browser'; +import type { HttpStart } from '@kbn/core-http-browser'; +import type { I18nStart } from '@kbn/core-i18n-browser'; +import type { ThemeServiceStart } from '@kbn/core-theme-browser'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { NotificationsStart } from '@kbn/core-notifications-browser'; +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { + MinimumScheduleInterval, + Rule, + RuleFormBaseErrors, + RuleFormParamsErrors, + RuleTypeModel, + RuleTypeParams, + RuleTypeRegistryContract, + RuleTypeWithDescription, +} from '../common/types'; export interface RuleFormData { name: Rule['name']; @@ -19,5 +39,34 @@ export interface RuleFormData { ruleTypeId?: Rule['ruleTypeId']; } +export interface RuleFormPlugins { + http: HttpStart; + i18n: I18nStart; + theme: ThemeServiceStart; + application: ApplicationStart; + notification: NotificationsStart; + charts: ChartsPluginSetup; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; + docLinks: DocLinksStart; + ruleTypeRegistry: RuleTypeRegistryContract; +} + +export interface RuleFormState { + id?: string; + formData: RuleFormData; + plugins: RuleFormPlugins; + baseErrors?: RuleFormBaseErrors; + paramsErrors?: RuleFormParamsErrors; + selectedRuleType: RuleTypeWithDescription; + selectedRuleTypeModel: RuleTypeModel; + multiConsumerSelection?: RuleCreationValidConsumer | null; + metadata?: Record; + minimumScheduleInterval?: MinimumScheduleInterval; + canShowConsumerSelection?: boolean; + validConsumers?: RuleCreationValidConsumer[]; +} + export type InitialRule = Partial & Pick; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts new file mode 100644 index 000000000000..7fdde54ec53b --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { RuleTypeWithDescription } from '../../common/types'; + +export const getAuthorizedConsumers = ({ + ruleType, + validConsumers, +}: { + ruleType: RuleTypeWithDescription; + validConsumers: RuleCreationValidConsumer[]; +}) => { + if (!ruleType.authorizedConsumers) { + return []; + } + return Object.entries(ruleType.authorizedConsumers).reduce( + (result, [authorizedConsumer, privilege]) => { + if ( + privilege.all && + validConsumers.includes(authorizedConsumer as RuleCreationValidConsumer) + ) { + result.push(authorizedConsumer as RuleCreationValidConsumer); + } + return result; + }, + [] + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_rule_types.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_rule_types.ts new file mode 100644 index 000000000000..38e63c58d739 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_rule_types.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { + RuleTypeModel, + RuleTypeRegistryContract, + RuleTypeWithDescription, +} from '../../common/types'; +import { ALERTING_FEATURE_ID, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; + +export type RuleTypeItems = Array<{ + ruleTypeModel: RuleTypeModel; + ruleType: RuleTypeWithDescription; +}>; + +const hasAllPrivilege = (consumer: string, ruleType: RuleTypeWithDescription): boolean => { + return ruleType.authorizedConsumers[consumer]?.all ?? false; +}; + +const authorizedToDisplayRuleType = ({ + consumer, + ruleType, + validConsumers, +}: { + consumer: string; + ruleType: RuleTypeWithDescription; + validConsumers?: RuleCreationValidConsumer[]; +}) => { + if (!ruleType) { + return false; + } + // If we have a generic threshold/ES query rule... + if (MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleType.id)) { + // And an array of valid consumers are passed in, we will show it + // if the rule type has at least one of the consumers as authorized + if (Array.isArray(validConsumers)) { + return validConsumers.some((c) => hasAllPrivilege(c, ruleType)); + } + // If no array was passed in, then we will show it if at least one of its + // authorized consumers allows it to be shown. + return Object.entries(ruleType.authorizedConsumers).some(([_, privilege]) => { + return privilege.all; + }); + } + // For non-generic threshold/ES query rules, we will still do the check + // against `alerts` since we are still setting rule consumers to `alerts` + return hasAllPrivilege(consumer, ruleType); +}; + +export const getAvailableRuleTypes = ({ + consumer, + ruleTypes, + ruleTypeRegistry, + validConsumers, +}: { + consumer: string; + ruleTypes: RuleTypeWithDescription[]; + ruleTypeRegistry: RuleTypeRegistryContract; + validConsumers?: RuleCreationValidConsumer[]; +}): RuleTypeItems => { + return ruleTypeRegistry + .list() + .reduce((arr: RuleTypeItems, ruleTypeRegistryItem: RuleTypeModel) => { + const ruleType = ruleTypes.find((item) => ruleTypeRegistryItem.id === item.id); + if (ruleType) { + arr.push({ + ruleType, + ruleTypeModel: ruleTypeRegistryItem, + }); + } + return arr; + }, []) + .filter(({ ruleType }) => + authorizedToDisplayRuleType({ + consumer, + ruleType, + validConsumers, + }) + ) + .filter((item) => + consumer === ALERTING_FEATURE_ID + ? !item.ruleTypeModel.requiresAppContext + : item.ruleType!.producer === consumer + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_errors.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_errors.ts deleted file mode 100644 index b0ff1f1fd067..000000000000 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_errors.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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { - RuleTypeModel, - RuleFormErrors, - ValidationResult, - MinimumScheduleInterval, -} from '../../common'; -import { parseDuration, formatDuration } from './parse_duration'; -import { - NAME_REQUIRED_TEXT, - CONSUMER_REQUIRED_TEXT, - RULE_TYPE_REQUIRED_TEXT, - INTERVAL_REQUIRED_TEXT, - INTERVAL_MINIMUM_TEXT, - RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT, -} from '../translations'; -import { InitialRule } from '../types'; - -export function validateBaseProperties({ - rule, - minimumScheduleInterval, -}: { - rule: InitialRule; - minimumScheduleInterval?: MinimumScheduleInterval; -}): ValidationResult { - const validationResult = { errors: {} }; - - const errors = { - name: new Array(), - interval: new Array(), - consumer: new Array(), - ruleTypeId: new Array(), - actionConnectors: new Array(), - alertDelay: new Array(), - }; - - validationResult.errors = errors; - - if (!rule.name) { - errors.name.push(NAME_REQUIRED_TEXT); - } - - if (rule.consumer === null) { - errors.consumer.push(CONSUMER_REQUIRED_TEXT); - } - - if (rule.schedule.interval.length < 2) { - errors.interval.push(INTERVAL_REQUIRED_TEXT); - } else if (minimumScheduleInterval && minimumScheduleInterval.enforce) { - const duration = parseDuration(rule.schedule.interval); - const minimumDuration = parseDuration(minimumScheduleInterval.value); - if (duration < minimumDuration) { - errors.interval.push( - INTERVAL_MINIMUM_TEXT(formatDuration(minimumScheduleInterval.value, true)) - ); - } - } - - if (!rule.ruleTypeId) { - errors.ruleTypeId.push(RULE_TYPE_REQUIRED_TEXT); - } - - if (rule.alertDelay?.active && rule.alertDelay?.active < 1) { - errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT); - } - - return validationResult; -} - -export function getRuleErrors({ - rule, - ruleTypeModel, - minimumScheduleInterval, - isServerless, -}: { - rule: InitialRule; - ruleTypeModel: RuleTypeModel | null; - minimumScheduleInterval?: MinimumScheduleInterval; - isServerless?: boolean; -}) { - const ruleParamsErrors: RuleFormErrors = ruleTypeModel - ? ruleTypeModel.validate(rule.params, isServerless).errors - : {}; - - const ruleBaseErrors = validateBaseProperties({ - rule, - minimumScheduleInterval, - }).errors as RuleFormErrors; - - const ruleErrors = { - ...ruleParamsErrors, - ...ruleBaseErrors, - } as RuleFormErrors; - - return { - ruleParamsErrors, - ruleBaseErrors, - ruleErrors, - }; -} diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_consumer.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_consumer.ts new file mode 100644 index 000000000000..15d61ac186e1 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_consumer.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RuleTypeWithDescription } from '../../common'; +import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; + +export const getInitialConsumer = ({ + consumer, + ruleType, + shouldUseRuleProducer, +}: { + consumer: string; + ruleType: RuleTypeWithDescription; + shouldUseRuleProducer: boolean; +}) => { + if (shouldUseRuleProducer && !MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleType.id)) { + return ruleType.producer; + } + return consumer; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_multi_consumer.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_multi_consumer.ts new file mode 100644 index 000000000000..029c0de434a7 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_multi_consumer.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils'; +import { RuleTypeWithDescription } from '../../common/types'; +import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; +import { FEATURE_NAME_MAP } from '../translations'; + +export const getValidatedMultiConsumer = ({ + multiConsumerSelection, + validConsumers, +}: { + multiConsumerSelection?: RuleCreationValidConsumer | null; + validConsumers: RuleCreationValidConsumer[]; +}) => { + if ( + multiConsumerSelection && + validConsumers.includes(multiConsumerSelection) && + FEATURE_NAME_MAP[multiConsumerSelection] + ) { + return multiConsumerSelection; + } + return null; +}; + +export const getInitialMultiConsumer = ({ + multiConsumerSelection, + validConsumers, + ruleType, +}: { + multiConsumerSelection?: RuleCreationValidConsumer | null; + validConsumers: RuleCreationValidConsumer[]; + ruleType: RuleTypeWithDescription; +}): RuleCreationValidConsumer | null => { + // If rule type doesn't support multi-consumer or no valid consumers exists, + // return nothing + if (!MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleType.id) || validConsumers.length === 0) { + return null; + } + + // Use the only value in valid consumers + if (validConsumers.length === 1) { + return validConsumers[0]; + } + + // If o11y is in the valid consumers, just use that + if (validConsumers.includes(AlertConsumers.OBSERVABILITY)) { + return AlertConsumers.OBSERVABILITY; + } + + // User passed in null explicitly, won't set initial consumer + if (multiConsumerSelection === null) { + return null; + } + + const validatedConsumer = getValidatedMultiConsumer({ + multiConsumerSelection, + validConsumers, + }); + + // If validated consumer exists and no o11y in valid consumers, just use that + if (validatedConsumer) { + return validatedConsumer; + } + + // If validated consumer doesn't exist and stack alerts does, use that + if (validConsumers.includes(AlertConsumers.STACK_ALERTS)) { + return AlertConsumers.STACK_ALERTS; + } + + // All else fails, just use the first valid consumer + return validConsumers[0]; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_schedule.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_schedule.ts new file mode 100644 index 000000000000..7e2226c1094c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_schedule.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseDuration } from './parse_duration'; +import { DEFAULT_RULE_INTERVAL } from '../constants'; +import { MinimumScheduleInterval, RuleTypeWithDescription } from '../../common/types'; +import { RuleFormData } from '../types'; + +const getInitialInterval = (interval: string) => { + if (parseDuration(interval) > parseDuration(DEFAULT_RULE_INTERVAL)) { + return interval; + } + return DEFAULT_RULE_INTERVAL; +}; + +export const getInitialSchedule = ({ + ruleType, + minimumScheduleInterval, + initialSchedule, +}: { + ruleType: RuleTypeWithDescription; + minimumScheduleInterval?: MinimumScheduleInterval; + initialSchedule?: RuleFormData['schedule']; +}): RuleFormData['schedule'] => { + if (initialSchedule) { + return initialSchedule; + } + + if (minimumScheduleInterval?.value) { + return { interval: getInitialInterval(minimumScheduleInterval.value) }; + } + + if (ruleType.defaultScheduleInterval) { + return { interval: ruleType.defaultScheduleInterval }; + } + + return { interval: DEFAULT_RULE_INTERVAL }; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts index b25e2f561a86..8c17eb7e17aa 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts @@ -6,6 +6,11 @@ * Side Public License, v 1. */ -export * from './get_errors'; export * from './get_time_options'; export * from './parse_duration'; +export * from './parse_rule_circuit_breaker_error_message'; +export * from './get_authorized_rule_types'; +export * from './get_authorized_consumers'; +export * from './get_initial_multi_consumer'; +export * from './get_initial_schedule'; +export * from './get_initial_consumer'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.ts index 81578eb5ae71..e1d65c64599f 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_duration.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export const DEFAULT_RULE_INTERVAL = '1m'; +import { DEFAULT_RULE_INTERVAL } from '../constants'; const SECONDS_REGEX = /^[1-9][0-9]*s$/; const MINUTES_REGEX = /^[1-9][0-9]*m$/; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_rule_circuit_breaker_error_message.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_rule_circuit_breaker_error_message.ts new file mode 100644 index 000000000000..93e5eb120500 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/parse_rule_circuit_breaker_error_message.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errorMessageHeader } from '@kbn/alerting-types'; + +export const parseRuleCircuitBreakerErrorMessage = ( + message: string +): { + summary: string; + details?: string; +} => { + if (!message.includes(errorMessageHeader)) { + return { + summary: message, + }; + } + const segments = message.split(' - '); + return { + summary: segments[1], + details: segments[2], + }; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/index.ts new file mode 100644 index 000000000000..f6ff7478601c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './validate_form'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.test.ts new file mode 100644 index 000000000000..1719ee68f399 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.test.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { validateRuleBase, validateRuleParams, hasRuleErrors } from './validate_form'; +import { RuleFormData } from '../types'; +import { + CONSUMER_REQUIRED_TEXT, + INTERVAL_MINIMUM_TEXT, + INTERVAL_REQUIRED_TEXT, + NAME_REQUIRED_TEXT, + RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT, + RULE_TYPE_REQUIRED_TEXT, +} from '../translations'; +import { formatDuration } from '../utils'; +import { RuleTypeModel } from '../../common'; + +const formDataMock: RuleFormData = { + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'alert.executionStatus.lastExecutionDate', + }, + consumer: 'stackAlerts', + schedule: { interval: '1m' }, + tags: [], + name: 'test', + notifyWhen: 'onActionGroupChange', + alertDelay: { + active: 10, + }, +}; + +const ruleTypeModelMock = { + validate: jest.fn().mockReturnValue({ + errors: { + someError: 'test', + }, + }), +}; + +describe('validateRuleBase', () => { + test('should validate name', () => { + const result = validateRuleBase({ + formData: { + ...formDataMock, + name: '', + }, + }); + expect(result.name).toEqual([NAME_REQUIRED_TEXT]); + }); + + test('should validate consumer', () => { + const result = validateRuleBase({ + formData: { + ...formDataMock, + consumer: '', + }, + }); + expect(result.consumer).toEqual([CONSUMER_REQUIRED_TEXT]); + }); + + test('should validate schedule', () => { + let result = validateRuleBase({ + formData: { + ...formDataMock, + schedule: { + interval: '1', + }, + }, + }); + expect(result.interval).toEqual([INTERVAL_REQUIRED_TEXT]); + + result = validateRuleBase({ + formData: { + ...formDataMock, + schedule: { + interval: '1m', + }, + }, + minimumScheduleInterval: { + value: '5m', + enforce: true, + }, + }); + expect(result.interval).toEqual([INTERVAL_MINIMUM_TEXT(formatDuration('5m', true))]); + }); + + test('should validate rule type ID', () => { + const result = validateRuleBase({ + formData: { + ...formDataMock, + ruleTypeId: '', + }, + }); + expect(result.ruleTypeId).toEqual([RULE_TYPE_REQUIRED_TEXT]); + }); + + test('should validate alert delay', () => { + const result = validateRuleBase({ + formData: { + ...formDataMock, + alertDelay: { + active: 0, + }, + }, + }); + expect(result.alertDelay).toEqual([RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT]); + }); +}); + +describe('validateRuleParams', () => { + test('should validate rule params', () => { + const result = validateRuleParams({ + formData: formDataMock, + ruleTypeModel: ruleTypeModelMock as unknown as RuleTypeModel, + isServerless: false, + }); + + expect(ruleTypeModelMock.validate).toHaveBeenCalledWith( + { + aggType: 'count', + groupBy: 'all', + index: ['.kibana'], + termSize: 5, + threshold: [1000], + thresholdComparator: '>', + timeField: 'alert.executionStatus.lastExecutionDate', + timeWindowSize: 5, + timeWindowUnit: 'm', + }, + false + ); + + expect(result).toEqual({ + someError: 'test', + }); + }); +}); +describe('hasRuleErrors', () => { + test('should return false if there are no errors', () => { + const result = hasRuleErrors({ + baseErrors: {}, + paramsErrors: {}, + }); + + expect(result).toBeFalsy(); + }); + + test('should return true if base has errors', () => { + const result = hasRuleErrors({ + baseErrors: { + name: ['error'], + }, + paramsErrors: {}, + }); + + expect(result).toBeTruthy(); + }); + + test('should return true if params have errors', () => { + let result = hasRuleErrors({ + baseErrors: {}, + paramsErrors: { + someValue: ['error'], + }, + }); + + expect(result).toBeTruthy(); + + result = hasRuleErrors({ + baseErrors: {}, + paramsErrors: { + someNestedValue: { + someValue: ['error'], + }, + }, + }); + + expect(result).toBeTruthy(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts new file mode 100644 index 000000000000..012a1dcf83d7 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isObject } from 'lodash'; +import { RuleFormData } from '../types'; +import { parseDuration, formatDuration } from '../utils'; +import { + NAME_REQUIRED_TEXT, + CONSUMER_REQUIRED_TEXT, + RULE_TYPE_REQUIRED_TEXT, + INTERVAL_REQUIRED_TEXT, + INTERVAL_MINIMUM_TEXT, + RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT, +} from '../translations'; +import { + MinimumScheduleInterval, + RuleFormBaseErrors, + RuleFormParamsErrors, + RuleTypeModel, +} from '../../common'; + +export function validateRuleBase({ + formData, + minimumScheduleInterval, +}: { + formData: RuleFormData; + minimumScheduleInterval?: MinimumScheduleInterval; +}): RuleFormBaseErrors { + const errors = { + name: new Array(), + interval: new Array(), + consumer: new Array(), + ruleTypeId: new Array(), + actionConnectors: new Array(), + alertDelay: new Array(), + tags: new Array(), + }; + + if (!formData.name) { + errors.name.push(NAME_REQUIRED_TEXT); + } + + if (!formData.consumer) { + errors.consumer.push(CONSUMER_REQUIRED_TEXT); + } + + if (formData.schedule.interval.length < 2) { + errors.interval.push(INTERVAL_REQUIRED_TEXT); + } else if (minimumScheduleInterval && minimumScheduleInterval.enforce) { + const duration = parseDuration(formData.schedule.interval); + const minimumDuration = parseDuration(minimumScheduleInterval.value); + if (duration < minimumDuration) { + errors.interval.push( + INTERVAL_MINIMUM_TEXT(formatDuration(minimumScheduleInterval.value, true)) + ); + } + } + + if (!formData.ruleTypeId) { + errors.ruleTypeId.push(RULE_TYPE_REQUIRED_TEXT); + } + + if ( + formData.alertDelay && + !isNaN(formData.alertDelay?.active) && + formData.alertDelay?.active < 1 + ) { + errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT); + } + + return errors; +} + +export const validateRuleParams = ({ + formData, + ruleTypeModel, + isServerless, +}: { + formData: RuleFormData; + ruleTypeModel: RuleTypeModel; + isServerless?: boolean; +}): RuleFormParamsErrors => { + return ruleTypeModel.validate(formData.params, isServerless).errors; +}; + +const hasRuleBaseErrors = (errors: RuleFormBaseErrors) => { + return Object.values(errors).some((error: string[]) => error.length > 0); +}; + +const hasRuleParamsErrors = (errors: RuleFormParamsErrors): boolean => { + const values = Object.values(errors); + let hasError = false; + for (const value of values) { + if (Array.isArray(value) && value.length > 0) { + return true; + } + if (typeof value === 'string' && value.trim() !== '') { + return true; + } + if (isObject(value)) { + hasError = hasRuleParamsErrors(value as RuleFormParamsErrors); + } + } + return hasError; +}; + +export const hasRuleErrors = ({ + baseErrors, + paramsErrors, +}: { + baseErrors: RuleFormBaseErrors; + paramsErrors: RuleFormParamsErrors; +}): boolean => { + return hasRuleBaseErrors(baseErrors) || hasRuleParamsErrors(paramsErrors); +}; diff --git a/packages/kbn-alerts-ui-shared/tsconfig.json b/packages/kbn-alerts-ui-shared/tsconfig.json index c22c1ac1beac..8edc3ff6eda7 100644 --- a/packages/kbn-alerts-ui-shared/tsconfig.json +++ b/packages/kbn-alerts-ui-shared/tsconfig.json @@ -38,5 +38,9 @@ "@kbn/charts-plugin", "@kbn/data-plugin", "@kbn/utility-types", + "@kbn/core-application-browser", + "@kbn/react-kibana-mount", + "@kbn/core-i18n-browser", + "@kbn/core-theme-browser", ] } diff --git a/packages/kbn-apm-synthtrace-client/src/lib/infra/docker_container.ts b/packages/kbn-apm-synthtrace-client/src/lib/infra/docker_container.ts index 2ed426a2a689..f93e5e1ae259 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/infra/docker_container.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/infra/docker_container.ts @@ -51,5 +51,13 @@ class DockerContainerMetrics extends Serializable { @@ -126,5 +130,9 @@ export function host(name: string): Host { 'agent.id': 'synthtrace', 'host.hostname': name, 'host.name': name, + 'host.ip': '10.128.0.2', + 'host.os.name': 'Linux', + 'host.os.version': '4.19.76-linuxkit', + 'cloud.provider': 'gcp', }); } diff --git a/packages/kbn-apm-synthtrace-client/src/lib/infra/k8s_container.ts b/packages/kbn-apm-synthtrace-client/src/lib/infra/k8s_container.ts index d2036555919c..6aa813913c11 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/infra/k8s_container.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/infra/k8s_container.ts @@ -47,5 +47,13 @@ export function k8sContainer(id: string, uid: string, nodeName: string): K8sCont 'container.id': id, 'kubernetes.pod.uid': uid, 'kubernetes.node.name': nodeName, + 'container.name': `container-${id}`, + 'container.runtime': 'containerd', + 'container.image.name': 'image-1', + 'host.name': 'host-1', + 'cloud.instance.id': 'instance-1', + 'cloud.image.id': 'image-1', + 'cloud.provider': 'aws', + 'event.dataset': 'kubernetes.container', }); } 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 489221eea3d7..4e7ab744d8ec 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/logs/index.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/logs/index.ts @@ -14,6 +14,7 @@ export type LogDocument = Fields & 'input.type': string; 'log.file.path'?: string; 'service.name'?: string; + 'service.environment'?: string; 'data_stream.namespace': string; 'data_stream.type': string; 'data_stream.dataset': string; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/infra_docker_containers.ts b/packages/kbn-apm-synthtrace/src/scenarios/infra_docker_containers.ts index 6df70ae0641d..1df91d130214 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/infra_docker_containers.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/infra_docker_containers.ts @@ -21,17 +21,7 @@ const scenario: Scenario = async (runOptions) => { .fill(0) .map((_, idx) => { const id = generateShortId(); - return infra.dockerContainer(id).defaults({ - 'container.name': `container-${idx}`, - 'container.id': id, - 'container.runtime': 'docker', - 'container.image.name': 'image-1', - 'host.name': 'host-1', - 'cloud.instance.id': 'instance-1', - 'cloud.image.id': 'image-1', - 'cloud.provider': 'aws', - 'event.dataset': 'docker.container', - }); + return infra.dockerContainer(id); }); const containers = range diff --git a/packages/kbn-apm-synthtrace/src/scenarios/infra_k8s_containers.ts b/packages/kbn-apm-synthtrace/src/scenarios/infra_k8s_containers.ts index 340da647bb9d..410b342e2eb3 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/infra_k8s_containers.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/infra_k8s_containers.ts @@ -21,19 +21,7 @@ const scenario: Scenario = async (runOptions) => { .fill(0) .map((_, idx) => { const id = generateShortId(); - return infra.k8sContainer(id, `pod-${idx}`, `node-${idx}`).defaults({ - 'container.id': id, - 'kubernetes.pod.uid': `pod-${idx}`, - 'kubernetes.node.name': `node-${idx}`, - 'container.name': `container-${idx}`, - 'container.runtime': 'docker', - 'container.image.name': 'image-1', - 'host.name': 'host-1', - 'cloud.instance.id': 'instance-1', - 'cloud.image.id': 'image-1', - 'cloud.provider': 'aws', - 'event.dataset': 'kubernetes.container', - }); + return infra.k8sContainer(id, `pod-${idx}`, `node-${idx}`); }); const containers = range diff --git a/packages/kbn-config-mocks/src/config_service.mock.ts b/packages/kbn-config-mocks/src/config_service.mock.ts index 268f5a455802..440662ee850e 100644 --- a/packages/kbn-config-mocks/src/config_service.mock.ts +++ b/packages/kbn-config-mocks/src/config_service.mock.ts @@ -29,6 +29,7 @@ const createConfigServiceMock = ({ getDeprecatedConfigPath$: jest.fn(), addDynamicConfigPaths: jest.fn(), setDynamicConfigOverrides: jest.fn(), + setGlobalStripUnknownKeys: jest.fn(), }; mocked.atPath.mockReturnValue(new BehaviorSubject(atPath)); diff --git a/packages/kbn-config-schema/README.md b/packages/kbn-config-schema/README.md index 45e46c078bae..10d96578d468 100644 --- a/packages/kbn-config-schema/README.md +++ b/packages/kbn-config-schema/README.md @@ -64,6 +64,8 @@ Every schema instance has a `validate` method that is used to perform a validati * `data: any` - **required**, data to be validated with the schema * `context: Record` - **optional**, object whose properties can be referenced by the [context references](#schemacontextref) * `namespace: string` - **optional**, arbitrary string that is used to prefix every error message thrown during validation +* `validationOptions: SchemaValidationOptions` - **optional**, global options to modify the default validation behavior + * `stripUnknownKeys: boolean` - **optional**, when `true`, it changes the default `unknowns: 'forbid'` to behave like `unknowns: 'ignore'`. This change of behavior only occurs in schemas without an explicit `unknowns` option. Refer to [`schema.object()`](#schemaobject) for more information about the `unknowns` option. ```typescript const valueSchema = schema.object({ @@ -243,7 +245,7 @@ __Output type:__ `{ [K in keyof TProps]: TypeOf } as TObject` __Options:__ * `defaultValue: TObject | Reference | (() => TObject)` - defines a default value, see [Default values](#default-values) section for more details. * `validate: (value: TObject) => string | void` - defines a custom validator function, see [Custom validation](#custom-validation) section for more details. - * `unknowns: 'allow' | 'ignore' | 'forbid'` - indicates whether unknown object properties should be allowed, ignored, or forbidden. It's `forbid` by default. + * `unknowns: 'allow' | 'ignore' | 'forbid'` - indicates whether unknown object properties and sub-properties should be allowed, ignored, or forbidden. It is `forbid` by default unless the global validation option `stripUnknownKeys` is set to `true` when calling `validate()`. Refer to [the `validate()` API options](#schema-building-blocks) to learn about `stripUnknownKeys`. __Usage:__ ```typescript @@ -255,6 +257,7 @@ const valueSchema = schema.object({ __Notes:__ * Using `unknowns: 'allow'` is discouraged and should only be used in exceptional circumstances. Consider using `schema.recordOf()` instead. +* Bear in mind that specifying `unknowns: 'allow' | 'ignore' | 'forbid'` applies to the entire tree of sub-objects. If you want this option to apply only to the properties in first level, make sure to override this option by setting a new `unknowns` option in the child `schema.object()`s. * Currently `schema.object()` always has a default value of `{}`, but this may change in the near future. Try to not rely on this behaviour and specify default value explicitly or use `schema.maybe()` if the value is optional. * `schema.object()` also supports a json string as input if it can be safely parsed using `JSON.parse` and if the resulting value is a plain object. diff --git a/packages/kbn-config-schema/index.ts b/packages/kbn-config-schema/index.ts index 819563160638..e0adf545f454 100644 --- a/packages/kbn-config-schema/index.ts +++ b/packages/kbn-config-schema/index.ts @@ -57,6 +57,7 @@ import { export type { AnyType, ConditionalType, TypeOf, Props, SchemaStructureEntry, NullableProps }; export { ObjectType, Type }; +export type { SchemaValidationOptions } from './src/types'; export { ByteSizeValue } from './src/byte_size_value'; export { SchemaTypeError, ValidationError } from './src/errors'; export { isConfigSchema } from './src/typeguards'; diff --git a/packages/kbn-config-schema/src/types/index.ts b/packages/kbn-config-schema/src/types/index.ts index 4c92bb6d078d..0cd2e8ec533d 100644 --- a/packages/kbn-config-schema/src/types/index.ts +++ b/packages/kbn-config-schema/src/types/index.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -export type { TypeOptions } from './type'; -export type { SchemaStructureEntry } from './type'; +export type { SchemaStructureEntry, SchemaValidationOptions, TypeOptions } from './type'; export { Type } from './type'; export { AnyType } from './any_type'; export type { ArrayOptions } from './array_type'; diff --git a/packages/kbn-config-schema/src/types/object_type.test.ts b/packages/kbn-config-schema/src/types/object_type.test.ts index bdf72c585b9e..d12688fa0409 100644 --- a/packages/kbn-config-schema/src/types/object_type.test.ts +++ b/packages/kbn-config-schema/src/types/object_type.test.ts @@ -338,12 +338,32 @@ test('allow and remove unknown keys when unknowns = `ignore`', () => { }); }); -test('unknowns = `ignore` affects only own keys', () => { +test('unknowns = `ignore` is recursive if no explicit preferences in sub-keys', () => { const type = schema.object( { foo: schema.object({ bar: schema.string() }) }, { unknowns: 'ignore' } ); + expect( + type.validate({ + foo: { + bar: 'bar', + baz: 'baz', + }, + }) + ).toEqual({ + foo: { + bar: 'bar', + }, + }); +}); + +test('unknowns = `ignore` respects local preferences in sub-keys', () => { + const type = schema.object( + { foo: schema.object({ bar: schema.string() }, { unknowns: 'forbid' }) }, + { unknowns: 'ignore' } + ); + expect(() => type.validate({ foo: { @@ -354,7 +374,7 @@ test('unknowns = `ignore` affects only own keys', () => { ).toThrowErrorMatchingInlineSnapshot(`"[foo.baz]: definition for this key is missing"`); }); -describe('nested unknows', () => { +describe('nested unknowns', () => { test('allow unknown keys when unknowns = `allow`', () => { const type = schema.object({ myObj: schema.object({ foo: schema.string({ defaultValue: 'test' }) }, { unknowns: 'allow' }), @@ -427,8 +447,7 @@ describe('nested unknows', () => { }, }); }); - - test('unknowns = `ignore` affects only own keys', () => { + test('unknowns = `ignore` is recursive if no explicit preferences in sub-keys', () => { const type = schema.object({ myObj: schema.object( { foo: schema.object({ bar: schema.string() }) }, @@ -436,6 +455,32 @@ describe('nested unknows', () => { ), }); + expect( + type.validate({ + myObj: { + foo: { + bar: 'bar', + baz: 'baz', + }, + }, + }) + ).toEqual({ + myObj: { + foo: { + bar: 'bar', + }, + }, + }); + }); + + test('unknowns = `ignore` respects local preferences in sub-keys', () => { + const type = schema.object({ + myObj: schema.object( + { foo: schema.object({ bar: schema.string() }, { unknowns: 'forbid' }) }, + { unknowns: 'ignore' } + ), + }); + expect(() => type.validate({ myObj: { diff --git a/packages/kbn-config-schema/src/types/object_type.ts b/packages/kbn-config-schema/src/types/object_type.ts index 76d0bab474ec..44c81c213fd3 100644 --- a/packages/kbn-config-schema/src/types/object_type.ts +++ b/packages/kbn-config-schema/src/types/object_type.ts @@ -87,16 +87,11 @@ export class ObjectType

extends Type> constructor(props: P, options: ObjectTypeOptions

= {}) { const schemaKeys = {} as Record; - const { unknowns = 'forbid', ...typeOptions } = options; + const { unknowns, ...typeOptions } = options; for (const [key, value] of Object.entries(props)) { schemaKeys[key] = value.getSchema(); } - let schema = internals - .object() - .keys(schemaKeys) - .default() - .optional() - .options({ stripUnknown: { objects: unknowns === 'ignore' } }); + let schema = internals.object().keys(schemaKeys).default().optional(); // We need to specify the `.unknown` property only when we want to override the default `forbid` // or it will break `stripUnknown` functionality. @@ -104,6 +99,11 @@ export class ObjectType

extends Type> schema = schema.unknown(unknowns === 'allow'); } + // Only set stripUnknown if we have an explicit value of `unknowns` + if (unknowns) { + schema = schema.options({ stripUnknown: { objects: unknowns === 'ignore' } }); + } + if (options.meta?.id) { schema = schema.id(options.meta.id); } diff --git a/packages/kbn-config-schema/src/types/type.ts b/packages/kbn-config-schema/src/types/type.ts index 3b8808dba61a..d7e02e439ae3 100644 --- a/packages/kbn-config-schema/src/types/type.ts +++ b/packages/kbn-config-schema/src/types/type.ts @@ -37,6 +37,16 @@ export interface SchemaStructureEntry { type: string; } +/** + * Global validation Options to be provided when calling the `schema.validate()` method. + */ +export interface SchemaValidationOptions { + /** + * Remove unknown config keys + */ + stripUnknownKeys?: boolean; +} + /** * Options for dealing with unknown keys: * - allow: unknown keys will be permitted @@ -129,10 +139,16 @@ export abstract class Type { * Validates the provided value against this schema. * If valid, the resulting output will be returned, otherwise an exception will be thrown. */ - public validate(value: unknown, context: Record = {}, namespace?: string): V { + public validate( + value: unknown, + context: Record = {}, + namespace?: string, + validationOptions?: SchemaValidationOptions + ): V { const { value: validatedValue, error } = this.internalSchema.validate(value, { context, presence: 'required', + stripUnknown: { objects: validationOptions?.stripUnknownKeys === true }, }); if (error) { diff --git a/packages/kbn-config/src/config_service.ts b/packages/kbn-config/src/config_service.ts index 0ad29cb79343..51a83f82664e 100644 --- a/packages/kbn-config/src/config_service.ts +++ b/packages/kbn-config/src/config_service.ts @@ -47,6 +47,7 @@ export class ConfigService { private readonly deprecationLog: Logger; private readonly docLinks: DocLinks; + private stripUnknownKeys = false; private validated = false; private readonly config$: Observable; private lastConfig?: Config; @@ -96,6 +97,14 @@ export class ConfigService { ); } + /** + * Set the global setting for stripUnknownKeys. Useful for running in Serverless-compatible way. + * @param stripUnknownKeys Set to `true` if unknown keys (not explicitly forbidden) should be dropped without failing validation + */ + public setGlobalStripUnknownKeys(stripUnknownKeys: boolean) { + this.stripUnknownKeys = stripUnknownKeys; + } + /** * Set config schema for a path and performs its validation */ @@ -139,7 +148,7 @@ export class ConfigService { public async validate(params: ConfigValidateParameters = { logDeprecations: true }) { const namespaces = [...this.schemas.keys()]; for (let i = 0; i < namespaces.length; i++) { - await this.getValidatedConfigAtPath$(namespaces[i]).pipe(first()).toPromise(); + await firstValueFrom(this.getValidatedConfigAtPath$(namespaces[i])); } if (params.logDeprecations) { @@ -313,7 +322,8 @@ export class ConfigService { serverless: this.env.packageInfo.buildFlavor === 'serverless', ...this.env.packageInfo, }, - `config validation of [${namespace}]` + `config validation of [${namespace}]`, + this.stripUnknownKeys ? { stripUnknownKeys: this.stripUnknownKeys } : {} ); } diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 6d8b18bb5d71..951bdbb531d6 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -848,6 +848,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D datastreamsDownsampling: `${ELASTICSEARCH_DOCS}downsampling.html`, installElasticAgent: `${FLEET_DOCS}install-fleet-managed-elastic-agent.html`, installElasticAgentStandalone: `${FLEET_DOCS}install-standalone-elastic-agent.html`, + grantESAccessToStandaloneAgents: `${FLEET_DOCS}grant-access-to-elasticsearch.html`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, apiKeysLearnMore: isServerless ? `${SERVERLESS_DOCS}api-keys` : `${KIBANA_DOCS}api-keys.html`, @@ -865,6 +866,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D scalingKubernetesResourcesAndLimits: `${FLEET_DOCS}scaling-on-kubernetes.html#_specifying_resources_and_limits_in_agent_manifests`, roleAndPrivileges: `${FLEET_DOCS}fleet-roles-and-privileges.html`, proxiesSettings: `${FLEET_DOCS}fleet-agent-proxy-support.html`, + unprivilegedMode: `${FLEET_DOCS}elastic-agent-unprivileged.html#unprivileged-change-mode`, }, integrationDeveloper: { upload: `${INTEGRATIONS_DEV_DOCS}upload-a-new-integration.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 4784ff45789d..7a296aac6d8b 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -537,6 +537,7 @@ export interface DocLinks { datastreamsDownsampling: string; installElasticAgent: string; installElasticAgentStandalone: string; + grantESAccessToStandaloneAgents: string; packageSignatures: string; upgradeElasticAgent: string; learnMoreBlog: string; @@ -554,6 +555,7 @@ export interface DocLinks { scalingKubernetesResourcesAndLimits: string; roleAndPrivileges: string; proxiesSettings: string; + unprivilegedMode: string; }>; readonly integrationDeveloper: { upload: string; diff --git a/packages/kbn-es/src/cli_commands/serverless.ts b/packages/kbn-es/src/cli_commands/serverless.ts index 6b0fe4acd5be..83e2ade9186b 100644 --- a/packages/kbn-es/src/cli_commands/serverless.ts +++ b/packages/kbn-es/src/cli_commands/serverless.ts @@ -6,10 +6,12 @@ * Side Public License, v 1. */ +import chalk from 'chalk'; import dedent from 'dedent'; import getopts from 'getopts'; import { ToolingLog } from '@kbn/tooling-log'; import { getTimeReporter } from '@kbn/ci-stats-reporter'; +import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils'; import { basename } from 'path'; import { SERVERLESS_RESOURCES_PATHS } from '../paths'; @@ -44,8 +46,8 @@ export const serverless: Command = { --kill Kill running ES serverless nodes if detected on startup --host Publish ES docker container on additional host IP --port The port to bind to on 127.0.0.1 [default: ${DEFAULT_PORT}] - --ssl Enable HTTP SSL on the ES cluster - --kibanaUrl Fully qualified URL where Kibana is hosted (including base path). [default: https://localhost:5601/] + --ssl Enable HTTP SSL on the ES cluster [default: true] + --kibanaUrl Fully qualified URL where Kibana is hosted (including base path). [default: http://localhost:5601/] --skipTeardown If this process exits, leave the ES cluster running in the background --waitForReady Wait for the ES cluster to be ready to serve requests --resources Overrides resources under ES 'config/' directory, which are by default @@ -103,7 +105,12 @@ export const serverless: Command = { ], boolean: ['clean', 'ssl', 'kill', 'background', 'skipTeardown', 'waitForReady'], - default: { ...defaults, kibanaUrl: 'https://localhost:5601/', dataPath: 'stateless' }, + default: { + ...defaults, + kibanaUrl: 'http://localhost:5601/', + dataPath: 'stateless', + ssl: true, + }, }) as unknown as ServerlessOptions; if (!options.projectType) { @@ -114,7 +121,16 @@ export const serverless: Command = { if (!isServerlessProjectType(options.projectType)) { throw createCliError( - `Invalid projectPype '${options.projectType}', supported values: ${supportedProjectTypesStr}` + `Invalid projectType '${options.projectType}', supported values: ${supportedProjectTypesStr}` + ); + } + + // In case `--no-ssl` CLI argument is provided. + if (!options.ssl) { + log.warning( + `Serverless ES cluster cannot configure ${chalk.bold.cyan( + MOCK_IDP_REALM_NAME + )} realm since TLS is disabled.` ); } diff --git a/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml b/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml index e47cc78eadc3..b05bb0de2f2c 100644 --- a/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml +++ b/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml @@ -35,6 +35,7 @@ viewer: - '.fleet-actions*' - 'risk-score.risk-score-*' - '.asset-criticality.asset-criticality-*' + - '.ml-anomalies-*' privileges: - read applications: @@ -100,6 +101,10 @@ editor: - 'read' - 'write' allow_restricted_indices: false + - names: + - '.ml-anomalies-*' + privileges: + - read applications: - application: 'kibana-.kibana' privileges: @@ -154,6 +159,7 @@ t1_analyst: - '.fleet-actions*' - risk-score.risk-score-* - .asset-criticality.asset-criticality-* + - '.ml-anomalies-*' privileges: - read applications: @@ -201,6 +207,7 @@ t2_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - '.ml-anomalies-*' privileges: - read - names: @@ -262,6 +269,7 @@ t3_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - '.ml-anomalies-*' privileges: - read applications: @@ -281,6 +289,7 @@ t3_analyst: - feature_siem.process_operations_all - feature_siem.actions_log_management_all # Response actions history - feature_siem.file_operations_all + - feature_siem.scan_operations_all - feature_securitySolutionCases.all - feature_securitySolutionAssistant.all - feature_actions.read @@ -331,6 +340,7 @@ threat_intelligence_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - '.ml-anomalies-*' privileges: - read applications: @@ -389,6 +399,7 @@ rule_author: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - '.ml-anomalies-*' privileges: - read applications: @@ -453,6 +464,7 @@ soc_manager: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - '.ml-anomalies-*' privileges: - read applications: @@ -513,6 +525,7 @@ detections_admin: - metrics-endpoint.metadata_current_* - .fleet-agents* - .fleet-actions* + - '.ml-anomalies-*' privileges: - read - names: @@ -570,6 +583,10 @@ platform_engineer: privileges: - read - write + - names: + - '.ml-anomalies-*' + privileges: + - read applications: - application: 'kibana-.kibana' privileges: @@ -620,6 +637,7 @@ endpoint_operations_analyst: - .lists* - .items* - risk-score.risk-score-* + - '.ml-anomalies-*' privileges: - read - names: @@ -710,6 +728,10 @@ endpoint_policy_manager: - read - write - manage + - names: + - '.ml-anomalies-*' + privileges: + - read applications: - application: 'kibana-.kibana' privileges: diff --git a/packages/kbn-es/src/utils/docker.ts b/packages/kbn-es/src/utils/docker.ts index 0e8182920feb..5e462a74ccfb 100644 --- a/packages/kbn-es/src/utils/docker.ts +++ b/packages/kbn-es/src/utils/docker.ts @@ -767,14 +767,9 @@ export async function runServerlessCluster(log: ToolingLog, options: ServerlessO Login with username ${chalk.bold.cyan(ELASTIC_SERVERLESS_SUPERUSER)} or ${chalk.bold.cyan( SYSTEM_INDICES_SUPERUSER )} and password ${chalk.bold.magenta(ELASTIC_SERVERLESS_SUPERUSER_PASSWORD)} - Stop the cluster: ${chalk.bold(`docker container stop ${nodeNames.join(' ')}`)} - `); - - if (options.ssl) { - log.warning(`SSL has been enabled for ES. Kibana should be started with the SSL flag so that it can authenticate with ES. See packages/kbn-es/src/serverless_resources/README.md for additional information on authentication. + Stop the cluster: ${chalk.bold(`docker container stop ${nodeNames.join(' ')}`)} `); - } if (!options.skipTeardown) { // SIGINT will not trigger in FTR (see cluster.runServerless for FTR signal) diff --git a/packages/kbn-esql-ast/src/ast_walker.ts b/packages/kbn-esql-ast/src/ast_walker.ts index e93c411541fe..870301b938a6 100644 --- a/packages/kbn-esql-ast/src/ast_walker.ts +++ b/packages/kbn-esql-ast/src/ast_walker.ts @@ -356,6 +356,7 @@ function getConstant(ctx: ConstantContext): ESQLAstItem { paramType: 'unnamed', text: ctx.getText(), name: '', + value: '', location: getPosition(ctx.start, ctx.stop), incomplete: Boolean(ctx.exception), }; diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index 5c6b1d2fdbb8..e61935f0b8a8 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -147,7 +147,7 @@ export interface ESQLParamLiteral extends ESQ type: 'literal'; literalType: 'param'; paramType: ParamType; - value?: string | number; + value: string | number; } /** diff --git a/packages/kbn-esql-utils/src/utils/append_to_query.test.ts b/packages/kbn-esql-utils/src/utils/append_to_query.test.ts index 2f3d28c46744..f7c69fbb5c68 100644 --- a/packages/kbn-esql-utils/src/utils/append_to_query.test.ts +++ b/packages/kbn-esql-utils/src/utils/append_to_query.test.ts @@ -31,7 +31,7 @@ describe('appendToQuery', () => { appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '+', 'string') ).toBe( `from logstash-* // meow -| where \`dest\`=="tada!"` +| WHERE \`dest\`=="tada!"` ); }); it('appends a filter out where clause in an existing query', () => { @@ -39,7 +39,7 @@ describe('appendToQuery', () => { appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '-', 'string') ).toBe( `from logstash-* // meow -| where \`dest\`!="tada!"` +| WHERE \`dest\`!="tada!"` ); }); @@ -48,14 +48,14 @@ describe('appendToQuery', () => { appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '-', 'ip') ).toBe( `from logstash-* // meow -| where \`dest\`::string!="tada!"` +| WHERE \`dest\`::string!="tada!"` ); }); it('appends a where clause in an existing query with casting to string when the type is not given', () => { expect(appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '-')).toBe( `from logstash-* // meow -| where \`dest\`::string!="tada!"` +| WHERE \`dest\`::string!="tada!"` ); }); @@ -70,7 +70,7 @@ describe('appendToQuery', () => { ) ).toBe( `from logstash-* // meow -| where \`dest\` is not null` +| WHERE \`dest\` is not null` ); }); @@ -85,7 +85,7 @@ describe('appendToQuery', () => { ) ).toBe( `from logstash-* // meow -| where \`dest\` is null` +| WHERE \`dest\` is null` ); }); @@ -100,7 +100,7 @@ describe('appendToQuery', () => { ) ).toBe( `from logstash-* | where country == "GR" -and \`dest\`=="Crete"` +AND \`dest\`=="Crete"` ); }); diff --git a/packages/kbn-esql-utils/src/utils/append_to_query.ts b/packages/kbn-esql-utils/src/utils/append_to_query.ts index d1bf0afa3375..0d8de16f03e7 100644 --- a/packages/kbn-esql-utils/src/utils/append_to_query.ts +++ b/packages/kbn-esql-utils/src/utils/append_to_query.ts @@ -85,9 +85,9 @@ export function appendWhereClauseToESQLQuery( } } // filter does not exist in the where clause - const whereClause = `and ${fieldName}${operator}${filterValue}`; + const whereClause = `AND ${fieldName}${operator}${filterValue}`; return appendToESQLQuery(baseESQLQuery, whereClause); } - const whereClause = `| where ${fieldName}${operator}${filterValue}`; + const whereClause = `| WHERE ${fieldName}${operator}${filterValue}`; return appendToESQLQuery(baseESQLQuery, whereClause); } diff --git a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts index e452127506b2..a18309d38ddd 100644 --- a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts +++ b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts @@ -17,15 +17,23 @@ describe('getESQLWithSafeLimit()', () => { }); it('should add the limit', () => { - expect(getESQLWithSafeLimit(' from logs', LIMIT)).toBe('from logs \n| LIMIT 10000'); + expect(getESQLWithSafeLimit('from logs', LIMIT)).toBe('from logs \n| LIMIT 10000'); expect(getESQLWithSafeLimit('FROM logs* | LIMIT 5', LIMIT)).toBe( - 'FROM logs* \n| LIMIT 10000| LIMIT 5' + 'FROM logs* \n| LIMIT 10000 | LIMIT 5' ); expect(getESQLWithSafeLimit('FROM logs* | SORT @timestamp | LIMIT 5', LIMIT)).toBe( - 'FROM logs* |SORT @timestamp \n| LIMIT 10000| LIMIT 5' + 'FROM logs* | SORT @timestamp \n| LIMIT 10000 | LIMIT 5' ); expect(getESQLWithSafeLimit('from logs* | STATS MIN(a) BY b', LIMIT)).toBe( - 'from logs* \n| LIMIT 10000| STATS MIN(a) BY b' + 'from logs* \n| LIMIT 10000 | STATS MIN(a) BY b' + ); + + expect(getESQLWithSafeLimit('from logs* | STATS MIN(a) BY b | SORT b', LIMIT)).toBe( + 'from logs* \n| LIMIT 10000 | STATS MIN(a) BY b | SORT b' + ); + + expect(getESQLWithSafeLimit('from logs* // | STATS MIN(a) BY b', LIMIT)).toBe( + 'from logs* \n| LIMIT 10000 // | STATS MIN(a) BY b' ); }); }); diff --git a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts index 793292909c68..21eef75b7880 100644 --- a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts +++ b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts @@ -5,30 +5,33 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { getAstAndSyntaxErrors } from '@kbn/esql-ast'; export function getESQLWithSafeLimit(esql: string, limit: number): string { - if (!esql.trim().toLowerCase().startsWith('from')) { + const { ast } = getAstAndSyntaxErrors(esql); + const sourceCommand = ast.find(({ name }) => ['from', 'metrics'].includes(name)); + if (!sourceCommand) { return esql; } - const parts = esql.split('|'); - if (!parts.length) { - return esql; + let sortCommandIndex = -1; + const sortCommand = ast.find(({ name }, index) => { + sortCommandIndex = index; + return name === 'sort'; + }); + + if (!sortCommand || (sortCommand && sortCommandIndex !== 1)) { + const sourcePipeText = esql.substring( + sourceCommand.location.min, + sourceCommand.location.max + 1 + ); + return esql.replace(sourcePipeText, `${sourcePipeText} \n| LIMIT ${limit}`); } - const fromCommandIndex = 0; - const sortCommandIndex = 1; - const index = - parts.length > 1 && parts[1].trim().toLowerCase().startsWith('sort') - ? sortCommandIndex - : fromCommandIndex; + const sourceSortPipeText = esql.substring( + sourceCommand.location.min, + sortCommand.location.max + 1 + ); - return parts - .map((part, i) => { - if (i === index) { - return `${part.trim()} \n| LIMIT ${limit}`; - } - return part; - }) - .join('|'); + return esql.replace(sourceSortPipeText, `${sourceSortPipeText} \n| LIMIT ${limit}`); } diff --git a/packages/kbn-esql-utils/src/utils/get_initial_esql_query.test.ts b/packages/kbn-esql-utils/src/utils/get_initial_esql_query.test.ts index bb4fe9e1a15d..45aac1344725 100644 --- a/packages/kbn-esql-utils/src/utils/get_initial_esql_query.test.ts +++ b/packages/kbn-esql-utils/src/utils/get_initial_esql_query.test.ts @@ -10,6 +10,6 @@ import { getInitialESQLQuery } from './get_initial_esql_query'; describe('getInitialESQLQuery', () => { it('should work correctly', () => { - expect(getInitialESQLQuery('logs*')).toBe('from logs* | limit 10'); + expect(getInitialESQLQuery('logs*')).toBe('FROM logs* | LIMIT 10'); }); }); diff --git a/packages/kbn-esql-utils/src/utils/get_initial_esql_query.ts b/packages/kbn-esql-utils/src/utils/get_initial_esql_query.ts index f2ccad78fa55..302f3c364f1a 100644 --- a/packages/kbn-esql-utils/src/utils/get_initial_esql_query.ts +++ b/packages/kbn-esql-utils/src/utils/get_initial_esql_query.ts @@ -11,5 +11,5 @@ * @param indexOrIndexPattern */ export function getInitialESQLQuery(indexOrIndexPattern: string): string { - return `from ${indexOrIndexPattern} | limit 10`; + return `FROM ${indexOrIndexPattern} | LIMIT 10`; } diff --git a/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts b/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts index 6806b11e4980..d50d7cc903fc 100644 --- a/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts +++ b/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts @@ -347,7 +347,11 @@ import type { FunctionDefinition } from './types'; const evalFunctionDefinitions: FunctionDefinition[] = []; for (const ESDefinition of ESFunctionDefinitions) { - if (aliases.has(ESDefinition.name) || excludedFunctions.has(ESDefinition.name)) { + if ( + aliases.has(ESDefinition.name) || + excludedFunctions.has(ESDefinition.name) || + ESDefinition.type !== 'eval' + ) { continue; } 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 8edbcd547259..6f0562d7f118 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -186,9 +186,11 @@ function getFunctionSignaturesByReturnType( .sort(({ name: a }, { name: b }) => a.localeCompare(b)) .map(({ type, name, signatures }) => { if (type === 'builtin') { - return signatures.some(({ params }) => params.length > 1) ? `${name} $0` : name; + return signatures.some(({ params }) => params.length > 1) + ? `${name.toUpperCase()} $0` + : name.toUpperCase(); } - return `${name}($0)`; + return `${name.toUpperCase()}($0)`; }); } @@ -337,31 +339,31 @@ describe('autocomplete', () => { describe('New command', () => { testSuggestions( ' ', - sourceCommands.map((name) => name + ' $0') + sourceCommands.map((name) => name.toUpperCase() + ' $0') ); testSuggestions( 'from a | ', commandDefinitions .filter(({ name }) => !sourceCommands.includes(name)) - .map(({ name }) => name + ' $0') + .map(({ name }) => name.toUpperCase() + ' $0') ); testSuggestions( 'from a [metadata _id] | ', commandDefinitions .filter(({ name }) => !sourceCommands.includes(name)) - .map(({ name }) => name + ' $0') + .map(({ name }) => name.toUpperCase() + ' $0') ); testSuggestions( 'from a | eval var0 = a | ', commandDefinitions .filter(({ name }) => !sourceCommands.includes(name)) - .map(({ name }) => name + ' $0') + .map(({ name }) => name.toUpperCase() + ' $0') ); testSuggestions( 'from a [metadata _id] | eval var0 = a | ', commandDefinitions .filter(({ name }) => !sourceCommands.includes(name)) - .map(({ name }) => name + ' $0') + .map(({ name }) => name.toUpperCase() + ' $0') ); }); @@ -371,11 +373,11 @@ describe('autocomplete', () => { // Monaco will filter further down here testSuggestions( 'f', - sourceCommands.map((name) => name + ' $0') + sourceCommands.map((name) => name.toUpperCase() + ' $0') ); testSuggestions('from ', suggestedIndexes); testSuggestions('from a,', suggestedIndexes); - testSuggestions('from a, b ', ['metadata $0', ',', '|']); + testSuggestions('from a, b ', ['METADATA $0', ',', '|']); testSuggestions('from *,', suggestedIndexes); testSuggestions('from index', suggestedIndexes, 5 /* space before index */); testSuggestions('from a, b [metadata ]', METADATA_FIELDS, ' ]'); @@ -403,14 +405,14 @@ describe('autocomplete', () => { }); describe('show', () => { - testSuggestions('show ', ['info']); + testSuggestions('show ', ['INFO']); for (const fn of ['info']) { testSuggestions(`show ${fn} `, ['|']); } }); describe('meta', () => { - testSuggestions('meta ', ['functions']); + testSuggestions('meta ', ['FUNCTIONS']); for (const fn of ['functions']) { testSuggestions(`meta ${fn} `, ['|']); } @@ -522,8 +524,8 @@ describe('autocomplete', () => { ',' ); - testSuggestions('from index | WHERE stringField not ', ['like $0', 'rlike $0', 'in $0']); - testSuggestions('from index | WHERE stringField NOT ', ['like $0', 'rlike $0', 'in $0']); + testSuggestions('from index | WHERE stringField not ', ['LIKE $0', 'RLIKE $0', 'IN $0']); + testSuggestions('from index | WHERE stringField NOT ', ['LIKE $0', 'RLIKE $0', 'IN $0']); testSuggestions('from index | WHERE not ', [ ...getFieldNamesByType('boolean'), ...getFunctionSignaturesByReturnType('eval', 'boolean', { evalMath: true }), @@ -577,7 +579,7 @@ describe('autocomplete', () => { testSuggestions(`from a | ${subExpression} ${command} stringField `, [constantPattern]); testSuggestions( `from a | ${subExpression} ${command} stringField ${constantPattern} `, - (command === 'dissect' ? ['append_separator = $0'] : []).concat(['|']) + (command === 'dissect' ? ['APPEND_SEPARATOR = $0'] : []).concat(['|']) ); if (command === 'dissect') { testSuggestions( @@ -616,7 +618,7 @@ describe('autocomplete', () => { describe('rename', () => { testSuggestions('from a | rename ', getFieldNamesByType('any')); - testSuggestions('from a | rename stringField ', ['as $0']); + testSuggestions('from a | rename stringField ', ['AS $0']); testSuggestions('from a | rename stringField as ', ['var0']); }); @@ -704,7 +706,7 @@ describe('autocomplete', () => { ], '(' ); - testSuggestions('from a | stats a=min(b) ', ['by $0', ',', '|']); + testSuggestions('from a | stats a=min(b) ', ['BY $0', ',', '|']); testSuggestions('from a | stats a=min(b) by ', [ 'var0 =', ...getFieldNamesByType('any'), @@ -737,7 +739,7 @@ describe('autocomplete', () => { ]); // smoke testing with suggestions not at the end of the string - testSuggestions('from a | stats a = min(b) | sort b', ['by $0', ',', '|'], ') '); + testSuggestions('from a | stats a = min(b) | sort b', ['BY $0', ',', '|'], ') '); testSuggestions( 'from a | stats avg(b) by stringField', [ @@ -854,7 +856,7 @@ describe('autocomplete', () => { testSuggestions(`from a ${prevCommand}| enrich _${mode.toUpperCase()}:`, policyNames, ':'); testSuggestions(`from a ${prevCommand}| enrich _${camelCase(mode)}:`, policyNames, ':'); } - testSuggestions(`from a ${prevCommand}| enrich policy `, ['on $0', 'with $0', '|']); + testSuggestions(`from a ${prevCommand}| enrich policy `, ['ON $0', 'WITH $0', '|']); testSuggestions(`from a ${prevCommand}| enrich policy on `, [ 'stringField', 'numberField', @@ -868,7 +870,7 @@ describe('autocomplete', () => { 'any#Char$Field', 'kubernetes.something.something', ]); - testSuggestions(`from a ${prevCommand}| enrich policy on b `, ['with $0', ',', '|']); + testSuggestions(`from a ${prevCommand}| enrich policy on b `, ['WITH $0', ',', '|']); testSuggestions(`from a ${prevCommand}| enrich policy on b with `, [ 'var0 =', ...getPolicyFields('policy'), @@ -915,8 +917,8 @@ describe('autocomplete', () => { ',', '|', ]); - testSuggestions('from index | EVAL stringField not ', ['like $0', 'rlike $0', 'in $0']); - testSuggestions('from index | EVAL stringField NOT ', ['like $0', 'rlike $0', 'in $0']); + testSuggestions('from index | EVAL stringField not ', ['LIKE $0', 'RLIKE $0', 'IN $0']); + testSuggestions('from index | EVAL stringField NOT ', ['LIKE $0', 'RLIKE $0', 'IN $0']); testSuggestions('from index | EVAL numberField in ', ['( $0 )']); testSuggestions( 'from index | EVAL numberField in ( )', diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index 1bc93193bd39..53e65c2f95ab 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -42,7 +42,7 @@ export function getSuggestionFunctionDefinition(fn: FunctionDefinition): Suggest const fullSignatures = getFunctionSignatures(fn); return { label: fullSignatures[0].declaration, - text: `${fn.name}($0)`, + text: `${fn.name.toUpperCase()}($0)`, asSnippet: true, kind: 'Function', detail: fn.description, @@ -60,7 +60,7 @@ export function getSuggestionBuiltinDefinition(fn: FunctionDefinition): Suggesti const hasArgs = fn.signatures.some(({ params }) => params.length > 1); return { label: fn.name, - text: hasArgs ? `${fn.name} $0` : fn.name, + text: hasArgs ? `${fn.name.toUpperCase()} $0` : fn.name.toUpperCase(), asSnippet: hasArgs, kind: 'Operator', detail: fn.description, @@ -103,10 +103,10 @@ export function getSuggestionCommandDefinition( const commandDefinition = getCommandDefinition(command.name); const commandSignature = getCommandSignature(commandDefinition); return { - label: commandDefinition.name, + label: commandDefinition.name.toUpperCase(), text: commandDefinition.signature.params.length - ? `${commandDefinition.name} $0` - : commandDefinition.name, + ? `${commandDefinition.name.toUpperCase()} $0` + : commandDefinition.name.toUpperCase(), asSnippet: true, kind: 'Method', detail: commandDefinition.description, @@ -247,14 +247,16 @@ export const buildOptionDefinition = ( isAssignType: boolean = false ) => { const completeItem: SuggestionRawDefinition = { - label: option.name, - text: option.name, + label: option.name.toUpperCase(), + text: option.name.toUpperCase(), kind: 'Reference', detail: option.description, sortText: '1', }; if (isAssignType || option.signature.params.length) { - completeItem.text = isAssignType ? `${option.name} = $0` : `${option.name} $0`; + completeItem.text = isAssignType + ? `${option.name.toUpperCase()} = $0` + : `${option.name.toUpperCase()} $0`; completeItem.asSnippet = true; completeItem.command = TRIGGER_SUGGESTION_COMMAND; } diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/aggs.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/aggs.ts index 08323d121a88..c1b7b5b00b58 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/aggs.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/aggs.ts @@ -224,4 +224,79 @@ export const statsAggregationFunctionDefinitions: FunctionDefinition[] = [ 'from index | stats all_sorted_agents=mv_sort(values(agents.keyword))', ], }, + { + name: 'top', + type: 'agg', + description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.topListDoc', { + defaultMessage: 'Collects top N values per bucket.', + }), + supportedCommands: ['stats', 'metrics'], + signatures: [ + { + params: [ + { + name: 'field', + type: 'any', + noNestingFunctions: true, + optional: false, + }, + { + name: 'limit', + type: 'number', + noNestingFunctions: true, + optional: false, + constantOnly: true, + }, + { + name: 'order', + type: 'string', + noNestingFunctions: true, + optional: false, + constantOnly: true, + literalOptions: ['asc', 'desc'], + }, + ], + returnType: 'any', + }, + ], + examples: [ + `from employees | stats top_salaries = top(salary, 10, "desc")`, + `from employees | stats date = top(hire_date, 2, "asc"), double = top(salary_change, 2, "asc"),`, + ], + }, + { + name: 'weighted_avg', + type: 'agg', + description: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.definitions.weightedAvgDoc', + { + defaultMessage: + 'An aggregation that computes the weighted average of numeric values that are extracted from the aggregated documents.', + } + ), + supportedCommands: ['stats', 'metrics'], + signatures: [ + { + params: [ + { + name: 'number', + type: 'number', + noNestingFunctions: true, + optional: false, + }, + { + name: 'weight', + type: 'number', + noNestingFunctions: true, + optional: false, + }, + ], + returnType: 'number', + }, + ], + examples: [ + `from employees | stats w_avg = weighted_avg(salary, height) by languages | eval w_avg = round(w_avg)`, + `from employees | stats w_avg_1 = weighted_avg(salary, 1), avg = avg(salary), w_avg_2 = weighted_avg(salary, height)`, + ], + }, ]); diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/functions.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/functions.ts index 718174ae09f4..ceb5789e0dd3 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/functions.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/functions.ts @@ -6,8 +6,24 @@ * Side Public License, v 1. */ -// NOTE: This file is generated by the generate_function_definitions.ts script -// Do not edit it manually +/** + * __AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.__ + * + * @note This file is generated by the `generate_function_definitions.ts` + * script. Do not edit it manually. + * + * + * + * + * + * + * + * + * + * + * + * + */ import type { ESQLFunction } from '@kbn/esql-ast'; import { i18n } from '@kbn/i18n'; @@ -447,7 +463,7 @@ const coalesceDefinition: FunctionDefinition = { minParams: 1, }, ], - supportedCommands: ['stats', 'eval', 'where', 'row', 'sort'], + supportedCommands: ['stats', 'metrics', 'eval', 'where', 'row', 'sort'], supportedOptions: ['by'], validate: undefined, examples: ['ROW a=null, b="b"\n| EVAL COALESCE(a, b)'], @@ -1052,7 +1068,7 @@ const ipPrefixDefinition: FunctionDefinition = { returnType: 'ip', }, ], - supportedCommands: ['stats', 'eval', 'where', 'row', 'sort'], + supportedCommands: ['stats', 'metrics', 'eval', 'where', 'row', 'sort'], supportedOptions: ['by'], validate: undefined, examples: [ @@ -1345,7 +1361,7 @@ const logDefinition: FunctionDefinition = { // do not really care here about the base and field // just need to check both values are not negative for (const arg of fnDef.args) { - if (isLiteralItem(arg) && Number(arg.value) < 0) { + if (isLiteralItem(arg) && arg.value < 0) { messages.push({ type: 'warning' as const, code: 'logOfNegativeValue', @@ -1398,7 +1414,7 @@ const log10Definition: FunctionDefinition = { // do not really care here about the base and field // just need to check both values are not negative for (const arg of fnDef.args) { - if (isLiteralItem(arg) && Number(arg.value) < 0) { + if (isLiteralItem(arg) && arg.value < 0) { messages.push({ type: 'warning' as const, code: 'logOfNegativeValue', @@ -1608,7 +1624,7 @@ const mvAppendDefinition: FunctionDefinition = { returnType: 'version', }, ], - supportedCommands: ['stats', 'eval', 'where', 'row', 'sort'], + supportedCommands: ['stats', 'metrics', 'eval', 'where', 'row', 'sort'], supportedOptions: ['by'], validate: undefined, examples: [], @@ -2846,7 +2862,7 @@ const repeatDefinition: FunctionDefinition = { returnType: 'string', }, ], - supportedCommands: ['stats', 'eval', 'where', 'row', 'sort'], + supportedCommands: ['stats', 'metrics', 'eval', 'where', 'row', 'sort'], supportedOptions: ['by'], validate: undefined, examples: ['ROW a = "Hello!"\n| EVAL triple_a = REPEAT(a, 3);'], @@ -3410,6 +3426,55 @@ const stDisjointDefinition: FunctionDefinition = { ], }; +// Do not edit this manually... generated by scripts/generate_function_definitions.ts +const stDistanceDefinition: FunctionDefinition = { + type: 'eval', + name: 'st_distance', + description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.st_distance', { + defaultMessage: + 'Computes the distance between two points.\nFor cartesian geometries, this is the pythagorean distance in the same units as the original coordinates.\nFor geographic geometries, this is the circular distance along the great circle in meters.', + }), + alias: undefined, + signatures: [ + { + params: [ + { + name: 'geomA', + type: 'cartesian_point', + optional: false, + }, + { + name: 'geomB', + type: 'cartesian_point', + optional: false, + }, + ], + returnType: 'number', + }, + { + params: [ + { + name: 'geomA', + type: 'geo_point', + optional: false, + }, + { + name: 'geomB', + type: 'geo_point', + optional: false, + }, + ], + returnType: 'number', + }, + ], + supportedCommands: ['stats', 'metrics', 'eval', 'where', 'row', 'sort'], + supportedOptions: ['by'], + validate: undefined, + examples: [ + 'FROM airports\n| WHERE abbrev == "CPH"\n| EVAL distance = ST_DISTANCE(location, city_location)\n| KEEP abbrev, name, location, city_location, distance', + ], +}; + // Do not edit this manually... generated by scripts/generate_function_definitions.ts const stIntersectsDefinition: FunctionDefinition = { type: 'eval', @@ -4865,6 +4930,7 @@ export const evalFunctionDefinitions = [ sqrtDefinition, stContainsDefinition, stDisjointDefinition, + stDistanceDefinition, stIntersectsDefinition, stWithinDefinition, stXDefinition, diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts index 7d70d1acc963..0ccb7855b284 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts @@ -56,12 +56,16 @@ export function getCommandSignature( { withTypes }: { withTypes: boolean } = { withTypes: true } ) { return { - declaration: `${name} ${printCommandArguments(signature, withTypes)} ${options.map( + declaration: `${name.toUpperCase()} ${printCommandArguments( + signature, + withTypes + )} ${options.map( (option) => - `${option.wrapped ? option.wrapped[0] : ''}${option.name} ${printCommandArguments( - option.signature, - withTypes - )}${option.wrapped ? option.wrapped[1] : ''}` + `${ + option.wrapped ? option.wrapped[0] : '' + }${option.name.toUpperCase()} ${printCommandArguments(option.signature, withTypes)}${ + option.wrapped ? option.wrapped[1] : '' + }` )}`, examples, }; diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts index 4a89a6b72d16..effd6b1b16dd 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -127,7 +127,7 @@ export function isComma(char: string) { } export function isSourceCommand({ label }: { label: string }) { - return ['from', 'row', 'show'].includes(String(label)); + return ['FROM', 'ROW', 'SHOW'].includes(label); } let fnLookups: Map | undefined; @@ -290,12 +290,13 @@ export function areFieldAndVariableTypesCompatible( return fieldType === variableType; } -export function printFunctionSignature(arg: ESQLFunction): string { +export function printFunctionSignature(arg: ESQLFunction, useCaps = true): string { const fnDef = getFunctionDefinition(arg.name); if (fnDef) { const signature = getFunctionSignatures( { ...fnDef, + name: useCaps ? fnDef.name.toUpperCase() : fnDef.name, signatures: [ { ...fnDef?.signatures[0], diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json b/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json index 0c84fe2bf113..daf8d7c6de86 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json +++ b/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json @@ -25901,6 +25901,472 @@ "error": [], "warning": [] }, + { + "query": "from a_index | stats var = top(stringField, 3, \"asc\")", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats top(stringField, 1, \"desc\")", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField, 5, \"asc\")", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats top(stringField, 5, \"asc\")", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField, 3)", + "error": [ + "Error: [top] function expects exactly 3 arguments, got 2." + ], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField)", + "error": [ + "Error: [top] function expects exactly 3 arguments, got 1." + ], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField, numberField, \"asc\")", + "error": [ + "Argument of [=] must be a constant, received [top(stringField,numberField,\"asc\")]" + ], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField, 100 + numberField, \"asc\")", + "error": [ + "Argument of [=] must be a constant, received [top(stringField,100+numberField,\"asc\")]" + ], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField, 1, stringField)", + "error": [ + "Argument of [=] must be a constant, received [top(stringField,1,stringField)]" + ], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField, 1, \"asdf\")", + "error": [], + "warning": [ + "Invalid option [\"asdf\"] for top. Supported options: [\"asc\", \"desc\"]." + ] + }, + { + "query": "from a_index | sort top(stringField, numberField, \"asc\")", + "error": [ + "SORT does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | where top(stringField, numberField, \"asc\")", + "error": [ + "WHERE does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | where top(stringField, numberField, \"asc\") > 0", + "error": [ + "WHERE does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = top(stringField, numberField, \"asc\")", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = top(stringField, numberField, \"asc\") > 0", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval top(stringField, numberField, \"asc\")", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval top(stringField, numberField, \"asc\") > 0", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | sort top(stringField, 5, \"asc\")", + "error": [ + "SORT does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | where top(stringField, 5, \"asc\")", + "error": [ + "WHERE does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | where top(stringField, 5, \"asc\") > 0", + "error": [ + "WHERE does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = top(stringField, 5, \"asc\")", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = top(stringField, 5, \"asc\") > 0", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval top(stringField, 5, \"asc\")", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | eval top(stringField, 5, \"asc\") > 0", + "error": [ + "EVAL does not support function top" + ], + "warning": [] + }, + { + "query": "from a_index | stats var = top(stringField, 5, \"asc\")", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats top(stringField, 5, \"asc\")", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats top(stringField, numberField, \"asc\")", + "error": [ + "Argument of [top] must be a constant, received [numberField]" + ], + "warning": [] + }, + { + "query": "from a_index | stats top(null, null, null)", + "error": [], + "warning": [] + }, + { + "query": "row nullVar = null | stats top(nullVar, nullVar, nullVar)", + "error": [ + "Argument of [top] must be a constant, received [nullVar]", + "Argument of [top] must be a constant, received [nullVar]" + ], + "warning": [] + }, + { + "query": "row var = st_distance(to_cartesianpoint(\"POINT (30 10)\"), to_cartesianpoint(\"POINT (30 10)\"))", + "error": [], + "warning": [] + }, + { + "query": "row st_distance(to_cartesianpoint(\"POINT (30 10)\"), to_cartesianpoint(\"POINT (30 10)\"))", + "error": [], + "warning": [] + }, + { + "query": "row var = st_distance(to_cartesianpoint(to_cartesianpoint(\"POINT (30 10)\")), to_cartesianpoint(to_cartesianpoint(\"POINT (30 10)\")))", + "error": [], + "warning": [] + }, + { + "query": "row var = st_distance(to_geopoint(\"POINT (30 10)\"), to_geopoint(\"POINT (30 10)\"))", + "error": [], + "warning": [] + }, + { + "query": "row st_distance(to_geopoint(\"POINT (30 10)\"), to_geopoint(\"POINT (30 10)\"))", + "error": [], + "warning": [] + }, + { + "query": "row var = st_distance(to_geopoint(to_geopoint(\"POINT (30 10)\")), to_geopoint(to_geopoint(\"POINT (30 10)\")))", + "error": [], + "warning": [] + }, + { + "query": "row var = st_distance(true, true)", + "error": [ + "Argument of [st_distance] must be [cartesian_point], found value [true] type [boolean]", + "Argument of [st_distance] must be [cartesian_point], found value [true] type [boolean]" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = st_distance(cartesianPointField, cartesianPointField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | eval st_distance(cartesianPointField, cartesianPointField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | eval var = st_distance(to_cartesianpoint(cartesianPointField), to_cartesianpoint(cartesianPointField))", + "error": [], + "warning": [] + }, + { + "query": "from a_index | eval st_distance(booleanField, booleanField)", + "error": [ + "Argument of [st_distance] must be [cartesian_point], found value [booleanField] type [boolean]", + "Argument of [st_distance] must be [cartesian_point], found value [booleanField] type [boolean]" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = st_distance(geoPointField, geoPointField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | eval st_distance(geoPointField, geoPointField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | eval var = st_distance(to_geopoint(geoPointField), to_geopoint(geoPointField))", + "error": [], + "warning": [] + }, + { + "query": "from a_index | eval st_distance(cartesianPointField, cartesianPointField, extraArg)", + "error": [ + "Error: [st_distance] function expects exactly 2 arguments, got 3." + ], + "warning": [] + }, + { + "query": "from a_index | sort st_distance(cartesianPointField, cartesianPointField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | eval st_distance(null, null)", + "error": [], + "warning": [] + }, + { + "query": "row nullVar = null | eval st_distance(nullVar, nullVar)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var = weighted_avg(numberField, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats weighted_avg(numberField, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var = round(weighted_avg(numberField, numberField))", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats round(weighted_avg(numberField, numberField))", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var = round(weighted_avg(numberField, numberField)) + weighted_avg(numberField, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats round(weighted_avg(numberField, numberField)) + weighted_avg(numberField, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats weighted_avg(numberField / 2, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var0 = weighted_avg(numberField / 2, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), weighted_avg(numberField / 2, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), var0 = weighted_avg(numberField / 2, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var0 = weighted_avg(numberField, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), weighted_avg(numberField, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), var0 = weighted_avg(numberField, numberField)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats weighted_avg(numberField, numberField) by round(numberField / 2)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var0 = weighted_avg(numberField, numberField) by var1 = round(numberField / 2)", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), weighted_avg(numberField, numberField) by round(numberField / 2), ipField", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), var0 = weighted_avg(numberField, numberField) by var1 = round(numberField / 2), ipField", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), weighted_avg(numberField, numberField) by round(numberField / 2), numberField / 2", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats avg(numberField), var0 = weighted_avg(numberField, numberField) by var1 = round(numberField / 2), numberField / 2", + "error": [], + "warning": [] + }, + { + "query": "from a_index | stats var = weighted_avg(avg(numberField), avg(numberField))", + "error": [ + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]", + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]" + ], + "warning": [] + }, + { + "query": "from a_index | stats weighted_avg(avg(numberField), avg(numberField))", + "error": [ + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]", + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]" + ], + "warning": [] + }, + { + "query": "from a_index | stats weighted_avg(booleanField, booleanField)", + "error": [ + "Argument of [weighted_avg] must be [number], found value [booleanField] type [boolean]", + "Argument of [weighted_avg] must be [number], found value [booleanField] type [boolean]" + ], + "warning": [] + }, + { + "query": "from a_index | sort weighted_avg(numberField, numberField)", + "error": [ + "SORT does not support function weighted_avg" + ], + "warning": [] + }, + { + "query": "from a_index | where weighted_avg(numberField, numberField)", + "error": [ + "WHERE does not support function weighted_avg" + ], + "warning": [] + }, + { + "query": "from a_index | where weighted_avg(numberField, numberField) > 0", + "error": [ + "WHERE does not support function weighted_avg" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = weighted_avg(numberField, numberField)", + "error": [ + "EVAL does not support function weighted_avg" + ], + "warning": [] + }, + { + "query": "from a_index | eval var = weighted_avg(numberField, numberField) > 0", + "error": [ + "EVAL does not support function weighted_avg" + ], + "warning": [] + }, + { + "query": "from a_index | eval weighted_avg(numberField, numberField)", + "error": [ + "EVAL does not support function weighted_avg" + ], + "warning": [] + }, + { + "query": "from a_index | eval weighted_avg(numberField, numberField) > 0", + "error": [ + "EVAL does not support function weighted_avg" + ], + "warning": [] + }, + { + "query": "from a_index | stats weighted_avg(null, null)", + "error": [], + "warning": [] + }, + { + "query": "row nullVar = null | stats weighted_avg(nullVar, nullVar)", + "error": [], + "warning": [] + }, { "query": "f", "error": [ diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts b/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts index f866d8276789..6c1b66574862 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts @@ -10156,6 +10156,343 @@ describe('validation logic', () => { testErrorsAndWarnings('from a_index | eval repeat(null, null)', []); testErrorsAndWarnings('row nullVar = null | eval repeat(nullVar, nullVar)', []); }); + + describe('top', () => { + describe('no errors on correct usage', () => { + testErrorsAndWarnings('from a_index | stats var = top(stringField, 3, "asc")', []); + testErrorsAndWarnings('from a_index | stats top(stringField, 1, "desc")', []); + testErrorsAndWarnings('from a_index | stats var = top(stringField, 5, "asc")', []); + testErrorsAndWarnings('from a_index | stats top(stringField, 5, "asc")', []); + }); + + describe('errors on invalid argument count', () => { + testErrorsAndWarnings('from a_index | stats var = top(stringField, 3)', [ + 'Error: [top] function expects exactly 3 arguments, got 2.', + ]); + testErrorsAndWarnings('from a_index | stats var = top(stringField)', [ + 'Error: [top] function expects exactly 3 arguments, got 1.', + ]); + }); + + describe('limit must be a literal', () => { + testErrorsAndWarnings('from a_index | stats var = top(stringField, numberField, "asc")', [ + 'Argument of [=] must be a constant, received [top(stringField,numberField,"asc")]', + ]); + testErrorsAndWarnings( + 'from a_index | stats var = top(stringField, 100 + numberField, "asc")', + [ + 'Argument of [=] must be a constant, received [top(stringField,100+numberField,"asc")]', + ] + ); + }); + + describe('order must be "asc" or "desc"', () => { + testErrorsAndWarnings('from a_index | stats var = top(stringField, 1, stringField)', [ + 'Argument of [=] must be a constant, received [top(stringField,1,stringField)]', + ]); + testErrorsAndWarnings( + 'from a_index | stats var = top(stringField, 1, "asdf")', + [], + ['Invalid option ["asdf"] for top. Supported options: ["asc", "desc"].'] + ); + }); + + testErrorsAndWarnings('from a_index | sort top(stringField, numberField, "asc")', [ + 'SORT does not support function top', + ]); + + testErrorsAndWarnings('from a_index | where top(stringField, numberField, "asc")', [ + 'WHERE does not support function top', + ]); + + testErrorsAndWarnings('from a_index | where top(stringField, numberField, "asc") > 0', [ + 'WHERE does not support function top', + ]); + + testErrorsAndWarnings('from a_index | eval var = top(stringField, numberField, "asc")', [ + 'EVAL does not support function top', + ]); + + testErrorsAndWarnings( + 'from a_index | eval var = top(stringField, numberField, "asc") > 0', + ['EVAL does not support function top'] + ); + + testErrorsAndWarnings('from a_index | eval top(stringField, numberField, "asc")', [ + 'EVAL does not support function top', + ]); + + testErrorsAndWarnings('from a_index | eval top(stringField, numberField, "asc") > 0', [ + 'EVAL does not support function top', + ]); + + testErrorsAndWarnings('from a_index | sort top(stringField, 5, "asc")', [ + 'SORT does not support function top', + ]); + + testErrorsAndWarnings('from a_index | where top(stringField, 5, "asc")', [ + 'WHERE does not support function top', + ]); + + testErrorsAndWarnings('from a_index | where top(stringField, 5, "asc") > 0', [ + 'WHERE does not support function top', + ]); + + testErrorsAndWarnings('from a_index | eval var = top(stringField, 5, "asc")', [ + 'EVAL does not support function top', + ]); + + testErrorsAndWarnings('from a_index | eval var = top(stringField, 5, "asc") > 0', [ + 'EVAL does not support function top', + ]); + + testErrorsAndWarnings('from a_index | eval top(stringField, 5, "asc")', [ + 'EVAL does not support function top', + ]); + + testErrorsAndWarnings('from a_index | eval top(stringField, 5, "asc") > 0', [ + 'EVAL does not support function top', + ]); + testErrorsAndWarnings('from a_index | stats var = top(stringField, 5, "asc")', []); + testErrorsAndWarnings('from a_index | stats top(stringField, 5, "asc")', []); + + testErrorsAndWarnings('from a_index | stats top(stringField, numberField, "asc")', [ + 'Argument of [top] must be a constant, received [numberField]', + ]); + + testErrorsAndWarnings('from a_index | stats top(null, null, null)', []); + testErrorsAndWarnings('row nullVar = null | stats top(nullVar, nullVar, nullVar)', [ + 'Argument of [top] must be a constant, received [nullVar]', + 'Argument of [top] must be a constant, received [nullVar]', + ]); + }); + + describe('st_distance', () => { + testErrorsAndWarnings( + 'row var = st_distance(to_cartesianpoint("POINT (30 10)"), to_cartesianpoint("POINT (30 10)"))', + [] + ); + + testErrorsAndWarnings( + 'row st_distance(to_cartesianpoint("POINT (30 10)"), to_cartesianpoint("POINT (30 10)"))', + [] + ); + + testErrorsAndWarnings( + 'row var = st_distance(to_cartesianpoint(to_cartesianpoint("POINT (30 10)")), to_cartesianpoint(to_cartesianpoint("POINT (30 10)")))', + [] + ); + + testErrorsAndWarnings( + 'row var = st_distance(to_geopoint("POINT (30 10)"), to_geopoint("POINT (30 10)"))', + [] + ); + + testErrorsAndWarnings( + 'row st_distance(to_geopoint("POINT (30 10)"), to_geopoint("POINT (30 10)"))', + [] + ); + + testErrorsAndWarnings( + 'row var = st_distance(to_geopoint(to_geopoint("POINT (30 10)")), to_geopoint(to_geopoint("POINT (30 10)")))', + [] + ); + + testErrorsAndWarnings('row var = st_distance(true, true)', [ + 'Argument of [st_distance] must be [cartesian_point], found value [true] type [boolean]', + 'Argument of [st_distance] must be [cartesian_point], found value [true] type [boolean]', + ]); + + testErrorsAndWarnings( + 'from a_index | eval var = st_distance(cartesianPointField, cartesianPointField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | eval st_distance(cartesianPointField, cartesianPointField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | eval var = st_distance(to_cartesianpoint(cartesianPointField), to_cartesianpoint(cartesianPointField))', + [] + ); + + testErrorsAndWarnings('from a_index | eval st_distance(booleanField, booleanField)', [ + 'Argument of [st_distance] must be [cartesian_point], found value [booleanField] type [boolean]', + 'Argument of [st_distance] must be [cartesian_point], found value [booleanField] type [boolean]', + ]); + + testErrorsAndWarnings( + 'from a_index | eval var = st_distance(geoPointField, geoPointField)', + [] + ); + testErrorsAndWarnings('from a_index | eval st_distance(geoPointField, geoPointField)', []); + + testErrorsAndWarnings( + 'from a_index | eval var = st_distance(to_geopoint(geoPointField), to_geopoint(geoPointField))', + [] + ); + + testErrorsAndWarnings( + 'from a_index | eval st_distance(cartesianPointField, cartesianPointField, extraArg)', + ['Error: [st_distance] function expects exactly 2 arguments, got 3.'] + ); + + testErrorsAndWarnings( + 'from a_index | sort st_distance(cartesianPointField, cartesianPointField)', + [] + ); + + testErrorsAndWarnings('from a_index | eval st_distance(null, null)', []); + testErrorsAndWarnings('row nullVar = null | eval st_distance(nullVar, nullVar)', []); + }); + + describe('weighted_avg', () => { + testErrorsAndWarnings( + 'from a_index | stats var = weighted_avg(numberField, numberField)', + [] + ); + testErrorsAndWarnings('from a_index | stats weighted_avg(numberField, numberField)', []); + + testErrorsAndWarnings( + 'from a_index | stats var = round(weighted_avg(numberField, numberField))', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats round(weighted_avg(numberField, numberField))', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats var = round(weighted_avg(numberField, numberField)) + weighted_avg(numberField, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats round(weighted_avg(numberField, numberField)) + weighted_avg(numberField, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats weighted_avg(numberField / 2, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats var0 = weighted_avg(numberField / 2, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), weighted_avg(numberField / 2, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), var0 = weighted_avg(numberField / 2, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats var0 = weighted_avg(numberField, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), weighted_avg(numberField, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), var0 = weighted_avg(numberField, numberField)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats weighted_avg(numberField, numberField) by round(numberField / 2)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats var0 = weighted_avg(numberField, numberField) by var1 = round(numberField / 2)', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), weighted_avg(numberField, numberField) by round(numberField / 2), ipField', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), var0 = weighted_avg(numberField, numberField) by var1 = round(numberField / 2), ipField', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), weighted_avg(numberField, numberField) by round(numberField / 2), numberField / 2', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats avg(numberField), var0 = weighted_avg(numberField, numberField) by var1 = round(numberField / 2), numberField / 2', + [] + ); + + testErrorsAndWarnings( + 'from a_index | stats var = weighted_avg(avg(numberField), avg(numberField))', + [ + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]", + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]", + ] + ); + + testErrorsAndWarnings( + 'from a_index | stats weighted_avg(avg(numberField), avg(numberField))', + [ + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]", + "Aggregate function's parameters must be an attribute, literal or a non-aggregation function; found [avg(numberField)] of type [number]", + ] + ); + + testErrorsAndWarnings('from a_index | stats weighted_avg(booleanField, booleanField)', [ + 'Argument of [weighted_avg] must be [number], found value [booleanField] type [boolean]', + 'Argument of [weighted_avg] must be [number], found value [booleanField] type [boolean]', + ]); + + testErrorsAndWarnings('from a_index | sort weighted_avg(numberField, numberField)', [ + 'SORT does not support function weighted_avg', + ]); + + testErrorsAndWarnings('from a_index | where weighted_avg(numberField, numberField)', [ + 'WHERE does not support function weighted_avg', + ]); + + testErrorsAndWarnings('from a_index | where weighted_avg(numberField, numberField) > 0', [ + 'WHERE does not support function weighted_avg', + ]); + + testErrorsAndWarnings('from a_index | eval var = weighted_avg(numberField, numberField)', [ + 'EVAL does not support function weighted_avg', + ]); + + testErrorsAndWarnings( + 'from a_index | eval var = weighted_avg(numberField, numberField) > 0', + ['EVAL does not support function weighted_avg'] + ); + + testErrorsAndWarnings('from a_index | eval weighted_avg(numberField, numberField)', [ + 'EVAL does not support function weighted_avg', + ]); + + testErrorsAndWarnings('from a_index | eval weighted_avg(numberField, numberField) > 0', [ + 'EVAL does not support function weighted_avg', + ]); + + testErrorsAndWarnings('from a_index | stats weighted_avg(null, null)', []); + testErrorsAndWarnings('row nullVar = null | stats weighted_avg(nullVar, nullVar)', []); + }); }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts b/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts index fcd17d545182..e3c94aee3f48 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts @@ -220,7 +220,7 @@ function validateNestedFunctionArg( values: { name: astFunction.name, argType: parameterDefinition.type, - value: printFunctionSignature(actualArg) || actualArg.name, + value: printFunctionSignature(actualArg, false) || actualArg.name, givenType: argFn.signatures[0].returnType, }, locations: actualArg.location, diff --git a/packages/kbn-expandable-flyout/src/components/preview_section.tsx b/packages/kbn-expandable-flyout/src/components/preview_section.tsx index bc0c1c2e9d93..64493c75bd3e 100644 --- a/packages/kbn-expandable-flyout/src/components/preview_section.tsx +++ b/packages/kbn-expandable-flyout/src/components/preview_section.tsx @@ -14,6 +14,7 @@ import { EuiText, useEuiTheme, EuiSplitPanel, + transparentize, } from '@elastic/eui'; import React from 'react'; import { css } from '@emotion/react'; @@ -120,8 +121,8 @@ export const PreviewSection: React.FC = ({

= ({ = ({ {banner.title} diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml index 3bfc686f9e84..07f1e79b0f5d 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml @@ -1,6 +1,6 @@ - - + + - - + + diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml index 64cb7ce551ee..699ad47c5824 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml @@ -1,6 +1,6 @@ - - + + diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts index 02197cf54fab..42f3dcd120e9 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts @@ -60,8 +60,8 @@ it('rewrites ftr reports with minimal changes', async () => { +++ ftr.xml @@ -1,53 +1,56 @@ ‹?xml version="1.0" encoding="utf-8"?› - ‹testsuites› - ‹testsuite timestamp="2019-06-05T23:37:10" time="903.670" tests="129" failures="5" skipped="71"› + ‹testsuites name="ftr" timestamp="2019-06-05T23:37:10" time="903.670" tests="129" failures="5" skipped="71" command-line="node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts"› + ‹testsuite timestamp="2019-06-05T23:37:10" time="903.670" tests="129" failures="5" skipped="71" command-line="node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts"› ‹testcase name="maps app maps loaded from sample data ecommerce "before all" hook" classname="Chrome X-Pack UI Functional Tests.x-pack/test/functional/apps/maps/sample_data·js" time="154.378"› - ‹system-out› - ‹![CDATA[[00:00:00] │ @@ -155,7 +155,7 @@ it('rewrites jest reports with minimal changes', async () => { --- jest.xml +++ jest.xml @@ -3,13 +3,17 @@ - ‹testsuite name="x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" timestamp="2019-06-07T03:42:21" time="14.504" tests="5" failures="1" skipped="0" file="/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts"› + ‹testsuite name="x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" timestamp="2019-06-07T03:42:21" time="14.504" tests="5" failures="1" skipped="0" file="/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" command-line="node scripts/jest --config some/jest/config.ts"› ‹testcase classname="X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp" name="launcher can start and end a process" time="1.316"/› ‹testcase classname="X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp" name="launcher can force kill the process if langServer can not exit" time="3.182"/› ‹testcase classname="X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp" name="launcher can reconnect if process died" time="7.060"› @@ -203,8 +203,8 @@ it('rewrites mocha reports with minimal changes', async () => { +++ mocha.xml @@ -1,13 +1,16 @@ ‹?xml version="1.0" encoding="utf-8"?› - ‹testsuites› - ‹testsuite timestamp="2019-06-13T23:29:36" time="30.739" tests="1444" failures="2" skipped="3"› + ‹testsuites command-line="node scripts/functional_tests --config super-mocha-test.config.js"› + ‹testsuite timestamp="2019-06-13T23:29:36" time="30.739" tests="1444" failures="2" skipped="3" command-line="node scripts/functional_tests --config super-mocha-test.config.js"› ‹testcase name="code in multiple nodes "before all" hook" classname="X-Pack Mocha Tests.x-pack/legacy/plugins/code/server/__tests__/multi_node·ts" time="0.121"› - ‹system-out› - ‹![CDATA[]]› diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts index 77d7cd93ce4b..d76662c60072 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts @@ -16,6 +16,7 @@ it('discovers failures in ftr report', async () => { Array [ Object { "classname": "Chrome X-Pack UI Functional Tests.x-pack/test/functional/apps/maps/sample_data·js", + "commandLine": "node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts", "failure": " Error: retry.try timeout: TimeoutError: Waiting for element to be located By(css selector, [data-test-subj~=\\"layerTocActionsPanelToggleButtonRoad_Map_-_Bright\\"]) Wait timed out after 10055ms @@ -37,6 +38,7 @@ it('discovers failures in ftr report', async () => { }, Object { "classname": "Chrome X-Pack UI Functional Tests.x-pack/test/functional/apps/maps", + "commandLine": "node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts", "failure": " { NoSuchSessionError: This driver instance does not have a valid session ID (did you call WebDriver.quit()?) and may no longer be used. at promise.finally (/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-ciGroup7/node/immutable/kibana/node_modules/selenium-webdriver/lib/webdriver.js:726:38) @@ -56,6 +58,7 @@ it('discovers failures in ftr report', async () => { }, Object { "classname": "Firefox XPack UI Functional Tests.x-pack/test/functional/apps/machine_learning/anomaly_detection/saved_search_job·ts", + "commandLine": "node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts", "failure": "{ NoSuchSessionError: Tried to run command without establishing a connection at Object.throwDecodedError (/dev/shm/workspace/kibana/node_modules/selenium-webdriver/lib/error.js:550:15) at parseHttpResponse (/dev/shm/workspace/kibana/node_modules/selenium-webdriver/lib/http.js:563:13) @@ -76,6 +79,7 @@ it('discovers failures in jest report', async () => { Array [ Object { "classname": "X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp", + "commandLine": "node scripts/jest --config some/jest/config.ts", "failure": " TypeError: Cannot read property '0' of undefined at Object..test (/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts:166:10) @@ -95,6 +99,7 @@ it('discovers failures in mocha report', async () => { Array [ Object { "classname": "X-Pack Mocha Tests.x-pack/legacy/plugins/code/server/__tests__/multi_node·ts", + "commandLine": "node scripts/functional_tests --config super-mocha-test.config.js", "failure": " Error: Unable to read artifact info from https://artifacts-api.elastic.co/v1/versions/8.0.0-SNAPSHOT/builds/latest/projects/elasticsearch: Service Temporarily Unavailable @@ -117,6 +122,7 @@ it('discovers failures in mocha report', async () => { }, Object { "classname": "X-Pack Mocha Tests.x-pack/legacy/plugins/code/server/__tests__/multi_node·ts", + "commandLine": "node scripts/functional_tests --config super-mocha-test.config.js", "failure": " TypeError: Cannot read property 'shutdown' of undefined at Context.shutdown (plugins/code/server/__tests__/multi_node.ts:125:23) diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts index e3230e3cdddc..f0955b17f995 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts @@ -16,6 +16,7 @@ export type TestFailure = FailedTestCase['$'] & { 'system-out'?: string; githubIssue?: string; failureCount?: number; + commandLine?: string; }; const getText = (node?: Array) => { @@ -71,19 +72,35 @@ const isLikelyIrrelevant = (name: string, failure: string) => { export function getFailures(report: TestReport) { const failures: TestFailure[] = []; + const commandLine = getCommandLineFromReport(report); + for (const testCase of makeFailedTestCaseIter(report)) { const failure = getText(testCase.failure); const likelyIrrelevant = isLikelyIrrelevant(testCase.$.name, failure); - failures.push({ + const failureObj = { // unwrap xml weirdness ...testCase.$, // Strip ANSI color characters failure, likelyIrrelevant, 'system-out': getText(testCase['system-out']), - }); + commandLine, + }; + + // cleaning up duplicates + delete failureObj['command-line']; + + failures.push(failureObj); } return failures; } + +function getCommandLineFromReport(report: TestReport) { + if ('testsuites' in report) { + return report.testsuites?.testsuite?.[0]?.$['command-line'] || ''; + } else { + return report.testsuite?.$['command-line'] || ''; + } +} diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts index ab54d7f60dfe..d2c0fb705d1a 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts @@ -170,14 +170,23 @@ export async function reportFailuresToFile(

${escape(failure.name)}

- Failures in tracked branches: ${ - failure.failureCount || 0 - } + ${ + failure.commandLine + ? `

+ Command Line: +
${escape(failure.commandLine)}
+
` + : '' + } +
+ Failures in tracked branches: + ${failure.failureCount || 0} +
${ failure.githubIssue - ? `
${escape( - failure.githubIssue - )}` + ? `` : '' }
diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts index e70aa44a2a08..ce817f90079e 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts @@ -37,6 +37,8 @@ export interface TestSuite { skipped: string; /* optional JSON encoded metadata */ 'metadata-json'?: string; + /* the command that ran this suite */ + 'command-line'?: string; }; testcase?: TestCase[]; } @@ -51,6 +53,8 @@ export interface TestCase { time: string; /* optional JSON encoded metadata */ 'metadata-json'?: string; + /* the command that ran this suite */ + 'command-line'?: string; }; /* contents of system-out elements */ 'system-out'?: Array; diff --git a/packages/kbn-management/settings/setting_ids/index.ts b/packages/kbn-management/settings/setting_ids/index.ts index 32df8ac3240c..94d56372a17c 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 = export const OBSERVABILITY_LOGS_EXPLORER_ALLOWED_DATA_VIEWS_ID = 'observability:logsExplorer:allowedDataViews'; export const OBSERVABILITY_APM_ENABLE_MULTI_SIGNAL = 'observability:apmEnableMultiSignal'; +export const OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID = 'observability:logSources'; // Reporting settings export const XPACK_REPORTING_CUSTOM_PDF_LOGO_ID = 'xpackReporting:customPdfLogo'; diff --git a/packages/kbn-mock-idp-plugin/public/login_page.tsx b/packages/kbn-mock-idp-plugin/public/login_page.tsx index e5329fd652f1..83fff6840800 100644 --- a/packages/kbn-mock-idp-plugin/public/login_page.tsx +++ b/packages/kbn-mock-idp-plugin/public/login_page.tsx @@ -145,7 +145,7 @@ export const LoginPage = () => { > Log in , - + More login options , ]} diff --git a/packages/kbn-openapi-generator/src/template_service/templates/ts_input_type.handlebars b/packages/kbn-openapi-generator/src/template_service/templates/ts_input_type.handlebars index 453e4cdf452d..5091c6f9a701 100644 --- a/packages/kbn-openapi-generator/src/template_service/templates/ts_input_type.handlebars +++ b/packages/kbn-openapi-generator/src/template_service/templates/ts_input_type.handlebars @@ -3,7 +3,8 @@ {{~/if~}} {{~#if $ref~}} - {{referenceName}}Input + {{referenceName}} + {{~#if (isCircularRef $ref)}}Input{{/if~}} {{~#if nullable}} | null {{/if~}} {{~/if~}} diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 73d9f0e23af2..00bcbf648193 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -95,6 +95,7 @@ pageLoadAssetSize: licensing: 29004 links: 44490 lists: 22900 + logsDataAccess: 16759 logsExplorer: 60000 logsShared: 281060 logstash: 53548 @@ -102,7 +103,7 @@ pageLoadAssetSize: maps: 90000 mapsEms: 26072 metricsDataAccess: 73287 - ml: 82210 + ml: 85000 mockIdpPlugin: 30000 monitoring: 80000 navigation: 37269 diff --git a/packages/kbn-search-api-panels/components/code_box.tsx b/packages/kbn-search-api-panels/components/code_box.tsx index 57483af87e79..11bf2bea8331 100644 --- a/packages/kbn-search-api-panels/components/code_box.tsx +++ b/packages/kbn-search-api-panels/components/code_box.tsx @@ -31,17 +31,18 @@ import { LanguageDefinition } from '../types'; import './code_box.scss'; interface CodeBoxProps { - languages: LanguageDefinition[]; + languages?: LanguageDefinition[]; codeSnippet: string; // overrides the language type for syntax highlighting languageType?: string; - selectedLanguage: LanguageDefinition; - setSelectedLanguage: (language: LanguageDefinition) => void; - assetBasePath: string; + selectedLanguage?: LanguageDefinition; + setSelectedLanguage?: (language: LanguageDefinition) => void; + assetBasePath?: string; application?: ApplicationStart; consolePlugin?: ConsolePluginStart; - sharePlugin: SharePluginStart; + sharePlugin?: SharePluginStart; consoleRequest?: string; + showTopBar?: boolean; } export const CodeBox: React.FC = ({ @@ -55,23 +56,28 @@ export const CodeBox: React.FC = ({ setSelectedLanguage, sharePlugin, consoleRequest, + showTopBar = true, }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const items = languages.map((language) => ( - { - setSelectedLanguage(language); - setIsPopoverOpen(false); - }} - > - {language.name} - - )); + const items = languages + ? languages.map((language) => ( + { + if (setSelectedLanguage) { + setSelectedLanguage(language); + setIsPopoverOpen(false); + } + }} + > + {language.name} + + )) + : []; - const button = ( + const button = selectedLanguage ? ( = ({ {selectedLanguage.name} - ); + ) : null; return ( - - - - setIsPopoverOpen(false)} - panelPaddingSize="none" - anchorPosition="downLeft" - > - - - - - - - {(copy) => ( - - {i18n.translate('searchApiPanels.welcomeBanner.codeBox.copyButtonLabel', { - defaultMessage: 'Copy', - })} - + {showTopBar && ( + <> + + {languages && button && ( + + + setIsPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + + )} + + + {(copy) => ( + + {i18n.translate('searchApiPanels.welcomeBanner.codeBox.copyButtonLabel', { + defaultMessage: 'Copy', + })} + + )} + + + {consoleRequest !== undefined && sharePlugin && ( + + + )} - - - {consoleRequest !== undefined && ( - - - - )} - - + + + + )} diff --git a/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx b/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx index c5781d2596f3..013b7da5bffc 100644 --- a/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx +++ b/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiBasicTable, EuiBasicTableColumn, EuiIcon, EuiToolTip, EuiCode } from '@elastic/eui'; -import { ByteSizeValue } from '@kbn/config-schema'; +import { ByteSizeValue } from '@kbn/config-schema/src/byte_size_value'; // importing from file to avoid leaking `joi` to the browser import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; diff --git a/packages/kbn-search-connectors/lib/create_connector.ts b/packages/kbn-search-connectors/lib/create_connector.ts index 666011e50f34..5aaf8d2610dd 100644 --- a/packages/kbn-search-connectors/lib/create_connector.ts +++ b/packages/kbn-search-connectors/lib/create_connector.ts @@ -53,6 +53,14 @@ export const createConnector = async ( }); } + if (input.configuration) { + await client.transport.request({ + method: 'PUT', + path: `/_connector/${connectorId}/_configuration`, + body: { configuration: input.configuration }, + }); + } + // createConnector function expects to return a Connector doc, so we fetch it from the index const connector = await fetchConnectorById(client, connectorId); diff --git a/packages/kbn-shared-svg/index.ts b/packages/kbn-shared-svg/index.ts index 215431706ab9..525b3f456fd7 100644 --- a/packages/kbn-shared-svg/index.ts +++ b/packages/kbn-shared-svg/index.ts @@ -10,5 +10,12 @@ import noResultsIllustrationDark from './src/assets/no_results_dark.svg'; import noResultsIllustrationLight from './src/assets/no_results_light.svg'; import dashboardsLight from './src/assets/dashboards_light.svg'; import dashboardsDark from './src/assets/dashboards_dark.svg'; +import apmLight from './src/assets/oblt_apm_light.svg'; -export { noResultsIllustrationDark, noResultsIllustrationLight, dashboardsLight, dashboardsDark }; +export { + noResultsIllustrationDark, + noResultsIllustrationLight, + dashboardsLight, + dashboardsDark, + apmLight, +}; diff --git a/packages/kbn-shared-svg/src/assets/oblt_apm_light.svg b/packages/kbn-shared-svg/src/assets/oblt_apm_light.svg new file mode 100644 index 000000000000..b3cd9c0e9c34 --- /dev/null +++ b/packages/kbn-shared-svg/src/assets/oblt_apm_light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts b/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts index edb109eaa700..ef6986183dd6 100644 --- a/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts +++ b/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts @@ -17,6 +17,7 @@ import { AggregatedResult, Test, BaseReporter } from '@jest/reporters'; import { escapeCdata } from '../../mocha/xml'; import { getUniqueJunitReportPath } from '../../report_path'; +import { prettifyCommandLine } from '../../prettify_command_line'; interface ReporterOptions { reportName?: string; @@ -71,6 +72,7 @@ export default class JestJUnitReporter extends BaseReporter { tests: results.numTotalTests, failures: results.numFailedTests, skipped: results.numPendingTests, + 'command-line': prettifyCommandLine(process.argv), }); // top level test results are the files/suites @@ -83,6 +85,7 @@ export default class JestJUnitReporter extends BaseReporter { failures: suite.numFailingTests, skipped: suite.numPendingTests, file: suite.testFilePath, + 'command-line': prettifyCommandLine(process.argv), }); // nested in there are the tests in that file diff --git a/packages/kbn-test/src/mocha/junit_report_generation.js b/packages/kbn-test/src/mocha/junit_report_generation.js index 4b35fba4fb1e..fcf31672d799 100644 --- a/packages/kbn-test/src/mocha/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.js @@ -17,6 +17,7 @@ import { getUniqueJunitReportPath } from '../report_path'; import { getSnapshotOfRunnableLogs } from './log_cache'; import { escapeCdata } from '../..'; +import { prettifyCommandLine } from '../prettify_command_line'; const dateNow = Date.now.bind(Date); @@ -95,14 +96,25 @@ export function setupJUnitReportGeneration(runner, options = {}) { // cache codeowners for quicker lookup const reversedCodeowners = getPathsWithOwnersReversed(); - const builder = xmlBuilder.create( + const commandLine = prettifyCommandLine(process.argv); + + const root = xmlBuilder.create( 'testsuites', { encoding: 'utf-8' }, {}, { skipNullAttributes: true } ); - const testsuitesEl = builder.ele('testsuite', { + root.att({ + name: 'ftr', + time: getDuration(stats), + tests: allTests.length + failedHooks.length, + failures: failures.length, + skipped: skippedResults.length, + 'command-line': commandLine, + }); + + const testsuitesEl = root.ele('testsuite', { name: reportName, timestamp: new Date(stats.startTime).toISOString().slice(0, -5), time: getDuration(stats), @@ -110,6 +122,7 @@ export function setupJUnitReportGeneration(runner, options = {}) { failures: failures.length, skipped: skippedResults.length, 'metadata-json': JSON.stringify(metadata ?? {}), + 'command-line': commandLine, }); function addTestcaseEl(node, failed) { @@ -147,7 +160,7 @@ export function setupJUnitReportGeneration(runner, options = {}) { }); const reportPath = getUniqueJunitReportPath(rootDirectory, reportName); - const reportXML = builder.end(); + const reportXML = root.end(); mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, reportXML, 'utf8'); }); diff --git a/packages/kbn-test/src/mocha/junit_report_generation.test.js b/packages/kbn-test/src/mocha/junit_report_generation.test.js index b6bc2e951d1d..aad96a93fd86 100644 --- a/packages/kbn-test/src/mocha/junit_report_generation.test.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.test.js @@ -45,9 +45,9 @@ describe('dev/mocha/junit report generation', () => { // test case results are wrapped in expect(report).toEqual({ - testsuites: { + testsuites: expect.objectContaining({ testsuite: [report.testsuites.testsuite[0]], - }, + }), }); // the single element at the root contains summary data for all tests results @@ -55,6 +55,8 @@ describe('dev/mocha/junit report generation', () => { expect(testsuite.$.time).toMatch(DURATION_REGEX); expect(testsuite.$.timestamp).toMatch(ISO_DATE_SEC_REGEX); expect(testsuite.$).toEqual({ + 'command-line': + 'node scripts/jest --config=packages/kbn-test/jest.config.js --runInBand --coverage=false --passWithNoTests', failures: '2', name: 'test', skipped: '1', diff --git a/packages/kbn-test/src/prettify_command_line.ts b/packages/kbn-test/src/prettify_command_line.ts new file mode 100644 index 000000000000..0f8f1eb75570 --- /dev/null +++ b/packages/kbn-test/src/prettify_command_line.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { execSync } from 'child_process'; +import * as path from 'path'; + +const kibanaRoot = execSync('git rev-parse --show-toplevel').toString().trim() || process.cwd(); + +export function prettifyCommandLine(args: string[]) { + let [executable, ...rest] = args; + if (executable.endsWith('node')) { + executable = 'node'; + } + rest = rest.map((arg) => path.relative(kibanaRoot, arg)); + + return [executable, ...rest].join(' '); +} diff --git a/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx b/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx index d1c3788c39a8..df32875a3366 100644 --- a/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx +++ b/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx @@ -2786,6 +2786,43 @@ export const functions = { | WHERE ST_DISJOINT(city_boundary, TO_GEOSHAPE("POLYGON((-10 -60, 120 -60, 120 60, -10 60, -10 -60))")) | KEEP abbrev, airport, region, city, city_location \`\`\` + `, + description: + 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', + ignoreTag: true, + } + )} + /> + ), + }, + // Do not edit manually... automatically generated by scripts/generate_esql_docs.ts + { + label: i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.st_distance', + { + defaultMessage: 'ST_DISTANCE', + } + ), + description: ( + + + ### ST_DISTANCE + Computes the distance between two points. + For cartesian geometries, this is the pythagorean distance in the same units as the original coordinates. + For geographic geometries, this is the circular distance along the great circle in meters. + + \`\`\` + FROM airports + | WHERE abbrev == "CPH" + | EVAL distance = ST_DISTANCE(location, city_location) + | KEEP abbrev, name, location, city_location, distance + \`\`\` `, description: 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', @@ -3706,6 +3743,39 @@ export const functions = { \`\`\` ROW v = TO_VERSION("1.2.3") \`\`\` + `, + description: + 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', + ignoreTag: true, + } + )} + /> + ), + }, + // Do not edit manually... automatically generated by scripts/generate_esql_docs.ts + { + label: i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.top_list', + { + defaultMessage: 'TOP_LIST', + } + ), + description: ( + + + ### TOP_LIST + Collects the top values for a field. Includes repeated values. + + \`\`\` + FROM employees + | STATS top_salaries = TOP_LIST(salary, 3, "desc"), top_salary = MAX(salary) + \`\`\` `, description: 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', @@ -4386,6 +4456,33 @@ The following boolean operators are supported: /> ), }, + { + label: i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.castOperator', + { + defaultMessage: 'Cast (::)', + } + ), + description: ( + \` type conversion functions. + +Example: +\`\`\` +ROW ver = CONCAT(("0"::INT + 1)::STRING, ".2.3")::VERSION +\`\`\` + `, + description: + 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', + } + )} + /> + ), + }, { label: i18n.translate( 'textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator', diff --git a/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.test.tsx b/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.test.tsx index bbd14526a0ac..ea740880c449 100644 --- a/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.test.tsx +++ b/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.test.tsx @@ -7,7 +7,7 @@ */ import { createMemoryHistory } from 'history'; -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook, act, cleanup } from '@testing-library/react-hooks'; import { coreMock } from '@kbn/core/public/mocks'; import { CoreScopedHistory } from '@kbn/core/public'; @@ -23,6 +23,20 @@ const navigateToUrl = jest.fn().mockImplementation(async (url) => { }); describe('useUnsavedChangesPrompt', () => { + let addSpy: jest.SpiedFunction; + let removeSpy: jest.SpiedFunction; + + beforeEach(() => { + addSpy = jest.spyOn(window, 'addEventListener'); + removeSpy = jest.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + addSpy.mockRestore(); + removeSpy.mockRestore(); + jest.resetAllMocks(); + }); + it('should not block if not edited', () => { renderHook(() => useUnsavedChangesPrompt({ @@ -39,6 +53,7 @@ describe('useUnsavedChangesPrompt', () => { expect(history.location.pathname).toBe('/test'); expect(history.location.search).toBe(''); expect(coreStart.overlays.openConfirm).not.toBeCalled(); + expect(addSpy).not.toBeCalledWith('beforeunload', expect.anything()); }); it('should block if edited', async () => { @@ -61,5 +76,23 @@ describe('useUnsavedChangesPrompt', () => { expect(navigateToUrl).toBeCalledWith('/mock/test', expect.anything()); expect(coreStart.overlays.openConfirm).toBeCalled(); + expect(addSpy).toBeCalledWith('beforeunload', expect.anything()); + }); + + it('beforeunload event should be cleaned up', async () => { + coreStart.overlays.openConfirm.mockResolvedValue(true); + + renderHook(() => + useUnsavedChangesPrompt({ + hasUnsavedChanges: true, + http: coreStart.http, + openConfirm: coreStart.overlays.openConfirm, + history, + navigateToUrl, + }) + ); + cleanup(); + expect(addSpy).toBeCalledWith('beforeunload', expect.anything()); + expect(removeSpy).toBeCalledWith('beforeunload', expect.anything()); }); }); diff --git a/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.tsx b/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.tsx index 20d815cffd8b..f424e0b27e01 100644 --- a/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.tsx +++ b/packages/kbn-unsaved-changes-prompt/src/unsaved_changes_prompt/unsaved_changes_prompt.tsx @@ -51,6 +51,20 @@ export const useUnsavedChangesPrompt = ({ confirmButtonText = DEFAULT_CONFIRM_BUTTON, cancelButtonText = DEFAULT_CANCEL_BUTTON, }: Props) => { + useEffect(() => { + if (hasUnsavedChanges) { + const handler = (event: BeforeUnloadEvent) => { + // These 2 lines of code are the recommendation from MDN for triggering a browser prompt for confirming + // whether or not a user wants to leave the current site. + event.preventDefault(); + event.returnValue = ''; + }; + // Adding this handler will prompt users if they are navigating to a new page, outside of the Kibana SPA + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + } + }, [hasUnsavedChanges]); + useEffect(() => { if (!hasUnsavedChanges) { return; diff --git a/packages/serverless/settings/observability_project/index.ts b/packages/serverless/settings/observability_project/index.ts index 58be247f0ff8..0374fc9a6b6e 100644 --- a/packages/serverless/settings/observability_project/index.ts +++ b/packages/serverless/settings/observability_project/index.ts @@ -21,7 +21,6 @@ export const OBSERVABILITY_PROJECT_SETTINGS = [ settings.OBSERVABILITY_APM_ENABLE_SERVICE_METRICS_ID, settings.OBSERVABILITY_APM_ENABLE_CONTINUOUS_ROLLUPS_ID, settings.OBSERVABILITY_APM_AGENT_EXPLORER_VIEW_ID, - settings.OBSERVABILITY_APM_ENABLE_PROFILING_INTEGRATION_ID, settings.OBSERVABILITY_APM_PROGRESSIVE_LOADING_ID, settings.OBSERVABILITY_APM_SERVICE_INVENTORY_OPTIMIZED_SORTING_ID, settings.OBSERVABILITY_APM_TRACE_EXPLORER_TAB_ID, diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 48b94a4fa8c6..bb2d6f668468 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -114,38 +114,15 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions, keystoreC // only used to set cliArgs.envName, we don't want to inject that into the config delete extraCliOptions.env; + let isServerlessSamlSupported = false; if (opts.dev) { if (opts.serverless) { setServerlessKibanaDevServiceAccountIfPossible(get, set, opts); - - // Configure realm if supported (ES only supports SAML when run with SSL) - if (opts.ssl && MOCK_IDP_PLUGIN_SUPPORTED) { - // Ensure the plugin is loaded in dynamically to exclude from production build - // eslint-disable-next-line import/no-dynamic-require - const { MOCK_IDP_REALM_NAME } = require(MOCK_IDP_PLUGIN_PATH); - - if (has('server.basePath')) { - console.log( - `Custom base path is not supported when running in Serverless, it will be removed.` - ); - _.unset(rawConfig, 'server.basePath'); - } - - set(`xpack.security.authc.providers.saml.${MOCK_IDP_REALM_NAME}`, { - order: Number.MAX_SAFE_INTEGER, - realm: MOCK_IDP_REALM_NAME, - icon: 'user', - description: 'Continue as Test User', - hint: 'Allows testing serverless user roles', - }); - // Add basic realm since defaults won't be applied when a provider has been configured - if (!has('xpack.security.authc.providers.basic')) { - set('xpack.security.authc.providers.basic.basic', { - order: 0, - enabled: true, - }); - } - } + isServerlessSamlSupported = tryConfigureServerlessSamlProvider( + rawConfig, + opts, + extraCliOptions + ); } if (!has('elasticsearch.serviceAccountToken') && opts.devCredentials !== false) { @@ -182,7 +159,7 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions, keystoreC } // Kib/ES encryption - if (opts.ssl) { + if (opts.ssl || isServerlessSamlSupported) { // @kbn/dev-utils is part of devDependencies // eslint-disable-next-line import/no-extraneous-dependencies const { CA_CERT_PATH } = require('@kbn/dev-utils'); @@ -278,6 +255,7 @@ export default function (program) { command .option('--dev', 'Run the server with development mode defaults') .option('--ssl', 'Run the dev server using HTTPS') + .option('--no-ssl', 'Run the server without HTTPS') .option('--http2', 'Run the dev server using HTTP2 with TLS') .option('--dist', 'Use production assets from kbn/optimizer') .option( @@ -303,13 +281,14 @@ export default function (program) { serverless: opts.serverless || unknownOptions.serverless, }); - const configsEvaluted = getConfigFromFiles(configs); + const configsEvaluated = getConfigFromFiles(configs); const isServerlessMode = !!( - configsEvaluted.serverless || + configsEvaluated.serverless || opts.serverless || unknownOptions.serverless ); + const isServerlessSamlSupported = isServerlessMode && opts.ssl !== false; const cliArgs = { dev: !!opts.dev, envName: unknownOptions.env ? unknownOptions.env.name : undefined, @@ -324,7 +303,7 @@ export default function (program) { // elastic.co links. // We also want to run without base path when running in serverless mode so that Elasticsearch can // connect to Kibana's mock identity provider. - basePath: opts.runExamples || isServerlessMode ? false : !!opts.basePath, + basePath: opts.runExamples || isServerlessSamlSupported ? false : !!opts.basePath, optimize: !!opts.optimize, disableOptimizer: !opts.optimizer, oss: !!opts.oss, @@ -362,3 +341,87 @@ function mergeAndReplaceArrays(objValue, srcValue) { return undefined; } } + +/** + * Tries to configure SAML provider in serverless mode and applies the necessary configuration. + * @param rawConfig Full configuration object. + * @param opts CLI options. + * @param extraCliOptions Extra CLI options. + * @returns {boolean} True if SAML provider was successfully configured. + */ +function tryConfigureServerlessSamlProvider(rawConfig, opts, extraCliOptions) { + if (!MOCK_IDP_PLUGIN_SUPPORTED || opts.ssl === false) { + return false; + } + + // Ensure the plugin is loaded in dynamically to exclude from production build + // eslint-disable-next-line import/no-dynamic-require + const { MOCK_IDP_REALM_NAME } = require(MOCK_IDP_PLUGIN_PATH); + + // Check if there are any custom authentication providers already configured with the order `0` reserved for the + // Serverless SAML provider or if there is an existing SAML provider with the name MOCK_IDP_REALM_NAME. We check + // both rawConfig and extraCliOptions because the latter can be used to override the former. + let hasBasicOrTokenProviderConfigured = false; + for (const configSource of [rawConfig, extraCliOptions]) { + const providersConfig = _.get(configSource, 'xpack.security.authc.providers', {}); + for (const [providerType, providers] of Object.entries(providersConfig)) { + if (providerType === 'basic' || providerType === 'token') { + hasBasicOrTokenProviderConfigured = true; + } + + for (const [providerName, provider] of Object.entries(providers)) { + if (provider.order === 0) { + console.warn( + `The serverless SAML authentication provider won't be configured because the order "0" is already used by the custom authentication provider "${providerType}/${providerName}".` + + `Please update the custom provider to use a different order or remove it to allow the serverless SAML provider to be configured.` + ); + return false; + } + + if (providerType === 'saml' && providerName === MOCK_IDP_REALM_NAME) { + console.warn( + `The serverless SAML authentication provider won't be configured because the SAML provider with "${MOCK_IDP_REALM_NAME}" name is already configured".` + ); + return false; + } + } + } + } + + if (_.has(rawConfig, 'server.basePath')) { + console.warn( + `Custom base path is not supported when running in Serverless, it will be removed.` + ); + _.unset(rawConfig, 'server.basePath'); + } + + if (opts.ssl) { + console.info( + 'Kibana is being served over HTTPS. Make sure to adjust the `--kibanaUrl` parameter while running the local Serverless ES cluster.' + ); + } + + // Make SAML provider the first in the provider chain + lodashSet(rawConfig, `xpack.security.authc.providers.saml.${MOCK_IDP_REALM_NAME}`, { + order: 0, + realm: MOCK_IDP_REALM_NAME, + icon: 'user', + description: 'Continue as Test User', + hint: 'Allows testing serverless user roles', + }); + + // Disable login selector to automatically trigger SAML authentication, unless it's explicitly enabled. + if (!_.has(rawConfig, 'xpack.security.authc.selector.enabled')) { + lodashSet(rawConfig, 'xpack.security.authc.selector.enabled', false); + } + + // Since we explicitly configured SAML authentication provider, default Basic provider won't be automatically + // configured, and we have to do it manually instead unless other Basic or Token provider was already configured. + if (!hasBasicOrTokenProviderConfigured) { + lodashSet(rawConfig, 'xpack.security.authc.providers.basic.basic', { + order: Number.MAX_SAFE_INTEGER, + }); + } + + return true; +} diff --git a/src/core/server/integration_tests/http/http2_protocol.test.ts b/src/core/server/integration_tests/http/http2_protocol.test.ts index f76076de81d4..b1db89a02323 100644 --- a/src/core/server/integration_tests/http/http2_protocol.test.ts +++ b/src/core/server/integration_tests/http/http2_protocol.test.ts @@ -17,12 +17,14 @@ import { config as httpConfig, cspConfig, externalUrlConfig, + permissionsPolicyConfig, } from '@kbn/core-http-server-internal'; import { mockCoreContext } from '@kbn/core-base-server-mocks'; import type { Logger } from '@kbn/logging'; const CSP_CONFIG = cspConfig.schema.validate({}); const EXTERNAL_URL_CONFIG = externalUrlConfig.schema.validate({}); +const PERMISSIONS_POLICY_CONFIG = permissionsPolicyConfig.schema.validate({}); describe('Http2 - Smoke tests', () => { let server: HttpServer; @@ -56,7 +58,7 @@ describe('Http2 - Smoke tests', () => { }, shutdownTimeout: '5s', }); - config = new HttpConfig(rawConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG); + config = new HttpConfig(rawConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG, PERMISSIONS_POLICY_CONFIG); server = new HttpServer(coreContext, 'tests', of(config.shutdownTimeout)); }); diff --git a/src/core/server/integration_tests/http/set_tls_config.test.ts b/src/core/server/integration_tests/http/set_tls_config.test.ts index 4966ecafce41..e0c30efafc10 100644 --- a/src/core/server/integration_tests/http/set_tls_config.test.ts +++ b/src/core/server/integration_tests/http/set_tls_config.test.ts @@ -14,12 +14,14 @@ import { config as httpConfig, cspConfig, externalUrlConfig, + permissionsPolicyConfig, } from '@kbn/core-http-server-internal'; import { flattenCertificateChain, fetchPeerCertificate, isServerTLS } from './tls_utils'; describe('setTlsConfig', () => { const CSP_CONFIG = cspConfig.schema.validate({}); const EXTERNAL_URL_CONFIG = externalUrlConfig.schema.validate({}); + const PERMISSIONS_POLICY_CONFIG = permissionsPolicyConfig.schema.validate({}); beforeAll(() => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; @@ -39,7 +41,12 @@ describe('setTlsConfig', () => { }, shutdownTimeout: '1s', }); - const firstConfig = new HttpConfig(rawHttpConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG); + const firstConfig = new HttpConfig( + rawHttpConfig, + CSP_CONFIG, + EXTERNAL_URL_CONFIG, + PERMISSIONS_POLICY_CONFIG + ); const serverOptions = getServerOptions(firstConfig); const server = createServer(serverOptions); @@ -85,7 +92,12 @@ describe('setTlsConfig', () => { shutdownTimeout: '1s', }); - const secondConfig = new HttpConfig(secondRawConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG); + const secondConfig = new HttpConfig( + secondRawConfig, + CSP_CONFIG, + EXTERNAL_URL_CONFIG, + PERMISSIONS_POLICY_CONFIG + ); setTlsConfig(server, secondConfig.ssl); diff --git a/src/core/server/integration_tests/http/tls_config_reload.test.ts b/src/core/server/integration_tests/http/tls_config_reload.test.ts index ad2c530faae5..030b22e2f497 100644 --- a/src/core/server/integration_tests/http/tls_config_reload.test.ts +++ b/src/core/server/integration_tests/http/tls_config_reload.test.ts @@ -17,6 +17,7 @@ import { config as httpConfig, cspConfig, externalUrlConfig, + permissionsPolicyConfig, } from '@kbn/core-http-server-internal'; import { isServerTLS, flattenCertificateChain, fetchPeerCertificate } from './tls_utils'; import { mockCoreContext } from '@kbn/core-base-server-mocks'; @@ -24,6 +25,7 @@ import type { Logger } from '@kbn/logging'; const CSP_CONFIG = cspConfig.schema.validate({}); const EXTERNAL_URL_CONFIG = externalUrlConfig.schema.validate({}); +const PERMISSIONS_POLICY_CONFIG = permissionsPolicyConfig.schema.validate({}); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); describe('HttpServer - TLS config', () => { @@ -54,7 +56,12 @@ describe('HttpServer - TLS config', () => { }, shutdownTimeout: '1s', }); - const firstConfig = new HttpConfig(rawHttpConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG); + const firstConfig = new HttpConfig( + rawHttpConfig, + CSP_CONFIG, + EXTERNAL_URL_CONFIG, + PERMISSIONS_POLICY_CONFIG + ); const config$ = new BehaviorSubject(firstConfig); @@ -109,7 +116,12 @@ describe('HttpServer - TLS config', () => { shutdownTimeout: '1s', }); - const secondConfig = new HttpConfig(secondRawConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG); + const secondConfig = new HttpConfig( + secondRawConfig, + CSP_CONFIG, + EXTERNAL_URL_CONFIG, + PERMISSIONS_POLICY_CONFIG + ); config$.next(secondConfig); const secondCertificate = await fetchPeerCertificate(firstConfig.host, firstConfig.port); diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 27d7a465186a..75237f0ea359 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -40,6 +40,7 @@ kibana_vars=( csp.report_uri csp.report_to csp.report_only.form_action + permissionsPolicy.report_to data.autocomplete.valueSuggestions.terminateAfter data.autocomplete.valueSuggestions.timeout data.search.asyncSearch.waitForCompletion @@ -390,6 +391,7 @@ kibana_vars=( xpack.security.authc.selector.enabled xpack.security.cookieName xpack.security.encryptionKey + xpack.security.experimental.fipsMode.enabled xpack.security.loginAssistanceMessage xpack.security.loginHelp xpack.security.sameSiteCookies diff --git a/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts b/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts index 571e6de45836..6d33c3465c59 100644 --- a/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts +++ b/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts @@ -210,7 +210,7 @@ function getFullAgentTargetingRule(queue: string): GobldGCPConfig { // Mapping based on expected fields in https://github.com/elastic/ci/blob/0df8430357109a19957dcfb1d867db9cfdd27937/docs/gobld/providers.mdx#L96 return removeNullish({ image: 'family/kibana-ubuntu-2004', - imageProject: 'elastic-images-qa', + imageProject: 'elastic-images-prod', provider: 'gcp', assignExternalIP: agent.disableExternalIp === true ? false : undefined, diskSizeGb: agent.diskSizeGb, diff --git a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts index edd03c5f536f..9b34fde53e38 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -10,7 +10,14 @@ import { Position } from '@elastic/charts'; import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, DatatableRow } from '@kbn/expressions-plugin/common'; import { LayerTypes } from '../constants'; -import { DataLayerConfig, ExtendedDataLayerConfig, XYProps } from '../types'; +import { + AnnotationLayerConfig, + CommonXYLayerConfig, + DataLayerConfig, + ExtendedDataLayerConfig, + ReferenceLineLayerConfig, + XYProps, +} from '../types'; export const mockPaletteOutput: PaletteOutput = { type: 'palette', @@ -46,6 +53,36 @@ export const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable = rows, }); +export const sampleAnnotationLayer: AnnotationLayerConfig = { + layerId: 'first', + type: 'annotationLayer', + layerType: LayerTypes.ANNOTATIONS, + annotations: [ + { + type: 'manual_point_event_annotation', + id: 'ann1', + time: '2021-01-01T00:00:00.000Z', + label: 'Manual annotation point', + }, + { + type: 'query_point_event_annotation', + id: 'ann2', + filter: { type: 'kibana_query', language: 'kql', query: 'a: *' }, + label: 'Query annotation point', + }, + ], +}; + +export const sampleReferenceLineLayer: ReferenceLineLayerConfig = { + layerId: 'first', + type: 'referenceLineLayer', + layerType: LayerTypes.REFERENCELINE, + accessors: ['b', 'c'], + columnToLabel: '{"b": "Label B", "c": "Label C"}', + decorations: [], + table: createSampleDatatableWithRows([]), +}; + export const sampleLayer: DataLayerConfig = { layerId: 'first', type: 'dataLayer', @@ -84,7 +121,7 @@ export const sampleExtendedLayer: ExtendedDataLayerConfig = { }; export const createArgsWithLayers = ( - layers: DataLayerConfig | DataLayerConfig[] = sampleLayer + layers: CommonXYLayerConfig | CommonXYLayerConfig[] = sampleLayer ): XYProps => ({ showTooltip: true, minBarHeight: 1, diff --git a/src/plugins/chart_expressions/expression_xy/public/expression_renderers/telemetry.test.ts b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/telemetry.test.ts new file mode 100644 index 000000000000..a4b0692b09e4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/telemetry.test.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CommonXYLayerConfig, LayerTypes } from '../../common'; +import { AnnotationLayerConfig, DataLayerConfig, XYProps } from '../../common/types'; +import { + createArgsWithLayers, + sampleAnnotationLayer, + sampleLayer, + sampleReferenceLineLayer, +} from '../../common/__mocks__'; +import { getDataLayers } from '../helpers'; +import { extractCounterEvents } from './xy_chart_renderer'; + +type PossibleLayerTypes = + | typeof LayerTypes.DATA + | typeof LayerTypes.ANNOTATIONS + | typeof LayerTypes.REFERENCELINE; + +function createLayer(type: PossibleLayerTypes) { + switch (type) { + case LayerTypes.ANNOTATIONS: { + return { ...sampleAnnotationLayer }; + } + case LayerTypes.REFERENCELINE: { + return { ...sampleReferenceLineLayer }; + } + case LayerTypes.DATA: + default: { + return { ...sampleLayer }; + } + } +} +function createLayers( + layerConfigs: Partial> +): CommonXYLayerConfig[] { + const layers = []; + for (const [type, { count }] of Object.entries(layerConfigs)) { + layers.push( + ...Array.from({ length: count }, () => createLayer(type as CommonXYLayerConfig['layerType'])) + ); + } + return layers; +} + +function createAnnotations(count: number) { + return Array.from({ length: count }, () => createLayer('annotations') as AnnotationLayerConfig); +} + +function getXYProps( + layersConfig: CommonXYLayerConfig[], + annotations?: AnnotationLayerConfig[] +): XYProps { + const args = createArgsWithLayers(layersConfig); + if (annotations?.length) { + if (!args.annotations) { + args.annotations = { + type: 'event_annotations_result', + layers: [], + datatable: { + type: 'datatable', + columns: [], + rows: [], + }, + }; + } + args.annotations!.layers = annotations; + } + return args; +} + +describe('should emit the right telemetry events', () => { + it('should emit the telemetry event for a single data layer', () => { + expect( + extractCounterEvents('lens', getXYProps(createLayers({ data: { count: 1 } })), false, { + getDataLayers, + }) + ).toMatchInlineSnapshot(` + Array [ + "render_lens_line", + ] + `); + }); + + it('should emit the telemetry event for multiple data layers', () => { + expect( + extractCounterEvents('lens', getXYProps(createLayers({ data: { count: 2 } })), false, { + getDataLayers, + }) + ).toMatchInlineSnapshot(` + Array [ + "render_lens_line", + "render_lens_multiple_data_layers", + ] + `); + }); + + it('should emit the telemetry event for multiple data layers with mixed types', () => { + const layers = createLayers({ data: { count: 2 } }); + // change layer 2 to be bar stacked + (layers[1] as DataLayerConfig).seriesType = 'bar'; + (layers[1] as DataLayerConfig).isStacked = true; + expect( + extractCounterEvents('lens', getXYProps(layers), false, { + getDataLayers, + }) + ).toMatchInlineSnapshot(` + Array [ + "render_lens_line", + "render_lens_multiple_data_layers", + "render_lens_mixed_xy", + ] + `); + }); + + it('should emit the telemetry dedicated event for percentage charts', () => { + const layers = createLayers({ data: { count: 1 } }); + // change layer 2 to be bar stacked + (layers[0] as DataLayerConfig).seriesType = 'bar'; + (layers[0] as DataLayerConfig).isPercentage = true; + (layers[0] as DataLayerConfig).isStacked = true; + expect( + extractCounterEvents('lens', getXYProps(layers), false, { + getDataLayers, + }) + ).toMatchInlineSnapshot(` + Array [ + "render_lens_vertical_bar_percentage_stacked", + ] + `); + }); + + it('should emit the telemetry event for a data layer and an additonal reference line layer', () => { + expect( + extractCounterEvents( + 'lens', + getXYProps(createLayers({ data: { count: 1 }, referenceLine: { count: 1 } })), + false, + { getDataLayers } + ) + ).toMatchInlineSnapshot(` + Array [ + "render_lens_line", + "render_lens_reference_layer", + ] + `); + }); + + it('should emit the telemetry event for a data layer and an additional annotations layer', () => { + expect( + extractCounterEvents( + 'lens', + getXYProps(createLayers({ data: { count: 1 } }), createAnnotations(1)), + false, + { getDataLayers } + ) + ).toMatchInlineSnapshot(` + Array [ + "render_lens_line", + "render_lens_annotation_layer", + ] + `); + }); + + it('should emit the telemetry event for a scenario with the navigate to lens feature', () => { + expect( + extractCounterEvents( + 'lens', + getXYProps( + createLayers({ data: { count: 1 }, referenceLine: { count: 1 } }), + createAnnotations(1) + ), + true, + { getDataLayers } + ) + ).toMatchInlineSnapshot(` + Array [ + "render_lens_line", + "render_lens_reference_layer", + "render_lens_annotation_layer", + "render_lens_render_line_convertable", + ] + `); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx index a1537cdd5e3e..0213c61f9a1f 100644 --- a/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx @@ -60,9 +60,9 @@ interface XyChartRendererDeps { getStartDeps: GetStartDepsFn; } -const extractCounterEvents = ( +export const extractCounterEvents = ( originatingApp: string, - { layers, yAxisConfigs }: XYChartProps['args'], + { annotations, layers, yAxisConfigs }: XYChartProps['args'], canNavigateToLens: boolean, services: { getDataLayers: typeof getDataLayers; @@ -77,7 +77,7 @@ const extractCounterEvents = ( ? `${dataLayer.isHorizontal ? 'horizontal_bar' : 'vertical_bar'}` : dataLayer.seriesType; - const byTypes = layers.reduce( + const byTypes = layers.concat(annotations?.layers || []).reduce( (acc, item) => { if ( !acc.mixedXY && diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index d7f75d5c268c..56085d7d5848 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -13,7 +13,7 @@ import type { DataControlInput } from '../types'; import { OptionsListSearchTechnique } from './suggestions_searching'; import type { OptionsListSortingType } from './suggestions_sorting'; -export const OPTIONS_LIST_CONTROL = 'optionsListControl'; +export const OPTIONS_LIST_CONTROL = 'optionsListControl'; // TODO: Replace with OPTIONS_LIST_CONTROL_TYPE export interface OptionsListEmbeddableInput extends DataControlInput { searchTechnique?: OptionsListSearchTechnique; diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts index fab4d18af400..16c2983c7a9c 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts @@ -169,7 +169,7 @@ describe('createFiltersFromClickEvent', () => { }); expect(queryString).toEqual(`from meow -| where \`columnA\`=="2048"`); +| WHERE \`columnA\`=="2048"`); }); }); }); diff --git a/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx b/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx index b11d8ac2e03e..2b043d55a8f2 100644 --- a/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx +++ b/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { INDEX_PATTERN_TYPE } from '@kbn/data-views-plugin/public'; +import { RollupDeprecationTooltip } from '@kbn/rollup'; import { UseField } from '../../shared_imports'; import { IndexPatternConfig } from '../../types'; @@ -57,6 +58,15 @@ const rollupSelectItem = ( +   + + + + + { { value: INDEX_PATTERN_TYPE.ROLLUP, inputDisplay: i18n.translate('indexPatternEditor.typeSelect.rollup', { - defaultMessage: 'Rollup', + defaultMessage: 'Rollup (deprecated)', }), dropdownDisplay: rollupSelectItem, }, diff --git a/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx b/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx index 1cb529891178..73aaa8cec2ef 100644 --- a/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx +++ b/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx @@ -27,7 +27,8 @@ import { import { Pager } from '@elastic/eui'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { FormattedMessage } from '@kbn/i18n-react'; -import { MatchedItem, Tag } from '@kbn/data-views-plugin/public'; +import { INDEX_PATTERN_TYPE, MatchedItem, Tag } from '@kbn/data-views-plugin/public'; +import { RollupDeprecationTooltip } from '@kbn/rollup'; export interface IndicesListProps { indices: MatchedItem[]; @@ -205,11 +206,19 @@ export class IndicesList extends React.Component{this.highlightIndexName(index.name, query)} {index.tags.map((tag: Tag) => { - return ( + const badge = ( {tag.name} ); + + return tag.key === INDEX_PATTERN_TYPE.ROLLUP ? ( + <> +  {badge} + + ) : ( + badge + ); })} diff --git a/src/plugins/data_view_editor/tsconfig.json b/src/plugins/data_view_editor/tsconfig.json index adfd30af81b7..3b15c5555d7b 100644 --- a/src/plugins/data_view_editor/tsconfig.json +++ b/src/plugins/data_view_editor/tsconfig.json @@ -20,6 +20,7 @@ "@kbn/kibana-utils-plugin", "@kbn/react-kibana-mount", "@kbn/code-editor", + "@kbn/rollup", ], "exclude": [ "target/**/*", diff --git a/src/plugins/data_views/public/services/get_indices.ts b/src/plugins/data_views/public/services/get_indices.ts index fba500436752..51c370375a7a 100644 --- a/src/plugins/data_views/public/services/get_indices.ts +++ b/src/plugins/data_views/public/services/get_indices.ts @@ -26,7 +26,7 @@ const frozenLabel = i18n.translate('dataViews.frozenLabel', { }); const rollupLabel = i18n.translate('dataViews.rollupLabel', { - defaultMessage: 'Rollup', + defaultMessage: 'Rollup (deprecated)', }); const getIndexTags = (isRollupIndex: (indexName: string) => boolean) => (indexName: string) => @@ -35,7 +35,7 @@ const getIndexTags = (isRollupIndex: (indexName: string) => boolean) => (indexNa { key: INDEX_PATTERN_TYPE.ROLLUP, name: rollupLabel, - color: 'primary', + color: 'warning', }, ] : []; diff --git a/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx b/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx index 5829bd42c68c..bf48c0bb83cf 100644 --- a/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx +++ b/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx @@ -20,6 +20,7 @@ import { SmartFieldFallbackTooltip, } from '@kbn/unified-field-list'; import type { DataVisualizerTableItem } from '@kbn/data-visualizer-plugin/public/application/common/components/stats_table/types'; +import { isOfAggregateQueryType } from '@kbn/es-query'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { FIELD_STATISTICS_LOADED } from './constants'; @@ -51,6 +52,7 @@ export const FieldStatisticsTable = React.memo((props: FieldStatisticsTableProps trackUiMetric, searchSessionId, additionalFieldGroups, + timeRange, } = props; const visibleFields = useMemo( @@ -156,6 +158,7 @@ export const FieldStatisticsTable = React.memo((props: FieldStatisticsTableProps dataView={dataView} savedSearch={savedSearch} filters={filters} + esqlQuery={isEsqlMode && isOfAggregateQueryType(query) ? query : undefined} query={query} visibleFieldNames={visibleFields} sessionId={searchSessionId} @@ -166,8 +169,9 @@ export const FieldStatisticsTable = React.memo((props: FieldStatisticsTableProps showPreviewByDefault={showPreviewByDefault} onTableUpdate={updateState} renderFieldName={renderFieldName} - esql={isEsqlMode} + isEsqlMode={isEsqlMode} overridableServices={overridableServices} + timeRange={timeRange} /> ); diff --git a/src/plugins/discover/public/application/main/components/field_stats_table/types.ts b/src/plugins/discover/public/application/main/components/field_stats_table/types.ts index ddd62285d044..79b3bbea8555 100644 --- a/src/plugins/discover/public/application/main/components/field_stats_table/types.ts +++ b/src/plugins/discover/public/application/main/components/field_stats_table/types.ts @@ -8,8 +8,12 @@ import type { DataViewField, DataView } from '@kbn/data-views-plugin/public'; import { type UiCounterMetricType } from '@kbn/analytics'; -import type { Filter, Query, AggregateQuery } from '@kbn/es-query'; -import type { SerializedTitles } from '@kbn/presentation-publishing'; +import type { Filter, Query, AggregateQuery, TimeRange } from '@kbn/es-query'; +import type { + PublishesBlockingError, + PublishesDataLoading, + SerializedTitles, +} from '@kbn/presentation-publishing'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import { type BehaviorSubject } from 'rxjs'; import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; @@ -116,7 +120,9 @@ interface FieldStatisticsTableEmbeddableComponentApi { export type FieldStatisticsTableEmbeddableApi = DefaultEmbeddableApi & - FieldStatisticsTableEmbeddableComponentApi; + FieldStatisticsTableEmbeddableComponentApi & + PublishesDataLoading & + PublishesBlockingError; export interface FieldStatisticsTableProps { /** @@ -173,4 +179,8 @@ export interface FieldStatisticsTableProps { * If table should query using ES|QL */ isEsqlMode?: boolean; + /** + * Time range + */ + timeRange?: TimeRange; } diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 861a0d50eeba..28c1599dbe3d 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -664,6 +664,7 @@ export class SavedSearchEmbeddable isEsqlMode, }); + const timeRange = this.getTimeRange(); if ( this.services.uiSettings.get(SHOW_FIELD_STATISTICS) === true && viewMode === VIEW_MODE.AGGREGATED_LEVEL && @@ -683,6 +684,7 @@ export class SavedSearchEmbeddable onAddFilter={searchProps.onFilter} searchSessionId={this.input.searchSessionId} isEsqlMode={isEsqlMode} + timeRange={timeRange} /> , diff --git a/src/plugins/embeddable/README.md b/src/plugins/embeddable/README.md index 0612226664da..91627a5f0e6e 100644 --- a/src/plugins/embeddable/README.md +++ b/src/plugins/embeddable/README.md @@ -41,6 +41,9 @@ Break your Component into a Package or another plugin to avoid circular plugin d #### Minimal API surface area Embeddable APIs are accessable to all Kibana systems and all embeddable siblings and parents. Functions and state that are internal to an embeddable including any child components should not be added to the API. Consider passing internal state to child as props or react context. +#### Error handling +Embeddables should never throw. Instead, use [PublishesBlockingError](https://github.com/elastic/kibana/blob/main/packages/presentation/presentation_publishing/interfaces/publishes_blocking_error.ts) interface to surface unrecoverable errors. When an embeddable publishes a blocking error, the parent component will display an error component instead of the embeddable Component. Be thoughtful about which errors are surfaced with the PublishesBlockingError interface. If the embeddable can still render, use less invasive error handling such as a warning toast or notifications in the embeddable Component UI. + ### Examples Examples available at [/examples/embeddable_examples](https://github.com/elastic/kibana/tree/main/examples/embeddable_examples) 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 cf62f45ce315..8285a0aee6b0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -499,6 +499,13 @@ export const stackManagementSchema: MakeSchemaFrom = { _meta: { description: 'Non-default value of setting.' }, }, }, + 'observability:logSources': { + type: 'array', + items: { + type: 'keyword', + _meta: { description: 'Non-default value of setting.' }, + }, + }, 'banners:placement': { type: 'keyword', _meta: { description: 'Non-default value of setting.' }, 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 6a5983df9ccf..95c72298a9b0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -53,6 +53,7 @@ export interface UsageStats { 'observability:apmEnableTableSearchBar': boolean; 'observability:apmEnableServiceInventoryTableSearchBar': boolean; 'observability:logsExplorer:allowedDataViews': string[]; + 'observability:logSources': string[]; 'observability:aiAssistantLogsIndexPattern': string; 'observability:aiAssistantResponseLanguage': string; 'observability:aiAssistantSimulatedFunctionCalling': boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 1e2c942ba2a5..22e75e5d4b65 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -10343,6 +10343,15 @@ } } }, + "observability:logSources": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "Non-default value of setting." + } + } + }, "banners:placement": { "type": "keyword", "_meta": { diff --git a/test/functional/apps/discover/esql/_esql_view.ts b/test/functional/apps/discover/esql/_esql_view.ts index 9f60f4991ab4..cea3b6ecce04 100644 --- a/test/functional/apps/discover/esql/_esql_view.ts +++ b/test/functional/apps/discover/esql/_esql_view.ts @@ -303,7 +303,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const historyItems = await esql.getHistoryItems(); log.debug(historyItems); const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 10'; + return item[1] === 'FROM logstash-* | LIMIT 10'; }); expect(queryAdded).to.be(true); @@ -564,7 +564,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| where \`geo.dest\`=="BT"` + `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| WHERE \`geo.dest\`=="BT"` ); // negate @@ -575,7 +575,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const newValue = await monacoEditor.getCodeEditorValue(); expect(newValue).to.eql( - `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| where \`geo.dest\`!="BT"` + `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| WHERE \`geo.dest\`!="BT"` ); }); @@ -597,7 +597,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB | where countB > 0\nand \`geo.dest\`=="BT"` + `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB | where countB > 0\nAND \`geo.dest\`=="BT"` ); }); }); diff --git a/test/functional/apps/discover/group6/_sidebar_field_stats.ts b/test/functional/apps/discover/group6/_sidebar_field_stats.ts index cbeb128036ab..dd148e43d2d6 100644 --- a/test/functional/apps/discover/group6/_sidebar_field_stats.ts +++ b/test/functional/apps/discover/group6/_sidebar_field_stats.ts @@ -172,7 +172,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('TextBasedLangEditor-expand'); const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`bytes\`==0` + `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`bytes\`==0` ); await PageObjects.unifiedFieldList.closeFieldPopover(); }); @@ -193,7 +193,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('TextBasedLangEditor-expand'); const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`extension.raw\`=="css"` + `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`extension.raw\`=="css"` ); await PageObjects.unifiedFieldList.closeFieldPopover(); @@ -215,7 +215,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('TextBasedLangEditor-expand'); const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`clientip\`::string=="216.126.255.31"` + `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`clientip\`::string=="216.126.255.31"` ); await PageObjects.unifiedFieldList.closeFieldPopover(); @@ -241,7 +241,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('TextBasedLangEditor-expand'); const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`@timestamp\` is not null` + `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`@timestamp\` is not null` ); await testSubjects.missingOrFail('dscFieldStats-statsFooter'); await PageObjects.unifiedFieldList.closeFieldPopover(); @@ -277,7 +277,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('TextBasedLangEditor-expand'); const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`extension\`=="css"` + `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`extension\`=="css"` ); await PageObjects.unifiedFieldList.closeFieldPopover(); @@ -317,7 +317,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('TextBasedLangEditor-expand'); const editorValue = await monacoEditor.getCodeEditorValue(); expect(editorValue).to.eql( - `from logstash-* | sort @timestamp desc | limit 50 | stats avg(bytes) by geo.dest | limit 3\n| where \`avg(bytes)\`==5453` + `from logstash-* | sort @timestamp desc | limit 50 | stats avg(bytes) by geo.dest | limit 3\n| WHERE \`avg(bytes)\`==5453` ); await PageObjects.unifiedFieldList.closeFieldPopover(); @@ -345,7 +345,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.unifiedFieldList.clickFieldListMinusFilter('enabled', 'true'); await testSubjects.click('TextBasedLangEditor-expand'); const editorValue = await monacoEditor.getCodeEditorValue(); - expect(editorValue).to.eql(`row enabled = true\n| where \`enabled\`!=true`); + expect(editorValue).to.eql(`row enabled = true\n| WHERE \`enabled\`!=true`); await PageObjects.unifiedFieldList.closeFieldPopover(); }); }); diff --git a/test/functional/apps/discover/group7/_new_search.ts b/test/functional/apps/discover/group7/_new_search.ts index 265602db217e..14632d6e2618 100644 --- a/test/functional/apps/discover/group7/_new_search.ts +++ b/test/functional/apps/discover/group7/_new_search.ts @@ -105,7 +105,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.clickNewSearchButton(); await PageObjects.discover.waitUntilSearchingHasFinished(); - expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 10'); + expect(await monacoEditor.getCodeEditorValue()).to.be('FROM logstash-* | LIMIT 10'); expect(await PageObjects.discover.getVisContextSuggestionType()).to.be('histogramForESQL'); expect(await PageObjects.discover.getHitCount()).to.be('10'); }); @@ -126,7 +126,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.clickNewSearchButton(); await PageObjects.discover.waitUntilSearchingHasFinished(); - expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 10'); + expect(await monacoEditor.getCodeEditorValue()).to.be('FROM logstash-* | LIMIT 10'); expect(await PageObjects.discover.getHitCount()).to.be('10'); }); }); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index dd3409451e70..ed7720dda0fd 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -47,6 +47,7 @@ "xpack.idxMgmtPackage": "packages/index-management", "xpack.indexLifecycleMgmt": "plugins/index_lifecycle_management", "xpack.infra": "plugins/observability_solution/infra", + "xpack.logsDataAccess": "plugins/observability_solution/logs_data_access", "xpack.logsExplorer": "plugins/observability_solution/logs_explorer", "xpack.logsShared": "plugins/observability_solution/logs_shared", "xpack.fleet": "plugins/fleet", diff --git a/x-pack/examples/triggers_actions_ui_example/public/application.tsx b/x-pack/examples/triggers_actions_ui_example/public/application.tsx index b605a1245ab8..49871c9e46ce 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/application.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/application.tsx @@ -7,11 +7,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { BrowserRouter as Router } from 'react-router-dom'; +import { Router, Routes, Route } from '@kbn/shared-ux-router'; import { QueryClient } from '@tanstack/react-query'; -import { Route } from '@kbn/shared-ux-router'; import { EuiPage, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; -import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { AppMountParameters, CoreStart, ScopedHistory } from '@kbn/core/public'; import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; @@ -22,6 +21,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { createRuleRoute, editRuleRoute, RuleForm } from '@kbn/alerts-ui-shared/src/rule_form'; import { TriggersActionsUiExamplePublicStartDeps } from './plugin'; import { Page } from './components/page'; @@ -38,13 +38,17 @@ import { RuleStatusFilterSandbox } from './components/rule_status_filter_sandbox import { AlertsTableSandbox } from './components/alerts_table_sandbox'; import { RulesSettingsLinkSandbox } from './components/rules_settings_link_sandbox'; -import { RuleDefinitionSandbox } from './components/rule_form/rule_definition_sandbox'; import { RuleActionsSandbox } from './components/rule_form/rule_actions_sandbox'; import { RuleDetailsSandbox } from './components/rule_form/rule_details_sandbox'; export interface TriggersActionsUiExampleComponentParams { http: CoreStart['http']; - basename: string; + notification: CoreStart['notifications']; + application: CoreStart['application']; + docLinks: CoreStart['docLinks']; + i18n: CoreStart['i18n']; + theme: CoreStart['theme']; + history: ScopedHistory; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; data: DataPublicPluginStart; charts: ChartsPluginSetup; @@ -54,144 +58,198 @@ export interface TriggersActionsUiExampleComponentParams { } const TriggersActionsUiExampleApp = ({ - basename, + history, triggersActionsUi, + http, + application, + notification, + docLinks, + i18n, + theme, data, charts, dataViews, unifiedSearch, }: TriggersActionsUiExampleComponentParams) => { return ( - + - - ( - - -

Welcome to the Triggers Actions UI plugin example

-
- - - This example plugin displays the shareable components in the Triggers Actions UI - plugin. It also serves as a sandbox to run functional tests to ensure the shareable - components are functioning correctly outside of their original plugin. - -
- )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> + + + ( + + +

Welcome to the Triggers Actions UI plugin example

+
+ + + This example plugin displays the shareable components in the Triggers Actions UI + plugin. It also serves as a sandbox to run functional tests to ensure the + shareable components are functioning correctly outside of their original plugin. + +
+ )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
); @@ -202,11 +260,12 @@ export const queryClient = new QueryClient(); export const renderApp = ( core: CoreStart, deps: TriggersActionsUiExamplePublicStartDeps, - { appBasePath, element }: AppMountParameters + { appBasePath, element, history }: AppMountParameters ) => { - const { http } = core; + const { http, notifications, docLinks, application, i18n, theme } = core; const { triggersActionsUi } = deps; const { ruleTypeRegistry, actionTypeRegistry } = triggersActionsUi; + ReactDOM.render( - - + + - - + + , element diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/page.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/page.tsx index a03712f77234..d464549d5d8d 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/components/page.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/components/page.tsx @@ -40,14 +40,14 @@ export const Page: React.FC = (props) => { } return ( - +

{title}

- {children} + {children}
); }; diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_definition_sandbox.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_definition_sandbox.tsx deleted file mode 100644 index 71b8c3ccf276..000000000000 --- a/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_definition_sandbox.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useState, useCallback } from 'react'; -import { EuiLoadingSpinner, EuiCodeBlock, EuiTitle, EuiButton } from '@elastic/eui'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { DocLinksStart } from '@kbn/core/public'; -import type { HttpStart } from '@kbn/core-http-browser'; -import type { ToastsStart } from '@kbn/core-notifications-browser'; -import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; -import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; -import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils'; -import { RuleDefinition, getRuleErrors, InitialRule } from '@kbn/alerts-ui-shared/src/rule_form'; -import { useLoadRuleTypesQuery } from '@kbn/alerts-ui-shared/src/common/hooks'; - -interface RuleDefinitionSandboxProps { - data: DataPublicPluginStart; - charts: ChartsPluginSetup; - dataViews: DataViewsPublicPluginStart; - unifiedSearch: UnifiedSearchPublicPluginStart; - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; -} - -export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [ - AlertConsumers.LOGS, - AlertConsumers.INFRASTRUCTURE, - AlertConsumers.STACK_ALERTS, -]; - -const DEFAULT_FORM_VALUES = (ruleTypeId: string) => ({ - id: 'test-id', - name: 'test', - params: {}, - schedule: { - interval: '1m', - }, - alertDelay: { - active: 5, - }, - notifyWhen: null, - consumer: 'stackAlerts', - enabled: true, - tags: [], - actions: [], - ruleTypeId, -}); - -export const RuleDefinitionSandbox = (props: RuleDefinitionSandboxProps) => { - const { data, charts, dataViews, unifiedSearch, triggersActionsUi } = props; - - const [ruleTypeId, setRuleTypeId] = useState('.es-query'); - - const [formValue, setFormValue] = useState(DEFAULT_FORM_VALUES(ruleTypeId)); - - const onChange = useCallback( - (property: string, value: unknown) => { - if (property === 'interval') { - setFormValue({ - ...formValue, - schedule: { - interval: value as string, - }, - }); - return; - } - if (property === 'params') { - setFormValue({ - ...formValue, - params: value as Record, - }); - return; - } - setFormValue({ - ...formValue, - [property]: value, - }); - }, - [formValue] - ); - - const onRuleTypeChange = useCallback((newRuleTypeId: string) => { - setRuleTypeId(newRuleTypeId); - setFormValue(DEFAULT_FORM_VALUES(newRuleTypeId)); - }, []); - - const { docLinks, http, toasts } = useKibana<{ - docLinks: DocLinksStart; - http: HttpStart; - toasts: ToastsStart; - }>().services; - - const { - ruleTypesState: { data: ruleTypeIndex, isLoading }, - } = useLoadRuleTypesQuery({ - http, - toasts, - }); - - const ruleTypes = useMemo(() => [...ruleTypeIndex.values()], [ruleTypeIndex]); - const selectedRuleType = ruleTypes.find((ruleType) => ruleType.id === ruleTypeId); - const selectedRuleTypeModel = triggersActionsUi.ruleTypeRegistry.get(ruleTypeId); - - const errors = useMemo(() => { - if (!selectedRuleType || !selectedRuleTypeModel) { - return {}; - } - - return getRuleErrors({ - rule: formValue, - minimumScheduleInterval: { - value: '1m', - enforce: true, - }, - ruleTypeModel: selectedRuleTypeModel, - }).ruleErrors; - }, [formValue, selectedRuleType, selectedRuleTypeModel]); - - if (isLoading || !selectedRuleType) { - return ; - } - - return ( - <> -
- -

Form State

-
- {JSON.stringify(formValue, null, 2)} -
-
- -

Switch Rule Types:

-
- onRuleTypeChange('.es-query')}>Es Query - onRuleTypeChange('metrics.alert.threshold')}> - Metric Threshold - - onRuleTypeChange('observability.rules.custom_threshold')}> - Custom Threshold - -
- - - ); -}; diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx index 6c1b83d79f46..56397d6030bf 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx @@ -5,32 +5,9 @@ * 2.0. */ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { RuleDetails } from '@kbn/alerts-ui-shared/src/rule_form'; -import { EuiCodeBlock, EuiTitle } from '@elastic/eui'; export const RuleDetailsSandbox = () => { - const [formValues, setFormValues] = useState({ - tags: [], - name: 'test-rule', - }); - - const onChange = useCallback((property: string, value: unknown) => { - setFormValues((prevFormValues) => ({ - ...prevFormValues, - [property]: value, - })); - }, []); - - return ( - <> -
- -

Form State

-
- {JSON.stringify(formValues, null, 2)} -
- - - ); + return ; }; diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx index a6dd96190574..7faaae11ef0f 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx @@ -6,11 +6,10 @@ */ import React from 'react'; -import { useHistory } from 'react-router-dom'; import { EuiPageSidebar, EuiSideNav } from '@elastic/eui'; +import { ScopedHistory } from '@kbn/core/public'; -export const Sidebar = () => { - const history = useHistory(); +export const Sidebar = ({ history }: { history: ScopedHistory }) => { return ( { id: 'rule-form-components', items: [ { - id: 'rule-definition', - name: 'Rule Definition', - onClick: () => history.push('/rule_definition'), + id: 'rule-create', + name: 'Rule Create', + onClick: () => history.push('/rule/create/.es-query'), + }, + { + id: 'rule-edit', + name: 'Rule Edit', + onClick: () => history.push('/rule/edit/test'), }, { id: 'rule-actions', diff --git a/x-pack/examples/triggers_actions_ui_example/tsconfig.json b/x-pack/examples/triggers_actions_ui_example/tsconfig.json index 0bb226e46c8a..601f23edd264 100644 --- a/x-pack/examples/triggers_actions_ui_example/tsconfig.json +++ b/x-pack/examples/triggers_actions_ui_example/tsconfig.json @@ -31,8 +31,6 @@ "@kbn/unified-search-plugin", "@kbn/alerts-ui-shared", "@kbn/data-view-editor-plugin", - "@kbn/core-http-browser", - "@kbn/core-notifications-browser", "@kbn/react-kibana-context-render", ] } diff --git a/x-pack/packages/kbn-elastic-assistant-common/constants.ts b/x-pack/packages/kbn-elastic-assistant-common/constants.ts index 74da6ab2476e..96af59095ab8 100755 --- a/x-pack/packages/kbn-elastic-assistant-common/constants.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/constants.ts @@ -7,13 +7,15 @@ export const ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION = '1'; -export const ELASTIC_AI_ASSISTANT_URL = '/api/elastic_assistant'; +export const ELASTIC_AI_ASSISTANT_URL = '/api/security_ai_assistant'; export const ELASTIC_AI_ASSISTANT_INTERNAL_URL = '/internal/elastic_assistant'; export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL = `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/current_user/conversations`; export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/{id}`; export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID_MESSAGES = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID}/messages`; +export const ELASTIC_AI_ASSISTANT_CHAT_COMPLETE_URL = `${ELASTIC_AI_ASSISTANT_URL}/chat/complete`; + export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/_bulk_action`; export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND = `${ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL}/_find`; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts new file mode 100644 index 000000000000..0b6c3bbe6cbb --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.gen.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Chat Complete API endpoint + * version: 2023-10-31 + */ + +import { z } from 'zod'; + +export type RootContext = z.infer; +export const RootContext = z.literal('security'); + +/** + * Message role. + */ +export type ChatMessageRole = z.infer; +export const ChatMessageRole = z.enum(['system', 'user', 'assistant']); +export type ChatMessageRoleEnum = typeof ChatMessageRole.enum; +export const ChatMessageRoleEnum = ChatMessageRole.enum; + +export type MessageData = z.infer; +export const MessageData = z.object({}).catchall(z.unknown()); + +/** + * AI assistant message. + */ +export type ChatMessage = z.infer; +export const ChatMessage = z.object({ + /** + * Message content. + */ + content: z.string().optional(), + /** + * Message role. + */ + role: ChatMessageRole, + /** + * ECS object to attach to the context of the message. + */ + data: MessageData.optional(), + fields_to_anonymize: z.array(z.string()).optional(), +}); + +export type ChatCompleteProps = z.infer; +export const ChatCompleteProps = z.object({ + conversationId: z.string().optional(), + promptId: z.string().optional(), + isStream: z.boolean().optional(), + responseLanguage: z.string().optional(), + langSmithProject: z.string().optional(), + langSmithApiKey: z.string().optional(), + connectorId: z.string(), + model: z.string().optional(), + persist: z.boolean(), + messages: z.array(ChatMessage), +}); + +export type ChatCompleteRequestBody = z.infer; +export const ChatCompleteRequestBody = ChatCompleteProps; +export type ChatCompleteRequestBodyInput = z.input; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.schema.yaml new file mode 100644 index 000000000000..21c348251b03 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/chat/post_chat_complete_route.schema.yaml @@ -0,0 +1,109 @@ +openapi: 3.0.0 +info: + title: Chat Complete API endpoint + version: '2023-10-31' +paths: + /api/elastic_assistant/chat/complete: + post: + operationId: ChatComplete + x-codegen-enabled: true + description: Creates a model response for the given chat conversation. + summary: Creates a model response for the given chat conversation. + tags: + - Chat Complete API + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatCompleteProps' + responses: + 200: + description: Indicates a successful call. + content: + application/octet-stream: + schema: + type: string + format: binary + 400: + description: Generic Error + content: + application/json: + schema: + type: object + properties: + statusCode: + type: number + error: + type: string + message: + type: string + +components: + schemas: + RootContext: + type: string + enum: + - security + + ChatMessageRole: + type: string + description: Message role. + enum: + - system + - user + - assistant + + MessageData: + type: object + additionalProperties: true + + ChatMessage: + type: object + description: AI assistant message. + required: + - 'role' + properties: + content: + type: string + description: Message content. + role: + $ref: '#/components/schemas/ChatMessageRole' + description: Message role. + data: + description: ECS object to attach to the context of the message. + $ref: '#/components/schemas/MessageData' + fields_to_anonymize: + type: array + items: + type: string + + ChatCompleteProps: + type: object + properties: + conversationId: + type: string + promptId: + type: string + isStream: + type: boolean + responseLanguage: + type: string + langSmithProject: + type: string + langSmithApiKey: + type: string + connectorId: + type: string + model: + type: string + persist: + type: boolean + messages: + type: array + items: + $ref: '#/components/schemas/ChatMessage' + required: + - messages + - persist + - connectorId diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts index 8f47731694cf..1cbe8bbf8e79 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts @@ -27,6 +27,9 @@ export * from './attack_discovery/get_attack_discovery_route.gen'; export * from './attack_discovery/post_attack_discovery_route.gen'; export * from './attack_discovery/cancel_attack_discovery_route.gen'; +// Chat Schemas +export * from './chat/post_chat_complete_route.gen'; + // Evaluation Schemas export * from './evaluation/post_evaluate_route.gen'; export * from './evaluation/get_evaluate_route.gen'; @@ -49,3 +52,6 @@ export * from './knowledge_base/bulk_crud_knowledge_base_route.gen'; export * from './knowledge_base/common_attributes.gen'; export * from './knowledge_base/crud_knowledge_base_route.gen'; export * from './knowledge_base/find_knowledge_base_entries_route.gen'; + +export * from './prompts/find_prompts_route.gen'; +export { PromptResponse, PromptTypeEnum } from './prompts/bulk_crud_prompts_route.gen'; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts index d0bd99e063d0..2d7a4d762eec 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen.ts @@ -34,6 +34,14 @@ export const PromptDetailsInError = z.object({ name: z.string().optional(), }); +/** + * Prompt type + */ +export type PromptType = z.infer; +export const PromptType = z.enum(['system', 'quick']); +export type PromptTypeEnum = typeof PromptType.enum; +export const PromptTypeEnum = PromptType.enum; + export type NormalizedPromptError = z.infer; export const NormalizedPromptError = z.object({ message: z.string(), @@ -47,11 +55,13 @@ export const PromptResponse = z.object({ id: NonEmptyString, timestamp: NonEmptyString.optional(), name: z.string(), - promptType: z.string(), + promptType: PromptType, content: z.string(), + categories: z.array(z.string()).optional(), + color: z.string().optional(), isNewConversationDefault: z.boolean().optional(), isDefault: z.boolean().optional(), - isShared: z.boolean().optional(), + consumer: z.string().optional(), updatedAt: z.string().optional(), updatedBy: z.string().optional(), createdAt: z.string().optional(), @@ -107,20 +117,24 @@ export const BulkActionBase = z.object({ export type PromptCreateProps = z.infer; export const PromptCreateProps = z.object({ name: z.string(), - promptType: z.string(), + promptType: PromptType, content: z.string(), + color: z.string().optional(), + categories: z.array(z.string()).optional(), isNewConversationDefault: z.boolean().optional(), isDefault: z.boolean().optional(), - isShared: z.boolean().optional(), + consumer: z.string().optional(), }); export type PromptUpdateProps = z.infer; export const PromptUpdateProps = z.object({ id: z.string(), content: z.string().optional(), + color: z.string().optional(), + categories: z.array(z.string()).optional(), isNewConversationDefault: z.boolean().optional(), isDefault: z.boolean().optional(), - isShared: z.boolean().optional(), + consumer: z.string().optional(), }); export type PerformBulkActionRequestBody = z.infer; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.schema.yaml index ede0136ba710..5be6bde140b8 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.schema.yaml @@ -78,6 +78,13 @@ components: required: - id + PromptType: + type: string + description: Prompt type + enum: + - system + - quick + NormalizedPromptError: type: object properties: @@ -111,15 +118,21 @@ components: name: type: string promptType: - type: string + $ref: '#/components/schemas/PromptType' content: type: string + categories: + type: array + items: + type: string + color: + type: string isNewConversationDefault: type: boolean isDefault: type: boolean - isShared: - type: boolean + consumer: + type: string updatedAt: type: string updatedBy: @@ -231,15 +244,21 @@ components: name: type: string promptType: - type: string + $ref: '#/components/schemas/PromptType' content: type: string + color: + type: string + categories: + type: array + items: + type: string isNewConversationDefault: type: boolean isDefault: type: boolean - isShared: - type: boolean + consumer: + type: string PromptUpdateProps: type: object @@ -250,9 +269,15 @@ components: type: string content: type: string + color: + type: string + categories: + type: array + items: + type: string isNewConversationDefault: type: boolean isDefault: type: boolean - isShared: - type: boolean + consumer: + type: string diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.tsx index b8c42a787621..8b6bba33f680 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/index.tsx @@ -11,6 +11,7 @@ import { API_ERROR } from '../translations'; import { getOptionalRequestParams } from '../helpers'; import { TraceOptions } from '../types'; export * from './conversations'; +export * from './prompts'; export interface FetchConnectorExecuteAction { conversationId: string; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.test.ts new file mode 100644 index 000000000000..d6ba2e726a76 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + API_VERSIONS, + ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, +} from '@kbn/elastic-assistant-common'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { IToasts } from '@kbn/core-notifications-browser'; +import { bulkUpdatePrompts } from './bulk_update_prompts'; +import { PromptTypeEnum } from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; + +const prompt1 = { + id: 'field1', + content: 'Prompt 1', + name: 'test', + promptType: PromptTypeEnum.system, +}; +const prompt2 = { + ...prompt1, + id: 'field2', + content: 'Prompt 2', + name: 'test2', + promptType: PromptTypeEnum.system, +}; +const toasts = { + addError: jest.fn(), +}; +describe('bulkUpdatePrompts', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createSetupContract(); + + jest.clearAllMocks(); + }); + it('should send a POST request with the correct parameters and receive a successful response', async () => { + const promptsActions = { + create: [], + update: [], + delete: { ids: [] }, + }; + + await bulkUpdatePrompts(httpMock, promptsActions); + + expect(httpMock.fetch).toHaveBeenCalledWith(ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, { + method: 'POST', + version: API_VERSIONS.internal.v1, + body: JSON.stringify({ + create: [], + update: [], + delete: { ids: [] }, + }), + }); + }); + + it('should transform the prompts dictionary to an array of fields to create', async () => { + const promptsActions = { + create: [prompt1, prompt2], + update: [], + delete: { ids: [] }, + }; + + await bulkUpdatePrompts(httpMock, promptsActions); + + expect(httpMock.fetch).toHaveBeenCalledWith(ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, { + method: 'POST', + version: API_VERSIONS.internal.v1, + body: JSON.stringify({ + create: [prompt1, prompt2], + update: [], + delete: { ids: [] }, + }), + }); + }); + + it('should transform the prompts dictionary to an array of fields to update', async () => { + const promptsActions = { + update: [prompt1, prompt2], + delete: { ids: [] }, + }; + + await bulkUpdatePrompts(httpMock, promptsActions); + + expect(httpMock.fetch).toHaveBeenCalledWith(ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, { + method: 'POST', + version: API_VERSIONS.internal.v1, + body: JSON.stringify({ + update: [prompt1, prompt2], + delete: { ids: [] }, + }), + }); + }); + + it('should throw an error with the correct message when receiving an unsuccessful response', async () => { + httpMock.fetch.mockResolvedValue({ + success: false, + attributes: { + errors: [ + { + statusCode: 400, + message: 'Error updating prompt', + prompts: [{ id: prompt1.id, name: prompt1.content }], + }, + ], + }, + }); + const promptsActions = { + create: [], + update: [prompt1], + delete: { ids: [] }, + }; + await bulkUpdatePrompts(httpMock, promptsActions, toasts as unknown as IToasts); + expect(toasts.addError.mock.calls[0][0]).toEqual( + new Error('Error message: Error updating prompt for prompt Prompt 1') + ); + }); + + it('should handle cases where result.attributes.errors is undefined', async () => { + httpMock.fetch.mockResolvedValue({ + success: false, + attributes: {}, + }); + const promptsActions = { + create: [], + update: [], + delete: { ids: [] }, + }; + + await bulkUpdatePrompts(httpMock, promptsActions, toasts as unknown as IToasts); + expect(toasts.addError.mock.calls[0][0]).toEqual(new Error('')); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.ts new file mode 100644 index 000000000000..2d7b053d7acb --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/bulk_update_prompts.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { HttpSetup, IToasts } from '@kbn/core/public'; +import { + ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, + API_VERSIONS, +} from '@kbn/elastic-assistant-common'; +import { + PerformBulkActionRequestBody, + PerformBulkActionResponse, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; + +export const bulkUpdatePrompts = async ( + http: HttpSetup, + prompts: PerformBulkActionRequestBody, + toasts?: IToasts +) => { + try { + const result = await http.fetch( + ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, + { + method: 'POST', + version: API_VERSIONS.internal.v1, + body: JSON.stringify(prompts), + } + ); + + if (!result.success) { + const serverError = result.attributes.errors + ?.map( + (e) => + `${e.status_code ? `Error code: ${e.status_code}. ` : ''}Error message: ${ + e.message + } for prompt ${e.prompts.map((c) => c.name).join(',')}` + ) + .join(',\n'); + throw new Error(serverError); + } + return result; + } catch (error) { + toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, { + title: i18n.translate('xpack.elasticAssistant.prompts.bulkActionspromptsError', { + defaultMessage: 'Error updating prompts {error}', + values: { error }, + }), + }); + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/index.tsx new file mode 100644 index 000000000000..5a2f6bb80992 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/index.tsx @@ -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. + */ + +export * from './bulk_update_prompts'; +export * from './use_fetch_prompts'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.test.tsx new file mode 100644 index 000000000000..3ec1586d6cb4 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import React from 'react'; +import { useFetchPrompts } from './use_fetch_prompts'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { useAssistantContext } from '../../../assistant_context'; +import { API_VERSIONS, defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; + +const http = { + fetch: jest.fn().mockResolvedValue(defaultAssistantFeatures), +} as unknown as HttpSetup; + +jest.mock('../../../assistant_context'); + +const createWrapper = () => { + const queryClient = new QueryClient(); + // eslint-disable-next-line react/display-name + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe('useFetchPrompts', () => { + (useAssistantContext as jest.Mock).mockReturnValue({ + http, + assistantAvailability: { + isAssistantEnabled: true, + }, + }); + it(`should make http request to fetch prompts`, async () => { + renderHook(() => useFetchPrompts(), { + wrapper: createWrapper(), + }); + + await act(async () => { + const { waitForNextUpdate } = renderHook(() => useFetchPrompts()); + await waitForNextUpdate(); + expect(http.fetch).toHaveBeenCalledWith('/internal/elastic_assistant/prompts/_find', { + method: 'GET', + query: { + page: 1, + per_page: 1000, + filter: 'consumer:*', + }, + version: API_VERSIONS.internal.v1, + signal: undefined, + }); + + expect(http.fetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts new file mode 100644 index 000000000000..18a229e524dc --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/prompts/use_fetch_prompts.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FindPromptsResponse } from '@kbn/elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen'; +import { useQuery } from '@tanstack/react-query'; +import { API_VERSIONS, ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND } from '@kbn/elastic-assistant-common'; +import { HttpSetup, IToasts } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { useAssistantContext } from '../../../assistant_context'; + +export interface UseFetchPromptsParams { + signal?: AbortSignal | undefined; + consumer?: string; +} + +/** + * API call for fetching prompts for current spaceId + * + * @param {Object} options - The options object. + * @param {string} options.consumer - prompt consumer + * @param {AbortSignal} [options.signal] - AbortSignal + * + * @returns {useQuery} hook for getting the status of the prompts + */ + +export const useFetchPrompts = (payload?: UseFetchPromptsParams) => { + const { + assistantAvailability: { isAssistantEnabled }, + http, + } = useAssistantContext(); + + const QUERY = { + page: 1, + per_page: 1000, // Continue use in-memory paging till the new design will be ready + filter: `consumer:${payload?.consumer ?? '*'}`, + }; + + const CACHING_KEYS = [ + ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND, + QUERY.page, + QUERY.per_page, + QUERY.filter, + API_VERSIONS.internal.v1, + ]; + + return useQuery( + CACHING_KEYS, + async () => + http.fetch(ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND, { + method: 'GET', + version: API_VERSIONS.internal.v1, + query: QUERY, + signal: payload?.signal, + }), + { + initialData: { + data: [], + page: 1, + perPage: 5, + total: 0, + }, + placeholderData: { + data: [], + page: 1, + perPage: 5, + total: 0, + }, + keepPreviousData: true, + enabled: isAssistantEnabled, + } + ); +}; + +export const getPrompts = async ({ + http, + signal, + toasts, +}: { + http: HttpSetup; + toasts: IToasts; + signal?: AbortSignal | undefined; +}) => { + try { + return await http.fetch(ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND, { + method: 'GET', + version: API_VERSIONS.internal.v1, + signal, + }); + } catch (error) { + toasts.addError(error.body && error.body.message ? new Error(error.body.message) : error, { + title: i18n.translate('xpack.elasticAssistant.prompts.getPromptsError', { + defaultMessage: 'Error fetching prompts', + }), + }); + throw error; + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/assistant_header_flyout.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/assistant_header_flyout.tsx index d9ba27b96655..5725d983eff3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/assistant_header_flyout.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/assistant_header_flyout.tsx @@ -6,6 +6,7 @@ */ import React, { useState, useMemo, useCallback } from 'react'; +import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; import { EuiFlexGroup, EuiFlexItem, @@ -47,6 +48,9 @@ interface OwnProps { refetchConversationsState: () => Promise; onConversationCreate: () => Promise; isAssistantEnabled: boolean; + refetchPrompts?: ( + options?: RefetchOptions & RefetchQueryFilters + ) => Promise>; } type Props = OwnProps; @@ -74,6 +78,7 @@ export const AssistantHeaderFlyout: React.FC = ({ refetchConversationsState, onConversationCreate, isAssistantEnabled, + refetchPrompts, }) => { const showAnonymizedValuesChecked = useMemo( () => @@ -125,6 +130,7 @@ export const AssistantHeaderFlyout: React.FC = ({ `, onClick: showDestroyModal, icon: 'refresh', + 'data-test-subj': 'clear-chat', }, ], }, @@ -163,6 +169,7 @@ export const AssistantHeaderFlyout: React.FC = ({ conversationsLoaded={conversationsLoaded} refetchConversationsState={refetchConversationsState} isFlyoutMode={true} + refetchPrompts={refetchPrompts} /> @@ -243,6 +250,7 @@ export const AssistantHeaderFlyout: React.FC = ({ aria-label="test" iconType="boxesVertical" onClick={onButtonClick} + data-test-subj="chat-context-menu" /> } isOpen={isPopoverOpen} @@ -266,6 +274,7 @@ export const AssistantHeaderFlyout: React.FC = ({ confirmButtonText={i18n.RESET_BUTTON_TEXT} buttonColor="danger" defaultFocusedButton="confirm" + data-test-subj="reset-conversation-modal" >

{i18n.CLEAR_CHAT_CONFIRMATION}

diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx index 2301078d57ba..f806f5d1ef7c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.test.tsx @@ -38,6 +38,7 @@ const testProps = { refetchConversationsState: jest.fn(), anonymizationFields: { total: 0, page: 1, perPage: 1000, data: [] }, refetchAnonymizationFieldsResults: jest.fn(), + allPrompts: [], }; jest.mock('../../connectorland/use_load_connectors', () => ({ 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 ac7ca235b36e..7507c1464861 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 @@ -14,9 +14,11 @@ import { EuiSwitch, EuiToolTip, } from '@elastic/eui'; +import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; import { css } from '@emotion/react'; import { DocLinksStart } from '@kbn/core-doc-links-browser'; import { isEmpty } from 'lodash'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { AIConnector } from '../../connectorland/connector_selector'; import { Conversation } from '../../..'; import { AssistantTitle } from '../assistant_title'; @@ -40,6 +42,10 @@ interface OwnProps { conversations: Record; conversationsLoaded: boolean; refetchConversationsState: () => Promise; + allPrompts: PromptResponse[]; + refetchPrompts?: ( + options?: RefetchOptions & RefetchQueryFilters + ) => Promise>; } type Props = OwnProps; @@ -64,6 +70,8 @@ export const AssistantHeader: React.FC = ({ conversations, conversationsLoaded, refetchConversationsState, + allPrompts, + refetchPrompts, }) => { const showAnonymizedValuesChecked = useMemo( () => @@ -122,6 +130,7 @@ export const AssistantHeader: React.FC = ({ isDisabled={isDisabled} conversations={conversations} onConversationDeleted={onConversationDeleted} + allPrompts={allPrompts} /> <> @@ -156,6 +165,7 @@ export const AssistantHeader: React.FC = ({ conversationsLoaded={conversationsLoaded} refetchConversationsState={refetchConversationsState} isFlyoutMode={false} + refetchPrompts={refetchPrompts} /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx index 020822821d16..9d5e822fcdf5 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx @@ -8,18 +8,18 @@ import React, { useCallback } from 'react'; import { HttpSetup } from '@kbn/core-http-browser'; import { i18n } from '@kbn/i18n'; -import { Replacements } from '@kbn/elastic-assistant-common'; +import { PromptResponse, Replacements } from '@kbn/elastic-assistant-common'; import type { ClientMessage } from '../../assistant_context/types'; import { SelectedPromptContext } from '../prompt_context/types'; import { useSendMessage } from '../use_send_message'; import { useConversation } from '../use_conversation'; import { getCombinedMessage } from '../prompt/helpers'; -import { Conversation, Prompt, useAssistantContext } from '../../..'; +import { Conversation, useAssistantContext } from '../../..'; import { getMessageFromRawResponse } from '../helpers'; import { getDefaultSystemPrompt } from '../use_conversation/helpers'; export interface UseChatSendProps { - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; currentConversation?: Conversation; editingSystemPromptId: string | undefined; http: HttpSetup; @@ -35,7 +35,7 @@ export interface UseChatSendProps { export interface UseChatSend { abortStream: () => void; - handleOnChatCleared: () => void; + handleOnChatCleared: () => Promise; handlePromptChange: (prompt: string) => void; handleSendMessage: (promptText: string) => void; handleRegenerateResponse: () => void; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx index 6c2ae3b6af21..613163db196a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.test.tsx @@ -50,6 +50,7 @@ const defaultProps = { defaultProvider: OpenAiProviderType.OpenAi, conversations: mockConversations, onConversationDeleted, + allPrompts: [], }; describe('Conversation selector', () => { beforeAll(() => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx index c55540b55b6c..fd9cddc39dbb 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_selector/index.tsx @@ -18,10 +18,13 @@ import { import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/react'; +import { + PromptResponse, + PromptTypeEnum, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { getGenAiConfig } from '../../../connectorland/helpers'; import { AIConnector } from '../../../connectorland/connector_selector'; import { Conversation } from '../../../..'; -import { useAssistantContext } from '../../../assistant_context'; import * as i18n from './translations'; import { DEFAULT_CONVERSATION_TITLE } from '../../use_conversation/translations'; import { useConversation } from '../../use_conversation'; @@ -35,6 +38,7 @@ interface Props { shouldDisableKeyboardShortcut?: () => boolean; isDisabled?: boolean; conversations: Record; + allPrompts: PromptResponse[]; } const getPreviousConversationId = (conversationIds: string[], selectedConversationId: string) => { @@ -64,10 +68,13 @@ export const ConversationSelector: React.FC = React.memo( shouldDisableKeyboardShortcut = () => false, isDisabled = false, conversations, + allPrompts, }) => { - const { allSystemPrompts } = useAssistantContext(); - const { createConversation } = useConversation(); + const allSystemPrompts = useMemo( + () => allPrompts.filter((p) => p.promptType === PromptTypeEnum.system), + [allPrompts] + ); const conversationIds = useMemo(() => Object.keys(conversations), [conversations]); const conversationOptions = useMemo(() => { return Object.values(conversations).map((conversation) => ({ diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx index adf2e012c0b8..cba17030e157 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx @@ -18,7 +18,8 @@ import React, { useMemo } from 'react'; import { HttpSetup } from '@kbn/core-http-browser'; import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; -import { Conversation, Prompt } from '../../../..'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; +import { Conversation } from '../../../..'; import * as i18n from './translations'; import { AIConnector } from '../../../connectorland/connector_selector'; @@ -33,7 +34,7 @@ import { getConversationApiConfig } from '../../use_conversation/helpers'; export interface ConversationSettingsProps { actionTypeRegistry: ActionTypeRegistryContract; - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; connectors?: AIConnector[]; conversationSettings: Record; conversationsSettingsBulkActions: ConversationsBulkActions; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings_editor.tsx index a0ddd33b34d0..41da376d21b7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings_editor.tsx @@ -12,7 +12,8 @@ import { HttpSetup } from '@kbn/core-http-browser'; import { FormattedMessage } from '@kbn/i18n-react'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; import { noop } from 'lodash/fp'; -import { Conversation, Prompt } from '../../../..'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; +import { Conversation } from '../../../..'; import * as i18n from './translations'; import * as i18nModel from '../../../connectorland/models/model_selector/translations'; @@ -25,7 +26,7 @@ import { ConversationsBulkActions } from '../../api'; import { getDefaultSystemPrompt } from '../../use_conversation/helpers'; export interface ConversationSettingsEditorProps { - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; conversationSettings: Record; conversationsSettingsBulkActions: ConversationsBulkActions; http: HttpSetup; @@ -268,7 +269,7 @@ export const ConversationSettingsEditor: React.FC ; conversationsSettingsBulkActions: ConversationsBulkActions; defaultConnector?: AIConnector; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx index c4b2f834ecec..485f89358f57 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/index.tsx @@ -8,13 +8,13 @@ import { EuiPanel, EuiSpacer, EuiConfirmModal, EuiInMemoryTable } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { Conversation } from '../../../assistant_context/types'; import { ConversationTableItem, useConversationsTable } from './use_conversations_table'; import { ConversationStreamingSwitch } from '../conversation_settings/conversation_streaming_switch'; import { AIConnector } from '../../../connectorland/connector_selector'; import * as i18n from './translations'; -import { Prompt } from '../../types'; import { ConversationsBulkActions } from '../../api'; import { useAssistantContext } from '../../../assistant_context'; import { useConversationDeleted } from '../conversation_settings/use_conversation_deleted'; @@ -27,7 +27,7 @@ import { CONVERSATION_TABLE_SESSION_STORAGE_KEY } from '../../../assistant_conte import { useSessionPagination } from '../../common/components/assistant_settings_management/pagination/use_session_pagination'; import { DEFAULT_PAGE_SIZE } from '../../settings/const'; interface Props { - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; assistantStreamingEnabled: boolean; connectors: AIConnector[] | undefined; conversationSettings: Record; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx index fb705db6bb33..446fb33ebb9e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings_management/use_conversations_table.tsx @@ -11,10 +11,10 @@ import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/publ import { EuiBadge, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; import { FormattedDate } from '@kbn/i18n-react'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { Conversation } from '../../../assistant_context/types'; import { AIConnector } from '../../../connectorland/connector_selector'; import { getConnectorTypeTitle } from '../../../connectorland/helpers'; -import { Prompt } from '../../../..'; import { getConversationApiConfig, getInitialDefaultSystemPrompt, @@ -25,7 +25,7 @@ import { RowActions } from '../../common/components/assistant_settings_managemen const emptyConversations = {}; export interface GetConversationsListParams { - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; actionTypeRegistry: ActionTypeRegistryContract; connectors: AIConnector[] | undefined; conversations: Record; @@ -126,7 +126,7 @@ export const useConversationsTable = () => { ); const connectorTypeTitle = getConnectorTypeTitle(connector, actionTypeRegistry); - const systemPrompt: Prompt | undefined = allSystemPrompts.find( + const systemPrompt: PromptResponse | undefined = allSystemPrompts.find( ({ id }) => id === conversation.apiConfig?.defaultSystemPromptId ); const defaultSystemPrompt = getInitialDefaultSystemPrompt({ @@ -135,10 +135,10 @@ export const useConversationsTable = () => { }); const systemPromptTitle = - systemPrompt?.label || systemPrompt?.name || - defaultSystemPrompt?.label || - defaultSystemPrompt?.name; + systemPrompt?.id || + defaultSystemPrompt?.name || + defaultSystemPrompt?.id; return { ...conversation, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx index c023970803da..b25945dd247b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { Assistant } from '.'; import type { IHttpFetchError } from '@kbn/core/public'; @@ -63,15 +63,25 @@ const mockData = { }, }; const mockDeleteConvo = jest.fn(); +const clearConversation = jest.fn(); const mockUseConversation = { + clearConversation: clearConversation.mockResolvedValue(mockData.welcome_id), getConversation: jest.fn(), getDefaultConversation: jest.fn().mockReturnValue(mockData.welcome_id), deleteConversation: mockDeleteConvo, setApiConfig: jest.fn().mockResolvedValue({}), }; +const refetchResults = jest.fn(); + describe('Assistant', () => { - beforeAll(() => { + let persistToLocalStorage: jest.Mock; + let persistToSessionStorage: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + persistToLocalStorage = jest.fn(); + persistToSessionStorage = jest.fn(); (useConversation as jest.Mock).mockReturnValue(mockUseConversation); jest.mocked(useConnectorSetup).mockReturnValue({ comments: [], @@ -89,13 +99,14 @@ describe('Assistant', () => { ]; jest.mocked(useLoadConnectors).mockReturnValue({ isFetched: true, + isFetchedAfterMount: true, data: connectors, } as unknown as UseQueryResult); jest.mocked(useFetchCurrentUserConversations).mockReturnValue({ data: mockData, isLoading: false, - refetch: jest.fn().mockResolvedValue({ + refetch: refetchResults.mockResolvedValue({ isLoading: false, data: { ...mockData, @@ -107,16 +118,6 @@ describe('Assistant', () => { }), isFetched: true, } as unknown as DefinedUseQueryResult, unknown>); - }); - - let persistToLocalStorage: jest.Mock; - let persistToSessionStorage: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - persistToLocalStorage = jest.fn(); - persistToSessionStorage = jest.fn(); - jest .mocked(useLocalStorage) .mockReturnValue([undefined, persistToLocalStorage] as unknown as ReturnType< @@ -234,6 +235,16 @@ describe('Assistant', () => { }); expect(mockDeleteConvo).toHaveBeenCalledWith(mockData.welcome_id.id); }); + it('should refetchConversationsState after clear chat history button click', async () => { + renderAssistant({ isFlyoutMode: true }); + fireEvent.click(screen.getByTestId('chat-context-menu')); + fireEvent.click(screen.getByTestId('clear-chat')); + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + await waitFor(() => { + expect(clearConversation).toHaveBeenCalled(); + expect(refetchResults).toHaveBeenCalled(); + }); + }); }); describe('when selected conversation changes and some connectors are loaded', () => { it('should persist the conversation id to local storage', async () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index 830c5d2b7080..6892fdcaf48b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -39,6 +39,7 @@ import deepEqual from 'fast-deep-equal'; import { find, isEmpty, uniqBy } from 'lodash'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { PromptTypeEnum } from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { useChatSend } from './chat_send/use_chat_send'; import { ChatSend } from './chat_send'; import { BlockBotCallToAction } from './block_bot/cta'; @@ -91,6 +92,7 @@ import { getGenAiConfig } from '../connectorland/helpers'; import { AssistantAnimatedIcon } from './assistant_animated_icon'; import { useFetchAnonymizationFields } from './api/anonymization_fields/use_fetch_anonymization_fields'; import { InstallKnowledgeBaseButton } from '../knowledge_base/install_knowledge_base_button'; +import { useFetchPrompts } from './api/prompts/use_fetch_prompts'; export interface Props { conversationTitle?: string; @@ -135,7 +137,6 @@ const AssistantComponent: React.FC = ({ setLastConversationId, getLastConversationId, title, - allSystemPrompts, baseConversations, } = useAssistantContext(); @@ -182,6 +183,19 @@ const AssistantComponent: React.FC = ({ isFetched: isFetchedAnonymizationFields, } = useFetchAnonymizationFields(); + const { + data: { data: allPrompts }, + refetch: refetchPrompts, + isLoading: isLoadingPrompts, + } = useFetchPrompts(); + + const allSystemPrompts = useMemo(() => { + if (!isLoadingPrompts) { + return allPrompts.filter((p) => p.promptType === PromptTypeEnum.system); + } + return []; + }, [allPrompts, isLoadingPrompts]); + // Connector details const { data: connectors, isFetchedAfterMount: areConnectorsFetched } = useLoadConnectors({ http, @@ -220,7 +234,7 @@ const AssistantComponent: React.FC = ({ ); useEffect(() => { - if (conversationsLoaded && Object.keys(conversations).length > 0) { + if (areConnectorsFetched && conversationsLoaded && Object.keys(conversations).length > 0) { setCurrentConversation((prev) => { const nextConversation = (currentConversationId && conversations[currentConversationId]) || @@ -256,13 +270,13 @@ const AssistantComponent: React.FC = ({ }); } }, [ + areConnectorsFetched, conversationTitle, conversations, - getDefaultConversation, - getLastConversationId, conversationsLoaded, - currentConversation?.id, currentConversationId, + getDefaultConversation, + getLastConversationId, isAssistantEnabled, isFlyoutMode, ]); @@ -397,7 +411,11 @@ const AssistantComponent: React.FC = ({ // End Scrolling const selectedSystemPrompt = useMemo( - () => getDefaultSystemPrompt({ allSystemPrompts, conversation: currentConversation }), + () => + getDefaultSystemPrompt({ + allSystemPrompts, + conversation: currentConversation, + }), [allSystemPrompts, currentConversation] ); @@ -409,20 +427,21 @@ const AssistantComponent: React.FC = ({ async ({ cId, cTitle }: { cId: string; cTitle: string }) => { const updatedConv = await refetchResults(); + let selectedConversation; if (cId === '') { setCurrentConversationId(cTitle); - setEditingSystemPromptId( - getDefaultSystemPrompt({ allSystemPrompts, conversation: updatedConv?.data?.[cTitle] }) - ?.id - ); + selectedConversation = updatedConv?.data?.[cTitle]; setCurrentConversationId(cTitle); } else { - const refetchedConversation = await refetchCurrentConversation({ cId }); - setEditingSystemPromptId( - getDefaultSystemPrompt({ allSystemPrompts, conversation: refetchedConversation })?.id - ); + selectedConversation = await refetchCurrentConversation({ cId }); setCurrentConversationId(cId); } + setEditingSystemPromptId( + getDefaultSystemPrompt({ + allSystemPrompts, + conversation: selectedConversation, + })?.id + ); }, [allSystemPrompts, refetchCurrentConversation, refetchResults] ); @@ -549,7 +568,7 @@ const AssistantComponent: React.FC = ({ const { abortStream, - handleOnChatCleared, + handleOnChatCleared: onChatCleared, handlePromptChange, handleSendMessage, handleRegenerateResponse, @@ -567,6 +586,11 @@ const AssistantComponent: React.FC = ({ setCurrentConversation, }); + const handleOnChatCleared = useCallback(async () => { + await onChatCleared(); + await refetchResults(); + }, [onChatCleared, refetchResults]); + const handleChatSend = useCallback( async (promptText: string) => { await handleSendMessage(promptText); @@ -634,18 +658,18 @@ const AssistantComponent: React.FC = ({ setIsSettingsModalVisible={setIsSettingsModalVisible} setSelectedPromptContexts={setSelectedPromptContexts} isFlyoutMode={isFlyoutMode} + allSystemPrompts={allSystemPrompts} /> )} ), [ + getComments, abortStream, - refetchCurrentConversation, currentConversation, - editingSystemPromptId, - getComments, showAnonymizedValues, + refetchCurrentConversation, handleRegenerateResponse, isEnabledKnowledgeBase, isEnabledRAGAlerts, @@ -653,12 +677,14 @@ const AssistantComponent: React.FC = ({ currentUserAvatar, isFlyoutMode, selectedPromptContextsCount, + editingSystemPromptId, isNewConversation, isSettingsModalVisible, promptContexts, promptTextPreview, handleOnSystemPromptSelectionChange, selectedPromptContexts, + allSystemPrompts, ] ); @@ -733,15 +759,7 @@ const AssistantComponent: React.FC = ({ } } })(); - }, [ - currentConversation, - defaultConnector, - refetchConversationsState, - setApiConfig, - showMissingConnectorCallout, - areConnectorsFetched, - mutateAsync, - ]); + }, [areConnectorsFetched, currentConversation, mutateAsync]); const handleCreateConversation = useCallback(async () => { const newChatExists = find(conversations, ['title', NEW_CHAT]); @@ -862,6 +880,7 @@ const AssistantComponent: React.FC = ({ isSettingsModalVisible={isSettingsModalVisible} setIsSettingsModalVisible={setIsSettingsModalVisible} isFlyoutMode + allSystemPrompts={allSystemPrompts} /> @@ -885,6 +904,7 @@ const AssistantComponent: React.FC = ({
); }, [ + allSystemPrompts, comments, connectorPrompt, currentConversation, @@ -951,6 +971,7 @@ const AssistantComponent: React.FC = ({ refetchConversationsState={refetchConversationsState} onConversationCreate={handleCreateConversation} isAssistantEnabled={isAssistantEnabled} + refetchPrompts={refetchPrompts} /> {/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */} @@ -1083,6 +1104,7 @@ const AssistantComponent: React.FC = ({ setIsSettingsModalVisible={setIsSettingsModalVisible} trackPrompt={trackPrompt} isFlyoutMode={isFlyoutMode} + allPrompts={allPrompts} /> )} @@ -1119,6 +1141,8 @@ const AssistantComponent: React.FC = ({ conversationsLoaded={conversationsLoaded} onConversationDeleted={handleOnConversationDeleted} refetchConversationsState={refetchConversationsState} + allPrompts={allPrompts} + refetchPrompts={refetchPrompts} /> )} @@ -1197,6 +1221,7 @@ const AssistantComponent: React.FC = ({ setIsSettingsModalVisible={setIsSettingsModalVisible} trackPrompt={trackPrompt} isFlyoutMode={isFlyoutMode} + allPrompts={allPrompts} /> )} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts index bd00058b4bbd..4868eff04b4e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts @@ -5,11 +5,10 @@ * 2.0. */ -import { Replacements, transformRawData } from '@kbn/elastic-assistant-common'; +import { Replacements, transformRawData, PromptResponse } from '@kbn/elastic-assistant-common'; import type { ClientMessage } from '../../assistant_context/types'; import { getAnonymizedValue as defaultGetAnonymizedValue } from '../get_anonymized_value'; import type { SelectedPromptContext } from '../prompt_context/types'; -import type { Prompt } from '../types'; import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations'; export const getSystemMessages = ({ @@ -17,7 +16,7 @@ export const getSystemMessages = ({ selectedSystemPrompt, }: { isNewChat: boolean; - selectedSystemPrompt: Prompt | undefined; + selectedSystemPrompt: PromptResponse | undefined; }): ClientMessage[] => { if (!isNewChat || selectedSystemPrompt == null) { return []; @@ -53,7 +52,7 @@ export function getCombinedMessage({ isNewChat: boolean; promptText: string; selectedPromptContexts: Record; - selectedSystemPrompt: Prompt | undefined; + selectedSystemPrompt: PromptResponse | undefined; }): ClientMessageWithReplacements { let replacements: Replacements = currentReplacements ?? {}; const onNewReplacements = (newReplacements: Replacements) => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx index c055d9bd6bb9..2171b13273a2 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.test.tsx @@ -7,11 +7,11 @@ import { getPromptById } from './helpers'; import { mockSystemPrompt, mockSuperheroSystemPrompt } from '../../mock/system_prompt'; -import type { Prompt } from '../types'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; describe('helpers', () => { describe('getPromptById', () => { - const prompts: Prompt[] = [mockSystemPrompt, mockSuperheroSystemPrompt]; + const prompts: PromptResponse[] = [mockSystemPrompt, mockSuperheroSystemPrompt]; it('returns the correct prompt by id', () => { const result = getPromptById({ prompts, id: mockSuperheroSystemPrompt.id }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.tsx index 7b3e91147635..f11d2e0af641 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/helpers.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import type { Prompt } from '../types'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; export const getPromptById = ({ prompts, id, }: { - prompts: Prompt[]; + prompts: PromptResponse[]; id: string; -}): Prompt | undefined => prompts.find((p) => p.id === id); +}): PromptResponse | undefined => prompts.find((p) => p.id === id); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx index 6a18b9794c59..6d421b649a38 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.test.tsx @@ -40,6 +40,7 @@ const defaultProps: Props = { setIsSettingsModalVisible: jest.fn(), setSelectedPromptContexts: jest.fn(), isFlyoutMode: false, + allSystemPrompts: [], }; describe('PromptEditorComponent', () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx index b2f5c1a3a2e3..1528435764ac 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/index.tsx @@ -10,6 +10,7 @@ import React, { useMemo } from 'react'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { Conversation } from '../../..'; import type { PromptContext, SelectedPromptContext } from '../prompt_context/types'; import { SystemPrompt } from './system_prompt'; @@ -31,6 +32,7 @@ export interface Props { React.SetStateAction> >; isFlyoutMode: boolean; + allSystemPrompts: PromptResponse[]; } const PreviewText = styled(EuiText)` @@ -49,12 +51,14 @@ const PromptEditorComponent: React.FC = ({ setIsSettingsModalVisible, setSelectedPromptContexts, isFlyoutMode, + allSystemPrompts, }) => { const commentBody = useMemo( () => ( <> {isNewConversation && ( = ({ ), [ + isNewConversation, + allSystemPrompts, conversation, editingSystemPromptId, - isNewConversation, - isSettingsModalVisible, onSystemPromptSelectionChange, + isSettingsModalVisible, + setIsSettingsModalVisible, + isFlyoutMode, promptContexts, - promptTextPreview, selectedPromptContexts, - setIsSettingsModalVisible, setSelectedPromptContexts, - isFlyoutMode, + promptTextPreview, ] ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx index 4a26b22b0b2d..82b04c60a569 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.test.tsx @@ -16,13 +16,13 @@ import { getOptions, getOptionFromPrompt } from './helpers'; describe('helpers', () => { describe('getOptionFromPrompt', () => { it('returns an EuiSuperSelectOption with the correct value', () => { - const option = getOptionFromPrompt(mockSystemPrompt); + const option = getOptionFromPrompt({ ...mockSystemPrompt, isFlyoutMode: true }); expect(option.value).toBe(mockSystemPrompt.id); }); it('returns an EuiSuperSelectOption with the correct inputDisplay', () => { - const option = getOptionFromPrompt(mockSystemPrompt); + const option = getOptionFromPrompt({ ...mockSystemPrompt, isFlyoutMode: false }); render(<>{option.inputDisplay}); @@ -30,7 +30,7 @@ describe('helpers', () => { }); it('shows the expected name in the dropdownDisplay', () => { - const option = getOptionFromPrompt(mockSystemPrompt); + const option = getOptionFromPrompt({ ...mockSystemPrompt, isFlyoutMode: true }); render({option.dropdownDisplay}); @@ -38,7 +38,7 @@ describe('helpers', () => { }); it('shows the expected prompt content in the dropdownDisplay', () => { - const option = getOptionFromPrompt(mockSystemPrompt); + const option = getOptionFromPrompt({ ...mockSystemPrompt, isFlyoutMode: true }); render({option.dropdownDisplay}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.tsx index a52ed303d4a6..bd217bb54e9f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/helpers.tsx @@ -13,7 +13,7 @@ import styled from 'styled-components'; import { css } from '@emotion/react'; import { isEmpty } from 'lodash/fp'; -import type { Prompt } from '../../types'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { EMPTY_PROMPT } from './translations'; const Strong = styled.strong` @@ -26,7 +26,10 @@ export const getOptionFromPrompt = ({ name, showTitles = false, isFlyoutMode, -}: Prompt & { showTitles?: boolean }): EuiSuperSelectOption => ({ +}: PromptResponse & { + showTitles?: boolean; + isFlyoutMode: boolean; +}): EuiSuperSelectOption => ({ value: id, inputDisplay: isFlyoutMode ? ( name @@ -60,13 +63,13 @@ export const getOptionFromPrompt = ({ }); interface GetOptionsProps { - prompts: Prompt[] | undefined; + prompts: PromptResponse[] | undefined; showTitles?: boolean; isFlyoutMode: boolean; } export const getOptions = ({ prompts, showTitles = false, - isFlyoutMode = false, + isFlyoutMode, }: GetOptionsProps): Array> => prompts?.map((p) => getOptionFromPrompt({ ...p, showTitles, isFlyoutMode })) ?? []; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx index e0fed34795df..34d40852ba50 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.test.tsx @@ -13,11 +13,11 @@ import { mockSystemPrompt } from '../../../mock/system_prompt'; import { SystemPrompt } from '.'; import { Conversation } from '../../../..'; import { DEFAULT_CONVERSATION_TITLE } from '../../use_conversation/translations'; -import { Prompt } from '../../types'; import { TestProviders } from '../../../mock/test_providers/test_providers'; import { TEST_IDS } from '../../constants'; import { useAssistantContext } from '../../../assistant_context'; import { WELCOME_CONVERSATION } from '../../use_conversation/sample_conversations'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; const BASE_CONVERSATION: Conversation = { ...WELCOME_CONVERSATION, @@ -32,7 +32,7 @@ const mockConversations = { [DEFAULT_CONVERSATION_TITLE]: BASE_CONVERSATION, }; -const mockSystemPrompts: Prompt[] = [mockSystemPrompt]; +const mockSystemPrompts: PromptResponse[] = [mockSystemPrompt]; const mockUseAssistantContext = { conversations: mockConversations, @@ -91,6 +91,7 @@ describe('SystemPrompt', () => { onSystemPromptSelectionChange={onSystemPromptSelectionChange} setIsSettingsModalVisible={setIsSettingsModalVisible} isFlyoutMode={false} + allSystemPrompts={mockSystemPrompts} /> ); }); @@ -117,11 +118,12 @@ describe('SystemPrompt', () => { render( ); }); @@ -157,6 +159,7 @@ describe('SystemPrompt', () => { onSystemPromptSelectionChange={onSystemPromptSelectionChange} setIsSettingsModalVisible={setIsSettingsModalVisible} isFlyoutMode={false} + allSystemPrompts={mockSystemPrompts} /> ); @@ -204,6 +207,7 @@ describe('SystemPrompt', () => { onSystemPromptSelectionChange={onSystemPromptSelectionChange} setIsSettingsModalVisible={setIsSettingsModalVisible} isFlyoutMode={false} + allSystemPrompts={mockSystemPrompts} /> ); @@ -265,6 +269,7 @@ describe('SystemPrompt', () => { onSystemPromptSelectionChange={onSystemPromptSelectionChange} setIsSettingsModalVisible={setIsSettingsModalVisible} isFlyoutMode={false} + allSystemPrompts={mockSystemPrompts} /> ); @@ -333,6 +338,7 @@ describe('SystemPrompt', () => { onSystemPromptSelectionChange={onSystemPromptSelectionChange} setIsSettingsModalVisible={setIsSettingsModalVisible} isFlyoutMode={false} + allSystemPrompts={mockSystemPrompts} /> ); @@ -416,6 +422,7 @@ describe('SystemPrompt', () => { onSystemPromptSelectionChange={onSystemPromptSelectionChange} setIsSettingsModalVisible={setIsSettingsModalVisible} isFlyoutMode={false} + allSystemPrompts={mockSystemPrompts} /> ); @@ -481,11 +488,12 @@ describe('SystemPrompt', () => { ); @@ -500,11 +508,12 @@ describe('SystemPrompt', () => { ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx index d6a507b6deee..f2808c3e204f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/index.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useMemo } from 'react'; import { css } from '@emotion/react'; import { isEmpty } from 'lodash/fp'; -import { useAssistantContext } from '../../../assistant_context'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { Conversation } from '../../../..'; import * as i18n from './translations'; import { SelectSystemPrompt } from './select_system_prompt'; @@ -22,6 +22,7 @@ interface Props { onSystemPromptSelectionChange: (systemPromptId: string | undefined) => void; setIsSettingsModalVisible: React.Dispatch>; isFlyoutMode: boolean; + allSystemPrompts: PromptResponse[]; } const SystemPromptComponent: React.FC = ({ @@ -31,17 +32,13 @@ const SystemPromptComponent: React.FC = ({ onSystemPromptSelectionChange, setIsSettingsModalVisible, isFlyoutMode, + allSystemPrompts, }) => { - const { allSystemPrompts } = useAssistantContext(); - const selectedPrompt = useMemo(() => { if (editingSystemPromptId !== undefined) { - return ( - allSystemPrompts?.find((p) => p.id === editingSystemPromptId) ?? - allSystemPrompts?.find((p) => p.id === conversation?.apiConfig?.defaultSystemPromptId) - ); + return allSystemPrompts.find((p) => p.id === editingSystemPromptId); } else { - return undefined; + return allSystemPrompts.find((p) => p.id === conversation?.apiConfig?.defaultSystemPromptId); } }, [allSystemPrompts, conversation?.apiConfig?.defaultSystemPromptId, editingSystemPromptId]); @@ -58,7 +55,7 @@ const SystemPromptComponent: React.FC = ({ if (isFlyoutMode) { return ( = ({
{selectedPrompt == null || isEditing ? ( ); const props: Props = { - allSystemPrompts: [ + allPrompts: [ { id: 'default-system-prompt', content: 'default', @@ -31,6 +54,8 @@ const props: Props = { }; const mockUseAssistantContext = { + http, + assistantAvailability: { isAssistantEnabled: true }, allSystemPrompts: [ { id: 'default-system-prompt', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx index 2cbbdf68b307..0296fa3e636c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/select_system_prompt/index.tsx @@ -18,10 +18,13 @@ import { import React, { useCallback, useMemo, useState } from 'react'; import { euiThemeVars } from '@kbn/ui-theme'; +import { + PromptResponse, + PromptTypeEnum, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { Conversation } from '../../../../..'; import { getOptions } from '../helpers'; import * as i18n from '../translations'; -import type { Prompt } from '../../../types'; import { useAssistantContext } from '../../../../assistant_context'; import { useConversation } from '../../../use_conversation'; import { TEST_IDS } from '../../../constants'; @@ -29,10 +32,10 @@ import { PROMPT_CONTEXT_SELECTOR_PREFIX } from '../../../quick_prompts/prompt_co import { SYSTEM_PROMPTS_TAB } from '../../../settings/const'; export interface Props { - allSystemPrompts: Prompt[]; + allPrompts: PromptResponse[]; compressed?: boolean; conversation?: Conversation; - selectedPrompt: Prompt | undefined; + selectedPrompt: PromptResponse | undefined; clearSelectedSystemPrompt?: () => void; isClearable?: boolean; isEditing?: boolean; @@ -49,7 +52,7 @@ export interface Props { const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT'; const SelectSystemPromptComponent: React.FC = ({ - allSystemPrompts, + allPrompts, compressed = false, conversation, selectedPrompt, @@ -68,21 +71,24 @@ const SelectSystemPromptComponent: React.FC = ({ const { setSelectedSettingsTab } = useAssistantContext(); const { setApiConfig } = useConversation(); - const [isOpenLocal, setIsOpenLocal] = useState(isOpen); - const [valueOfSelected, setValueOfSelected] = useState( - selectedPrompt?.id ?? allSystemPrompts?.[0]?.id + const allSystemPrompts = useMemo( + () => allPrompts.filter((p) => p.promptType === PromptTypeEnum.system), + [allPrompts] ); + + const [isOpenLocal, setIsOpenLocal] = useState(isOpen); const handleOnBlur = useCallback(() => setIsOpenLocal(false), []); + const valueOfSelected = useMemo(() => selectedPrompt?.id, [selectedPrompt?.id]); // Write the selected system prompt to the conversation config const setSelectedSystemPrompt = useCallback( - (prompt: Prompt | undefined) => { + (promptId?: string) => { if (conversation && conversation.apiConfig) { setApiConfig({ conversation, apiConfig: { ...conversation.apiConfig, - defaultSystemPromptId: prompt?.id, + defaultSystemPromptId: promptId, }, }); } @@ -126,14 +132,11 @@ const SelectSystemPromptComponent: React.FC = ({ // Note: if callback is provided, this component does not persist. Extract to separate component if (onSystemPromptSelectionChange != null) { onSystemPromptSelectionChange(selectedSystemPromptId); - } else { - setSelectedSystemPrompt(allSystemPrompts.find((sp) => sp.id === selectedSystemPromptId)); } - setValueOfSelected(selectedSystemPromptId); + setSelectedSystemPrompt(selectedSystemPromptId); setIsEditing?.(false); }, [ - allSystemPrompts, onSystemPromptSelectionChange, setIsEditing, setIsSettingsModalVisible, @@ -146,7 +149,6 @@ const SelectSystemPromptComponent: React.FC = ({ setSelectedSystemPrompt(undefined); setIsEditing?.(false); clearSelectedSystemPrompt?.(); - setValueOfSelected(undefined); }, [clearSelectedSystemPrompt, setIsEditing, setSelectedSystemPrompt]); const onShowSelectSystemPrompt = useCallback(() => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_editor.tsx index 3fd7dfeb00e7..fecb2ed401a4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_editor.tsx @@ -18,9 +18,13 @@ import { import { keyBy } from 'lodash/fp'; import { css } from '@emotion/react'; +import { + PromptResponse, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { ApiConfig } from '@kbn/elastic-assistant-common'; import { AIConnector } from '../../../../connectorland/connector_selector'; -import { Conversation, Prompt } from '../../../../..'; +import { Conversation } from '../../../../..'; import * as i18n from './translations'; import { ConversationMultiSelector } from './conversation_multi_selector/conversation_multi_selector'; import { SystemPromptSelector } from './system_prompt_selector/system_prompt_selector'; @@ -34,16 +38,18 @@ interface Props { connectors: AIConnector[] | undefined; conversationSettings: Record; conversationsSettingsBulkActions: ConversationsBulkActions; - onSelectedSystemPromptChange: (systemPrompt?: Prompt) => void; - selectedSystemPrompt: Prompt | undefined; - setUpdatedSystemPromptSettings: React.Dispatch>; + onSelectedSystemPromptChange: (systemPrompt?: PromptResponse) => void; + selectedSystemPrompt: PromptResponse | undefined; + setUpdatedSystemPromptSettings: React.Dispatch>; setConversationSettings: React.Dispatch>>; - systemPromptSettings: Prompt[]; + systemPromptSettings: PromptResponse[]; setConversationsSettingsBulkActions: React.Dispatch< React.SetStateAction >; defaultConnector?: AIConnector; resetSettings?: () => void; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; } /** @@ -61,6 +67,8 @@ export const SystemPromptEditorComponent: React.FC = ({ setConversationsSettingsBulkActions, defaultConnector, resetSettings, + promptsBulkActions, + setPromptsBulkActions, }) => { // Prompt const promptContent = useMemo( @@ -72,11 +80,11 @@ export const SystemPromptEditorComponent: React.FC = ({ const handlePromptContentChange = useCallback( (e: React.ChangeEvent) => { if (selectedSystemPrompt != null) { - setUpdatedSystemPromptSettings((prev): Prompt[] => { + setUpdatedSystemPromptSettings((prev): PromptResponse[] => { const alreadyExists = prev.some((sp) => sp.id === selectedSystemPrompt.id); if (alreadyExists) { - return prev.map((sp): Prompt => { + return prev.map((sp): PromptResponse => { if (sp.id === selectedSystemPrompt.id) { return { ...sp, @@ -89,9 +97,44 @@ export const SystemPromptEditorComponent: React.FC = ({ return prev; }); + const existingPrompt = systemPromptSettings.find((sp) => sp.id === selectedSystemPrompt.id); + if (existingPrompt) { + setPromptsBulkActions({ + ...promptsBulkActions, + ...(selectedSystemPrompt.name !== selectedSystemPrompt.id + ? { + update: [ + ...(promptsBulkActions.update ?? []).filter( + (p) => p.id !== selectedSystemPrompt.id + ), + { + ...selectedSystemPrompt, + content: e.target.value, + }, + ], + } + : { + create: [ + ...(promptsBulkActions.create ?? []).filter( + (p) => p.name !== selectedSystemPrompt.name + ), + { + ...selectedSystemPrompt, + content: e.target.value, + }, + ], + }), + }); + } } }, - [selectedSystemPrompt, setUpdatedSystemPromptSettings] + [ + promptsBulkActions, + selectedSystemPrompt, + setPromptsBulkActions, + setUpdatedSystemPromptSettings, + systemPromptSettings, + ] ); const conversationsWithApiConfig = Object.entries(conversationSettings).reduce< @@ -258,14 +301,47 @@ export const SystemPromptEditorComponent: React.FC = ({ }; }); }); + setPromptsBulkActions({ + ...promptsBulkActions, + ...(selectedSystemPrompt.name !== selectedSystemPrompt.id + ? { + update: [ + ...(promptsBulkActions.update ?? []).filter( + (p) => p.id !== selectedSystemPrompt.id + ), + { + ...selectedSystemPrompt, + isNewConversationDefault: isChecked, + }, + ], + } + : { + create: [ + ...(promptsBulkActions.create ?? []).filter( + (p) => p.name !== selectedSystemPrompt.name + ), + { + ...selectedSystemPrompt, + isNewConversationDefault: isChecked, + }, + ], + }), + }); } }, - [selectedSystemPrompt, setUpdatedSystemPromptSettings] + [ + promptsBulkActions, + selectedSystemPrompt, + setPromptsBulkActions, + setUpdatedSystemPromptSettings, + ] ); const { onSystemPromptSelectionChange, onSystemPromptDeleted } = useSystemPromptEditor({ setUpdatedSystemPromptSettings, onSelectedSystemPromptChange, + promptsBulkActions, + setPromptsBulkActions, }); return ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.test.tsx index 45f320528ec6..cbf5efe79213 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.test.tsx @@ -35,7 +35,7 @@ describe('SystemPromptSelector', () => { fireEvent.click(getByTestId('comboBoxToggleListButton')); // there is only one delete system prompt because there is only one custom option fireEvent.click(getAllByTestId('delete-prompt')[1]); - expect(onSystemPromptDeleted).toHaveBeenCalledWith(mockSystemPrompts[1].name); + expect(onSystemPromptDeleted).toHaveBeenCalledWith(mockSystemPrompts[1].id); expect(onSystemPromptSelectionChange).not.toHaveBeenCalled(); }); it('Deletes a system prompt that is selected', () => { @@ -43,7 +43,7 @@ describe('SystemPromptSelector', () => { fireEvent.click(getByTestId('comboBoxToggleListButton')); // there is only one delete system prompt because there is only one custom option fireEvent.click(getAllByTestId('delete-prompt')[0]); - expect(onSystemPromptDeleted).toHaveBeenCalledWith(mockSystemPrompts[0].name); + expect(onSystemPromptDeleted).toHaveBeenCalledWith(mockSystemPrompts[0].id); expect(onSystemPromptSelectionChange).toHaveBeenCalledWith(undefined); }); it('Selects existing system prompt from the search input', () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx index 53b6414d05b5..2c4826940a7c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_selector/system_prompt_selector.tsx @@ -18,8 +18,8 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { TEST_IDS } from '../../../../constants'; -import { Prompt } from '../../../../../..'; import * as i18n from './translations'; import { SYSTEM_PROMPT_DEFAULT_NEW_CONVERSATION } from '../translations'; @@ -28,10 +28,10 @@ export const SYSTEM_PROMPT_SELECTOR_CLASSNAME = 'systemPromptSelector'; interface Props { autoFocus?: boolean; onSystemPromptDeleted: (systemPromptTitle: string) => void; - onSystemPromptSelectionChange: (systemPrompt?: Prompt | string) => void; + onSystemPromptSelectionChange: (systemPrompt?: PromptResponse | string) => void; + systemPrompts: PromptResponse[]; + selectedSystemPrompt?: PromptResponse; resetSettings?: () => void; - selectedSystemPrompt?: Prompt; - systemPrompts: Prompt[]; } export type SystemPromptSelectorOption = EuiComboBoxOptionOption<{ @@ -59,6 +59,7 @@ export const SystemPromptSelector: React.FC = React.memo( isNewConversationDefault: sp.isNewConversationDefault ?? false, }, label: sp.name, + id: sp.id, 'data-test-subj': `${TEST_IDS.SYSTEM_PROMPT_SELECTOR}-${sp.id}`, })) ); @@ -70,6 +71,7 @@ export const SystemPromptSelector: React.FC = React.memo( isDefault: selectedSystemPrompt.isDefault ?? false, isNewConversationDefault: selectedSystemPrompt.isNewConversationDefault ?? false, }, + id: selectedSystemPrompt.id, label: selectedSystemPrompt.name, }, ] @@ -106,6 +108,7 @@ export const SystemPromptSelector: React.FC = React.memo( const newOption = { value: searchValue, + id: searchValue, label: searchValue, }; @@ -132,11 +135,12 @@ export const SystemPromptSelector: React.FC = React.memo( // Callback for when user deletes a quick prompt const onDelete = useCallback( (label: string) => { + const deleteId = options.find((o) => o.label === label)?.id; setOptions(options.filter((o) => o.label !== label)); if (selectedOptions?.[0]?.label === label) { handleSelectionChange([]); } - onSystemPromptDeleted(label); + onSystemPromptDeleted(deleteId ?? label); }, [handleSelectionChange, onSystemPromptDeleted, options, selectedOptions] ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.test.tsx index be9e33f615e4..5116da2a5620 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.test.tsx @@ -36,6 +36,8 @@ const testProps = { systemPromptSettings: mockSystemPrompts, conversationsSettingsBulkActions: {}, setConversationsSettingsBulkActions: jest.fn(), + promptsBulkActions: {}, + setPromptsBulkActions: jest.fn(), }; jest.mock('./system_prompt_selector/system_prompt_selector', () => ({ @@ -96,6 +98,7 @@ describe('SystemPromptSettings', () => { ); fireEvent.click(getByTestId('change-sp-custom')); const customOption = { + consumer: 'test', content: '', id: 'sooper custom prompt', name: 'sooper custom prompt', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx index b7f66acba85c..7b8e45144988 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/system_prompt_settings.tsx @@ -26,7 +26,9 @@ export const SystemPromptSettings: React.FC = React.m systemPromptSettings, conversationsSettingsBulkActions, setConversationsSettingsBulkActions, + promptsBulkActions, defaultConnector, + setPromptsBulkActions, }) => { return ( <> @@ -48,6 +50,8 @@ export const SystemPromptSettings: React.FC = React.m conversationsSettingsBulkActions={conversationsSettingsBulkActions} setConversationsSettingsBulkActions={setConversationsSettingsBulkActions} defaultConnector={defaultConnector} + setPromptsBulkActions={setPromptsBulkActions} + promptsBulkActions={promptsBulkActions} /> ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/types.ts index 63025566c940..e92961cb1763 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/types.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/types.ts @@ -4,21 +4,27 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { + PromptResponse, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { AIConnector } from '../../../../connectorland/connector_selector'; -import { Conversation, Prompt } from '../../../../..'; +import { Conversation } from '../../../../..'; import { ConversationsBulkActions } from '../../../api'; export interface SystemPromptSettingsProps { connectors: AIConnector[] | undefined; conversationSettings: Record; conversationsSettingsBulkActions: ConversationsBulkActions; - onSelectedSystemPromptChange: (systemPrompt?: Prompt) => void; - selectedSystemPrompt: Prompt | undefined; - setUpdatedSystemPromptSettings: React.Dispatch>; + onSelectedSystemPromptChange: (systemPrompt?: PromptResponse) => void; + selectedSystemPrompt: PromptResponse | undefined; + setUpdatedSystemPromptSettings: React.Dispatch>; setConversationSettings: React.Dispatch>>; - systemPromptSettings: Prompt[]; + systemPromptSettings: PromptResponse[]; setConversationsSettingsBulkActions: React.Dispatch< React.SetStateAction >; defaultConnector?: AIConnector; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.test.tsx index 85efe9997965..009ee6c5a83c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.test.tsx @@ -6,21 +6,27 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; import { useSystemPromptEditor } from './use_system_prompt_editor'; -import { Prompt } from '../../../types'; import { mockSystemPrompt, mockSuperheroSystemPrompt, mockSystemPrompts, } from '../../../../mock/system_prompt'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; +import { useAssistantContext } from '../../../../assistant_context'; +jest.mock('../../../../assistant_context'); // Mock functions for the tests const mockOnSelectedSystemPromptChange = jest.fn(); const mockSetUpdatedSystemPromptSettings = jest.fn(); +const mockSetPromptsBulkActions = jest.fn(); const mockPreviousSystemPrompts = [...mockSystemPrompts]; describe('useSystemPromptEditor', () => { beforeEach(() => { jest.clearAllMocks(); + (useAssistantContext as jest.Mock).mockReturnValue({ + currentAppId: 'securitySolutionUI', + }); }); test('should delete a system prompt by id', () => { @@ -28,6 +34,8 @@ describe('useSystemPromptEditor', () => { useSystemPromptEditor({ onSelectedSystemPromptChange: mockOnSelectedSystemPromptChange, setUpdatedSystemPromptSettings: mockSetUpdatedSystemPromptSettings, + setPromptsBulkActions: mockSetPromptsBulkActions, + promptsBulkActions: {}, }) ); @@ -41,11 +49,13 @@ describe('useSystemPromptEditor', () => { }); test('should handle selection of an existing system prompt', () => { - const existingPrompt: Prompt = mockSystemPrompt; + const existingPrompt: PromptResponse = mockSystemPrompt; const { result } = renderHook(() => useSystemPromptEditor({ onSelectedSystemPromptChange: mockOnSelectedSystemPromptChange, setUpdatedSystemPromptSettings: mockSetUpdatedSystemPromptSettings, + setPromptsBulkActions: mockSetPromptsBulkActions, + promptsBulkActions: {}, }) ); @@ -65,6 +75,8 @@ describe('useSystemPromptEditor', () => { useSystemPromptEditor({ onSelectedSystemPromptChange: mockOnSelectedSystemPromptChange, setUpdatedSystemPromptSettings: mockSetUpdatedSystemPromptSettings, + setPromptsBulkActions: mockSetPromptsBulkActions, + promptsBulkActions: {}, }) ); @@ -72,11 +84,12 @@ describe('useSystemPromptEditor', () => { result.current.onSystemPromptSelectionChange(newPromptId); }); - const newPrompt: Prompt = { + const newPrompt: PromptResponse = { id: newPromptId, content: '', name: newPromptId, promptType: 'system', + consumer: 'securitySolutionUI', }; expect(mockOnSelectedSystemPromptChange).toHaveBeenCalledWith(newPrompt); @@ -90,10 +103,12 @@ describe('useSystemPromptEditor', () => { useSystemPromptEditor({ onSelectedSystemPromptChange: mockOnSelectedSystemPromptChange, setUpdatedSystemPromptSettings: mockSetUpdatedSystemPromptSettings, + setPromptsBulkActions: mockSetPromptsBulkActions, + promptsBulkActions: {}, }) ); - const expectedPrompt: Prompt = mockSuperheroSystemPrompt; + const expectedPrompt: PromptResponse = mockSuperheroSystemPrompt; act(() => { result.current.onSystemPromptSelectionChange(expectedPrompt); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.tsx index 87e284d6dcf2..ec77de113b5d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_modal/use_system_prompt_editor.tsx @@ -5,28 +5,38 @@ * 2.0. */ +import { + PromptResponse, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { useCallback } from 'react'; -import { Prompt } from '../../../types'; +import { useAssistantContext } from '../../../../..'; interface Props { - setUpdatedSystemPromptSettings: React.Dispatch>; - onSelectedSystemPromptChange: (systemPrompt?: Prompt) => void; + setUpdatedSystemPromptSettings: React.Dispatch>; + onSelectedSystemPromptChange: (systemPrompt?: PromptResponse) => void; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; } export const useSystemPromptEditor = ({ setUpdatedSystemPromptSettings, onSelectedSystemPromptChange, + promptsBulkActions, + setPromptsBulkActions, }: Props) => { + const { currentAppId } = useAssistantContext(); // When top level system prompt selection changes const onSystemPromptSelectionChange = useCallback( - (systemPrompt?: Prompt | string) => { + (systemPrompt?: PromptResponse | string) => { const isNew = typeof systemPrompt === 'string'; - const newSelectedSystemPrompt: Prompt | undefined = isNew + const newSelectedSystemPrompt: PromptResponse | undefined = isNew ? { id: systemPrompt ?? '', content: '', name: systemPrompt ?? '', promptType: 'system', + consumer: currentAppId, } : systemPrompt; @@ -40,18 +50,42 @@ export const useSystemPromptEditor = ({ return prev; }); + + if (isNew) { + setPromptsBulkActions({ + ...promptsBulkActions, + create: [ + ...(promptsBulkActions.create ?? []), + { + ...newSelectedSystemPrompt, + }, + ], + }); + } } onSelectedSystemPromptChange(newSelectedSystemPrompt); }, - [onSelectedSystemPromptChange, setUpdatedSystemPromptSettings] + [ + currentAppId, + onSelectedSystemPromptChange, + promptsBulkActions, + setPromptsBulkActions, + setUpdatedSystemPromptSettings, + ] ); const onSystemPromptDeleted = useCallback( (id: string) => { setUpdatedSystemPromptSettings((prev) => prev.filter((sp) => sp.id !== id)); + setPromptsBulkActions({ + ...promptsBulkActions, + delete: { + ids: [...(promptsBulkActions.delete?.ids ?? []), id], + }, + }); }, - [setUpdatedSystemPromptSettings] + [promptsBulkActions, setPromptsBulkActions, setUpdatedSystemPromptSettings] ); return { onSystemPromptSelectionChange, onSystemPromptDeleted }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx index e0f27f3fa8c7..14b6ecb868ea 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/index.tsx @@ -16,6 +16,10 @@ import { } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; +import { + PromptResponse, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { Conversation, ConversationsBulkActions, useAssistantContext } from '../../../../..'; import { SYSTEM_PROMPT_TABLE_SESSION_STORAGE_KEY } from '../../../../assistant_context/constants'; import { AIConnector } from '../../../../connectorland/connector_selector'; @@ -26,7 +30,6 @@ import { useSessionPagination, } from '../../../common/components/assistant_settings_management/pagination/use_session_pagination'; import { CANCEL, DELETE } from '../../../settings/translations'; -import { Prompt } from '../../../types'; import { SystemPromptEditor } from '../system_prompt_modal/system_prompt_editor'; import { SETTINGS_TITLE } from '../system_prompt_modal/translations'; import { useSystemPromptEditor } from '../system_prompt_modal/use_system_prompt_editor'; @@ -37,11 +40,11 @@ interface Props { connectors: AIConnector[] | undefined; conversationSettings: Record; conversationsSettingsBulkActions: ConversationsBulkActions; - onSelectedSystemPromptChange: (systemPrompt?: Prompt) => void; - selectedSystemPrompt: Prompt | undefined; - setUpdatedSystemPromptSettings: React.Dispatch>; + onSelectedSystemPromptChange: (systemPrompt?: PromptResponse) => void; + selectedSystemPrompt: PromptResponse | undefined; + setUpdatedSystemPromptSettings: React.Dispatch>; setConversationSettings: React.Dispatch>>; - systemPromptSettings: Prompt[]; + systemPromptSettings: PromptResponse[]; setConversationsSettingsBulkActions: React.Dispatch< React.SetStateAction >; @@ -49,6 +52,8 @@ interface Props { handleSave: (shouldRefetchConversation?: boolean) => void; onCancelClick: () => void; resetSettings: () => void; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; } const SystemPromptSettingsManagementComponent = ({ @@ -65,6 +70,8 @@ const SystemPromptSettingsManagementComponent = ({ handleSave, onCancelClick, resetSettings, + promptsBulkActions, + setPromptsBulkActions, }: Props) => { const { nameSpace } = useAssistantContext(); const { isFlyoutOpen: editFlyoutVisible, openFlyout, closeFlyout } = useFlyoutModalVisibility(); @@ -73,7 +80,7 @@ const SystemPromptSettingsManagementComponent = ({ openFlyout: openConfirmModal, closeFlyout: closeConfirmModal, } = useFlyoutModalVisibility(); - const [deletedPrompt, setDeletedPrompt] = useState(); + const [deletedPrompt, setDeletedPrompt] = useState(); const onCreate = useCallback(() => { onSelectedSystemPromptChange({ @@ -88,10 +95,12 @@ const SystemPromptSettingsManagementComponent = ({ const { onSystemPromptSelectionChange, onSystemPromptDeleted } = useSystemPromptEditor({ setUpdatedSystemPromptSettings, onSelectedSystemPromptChange, + promptsBulkActions, + setPromptsBulkActions, }); const onEditActionClicked = useCallback( - (prompt: Prompt) => { + (prompt: PromptResponse) => { onSystemPromptSelectionChange(prompt); openFlyout(); }, @@ -99,7 +108,7 @@ const SystemPromptSettingsManagementComponent = ({ ); const onDeleteActionClicked = useCallback( - (prompt: Prompt) => { + (prompt: PromptResponse) => { setDeletedPrompt(prompt); onSystemPromptDeleted(prompt.id); openConfirmModal(); @@ -200,6 +209,8 @@ const SystemPromptSettingsManagementComponent = ({ setConversationsSettingsBulkActions={setConversationsSettingsBulkActions} defaultConnector={defaultConnector} resetSettings={resetSettings} + promptsBulkActions={promptsBulkActions} + setPromptsBulkActions={setPromptsBulkActions} /> {deleteConfirmModalVisibility && deletedPrompt?.name && ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx index 90cea2319714..48d3232f0ae3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.test.tsx @@ -7,26 +7,25 @@ import { renderHook } from '@testing-library/react-hooks'; import { useSystemPromptTable } from './use_system_prompt_table'; -import { Prompt } from '../../../types'; import { Conversation } from '../../../../assistant_context/types'; import { AIConnector } from '../../../../connectorland/connector_selector'; import { customConvo, welcomeConvo } from '../../../../mock/conversation'; import { mockConnectors } from '../../../../mock/connectors'; -import { ApiConfig } from '@kbn/elastic-assistant-common'; +import { ApiConfig, PromptResponse } from '@kbn/elastic-assistant-common'; // Mock data for tests -const mockSystemPrompts: Prompt[] = [ +const mockSystemPrompts: PromptResponse[] = [ { id: 'prompt-1', content: 'Prompt 1', name: 'Prompt 1', - promptType: 'user', + promptType: 'quick', }, { id: 'prompt-2', content: 'Prompt 2', name: 'Prompt 2', - promptType: 'user', + promptType: 'quick', isNewConversationDefault: true, }, ]; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx index 7cf907bb7adf..46e082b86f2c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/use_system_prompt_table.tsx @@ -6,11 +6,11 @@ */ import { EuiBasicTableColumn, EuiIcon, EuiLink } from '@elastic/eui'; import React, { useCallback } from 'react'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { Conversation } from '../../../../assistant_context/types'; import { AIConnector } from '../../../../connectorland/connector_selector'; import { BadgesColumn } from '../../../common/components/assistant_settings_management/badges'; import { RowActions } from '../../../common/components/assistant_settings_management/row_actions'; -import { Prompt } from '../../../types'; import { getConversationApiConfig, getInitialDefaultSystemPrompt, @@ -21,10 +21,10 @@ import { getSelectedConversations } from './utils'; type ConversationsWithSystemPrompt = Record< string, - Conversation & { systemPrompt: Prompt | undefined } + Conversation & { systemPrompt: PromptResponse | undefined } >; -type SystemPromptTableItem = Prompt & { defaultConversations: string[] }; +type SystemPromptTableItem = PromptResponse & { defaultConversations: string[] }; export const useSystemPromptTable = () => { const getColumns = useCallback( @@ -97,7 +97,7 @@ export const useSystemPromptTable = () => { connectors: AIConnector[] | undefined; conversationSettings: Record; defaultConnector: AIConnector | undefined; - systemPromptSettings: Prompt[]; + systemPromptSettings: PromptResponse[]; }): SystemPromptTableItem[] => { const conversationsWithApiConfig = Object.entries( conversationSettings diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.test.tsx index 5f10e3bb59c6..9fbfb3a8782e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.test.tsx @@ -6,8 +6,8 @@ */ import { ProviderEnum } from '@kbn/elastic-assistant-common'; import { mockSystemPrompts } from '../../../../mock/system_prompt'; -import { PromptType } from '../../../types'; import { getSelectedConversations } from './utils'; +import { PromptTypeEnum } from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; describe('getSelectedConversations', () => { const allSystemPrompts = [...mockSystemPrompts]; const conversationSettings = { @@ -39,7 +39,7 @@ describe('getSelectedConversations', () => { content: 'You are a helpful, expert assistant who answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.\nProvide the most detailed and relevant answer possible, as if you were relaying this information back to a cyber security expert.\nIf you answer a question related to KQL, EQL, or ES|QL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query. xxx', name: 'Enhanced system prompt', - promptType: 'system' as PromptType, + promptType: PromptTypeEnum.system, isDefault: true, isNewConversationDefault: true, }, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.tsx index 5fde200db9b1..fd01b8eb318a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_editor/system_prompt/system_prompt_settings_management/utils.tsx @@ -5,11 +5,11 @@ * 2.0. */ +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { Conversation } from '../../../../assistant_context/types'; -import { Prompt } from '../../../types'; export const getSelectedConversations = ( - allSystemPrompts: Prompt[], + allSystemPrompts: PromptResponse[], conversationSettings: Record, systemPromptId: string ) => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.test.tsx index 04ccd478e3bc..941b442ce4d4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.test.tsx @@ -25,9 +25,9 @@ describe('QuickPromptSelector', () => { }); it('Selects an existing quick prompt', () => { const { getByTestId } = render(); - expect(getByTestId('euiComboBoxPill')).toHaveTextContent(MOCK_QUICK_PROMPTS[0].title); + expect(getByTestId('euiComboBoxPill')).toHaveTextContent(MOCK_QUICK_PROMPTS[0].name); fireEvent.click(getByTestId('comboBoxToggleListButton')); - fireEvent.click(getByTestId(MOCK_QUICK_PROMPTS[1].title)); + fireEvent.click(getByTestId(MOCK_QUICK_PROMPTS[1].name)); expect(onQuickPromptSelectionChange).toHaveBeenCalledWith(MOCK_QUICK_PROMPTS[1]); }); it('Only custom option can be deleted', () => { @@ -49,8 +49,10 @@ describe('QuickPromptSelector', () => { expect(onQuickPromptSelectionChange).toHaveBeenCalledWith({ categories: [], color: '#D36086', - prompt: 'quickly prompt please', - title: 'A_CUSTOM_OPTION', + content: 'quickly prompt please', + id: 'A_CUSTOM_OPTION', + name: 'A_CUSTOM_OPTION', + promptType: 'quick', }); }); it('Reset settings every time before selecting an system prompt from the input if resetSettings is provided', () => { @@ -60,7 +62,7 @@ describe('QuickPromptSelector', () => { ); // changing the selection fireEvent.change(getByTestId('comboBoxSearchInput'), { - target: { value: MOCK_QUICK_PROMPTS[1].title }, + target: { value: MOCK_QUICK_PROMPTS[1].name }, }); fireEvent.keyDown(getByTestId('comboBoxSearchInput'), { key: 'Enter', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.tsx index 3fb0ba17cf4b..d29887e8c4f6 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_selector/quick_prompt_selector.tsx @@ -18,16 +18,16 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import * as i18n from './translations'; -import { QuickPrompt } from '../types'; interface Props { isDisabled?: boolean; onQuickPromptDeleted: (quickPromptTitle: string) => void; - onQuickPromptSelectionChange: (quickPrompt?: QuickPrompt | string) => void; - quickPrompts: QuickPrompt[]; + onQuickPromptSelectionChange: (quickPrompt?: PromptResponse | string) => void; + quickPrompts: PromptResponse[]; + selectedQuickPrompt?: PromptResponse; resetSettings?: () => void; - selectedQuickPrompt?: QuickPrompt; } export type QuickPromptSelectorOption = EuiComboBoxOptionOption<{ isDefault: boolean }>; @@ -50,8 +50,9 @@ export const QuickPromptSelector: React.FC = React.memo( value: { isDefault: qp.isDefault ?? false, }, - label: qp.title, - 'data-test-subj': qp.title, + label: qp.name, + 'data-test-subj': qp.name, + id: qp.id, color: qp.color, })) ); @@ -62,7 +63,8 @@ export const QuickPromptSelector: React.FC = React.memo( value: { isDefault: true, }, - label: selectedQuickPrompt.title, + label: selectedQuickPrompt.name, + id: selectedQuickPrompt.id, color: selectedQuickPrompt.color, }, ] @@ -76,7 +78,7 @@ export const QuickPromptSelector: React.FC = React.memo( const newQuickPrompt = quickPromptSelectorOption.length === 0 ? undefined - : quickPrompts.find((qp) => qp.title === quickPromptSelectorOption[0]?.label) ?? + : quickPrompts.find((qp) => qp.name === quickPromptSelectorOption[0]?.label) ?? quickPromptSelectorOption[0]?.label; onQuickPromptSelectionChange(newQuickPrompt); }, @@ -100,6 +102,7 @@ export const QuickPromptSelector: React.FC = React.memo( const newOption = { value: searchValue, label: searchValue, + id: searchValue, }; if (!optionExists) { @@ -125,11 +128,12 @@ export const QuickPromptSelector: React.FC = React.memo( // Callback for when user deletes a quick prompt const onDelete = useCallback( (label: string) => { + const deleteId = options.find((o) => o.label === label)?.id; setOptions(options.filter((o) => o.label !== label)); if (selectedOptions?.[0]?.label === label) { handleSelectionChange([]); } - onQuickPromptDeleted(label); + onQuickPromptDeleted(deleteId ?? label); }, [handleSelectionChange, onQuickPromptDeleted, options, selectedOptions] ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_editor.tsx index 4300e53525b3..01ffe00d1110 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_editor.tsx @@ -10,9 +10,12 @@ import { EuiFormRow, EuiColorPicker, EuiTextArea } from '@elastic/eui'; import { EuiSetColorMethod } from '@elastic/eui/src/services/color_picker/color_picker'; import { css } from '@emotion/react'; +import { + PromptResponse, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { PromptContextTemplate } from '../../../..'; import * as i18n from './translations'; -import { QuickPrompt } from '../types'; import { QuickPromptSelector } from '../quick_prompt_selector/quick_prompt_selector'; import { PromptContextSelector } from '../prompt_context_selector/prompt_context_selector'; import { useAssistantContext } from '../../../assistant_context'; @@ -21,11 +24,13 @@ import { useQuickPromptEditor } from './use_quick_prompt_editor'; const DEFAULT_COLOR = '#D36086'; interface Props { - onSelectedQuickPromptChange: (quickPrompt?: QuickPrompt) => void; - quickPromptSettings: QuickPrompt[]; + onSelectedQuickPromptChange: (quickPrompt?: PromptResponse) => void; + quickPromptSettings: PromptResponse[]; resetSettings?: () => void; - selectedQuickPrompt: QuickPrompt | undefined; - setUpdatedQuickPromptSettings: React.Dispatch>; + selectedQuickPrompt: PromptResponse | undefined; + setUpdatedQuickPromptSettings: React.Dispatch>; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; } const QuickPromptSettingsEditorComponent = ({ @@ -34,28 +39,30 @@ const QuickPromptSettingsEditorComponent = ({ resetSettings, selectedQuickPrompt, setUpdatedQuickPromptSettings, + promptsBulkActions, + setPromptsBulkActions, }: Props) => { const { basePromptContexts } = useAssistantContext(); // Prompt - const prompt = useMemo( + const promptContent = useMemo( // Fixing Cursor Jump in text area - () => quickPromptSettings.find((p) => p.title === selectedQuickPrompt?.title)?.prompt ?? '', - [selectedQuickPrompt?.title, quickPromptSettings] + () => quickPromptSettings.find((p) => p.id === selectedQuickPrompt?.id)?.content ?? '', + [selectedQuickPrompt?.id, quickPromptSettings] ); const handlePromptChange = useCallback( (e: React.ChangeEvent) => { if (selectedQuickPrompt != null) { - setUpdatedQuickPromptSettings((prev) => { - const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title); + setUpdatedQuickPromptSettings((prev): PromptResponse[] => { + const alreadyExists = prev.some((qp) => qp.id === selectedQuickPrompt.id); if (alreadyExists) { return prev.map((qp) => { - if (qp.title === selectedQuickPrompt.title) { + if (qp.id === selectedQuickPrompt.id) { return { ...qp, - prompt: e.target.value, + content: e.target.value, }; } return qp; @@ -64,9 +71,45 @@ const QuickPromptSettingsEditorComponent = ({ return prev; }); + + const existingPrompt = quickPromptSettings.find((sp) => sp.id === selectedQuickPrompt.id); + if (existingPrompt) { + setPromptsBulkActions({ + ...promptsBulkActions, + ...(selectedQuickPrompt.name !== selectedQuickPrompt.id + ? { + update: [ + ...(promptsBulkActions.update ?? []).filter( + (p) => p.id !== selectedQuickPrompt.id + ), + { + ...selectedQuickPrompt, + content: e.target.value, + }, + ], + } + : { + create: [ + ...(promptsBulkActions.create ?? []).filter( + (p) => p.name !== selectedQuickPrompt.name + ), + { + ...selectedQuickPrompt, + content: e.target.value, + }, + ], + }), + }); + } } }, - [selectedQuickPrompt, setUpdatedQuickPromptSettings] + [ + promptsBulkActions, + quickPromptSettings, + selectedQuickPrompt, + setPromptsBulkActions, + setUpdatedQuickPromptSettings, + ] ); // Color @@ -79,11 +122,11 @@ const QuickPromptSettingsEditorComponent = ({ (color, { hex, isValid }) => { if (selectedQuickPrompt != null) { setUpdatedQuickPromptSettings((prev) => { - const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title); + const alreadyExists = prev.some((qp) => qp.name === selectedQuickPrompt.name); if (alreadyExists) { return prev.map((qp) => { - if (qp.title === selectedQuickPrompt.title) { + if (qp.name === selectedQuickPrompt.name) { return { ...qp, color, @@ -94,9 +137,44 @@ const QuickPromptSettingsEditorComponent = ({ } return prev; }); + const existingPrompt = quickPromptSettings.find((sp) => sp.id === selectedQuickPrompt.id); + if (existingPrompt) { + setPromptsBulkActions({ + ...promptsBulkActions, + ...(selectedQuickPrompt.name !== selectedQuickPrompt.id + ? { + update: [ + ...(promptsBulkActions.update ?? []).filter( + (p) => p.id !== selectedQuickPrompt.id + ), + { + ...selectedQuickPrompt, + color, + }, + ], + } + : { + create: [ + ...(promptsBulkActions.create ?? []).filter( + (p) => p.name !== selectedQuickPrompt.name + ), + { + ...selectedQuickPrompt, + color, + }, + ], + }), + }); + } } }, - [selectedQuickPrompt, setUpdatedQuickPromptSettings] + [ + promptsBulkActions, + quickPromptSettings, + selectedQuickPrompt, + setPromptsBulkActions, + setUpdatedQuickPromptSettings, + ] ); // Prompt Contexts @@ -112,11 +190,11 @@ const QuickPromptSettingsEditorComponent = ({ (pc: PromptContextTemplate[]) => { if (selectedQuickPrompt != null) { setUpdatedQuickPromptSettings((prev) => { - const alreadyExists = prev.some((qp) => qp.title === selectedQuickPrompt.title); + const alreadyExists = prev.some((qp) => qp.name === selectedQuickPrompt.name); if (alreadyExists) { return prev.map((qp) => { - if (qp.title === selectedQuickPrompt.title) { + if (qp.name === selectedQuickPrompt.name) { return { ...qp, categories: pc.map((p) => p.category), @@ -127,15 +205,53 @@ const QuickPromptSettingsEditorComponent = ({ } return prev; }); + + const existingPrompt = quickPromptSettings.find((sp) => sp.id === selectedQuickPrompt.id); + if (existingPrompt) { + setPromptsBulkActions({ + ...promptsBulkActions, + ...(selectedQuickPrompt.name !== selectedQuickPrompt.id + ? { + update: [ + ...(promptsBulkActions.update ?? []).filter( + (p) => p.id !== selectedQuickPrompt.id + ), + { + ...selectedQuickPrompt, + categories: pc.map((p) => p.category), + }, + ], + } + : { + create: [ + ...(promptsBulkActions.create ?? []).filter( + (p) => p.name !== selectedQuickPrompt.name + ), + { + ...selectedQuickPrompt, + categories: pc.map((p) => p.category), + }, + ], + }), + }); + } } }, - [selectedQuickPrompt, setUpdatedQuickPromptSettings] + [ + promptsBulkActions, + quickPromptSettings, + selectedQuickPrompt, + setPromptsBulkActions, + setUpdatedQuickPromptSettings, + ] ); // When top level quick prompt selection changes const { onQuickPromptDeleted, onQuickPromptSelectionChange } = useQuickPromptEditor({ onSelectedQuickPromptChange, setUpdatedQuickPromptSettings, + promptsBulkActions, + setPromptsBulkActions, }); return ( @@ -158,7 +274,7 @@ const QuickPromptSettingsEditorComponent = ({ data-test-subj="quick-prompt-prompt" onChange={handlePromptChange} placeholder={i18n.QUICK_PROMPT_PROMPT_PLACEHOLDER} - value={prompt} + value={promptContent} css={css` min-height: 150px; `} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.test.tsx index 6aa939934d58..eb8cc2cb2569 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.test.tsx @@ -13,6 +13,7 @@ import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt'; import { mockPromptContexts } from '../../../mock/prompt_context'; const onSelectedQuickPromptChange = jest.fn(); +const setPromptsBulkActions = jest.fn(); const setUpdatedQuickPromptSettings = jest.fn().mockImplementation((fn) => { return fn(MOCK_QUICK_PROMPTS); }); @@ -22,6 +23,8 @@ const testProps = { quickPromptSettings: MOCK_QUICK_PROMPTS, selectedQuickPrompt: MOCK_QUICK_PROMPTS[0], setUpdatedQuickPromptSettings, + promptsBulkActions: {}, + setPromptsBulkActions, }; const mockContext = { basePromptContexts: MOCK_QUICK_PROMPTS, @@ -91,8 +94,11 @@ describe('QuickPromptSettings', () => { const customOption = { categories: [], color: '#D36086', - prompt: '', - title: 'sooper custom prompt', + consumer: undefined, + content: '', + id: 'sooper custom prompt', + name: 'sooper custom prompt', + promptType: 'quick', }; expect(setUpdatedQuickPromptSettings).toHaveReturnedWith([...MOCK_QUICK_PROMPTS, customOption]); expect(onSelectedQuickPromptChange).toHaveBeenCalledWith(customOption); @@ -130,7 +136,7 @@ describe('QuickPromptSettings', () => { const previousFirstElementOfTheArray = mutatableQuickPrompts.shift(); expect(setUpdatedQuickPromptSettings).toHaveReturnedWith([ - { ...previousFirstElementOfTheArray, prompt: 'what does this do' }, + { ...previousFirstElementOfTheArray, content: 'what does this do' }, ...mutatableQuickPrompts, ]); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx index 4b8b6a8f8039..61496c64fd73 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/quick_prompt_settings.tsx @@ -8,15 +8,20 @@ import React from 'react'; import { EuiTitle, EuiText, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { + PromptResponse, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import * as i18n from './translations'; -import { QuickPrompt } from '../types'; import { QuickPromptSettingsEditor } from './quick_prompt_editor'; interface Props { - onSelectedQuickPromptChange: (quickPrompt?: QuickPrompt) => void; - quickPromptSettings: QuickPrompt[]; - selectedQuickPrompt: QuickPrompt | undefined; - setUpdatedQuickPromptSettings: React.Dispatch>; + onSelectedQuickPromptChange: (quickPrompt?: PromptResponse) => void; + quickPromptSettings: PromptResponse[]; + selectedQuickPrompt: PromptResponse | undefined; + setUpdatedQuickPromptSettings: React.Dispatch>; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; } /** @@ -28,6 +33,8 @@ export const QuickPromptSettings: React.FC = React.memo( quickPromptSettings, selectedQuickPrompt, setUpdatedQuickPromptSettings, + promptsBulkActions, + setPromptsBulkActions, }) => { return ( <> @@ -43,6 +50,8 @@ export const QuickPromptSettings: React.FC = React.memo( quickPromptSettings={quickPromptSettings} selectedQuickPrompt={selectedQuickPrompt} setUpdatedQuickPromptSettings={setUpdatedQuickPromptSettings} + promptsBulkActions={promptsBulkActions} + setPromptsBulkActions={setPromptsBulkActions} /> ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.test.tsx index ec3a0256716a..509db5991455 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.test.tsx @@ -7,18 +7,24 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useQuickPromptEditor, DEFAULT_COLOR } from './use_quick_prompt_editor'; -import { QuickPrompt } from '../types'; import { mockAlertPromptContext } from '../../../mock/prompt_context'; import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; +import { useAssistantContext } from '../../../assistant_context'; +jest.mock('../../../assistant_context'); // Mock functions for the tests const mockOnSelectedQuickPromptChange = jest.fn(); const mockSetUpdatedQuickPromptSettings = jest.fn(); const mockPreviousQuickPrompts = [...MOCK_QUICK_PROMPTS]; +const setPromptsBulkActions = jest.fn(); describe('useQuickPromptEditor', () => { beforeEach(() => { jest.clearAllMocks(); + (useAssistantContext as jest.Mock).mockReturnValue({ + currentAppId: 'securitySolutionUI', + }); }); test('should delete a quick prompt by title', () => { @@ -26,6 +32,8 @@ describe('useQuickPromptEditor', () => { useQuickPromptEditor({ onSelectedQuickPromptChange: mockOnSelectedQuickPromptChange, setUpdatedQuickPromptSettings: mockSetUpdatedQuickPromptSettings, + setPromptsBulkActions, + promptsBulkActions: {}, }) ); @@ -34,7 +42,7 @@ describe('useQuickPromptEditor', () => { }); expect(mockSetUpdatedQuickPromptSettings.mock.calls[0][0]?.(mockPreviousQuickPrompts)).toEqual( - MOCK_QUICK_PROMPTS.filter((qp) => qp.title !== 'ALERT_SUMMARIZATION_TITLE') + MOCK_QUICK_PROMPTS.filter((qp) => qp.name !== 'ALERT_SUMMARIZATION_TITLE') ); }); @@ -44,6 +52,8 @@ describe('useQuickPromptEditor', () => { useQuickPromptEditor({ onSelectedQuickPromptChange: mockOnSelectedQuickPromptChange, setUpdatedQuickPromptSettings: mockSetUpdatedQuickPromptSettings, + setPromptsBulkActions, + promptsBulkActions: {}, }) ); @@ -51,11 +61,14 @@ describe('useQuickPromptEditor', () => { result.current.onQuickPromptSelectionChange(newPromptTitle); }); - const newPrompt: QuickPrompt = { - title: newPromptTitle, - prompt: '', + const newPrompt: PromptResponse = { + name: newPromptTitle, + content: '', color: DEFAULT_COLOR, categories: [], + id: newPromptTitle, + promptType: 'quick', + consumer: 'securitySolutionUI', }; expect(mockOnSelectedQuickPromptChange).toHaveBeenCalledWith(newPrompt); @@ -70,17 +83,21 @@ describe('useQuickPromptEditor', () => { useQuickPromptEditor({ onSelectedQuickPromptChange: mockOnSelectedQuickPromptChange, setUpdatedQuickPromptSettings: mockSetUpdatedQuickPromptSettings, + setPromptsBulkActions, + promptsBulkActions: {}, }) ); const alertData = await mockAlertPromptContext.getPromptContext(); - const expectedPrompt: QuickPrompt = { - title: mockAlertPromptContext.description, - prompt: alertData, + const expectedPrompt: PromptResponse = { + name: mockAlertPromptContext.description, + content: JSON.stringify(alertData ?? {}), color: DEFAULT_COLOR, categories: [mockAlertPromptContext.category], - } as QuickPrompt; + id: '', + promptType: 'quick', + }; act(() => { result.current.onQuickPromptSelectionChange(expectedPrompt); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.tsx index 716298afb21d..d96c4fca716d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings/use_quick_prompt_editor.tsx @@ -5,41 +5,60 @@ * 2.0. */ +import { + PromptResponse, + PromptTypeEnum, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { useCallback } from 'react'; -import { QuickPrompt } from '../types'; +import { useAssistantContext } from '../../../..'; export const DEFAULT_COLOR = '#D36086'; export const useQuickPromptEditor = ({ onSelectedQuickPromptChange, setUpdatedQuickPromptSettings, + promptsBulkActions, + setPromptsBulkActions, }: { - onSelectedQuickPromptChange: (quickPrompt?: QuickPrompt) => void; - setUpdatedQuickPromptSettings: React.Dispatch>; + onSelectedQuickPromptChange: (quickPrompt?: PromptResponse) => void; + setUpdatedQuickPromptSettings: React.Dispatch>; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; }) => { + const { currentAppId } = useAssistantContext(); const onQuickPromptDeleted = useCallback( - (title: string) => { - setUpdatedQuickPromptSettings((prev) => prev.filter((qp) => qp.title !== title)); + (id: string) => { + setUpdatedQuickPromptSettings((prev) => prev.filter((qp) => qp.id !== id)); + setPromptsBulkActions({ + ...promptsBulkActions, + delete: { + ids: [...(promptsBulkActions.delete?.ids ?? []), id], + }, + }); }, - [setUpdatedQuickPromptSettings] + [promptsBulkActions, setPromptsBulkActions, setUpdatedQuickPromptSettings] ); // When top level quick prompt selection changes const onQuickPromptSelectionChange = useCallback( - (quickPrompt?: QuickPrompt | string) => { + (quickPrompt?: PromptResponse | string) => { const isNew = typeof quickPrompt === 'string'; - const newSelectedQuickPrompt: QuickPrompt | undefined = isNew + const newSelectedQuickPrompt: PromptResponse | undefined = isNew ? { - title: quickPrompt ?? '', - prompt: '', + name: quickPrompt, + id: quickPrompt, + content: '', color: DEFAULT_COLOR, categories: [], + promptType: PromptTypeEnum.quick, + consumer: currentAppId, } : quickPrompt; if (newSelectedQuickPrompt != null) { setUpdatedQuickPromptSettings((prev) => { - const alreadyExists = prev.some((qp) => qp.title === newSelectedQuickPrompt.title); + const alreadyExists = prev.some((qp) => qp.name === newSelectedQuickPrompt.name); if (!alreadyExists) { return [...prev, newSelectedQuickPrompt]; @@ -47,11 +66,29 @@ export const useQuickPromptEditor = ({ return prev; }); + + if (isNew) { + setPromptsBulkActions({ + ...promptsBulkActions, + create: [ + ...(promptsBulkActions.create ?? []), + { + ...newSelectedQuickPrompt, + }, + ], + }); + } } onSelectedQuickPromptChange(newSelectedQuickPrompt); }, - [onSelectedQuickPromptChange, setUpdatedQuickPromptSettings] + [ + currentAppId, + onSelectedQuickPromptChange, + promptsBulkActions, + setPromptsBulkActions, + setUpdatedQuickPromptSettings, + ] ); return { onQuickPromptDeleted, onQuickPromptSelectionChange }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx index e8362db44171..ac93161d35c1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/index.tsx @@ -14,7 +14,10 @@ import { EuiPanel, EuiSpacer, } from '@elastic/eui'; -import { QuickPrompt } from '../types'; +import { + PromptResponse, + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { QuickPromptSettingsEditor } from '../quick_prompt_settings/quick_prompt_editor'; import * as i18n from './translations'; import { useFlyoutModalVisibility } from '../../common/components/assistant_settings_management/flyout/use_flyout_modal_visibility'; @@ -32,11 +35,13 @@ import { useAssistantContext } from '../../../assistant_context'; interface Props { handleSave: (shouldRefetchConversation?: boolean) => void; onCancelClick: () => void; - onSelectedQuickPromptChange: (quickPrompt?: QuickPrompt) => void; - quickPromptSettings: QuickPrompt[]; + onSelectedQuickPromptChange: (quickPrompt?: PromptResponse) => void; + quickPromptSettings: PromptResponse[]; resetSettings?: () => void; - selectedQuickPrompt: QuickPrompt | undefined; - setUpdatedQuickPromptSettings: React.Dispatch>; + selectedQuickPrompt: PromptResponse | undefined; + setUpdatedQuickPromptSettings: React.Dispatch>; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; } const QuickPromptSettingsManagementComponent = ({ @@ -47,11 +52,13 @@ const QuickPromptSettingsManagementComponent = ({ resetSettings, selectedQuickPrompt, setUpdatedQuickPromptSettings, + promptsBulkActions, + setPromptsBulkActions, }: Props) => { const { nameSpace, basePromptContexts } = useAssistantContext(); const { isFlyoutOpen: editFlyoutVisible, openFlyout, closeFlyout } = useFlyoutModalVisibility(); - const [deletedQuickPrompt, setDeletedQuickPrompt] = useState(); + const [deletedQuickPrompt, setDeletedQuickPrompt] = useState(); const { isFlyoutOpen: deleteConfirmModalVisibility, openFlyout: openConfirmModal, @@ -61,10 +68,12 @@ const QuickPromptSettingsManagementComponent = ({ const { onQuickPromptDeleted, onQuickPromptSelectionChange } = useQuickPromptEditor({ onSelectedQuickPromptChange, setUpdatedQuickPromptSettings, + promptsBulkActions, + setPromptsBulkActions, }); const onEditActionClicked = useCallback( - (prompt: QuickPrompt) => { + (prompt: PromptResponse) => { onQuickPromptSelectionChange(prompt); openFlyout(); }, @@ -72,9 +81,9 @@ const QuickPromptSettingsManagementComponent = ({ ); const onDeleteActionClicked = useCallback( - (prompt: QuickPrompt) => { + (prompt: PromptResponse) => { setDeletedQuickPrompt(prompt); - onQuickPromptDeleted(prompt.title); + onQuickPromptDeleted(prompt.id); openConfirmModal(); }, [onQuickPromptDeleted, openConfirmModal] @@ -123,10 +132,10 @@ const QuickPromptSettingsManagementComponent = ({ const confirmationTitle = useMemo( () => - deletedQuickPrompt?.title - ? i18n.DELETE_QUICK_PROMPT_MODAL_TITLE(deletedQuickPrompt.title) + deletedQuickPrompt?.name + ? i18n.DELETE_QUICK_PROMPT_MODAL_TITLE(deletedQuickPrompt.name) : i18n.DELETE_QUICK_PROMPT_MODAL_DEFAULT_TITLE, - [deletedQuickPrompt?.title] + [deletedQuickPrompt?.name] ); return ( @@ -161,6 +170,8 @@ const QuickPromptSettingsManagementComponent = ({ resetSettings={resetSettings} selectedQuickPrompt={selectedQuickPrompt} setUpdatedQuickPromptSettings={setUpdatedQuickPromptSettings} + promptsBulkActions={promptsBulkActions} + setPromptsBulkActions={setPromptsBulkActions} /> {deleteConfirmModalVisibility && deletedQuickPrompt && ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx index 316b43f6cfb3..ca647dc53026 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.test.tsx @@ -8,9 +8,9 @@ import { renderHook } from '@testing-library/react-hooks'; import { useQuickPromptTable } from './use_quick_prompt_table'; import { EuiTableComputedColumnType } from '@elastic/eui'; -import { QuickPrompt } from '../types'; import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt'; import { mockPromptContexts } from '../../../mock/prompt_context'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; const mockOnEditActionClicked = jest.fn(); const mockOnDeleteActionClicked = jest.fn(); @@ -43,7 +43,7 @@ describe('useQuickPromptTable', () => { }); const mockQuickPrompt = { ...MOCK_QUICK_PROMPTS[0], categories: ['alert'] }; - const mockBadgesColumn = (columns[1] as EuiTableComputedColumnType).render( + const mockBadgesColumn = (columns[1] as EuiTableComputedColumnType).render( mockQuickPrompt ); const selectedPromptContexts = mockPromptContexts @@ -51,7 +51,7 @@ describe('useQuickPromptTable', () => { .map((bpc) => bpc.description); expect(mockBadgesColumn).toHaveProperty('props', { items: selectedPromptContexts, - prefix: MOCK_QUICK_PROMPTS[0].title, + prefix: MOCK_QUICK_PROMPTS[0].name, }); }); @@ -62,7 +62,7 @@ describe('useQuickPromptTable', () => { onDeleteActionClicked: mockOnDeleteActionClicked, }); - const mockRowActions = (columns[2] as EuiTableComputedColumnType).render( + const mockRowActions = (columns[2] as EuiTableComputedColumnType).render( MOCK_QUICK_PROMPTS[0] ); @@ -83,7 +83,7 @@ describe('useQuickPromptTable', () => { const nonDefaultPrompt = MOCK_QUICK_PROMPTS.find((qp) => !qp.isDefault); if (nonDefaultPrompt) { - const mockRowActions = (columns[2] as EuiTableComputedColumnType).render( + const mockRowActions = (columns[2] as EuiTableComputedColumnType).render( nonDefaultPrompt ); expect(mockRowActions).toHaveProperty('props', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx index 9ec334f81734..1899905db0ea 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompt_settings_management/use_quick_prompt_table.tsx @@ -7,10 +7,10 @@ import { EuiBasicTableColumn, EuiLink } from '@elastic/eui'; import React, { useCallback } from 'react'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { BadgesColumn } from '../../common/components/assistant_settings_management/badges'; import { RowActions } from '../../common/components/assistant_settings_management/row_actions'; import { PromptContextTemplate } from '../../prompt_context/types'; -import { QuickPrompt } from '../types'; import * as i18n from './translations'; export const useQuickPromptTable = () => { @@ -21,29 +21,29 @@ export const useQuickPromptTable = () => { onDeleteActionClicked, }: { basePromptContexts: PromptContextTemplate[]; - onEditActionClicked: (prompt: QuickPrompt) => void; - onDeleteActionClicked: (prompt: QuickPrompt) => void; - }): Array> => [ + onEditActionClicked: (prompt: PromptResponse) => void; + onDeleteActionClicked: (prompt: PromptResponse) => void; + }): Array> => [ { align: 'left', name: i18n.QUICK_PROMPTS_TABLE_COLUMN_NAME, - render: (prompt: QuickPrompt) => - prompt?.title ? ( - onEditActionClicked(prompt)}>{prompt?.title} + render: (prompt: PromptResponse) => + prompt?.name ? ( + onEditActionClicked(prompt)}>{prompt?.name} ) : null, - sortable: ({ title }: QuickPrompt) => title, + sortable: ({ name }: PromptResponse) => name, }, { align: 'left', name: i18n.QUICK_PROMPTS_TABLE_COLUMN_CONTEXTS, - render: (prompt: QuickPrompt) => { + render: (prompt: PromptResponse) => { const selectedPromptContexts = ( basePromptContexts.filter((bpc) => prompt?.categories?.some((cat) => bpc?.category === cat) ) ?? [] ).map((bpc) => bpc?.description); return selectedPromptContexts ? ( - + ) : null; }, }, @@ -58,13 +58,13 @@ export const useQuickPromptTable = () => { align: 'center', name: i18n.QUICK_PROMPTS_TABLE_COLUMN_ACTIONS, width: '120px', - render: (prompt: QuickPrompt) => { + render: (prompt: PromptResponse) => { if (!prompt) { return null; } const isDeletable = !prompt.isDefault; return ( - + rowItem={prompt} onDelete={isDeletable ? onDeleteActionClicked : undefined} onEdit={onEditActionClicked} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx index 7fb2c9760fc7..6e5172dc0c2a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; -import { QuickPrompts } from './quick_prompts'; import { TestProviders } from '../../mock/test_providers/test_providers'; import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt'; import { QUICK_PROMPTS_TAB } from '../settings/const'; +import { QuickPrompts } from './quick_prompts'; const setInput = jest.fn(); const setIsSettingsModalVisible = jest.fn(); @@ -20,6 +20,7 @@ const testProps = { setIsSettingsModalVisible, trackPrompt, isFlyoutMode: false, + allPrompts: MOCK_QUICK_PROMPTS, }; const setSelectedSettingsTab = jest.fn(); const mockUseAssistantContext = { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx index 7d08d20f432b..c578a58be728 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx @@ -17,7 +17,10 @@ import { import { useMeasure } from 'react-use'; import { css } from '@emotion/react'; -import { QuickPrompt } from '../../..'; +import { + PromptResponse, + PromptTypeEnum, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; import { QUICK_PROMPTS_TAB } from '../settings/const'; @@ -30,6 +33,7 @@ interface QuickPromptsProps { setIsSettingsModalVisible: React.Dispatch>; trackPrompt: (prompt: string) => void; isFlyoutMode: boolean; + allPrompts: PromptResponse[]; } /** @@ -38,11 +42,10 @@ interface QuickPromptsProps { * and localstorage for storing new and edited prompts. */ export const QuickPrompts: React.FC = React.memo( - ({ setInput, setIsSettingsModalVisible, trackPrompt, isFlyoutMode }) => { + ({ setInput, setIsSettingsModalVisible, trackPrompt, isFlyoutMode, allPrompts }) => { const [quickPromptsContainerRef, { width }] = useMeasure(); - const { allQuickPrompts, knowledgeBase, promptContexts, setSelectedSettingsTab } = - useAssistantContext(); + const { knowledgeBase, promptContexts, setSelectedSettingsTab } = useAssistantContext(); const contextFilteredQuickPrompts = useMemo(() => { const registeredPromptContextTitles = Object.values(promptContexts).map((pc) => pc.category); @@ -50,17 +53,21 @@ export const QuickPrompts: React.FC = React.memo( if (knowledgeBase.isEnabledKnowledgeBase) { registeredPromptContextTitles.push(KNOWLEDGE_BASE_CATEGORY); } - return allQuickPrompts.filter((quickPrompt) => { + return allPrompts.filter((prompt) => { + // only quick prompts + if (prompt.promptType !== PromptTypeEnum.quick) { + return false; + } // Return quick prompt as match if it has no categories, otherwise ensure category exists in registered prompt contexts - if (quickPrompt.categories == null || quickPrompt.categories.length === 0) { + if (!prompt.categories || prompt.categories.length === 0) { return true; } else { - return quickPrompt.categories.some((category) => { + return prompt.categories?.some((category) => { return registeredPromptContextTitles.includes(category); }); } }); - }, [allQuickPrompts, knowledgeBase.isEnabledKnowledgeBase, promptContexts]); + }, [allPrompts, knowledgeBase.isEnabledKnowledgeBase, promptContexts]); // Overflow state const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false); @@ -71,10 +78,10 @@ export const QuickPrompts: React.FC = React.memo( const closeOverflowPopover = useCallback(() => setIsOverflowPopoverOpen(false), []); const onClickAddQuickPrompt = useCallback( - (badge: QuickPrompt) => { - setInput(badge.prompt); + (badge: PromptResponse) => { + setInput(badge.content); if (badge.isDefault) { - trackPrompt(badge.title); + trackPrompt(badge.name); } else { trackPrompt('Custom'); } @@ -83,7 +90,7 @@ export const QuickPrompts: React.FC = React.memo( ); const onClickOverflowQuickPrompt = useCallback( - (badge: QuickPrompt) => { + (badge: PromptResponse) => { onClickAddQuickPrompt(badge); closeOverflowPopover(); }, @@ -137,9 +144,9 @@ export const QuickPrompts: React.FC = React.memo( onClickAddQuickPrompt(badge)} - onClickAriaLabel={badge.title} + onClickAriaLabel={badge.name} > - {badge.title} + {badge.name} ))} @@ -172,9 +179,9 @@ export const QuickPrompts: React.FC = React.memo( onClickOverflowQuickPrompt(badge)} - onClickAriaLabel={badge.title} + onClickAriaLabel={badge.name} > - {badge.title} + {badge.name} ))} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx deleted file mode 100644 index c0688f432e7d..000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/types.tsx +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { PromptContext } from '../../..'; - -/** - * A QuickPrompt is a badge that is displayed below the Assistant's input field. They provide - * a quick way for users to insert prompts as templates into the Assistant's input field. If no - * categories are provided they will always display with the assistant, however categories can be - * supplied to only display the QuickPrompt when the Assistant is registered with corresponding - * PromptContext's containing the same category. - * - * isDefault: If true, this QuickPrompt cannot be deleted by the user - */ -export interface QuickPrompt { - title: string; - prompt: string; - color: string; - categories?: Array; - isDefault?: boolean; -} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx index 68a8049b825b..d5bbefe30420 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx @@ -23,8 +23,9 @@ import { // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; import { css } from '@emotion/react'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { AIConnector } from '../../connectorland/connector_selector'; -import { Conversation, Prompt, QuickPrompt, useLoadConnectors } from '../../..'; +import { Conversation, useLoadConnectors } from '../../..'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; import { TEST_IDS } from '../constants'; @@ -46,6 +47,7 @@ import { QUICK_PROMPTS_TAB, SYSTEM_PROMPTS_TAB, } from './const'; +import { useFetchPrompts } from '../api/prompts/use_fetch_prompts'; const StyledEuiModal = styled(EuiModal)` width: 800px; @@ -97,6 +99,7 @@ export const AssistantSettings: React.FC = React.memo( const { data: anonymizationFields, refetch: refetchAnonymizationFieldsResults } = useFetchAnonymizationFields(); + const { data: allPrompts } = useFetchPrompts(); const { data: connectors } = useLoadConnectors({ http, @@ -112,7 +115,7 @@ export const AssistantSettings: React.FC = React.memo( setUpdatedAssistantStreamingEnabled, setUpdatedKnowledgeBaseSettings, setUpdatedQuickPromptSettings, - setUpdatedSystemPromptSettings, + promptsBulkActions, saveSettings, conversationsSettingsBulkActions, updatedAnonymizationData, @@ -120,7 +123,9 @@ export const AssistantSettings: React.FC = React.memo( anonymizationFieldsBulkActions, setAnonymizationFieldsBulkActions, setUpdatedAnonymizationData, - } = useSettingsUpdater(conversations, conversationsLoaded, anonymizationFields); + setPromptsBulkActions, + setUpdatedSystemPromptSettings, + } = useSettingsUpdater(conversations, allPrompts, conversationsLoaded, anonymizationFields); // Local state for saving previously selected items so tab switching is friendlier // Conversation Selection State @@ -137,21 +142,21 @@ export const AssistantSettings: React.FC = React.memo( ); // Quick Prompt Selection State - const [selectedQuickPrompt, setSelectedQuickPrompt] = useState(); - const onHandleSelectedQuickPromptChange = useCallback((quickPrompt?: QuickPrompt) => { + const [selectedQuickPrompt, setSelectedQuickPrompt] = useState(); + const onHandleSelectedQuickPromptChange = useCallback((quickPrompt?: PromptResponse) => { setSelectedQuickPrompt(quickPrompt); }, []); useEffect(() => { if (selectedQuickPrompt != null) { setSelectedQuickPrompt( - quickPromptSettings.find((q) => q.title === selectedQuickPrompt.title) + quickPromptSettings.find((q) => q.name === selectedQuickPrompt.name) ); } }, [quickPromptSettings, selectedQuickPrompt]); // System Prompt Selection State - const [selectedSystemPrompt, setSelectedSystemPrompt] = useState(); - const onHandleSelectedSystemPromptChange = useCallback((systemPrompt?: Prompt) => { + const [selectedSystemPrompt, setSelectedSystemPrompt] = useState(); + const onHandleSelectedSystemPromptChange = useCallback((systemPrompt?: PromptResponse) => { setSelectedSystemPrompt(systemPrompt); }, []); useEffect(() => { @@ -342,6 +347,8 @@ export const AssistantSettings: React.FC = React.memo( onSelectedQuickPromptChange={onHandleSelectedQuickPromptChange} selectedQuickPrompt={selectedQuickPrompt} setUpdatedQuickPromptSettings={setUpdatedQuickPromptSettings} + setPromptsBulkActions={setPromptsBulkActions} + promptsBulkActions={promptsBulkActions} /> )} {selectedSettingsTab === SYSTEM_PROMPTS_TAB && ( @@ -356,6 +363,8 @@ export const AssistantSettings: React.FC = React.memo( setConversationsSettingsBulkActions={setConversationsSettingsBulkActions} conversationsSettingsBulkActions={conversationsSettingsBulkActions} setUpdatedSystemPromptSettings={setUpdatedSystemPromptSettings} + setPromptsBulkActions={setPromptsBulkActions} + promptsBulkActions={promptsBulkActions} /> )} {selectedSettingsTab === ANONYMIZATION_TAB && ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx index 432730194d1a..30f141f21947 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx @@ -8,6 +8,7 @@ import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; import { AIConnector } from '../../connectorland/connector_selector'; import { Conversation } from '../../..'; import { AssistantSettings } from './assistant_settings'; @@ -26,6 +27,9 @@ interface Props { conversations: Record; conversationsLoaded: boolean; refetchConversationsState: () => Promise; + refetchPrompts?: ( + options?: RefetchOptions & RefetchQueryFilters + ) => Promise>; } /** @@ -43,6 +47,7 @@ export const AssistantSettingsButton: React.FC = React.memo( conversations, conversationsLoaded, refetchConversationsState, + refetchPrompts, }) => { const { toasts, setSelectedSettingsTab } = useAssistantContext(); @@ -59,6 +64,9 @@ export const AssistantSettingsButton: React.FC = React.memo( async (success: boolean) => { cleanupAndCloseModal(); await refetchConversationsState(); + if (refetchPrompts) { + await refetchPrompts(); + } if (success) { toasts?.addSuccess({ iconType: 'check', @@ -66,7 +74,7 @@ export const AssistantSettingsButton: React.FC = React.memo( }); } }, - [cleanupAndCloseModal, refetchConversationsState, toasts] + [cleanupAndCloseModal, refetchConversationsState, refetchPrompts, toasts] ); const handleShowConversationSettings = useCallback(() => { 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 3b34b3467aa8..15fb05ca1c80 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 @@ -23,6 +23,7 @@ import { QUICK_PROMPTS_TAB, SYSTEM_PROMPTS_TAB, } from './const'; +import { mockSystemPrompts } from '../../mock/system_prompt'; const mockConversations = { [alertConvo.title]: alertConvo, @@ -33,6 +34,8 @@ const saveSettings = jest.fn(); const mockValues = { conversationSettings: mockConversations, saveSettings, + systemPromptSettings: mockSystemPrompts, + quickPromptSettings: [], }; const setSelectedSettingsTab = jest.fn(); 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 4e89bb3bba4f..3f9be4972fe7 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 @@ -19,7 +19,8 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; -import { Conversation, Prompt, QuickPrompt } from '../../..'; +import { PromptResponse, PromptTypeEnum } from '@kbn/elastic-assistant-common'; +import { Conversation } from '../../..'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; import { useSettingsUpdater } from './use_settings_updater/use_settings_updater'; @@ -42,6 +43,7 @@ import { QUICK_PROMPTS_TAB, SYSTEM_PROMPTS_TAB, } from './const'; +import { useFetchPrompts } from '../api/prompts/use_fetch_prompts'; interface Props { conversations: Record; @@ -73,6 +75,9 @@ export const AssistantSettingsManagement: React.FC = React.memo( const { data: anonymizationFields } = useFetchAnonymizationFields(); + const { data: allPrompts } = useFetchPrompts(); + + // Connector details const { data: connectors } = useLoadConnectors({ http, }); @@ -92,7 +97,7 @@ export const AssistantSettingsManagement: React.FC = React.memo( setUpdatedAssistantStreamingEnabled, setUpdatedKnowledgeBaseSettings, setUpdatedQuickPromptSettings, - setUpdatedSystemPromptSettings, + setPromptsBulkActions, saveSettings, conversationsSettingsBulkActions, updatedAnonymizationData, @@ -100,13 +105,32 @@ export const AssistantSettingsManagement: React.FC = React.memo( anonymizationFieldsBulkActions, setAnonymizationFieldsBulkActions, setUpdatedAnonymizationData, + setUpdatedSystemPromptSettings, + promptsBulkActions, resetSettings, } = useSettingsUpdater( conversations, + allPrompts, conversationsLoaded, anonymizationFields ?? { page: 0, perPage: 0, total: 0, data: [] } ); + const quickPrompts = useMemo( + () => + quickPromptSettings.length === 0 + ? allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.quick) + : quickPromptSettings, + [allPrompts.data, quickPromptSettings] + ); + + const systemPrompts = useMemo( + () => + systemPromptSettings.length === 0 + ? allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.system) + : systemPromptSettings, + [allPrompts.data, systemPromptSettings] + ); + // Local state for saving previously selected items so tab switching is friendlier // Conversation Selection State const [selectedConversation, setSelectedConversation] = useState( @@ -136,21 +160,21 @@ export const AssistantSettingsManagement: React.FC = React.memo( }, [selectedSettingsTab, setSelectedSettingsTab]); // Quick Prompt Selection State - const [selectedQuickPrompt, setSelectedQuickPrompt] = useState(); - const onHandleSelectedQuickPromptChange = useCallback((quickPrompt?: QuickPrompt) => { + const [selectedQuickPrompt, setSelectedQuickPrompt] = useState(); + const onHandleSelectedQuickPromptChange = useCallback((quickPrompt?: PromptResponse) => { setSelectedQuickPrompt(quickPrompt); }, []); useEffect(() => { if (selectedQuickPrompt != null) { setSelectedQuickPrompt( - quickPromptSettings.find((q) => q.title === selectedQuickPrompt.title) + quickPromptSettings.find((q) => q.name === selectedQuickPrompt.name) ); } }, [quickPromptSettings, selectedQuickPrompt]); // System Prompt Selection State - const [selectedSystemPrompt, setSelectedSystemPrompt] = useState(); - const onHandleSelectedSystemPromptChange = useCallback((systemPrompt?: Prompt) => { + const [selectedSystemPrompt, setSelectedSystemPrompt] = useState(); + const onHandleSelectedSystemPromptChange = useCallback((systemPrompt?: PromptResponse) => { setSelectedSystemPrompt(systemPrompt); }, []); useEffect(() => { @@ -303,7 +327,9 @@ export const AssistantSettingsManagement: React.FC = React.memo( setConversationSettings={setConversationSettings} setConversationsSettingsBulkActions={setConversationsSettingsBulkActions} setUpdatedSystemPromptSettings={setUpdatedSystemPromptSettings} - systemPromptSettings={systemPromptSettings} + systemPromptSettings={systemPrompts} + promptsBulkActions={promptsBulkActions} + setPromptsBulkActions={setPromptsBulkActions} /> )} {selectedSettingsTab === QUICK_PROMPTS_TAB && ( @@ -311,10 +337,12 @@ export const AssistantSettingsManagement: React.FC = React.memo( handleSave={handleSave} onCancelClick={onCancelClick} onSelectedQuickPromptChange={onHandleSelectedQuickPromptChange} - quickPromptSettings={quickPromptSettings} + quickPromptSettings={quickPrompts} resetSettings={resetSettings} selectedQuickPrompt={selectedQuickPrompt} setUpdatedQuickPromptSettings={setUpdatedQuickPromptSettings} + promptsBulkActions={promptsBulkActions} + setPromptsBulkActions={setPromptsBulkActions} /> )} {selectedSettingsTab === ANONYMIZATION_TAB && ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.test.tsx index 20e5c86ddd25..0a2c72ba80ac 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.test.tsx @@ -9,13 +9,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { DEFAULT_LATEST_ALERTS } from '../../../assistant_context/constants'; import { alertConvo, welcomeConvo } from '../../../mock/conversation'; import { useSettingsUpdater } from './use_settings_updater'; -import { Prompt } from '../../../..'; -import { - defaultSystemPrompt, - mockSuperheroSystemPrompt, - mockSystemPrompt, -} from '../../../mock/system_prompt'; +import { defaultQuickPrompt, mockSystemPrompt } from '../../../mock/system_prompt'; import { HttpSetup } from '@kbn/core/public'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; const mockConversations = { [alertConvo.title]: alertConvo, @@ -27,8 +23,8 @@ const mockHttp = { fetch: jest.fn(), } as unknown as HttpSetup; -const mockSystemPrompts: Prompt[] = [mockSystemPrompt]; -const mockQuickPrompts: Prompt[] = [defaultSystemPrompt]; +const mockSystemPrompts: PromptResponse[] = [mockSystemPrompt]; +const mockQuickPrompts: PromptResponse[] = [defaultQuickPrompt]; const anonymizationFields = { total: 2, @@ -40,8 +36,6 @@ const anonymizationFields = { ], }; -const setAllQuickPromptsMock = jest.fn(); -const setAllSystemPromptsMock = jest.fn(); const setAssistantStreamingEnabled = jest.fn(); const setKnowledgeBaseMock = jest.fn(); const reportAssistantSettingToggled = jest.fn(); @@ -58,8 +52,6 @@ const mockValues = { latestAlerts: DEFAULT_LATEST_ALERTS, }, baseConversations: {}, - setAllQuickPrompts: setAllQuickPromptsMock, - setAllSystemPrompts: setAllSystemPromptsMock, setKnowledgeBase: setKnowledgeBaseMock, http: mockHttp, anonymizationFieldsBulkActions: {}, @@ -67,8 +59,18 @@ const mockValues = { const updatedValues = { conversations: { ...mockConversations }, - allSystemPrompts: [mockSuperheroSystemPrompt], - allQuickPrompts: [{ title: 'Prompt 2', prompt: 'Prompt 2', color: 'red' }], + allSystemPrompts: [mockSystemPrompt], + allQuickPrompts: [ + { + consumer: 'securitySolutionUI', + content: + 'You are a helpful, expert assistant who answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.\nIf you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.\nUse the following context to answer questions:', + id: 'default-system-prompt', + name: 'Default system prompt', + promptType: 'quick', + color: 'red', + }, + ], updatedAnonymizationData: { total: 2, page: 1, @@ -101,23 +103,31 @@ describe('useSettingsUpdater', () => { it('should set all state variables to their initial values when resetSettings is called', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useSettingsUpdater(mockConversations, conversationsLoaded, anonymizationFields) + useSettingsUpdater( + mockConversations, + { + data: [...mockSystemPrompts, ...mockQuickPrompts], + page: 1, + perPage: 100, + total: 10, + }, + conversationsLoaded, + anonymizationFields + ) ); await waitForNextUpdate(); const { setConversationSettings, setConversationsSettingsBulkActions, - setUpdatedQuickPromptSettings, - setUpdatedSystemPromptSettings, setUpdatedKnowledgeBaseSettings, setUpdatedAssistantStreamingEnabled, resetSettings, + setPromptsBulkActions, } = result.current; setConversationSettings(updatedValues.conversations); setConversationsSettingsBulkActions({}); - setUpdatedQuickPromptSettings(updatedValues.allQuickPrompts); - setUpdatedSystemPromptSettings(updatedValues.allSystemPrompts); + setPromptsBulkActions({}); setUpdatedAnonymizationData(updatedValues.updatedAnonymizationData); setUpdatedKnowledgeBaseSettings(updatedValues.knowledgeBase); setUpdatedAssistantStreamingEnabled(updatedValues.assistantStreamingEnabled); @@ -149,23 +159,31 @@ describe('useSettingsUpdater', () => { it('should update all state variables to their updated values when saveSettings is called', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useSettingsUpdater(mockConversations, conversationsLoaded, anonymizationFields) + useSettingsUpdater( + mockConversations, + { + data: mockSystemPrompts, + page: 1, + perPage: 100, + total: 10, + }, + conversationsLoaded, + anonymizationFields + ) ); await waitForNextUpdate(); const { setConversationSettings, setConversationsSettingsBulkActions, - setUpdatedQuickPromptSettings, - setUpdatedSystemPromptSettings, setAnonymizationFieldsBulkActions, setUpdatedKnowledgeBaseSettings, + setPromptsBulkActions, } = result.current; setConversationSettings(updatedValues.conversations); setConversationsSettingsBulkActions({ delete: { ids: ['1'] } }); setAnonymizationFieldsBulkActions({ delete: { ids: ['1'] } }); - setUpdatedQuickPromptSettings(updatedValues.allQuickPrompts); - setUpdatedSystemPromptSettings(updatedValues.allSystemPrompts); + setPromptsBulkActions({}); setUpdatedAnonymizationData(updatedValues.updatedAnonymizationData); setUpdatedKnowledgeBaseSettings(updatedValues.knowledgeBase); @@ -179,8 +197,6 @@ describe('useSettingsUpdater', () => { body: '{"delete":{"ids":["1"]}}', } ); - expect(setAllQuickPromptsMock).toHaveBeenCalledWith(updatedValues.allQuickPrompts); - expect(setAllSystemPromptsMock).toHaveBeenCalledWith(updatedValues.allSystemPrompts); expect(setUpdatedAnonymizationData).toHaveBeenCalledWith( updatedValues.updatedAnonymizationData ); @@ -190,7 +206,17 @@ describe('useSettingsUpdater', () => { it('should track which toggles have been updated when saveSettings is called', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useSettingsUpdater(mockConversations, conversationsLoaded, anonymizationFields) + useSettingsUpdater( + mockConversations, + { + data: mockSystemPrompts, + page: 1, + perPage: 100, + total: 10, + }, + conversationsLoaded, + anonymizationFields + ) ); await waitForNextUpdate(); const { setUpdatedKnowledgeBaseSettings } = result.current; @@ -207,7 +233,17 @@ describe('useSettingsUpdater', () => { it('should track only toggles that updated', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useSettingsUpdater(mockConversations, conversationsLoaded, anonymizationFields) + useSettingsUpdater( + mockConversations, + { + data: mockSystemPrompts, + page: 1, + perPage: 100, + total: 10, + }, + conversationsLoaded, + anonymizationFields + ) ); await waitForNextUpdate(); const { setUpdatedKnowledgeBaseSettings } = result.current; @@ -225,7 +261,17 @@ describe('useSettingsUpdater', () => { it('if no toggles update, do not track anything', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useSettingsUpdater(mockConversations, conversationsLoaded, anonymizationFields) + useSettingsUpdater( + mockConversations, + { + data: mockSystemPrompts, + page: 1, + perPage: 100, + total: 10, + }, + conversationsLoaded, + anonymizationFields + ) ); await waitForNextUpdate(); const { setUpdatedKnowledgeBaseSettings } = result.current; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx index c6cf81c4bf94..1ae1c9e5b1b7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/use_settings_updater/use_settings_updater.tsx @@ -8,7 +8,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen'; import { PerformBulkActionRequestBody } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import { Conversation, Prompt, QuickPrompt } from '../../../..'; +import { + PerformBulkActionRequestBody as PromptsPerformBulkActionRequestBody, + PromptResponse, + PromptTypeEnum, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; +import { FindPromptsResponse } from '@kbn/elastic-assistant-common/impl/schemas/prompts/find_prompts_route.gen'; +import { Conversation } from '../../../..'; import { useAssistantContext } from '../../../assistant_context'; import type { KnowledgeBaseConfig } from '../../types'; import { @@ -16,6 +22,7 @@ import { bulkUpdateConversations, } from '../../api/conversations/bulk_update_actions_conversations'; import { bulkUpdateAnonymizationFields } from '../../api/anonymization_fields/bulk_update_anonymization_fields'; +import { bulkUpdatePrompts } from '../../api/prompts/bulk_update_prompts'; interface UseSettingsUpdater { assistantStreamingEnabled: boolean; @@ -23,9 +30,9 @@ interface UseSettingsUpdater { conversationsSettingsBulkActions: ConversationsBulkActions; updatedAnonymizationData: FindAnonymizationFieldsResponse; knowledgeBase: KnowledgeBaseConfig; - quickPromptSettings: QuickPrompt[]; + quickPromptSettings: PromptResponse[]; resetSettings: () => void; - systemPromptSettings: Prompt[]; + systemPromptSettings: PromptResponse[]; setUpdatedAnonymizationData: React.Dispatch< React.SetStateAction >; @@ -37,26 +44,25 @@ interface UseSettingsUpdater { setAnonymizationFieldsBulkActions: React.Dispatch< React.SetStateAction >; + promptsBulkActions: PromptsPerformBulkActionRequestBody; + setPromptsBulkActions: React.Dispatch>; setUpdatedKnowledgeBaseSettings: React.Dispatch>; - setUpdatedQuickPromptSettings: React.Dispatch>; - setUpdatedSystemPromptSettings: React.Dispatch>; + setUpdatedQuickPromptSettings: React.Dispatch>; + setUpdatedSystemPromptSettings: React.Dispatch>; setUpdatedAssistantStreamingEnabled: React.Dispatch>; saveSettings: () => Promise; } export const useSettingsUpdater = ( conversations: Record, + allPrompts: FindPromptsResponse, conversationsLoaded: boolean, anonymizationFields: FindAnonymizationFieldsResponse ): UseSettingsUpdater => { // Initial state from assistant context const { - allQuickPrompts, - allSystemPrompts, assistantTelemetry, knowledgeBase, - setAllQuickPrompts, - setAllSystemPrompts, assistantStreamingEnabled, setAssistantStreamingEnabled, setKnowledgeBase, @@ -73,14 +79,20 @@ export const useSettingsUpdater = ( const [conversationsSettingsBulkActions, setConversationsSettingsBulkActions] = useState({}); // Quick Prompts - const [updatedQuickPromptSettings, setUpdatedQuickPromptSettings] = - useState(allQuickPrompts); + const [quickPromptSettings, setUpdatedQuickPromptSettings] = useState( + allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.quick) + ); // System Prompts - const [updatedSystemPromptSettings, setUpdatedSystemPromptSettings] = - useState(allSystemPrompts); + const [systemPromptSettings, setUpdatedSystemPromptSettings] = useState( + allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.system) + ); // Anonymization const [anonymizationFieldsBulkActions, setAnonymizationFieldsBulkActions] = useState({}); + // Prompts + const [promptsBulkActions, setPromptsBulkActions] = useState( + {} + ); const [updatedAnonymizationData, setUpdatedAnonymizationData] = useState(anonymizationFields); const [updatedAssistantStreamingEnabled, setUpdatedAssistantStreamingEnabled] = @@ -95,31 +107,57 @@ export const useSettingsUpdater = ( const resetSettings = useCallback((): void => { setConversationSettings(conversations); setConversationsSettingsBulkActions({}); - setUpdatedQuickPromptSettings(allQuickPrompts); + setUpdatedQuickPromptSettings( + allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.quick) + ); setUpdatedKnowledgeBaseSettings(knowledgeBase); setUpdatedAssistantStreamingEnabled(assistantStreamingEnabled); - setUpdatedSystemPromptSettings(allSystemPrompts); + setUpdatedSystemPromptSettings( + allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.system) + ); setUpdatedAnonymizationData(anonymizationFields); - }, [ - allQuickPrompts, - allSystemPrompts, - anonymizationFields, - assistantStreamingEnabled, - conversations, - knowledgeBase, - ]); + }, [allPrompts, anonymizationFields, assistantStreamingEnabled, conversations, knowledgeBase]); + + const hasBulkConversations = + conversationsSettingsBulkActions.create || + conversationsSettingsBulkActions.update || + conversationsSettingsBulkActions.delete; + + const hasBulkAnonymizationFields = + anonymizationFieldsBulkActions.create || + anonymizationFieldsBulkActions.update || + anonymizationFieldsBulkActions.delete; + const hasBulkPrompts = + promptsBulkActions.create || promptsBulkActions.update || promptsBulkActions.delete; /** * Save all pending settings */ const saveSettings = useCallback(async (): Promise => { - setAllQuickPrompts(updatedQuickPromptSettings); - setAllSystemPrompts(updatedSystemPromptSettings); + const bulkPromptsResult = hasBulkPrompts + ? await bulkUpdatePrompts(http, promptsBulkActions, toasts) + : undefined; + + // replace conversation references for created + if (bulkPromptsResult) { + bulkPromptsResult.attributes.results.created.forEach((p) => { + if (conversationsSettingsBulkActions.create) { + Object.values(conversationsSettingsBulkActions.create).forEach((c) => { + if (c.apiConfig?.defaultSystemPromptId === p.name) { + c.apiConfig.defaultSystemPromptId = p.id; + } + }); + } + if (conversationsSettingsBulkActions.update) { + Object.values(conversationsSettingsBulkActions.update).forEach((c) => { + if (c.apiConfig?.defaultSystemPromptId === p.name) { + c.apiConfig.defaultSystemPromptId = p.id; + } + }); + } + }); + } - const hasBulkConversations = - conversationsSettingsBulkActions.create || - conversationsSettingsBulkActions.update || - conversationsSettingsBulkActions.delete; const bulkResult = hasBulkConversations ? await bulkUpdateConversations(http, conversationsSettingsBulkActions, toasts) : undefined; @@ -145,21 +183,20 @@ export const useSettingsUpdater = ( } setAssistantStreamingEnabled(updatedAssistantStreamingEnabled); setKnowledgeBase(updatedKnowledgeBaseSettings); - const hasBulkAnonymizationFields = - anonymizationFieldsBulkActions.create || - anonymizationFieldsBulkActions.update || - anonymizationFieldsBulkActions.delete; + const bulkAnonymizationFieldsResult = hasBulkAnonymizationFields ? await bulkUpdateAnonymizationFields(http, anonymizationFieldsBulkActions, toasts) : undefined; - return (bulkResult?.success ?? true) && (bulkAnonymizationFieldsResult?.success ?? true); + + return ( + (bulkResult?.success ?? true) && + (bulkAnonymizationFieldsResult?.success ?? true) && + (bulkPromptsResult?.success ?? true) + ); }, [ - setAllQuickPrompts, - updatedQuickPromptSettings, - setAllSystemPrompts, - updatedSystemPromptSettings, - conversationsSettingsBulkActions, + hasBulkConversations, http, + conversationsSettingsBulkActions, toasts, knowledgeBase.isEnabledKnowledgeBase, knowledgeBase.isEnabledRAGAlerts, @@ -168,7 +205,10 @@ export const useSettingsUpdater = ( updatedAssistantStreamingEnabled, setAssistantStreamingEnabled, setKnowledgeBase, + hasBulkAnonymizationFields, anonymizationFieldsBulkActions, + hasBulkPrompts, + promptsBulkActions, assistantTelemetry, ]); @@ -200,9 +240,9 @@ export const useSettingsUpdater = ( conversationsSettingsBulkActions, knowledgeBase: updatedKnowledgeBaseSettings, assistantStreamingEnabled: updatedAssistantStreamingEnabled, - quickPromptSettings: updatedQuickPromptSettings, + quickPromptSettings, resetSettings, - systemPromptSettings: updatedSystemPromptSettings, + systemPromptSettings, saveSettings, updatedAnonymizationData, setUpdatedAnonymizationData, @@ -214,5 +254,7 @@ export const useSettingsUpdater = ( setUpdatedSystemPromptSettings, setConversationSettings, setConversationsSettingsBulkActions, + promptsBulkActions, + setPromptsBulkActions, }; }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts index 91ee3468a12d..587be76910c3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/types.ts @@ -5,19 +5,6 @@ * 2.0. */ -export type PromptType = 'system' | 'user'; - -export interface Prompt { - id: string; - content: string; - name: string; - promptType: PromptType; - isDefault?: boolean; // TODO: Should be renamed to isImmutable as this flag is used to prevent users from deleting prompts - isNewConversationDefault?: boolean; - isFlyoutMode?: boolean; - label?: string; -} - export interface KnowledgeBaseConfig { isEnabledRAGAlerts: boolean; isEnabledKnowledgeBase: boolean; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.test.ts index c8c8ab5ff772..3e997fef5d57 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.test.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.test.ts @@ -13,7 +13,8 @@ import { getDefaultSystemPrompt, } from './helpers'; import { AIConnector } from '../../connectorland/connector_selector'; -import { Conversation, Prompt } from '../../..'; +import { Conversation } from '../../..'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; const tilde = '`'; const codeDelimiter = '```'; @@ -61,28 +62,28 @@ ${codeDelimiter} This query will filter the events based on the condition that the ${tilde}user.name${tilde} field should exactly match the value \"9dcc9960-78cf-4ef6-9a2e-dbd5816daa60\".`; describe('useConversation helpers', () => { - const allSystemPrompts: Prompt[] = [ + const allSystemPrompts: PromptResponse[] = [ { id: '1', content: 'Prompt 1', name: 'Prompt 1', - promptType: 'user', + promptType: 'quick', }, { id: '2', content: 'Prompt 2', name: 'Prompt 2', - promptType: 'user', + promptType: 'quick', isNewConversationDefault: true, }, { id: '3', content: 'Prompt 3', name: 'Prompt 3', - promptType: 'user', + promptType: 'quick', }, ]; - const allSystemPromptsNoDefault: Prompt[] = allSystemPrompts.filter( + const allSystemPromptsNoDefault: PromptResponse[] = allSystemPrompts.filter( ({ isNewConversationDefault }) => isNewConversationDefault !== true ); @@ -237,25 +238,25 @@ describe('useConversation helpers', () => { }); describe('getConversationApiConfig', () => { - const allSystemPrompts: Prompt[] = [ + const allSystemPrompts: PromptResponse[] = [ { id: '1', content: 'Prompt 1', name: 'Prompt 1', - promptType: 'user', + promptType: 'quick', }, { id: '2', content: 'Prompt 2', name: 'Prompt 2', - promptType: 'user', + promptType: 'quick', isNewConversationDefault: true, }, { id: '3', content: 'Prompt 3', name: 'Prompt 3', - promptType: 'user', + promptType: 'quick', }, ]; @@ -390,7 +391,7 @@ describe('getConversationApiConfig', () => { }); test('should return the first system prompt if both conversation system prompt and default new system prompt do not exist', () => { - const allSystemPromptsNoDefault: Prompt[] = allSystemPrompts.filter( + const allSystemPromptsNoDefault: PromptResponse[] = allSystemPrompts.filter( ({ isNewConversationDefault }) => isNewConversationDefault !== true ); @@ -418,7 +419,7 @@ describe('getConversationApiConfig', () => { }); test('should return the first system prompt if conversation system prompt does not exist within all system prompts', () => { - const allSystemPromptsNoDefault: Prompt[] = allSystemPrompts.filter( + const allSystemPromptsNoDefault: PromptResponse[] = allSystemPrompts.filter( ({ isNewConversationDefault }) => isNewConversationDefault !== true ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts index 2d6c4075fba0..fde1c1d3d943 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/helpers.ts @@ -6,7 +6,7 @@ */ import React from 'react'; -import { Prompt } from '../types'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; import { Conversation } from '../../assistant_context/types'; import { AIConnector } from '../../connectorland/connector_selector'; import { getGenAiConfig } from '../../connectorland/helpers'; @@ -75,7 +75,7 @@ export const analyzeMarkdown = (markdown: string): CodeBlockDetails[] => { * * @param allSystemPrompts All available System Prompts */ -export const getDefaultNewSystemPrompt = (allSystemPrompts: Prompt[]) => +export const getDefaultNewSystemPrompt = (allSystemPrompts: PromptResponse[]) => allSystemPrompts.find((prompt) => prompt.isNewConversationDefault) ?? allSystemPrompts?.[0]; /** @@ -88,15 +88,15 @@ export const getDefaultSystemPrompt = ({ allSystemPrompts, conversation, }: { - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; conversation: Conversation | undefined; -}): Prompt | undefined => { +}): PromptResponse | undefined => { const conversationSystemPrompt = allSystemPrompts.find( (prompt) => prompt.id === conversation?.apiConfig?.defaultSystemPromptId ); const defaultNewSystemPrompt = getDefaultNewSystemPrompt(allSystemPrompts); - return conversationSystemPrompt ?? defaultNewSystemPrompt; + return conversationSystemPrompt?.id ? conversationSystemPrompt : defaultNewSystemPrompt; }; /** @@ -109,9 +109,9 @@ export const getInitialDefaultSystemPrompt = ({ allSystemPrompts, conversation, }: { - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; conversation: Conversation | undefined; -}): Prompt | undefined => { +}): PromptResponse | undefined => { const conversationSystemPrompt = allSystemPrompts.find( (prompt) => prompt.id === conversation?.apiConfig?.defaultSystemPromptId ); @@ -133,7 +133,7 @@ export const getConversationApiConfig = ({ connectors, defaultConnector, }: { - allSystemPrompts: Prompt[]; + allSystemPrompts: PromptResponse[]; conversation: Conversation; connectors?: AIConnector[]; defaultConnector?: AIConnector; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx index 84fa21417ae7..a276aea3ff4a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx @@ -18,6 +18,7 @@ import { updateConversation, } from '../api/conversations'; import { WELCOME_CONVERSATION } from './sample_conversations'; +import { useFetchPrompts } from '../api/prompts/use_fetch_prompts'; export const DEFAULT_CONVERSATION_STATE: Conversation = { id: '', @@ -63,7 +64,10 @@ interface UseConversation { } export const useConversation = (): UseConversation => { - const { allSystemPrompts, http, toasts } = useAssistantContext(); + const { http, toasts } = useAssistantContext(); + const { + data: { data: allPrompts }, + } = useFetchPrompts(); const getConversation = useCallback( async (conversationId: string, silent?: boolean) => { @@ -101,7 +105,7 @@ export const useConversation = (): UseConversation => { async (conversation: Conversation) => { if (conversation.apiConfig) { const defaultSystemPromptId = getDefaultSystemPrompt({ - allSystemPrompts, + allSystemPrompts: allPrompts, conversation, })?.id; @@ -115,7 +119,7 @@ export const useConversation = (): UseConversation => { }); } }, - [allSystemPrompts, http, toasts] + [allPrompts, http, toasts] ); /** diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index ffafb4f704a1..78336f8a8b03 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -25,17 +25,13 @@ import type { Conversation } from './types'; import { DEFAULT_ASSISTANT_TITLE } from '../assistant/translations'; import { CodeBlockDetails } from '../assistant/use_conversation/helpers'; import { PromptContextTemplate } from '../assistant/prompt_context/types'; -import { QuickPrompt } from '../assistant/quick_prompts/types'; -import { KnowledgeBaseConfig, Prompt, TraceOptions } from '../assistant/types'; -import { BASE_SYSTEM_PROMPTS } from '../content/prompts/system'; +import { KnowledgeBaseConfig, TraceOptions } from '../assistant/types'; import { DEFAULT_ASSISTANT_NAMESPACE, DEFAULT_KNOWLEDGE_BASE_SETTINGS, KNOWLEDGE_BASE_LOCAL_STORAGE_KEY, LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY, - QUICK_PROMPT_LOCAL_STORAGE_KEY, STREAMING_LOCAL_STORAGE_KEY, - SYSTEM_PROMPT_LOCAL_STORAGE_KEY, TRACE_OPTIONS_SESSION_STORAGE_KEY, } from './constants'; import { AssistantAvailability, AssistantTelemetry } from './types'; @@ -65,8 +61,6 @@ export interface AssistantProviderProps { ) => CodeBlockDetails[][]; basePath: string; basePromptContexts?: PromptContextTemplate[]; - baseQuickPrompts?: QuickPrompt[]; - baseSystemPrompts?: Prompt[]; docLinks: Omit; children: React.ReactNode; getComments: (commentArgs: { @@ -87,6 +81,7 @@ export interface AssistantProviderProps { navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; title?: string; toasts?: IToasts; + currentAppId: string; } export interface UserAvatar { @@ -106,13 +101,8 @@ export interface UseAssistantContext { currentConversation: Conversation, showAnonymizedValues: boolean ) => CodeBlockDetails[][]; - allQuickPrompts: QuickPrompt[]; - allSystemPrompts: Prompt[]; docLinks: Omit; basePath: string; - basePromptContexts: PromptContextTemplate[]; - baseQuickPrompts: QuickPrompt[]; - baseSystemPrompts: Prompt[]; baseConversations: Record; getComments: (commentArgs: { abortStream: () => void; @@ -134,8 +124,6 @@ export interface UseAssistantContext { nameSpace: string; registerPromptContext: RegisterPromptContext; selectedSettingsTab: SettingsTabs | null; - setAllQuickPrompts: React.Dispatch>; - setAllSystemPrompts: React.Dispatch>; setAssistantStreamingEnabled: React.Dispatch>; setKnowledgeBase: React.Dispatch>; setLastConversationId: React.Dispatch>; @@ -150,7 +138,9 @@ export interface UseAssistantContext { title: string; toasts: IToasts | undefined; traceOptions: TraceOptions; + basePromptContexts: PromptContextTemplate[]; unRegisterPromptContext: UnRegisterPromptContext; + currentAppId: string; } const AssistantContext = React.createContext(undefined); @@ -164,8 +154,6 @@ export const AssistantProvider: React.FC = ({ docLinks, basePath, basePromptContexts = [], - baseQuickPrompts = [], - baseSystemPrompts = BASE_SYSTEM_PROMPTS, children, getComments, http, @@ -174,6 +162,7 @@ export const AssistantProvider: React.FC = ({ nameSpace = DEFAULT_ASSISTANT_NAMESPACE, title = DEFAULT_ASSISTANT_TITLE, toasts, + currentAppId, }) => { /** * Session storage for traceOptions, including APM URL and LangSmith Project/API Key @@ -189,22 +178,6 @@ export const AssistantProvider: React.FC = ({ defaultTraceOptions ); - /** - * Local storage for all quick prompts, prefixed by assistant nameSpace - */ - const [localStorageQuickPrompts, setLocalStorageQuickPrompts] = useLocalStorage( - `${nameSpace}.${QUICK_PROMPT_LOCAL_STORAGE_KEY}`, - baseQuickPrompts - ); - - /** - * Local storage for all system prompts, prefixed by assistant nameSpace - */ - const [localStorageSystemPrompts, setLocalStorageSystemPrompts] = useLocalStorage( - `${nameSpace}.${SYSTEM_PROMPT_LOCAL_STORAGE_KEY}`, - baseSystemPrompts - ); - const [localStorageLastConversationId, setLocalStorageLastConversationId] = useLocalStorage(`${nameSpace}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}`); @@ -290,12 +263,8 @@ export const AssistantProvider: React.FC = ({ assistantFeatures: assistantFeatures ?? defaultAssistantFeatures, assistantTelemetry, augmentMessageCodeBlocks, - allQuickPrompts: localStorageQuickPrompts ?? [], - allSystemPrompts: localStorageSystemPrompts ?? [], basePath, basePromptContexts, - baseQuickPrompts, - baseSystemPrompts, docLinks, getComments, http, @@ -308,8 +277,6 @@ export const AssistantProvider: React.FC = ({ // can be undefined from localStorage, if not defined, default to true assistantStreamingEnabled: localStorageStreaming ?? true, setAssistantStreamingEnabled: setLocalStorageStreaming, - setAllQuickPrompts: setLocalStorageQuickPrompts, - setAllSystemPrompts: setLocalStorageSystemPrompts, setKnowledgeBase: setLocalStorageKnowledgeBase, setSelectedSettingsTab, setShowAssistantOverlay, @@ -322,6 +289,7 @@ export const AssistantProvider: React.FC = ({ getLastConversationId, setLastConversationId: setLocalStorageLastConversationId, baseConversations, + currentAppId, }), [ actionTypeRegistry, @@ -330,12 +298,8 @@ export const AssistantProvider: React.FC = ({ assistantFeatures, assistantTelemetry, augmentMessageCodeBlocks, - localStorageQuickPrompts, - localStorageSystemPrompts, basePath, basePromptContexts, - baseQuickPrompts, - baseSystemPrompts, docLinks, getComments, http, @@ -347,8 +311,6 @@ export const AssistantProvider: React.FC = ({ selectedSettingsTab, localStorageStreaming, setLocalStorageStreaming, - setLocalStorageQuickPrompts, - setLocalStorageSystemPrompts, setLocalStorageKnowledgeBase, setSessionStorageTraceOptions, showAssistantOverlay, @@ -359,6 +321,7 @@ export const AssistantProvider: React.FC = ({ getLastConversationId, setLocalStorageLastConversationId, baseConversations, + currentAppId, ] ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx deleted file mode 100644 index a73fbf4854ef..000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/system/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Prompt } from '../../../..'; -import { - DEFAULT_SYSTEM_PROMPT_LABEL, - DEFAULT_SYSTEM_PROMPT_NAME, - DEFAULT_SYSTEM_PROMPT_NON_I18N, - SUPERHERO_SYSTEM_PROMPT_LABEL, - SUPERHERO_SYSTEM_PROMPT_NAME, - SUPERHERO_SYSTEM_PROMPT_NON_I18N, -} from './translations'; - -/** - * Base System Prompts for Elastic AI Assistant (if not overridden on initialization). - */ -export const BASE_SYSTEM_PROMPTS: Prompt[] = [ - { - id: 'default-system-prompt', - content: DEFAULT_SYSTEM_PROMPT_NON_I18N, - name: DEFAULT_SYSTEM_PROMPT_NAME, - promptType: 'system', - label: DEFAULT_SYSTEM_PROMPT_LABEL, - }, - { - id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28', - content: SUPERHERO_SYSTEM_PROMPT_NON_I18N, - name: SUPERHERO_SYSTEM_PROMPT_NAME, - promptType: 'system', - label: SUPERHERO_SYSTEM_PROMPT_LABEL, - }, -]; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/user/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/user/translations.ts deleted file mode 100644 index 28cda1f9414a..000000000000 --- a/x-pack/packages/kbn-elastic-assistant/impl/content/prompts/user/translations.ts +++ /dev/null @@ -1,26 +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 THEN_SUMMARIZE_SUGGESTED_KQL_AND_EQL_QUERIES = i18n.translate( - 'xpack.elasticAssistant.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries', - { - defaultMessage: - 'Evaluate the event from the context above and format your output neatly in markdown syntax for my Elastic Security case.', - } -); - -export const FINALLY_SUGGEST_INVESTIGATION_GUIDE_AND_FORMAT_AS_MARKDOWN = i18n.translate( - 'xpack.elasticAssistant.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown', - { - defaultMessage: `Add your description, recommended actions and bulleted triage steps. Use the MITRE ATT&CK data provided to add more context and recommendations from MITRE, and hyperlink to the relevant pages on MITRE\'s website. Be sure to include the user and host risk score data from the context. Your response should include steps that point to Elastic Security specific features, including endpoint response actions, the Elastic Agent OSQuery manager integration (with example osquery queries), timelines and entity analytics and link to all the relevant Elastic Security documentation.`, - } -); - -export const EXPLAIN_THEN_SUMMARIZE_SUGGEST_INVESTIGATION_GUIDE_NON_I18N = `${THEN_SUMMARIZE_SUGGESTED_KQL_AND_EQL_QUERIES} -${FINALLY_SUGGEST_INVESTIGATION_GUIDE_AND_FORMAT_AS_MARKDOWN}`; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/quick_prompt.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/quick_prompt.ts index 14318f4b1b53..31fa9bb6508b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/quick_prompt.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/quick_prompt.ts @@ -5,52 +5,69 @@ * 2.0. */ -import { QuickPrompt } from '../..'; +import { + PromptResponse, + PromptTypeEnum, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; -export const MOCK_QUICK_PROMPTS: QuickPrompt[] = [ +export const MOCK_QUICK_PROMPTS: PromptResponse[] = [ { - title: 'ALERT_SUMMARIZATION_TITLE', - prompt: 'ALERT_SUMMARIZATION_PROMPT', + name: 'ALERT_SUMMARIZATION_TITLE', + content: 'ALERT_SUMMARIZATION_PROMPT', color: '#F68FBE', categories: ['PROMPT_CONTEXT_ALERT_CATEGORY'], isDefault: true, + id: 'ALERT_SUMMARIZATION_TITLE', + promptType: PromptTypeEnum.quick, }, { - title: 'RULE_CREATION_TITLE', - prompt: 'RULE_CREATION_PROMPT', + name: 'RULE_CREATION_TITLE', + content: 'RULE_CREATION_PROMPT', categories: ['PROMPT_CONTEXT_DETECTION_RULES_CATEGORY'], color: '#7DDED8', isDefault: true, + id: 'RULE_CREATION_TITLE', + promptType: PromptTypeEnum.quick, }, { - title: 'WORKFLOW_ANALYSIS_TITLE', - prompt: 'WORKFLOW_ANALYSIS_PROMPT', + name: 'WORKFLOW_ANALYSIS_TITLE', + content: 'WORKFLOW_ANALYSIS_PROMPT', color: '#36A2EF', isDefault: true, + id: 'WORKFLOW_ANALYSIS_TITLE', + promptType: PromptTypeEnum.quick, }, { - title: 'THREAT_INVESTIGATION_GUIDES_TITLE', - prompt: 'THREAT_INVESTIGATION_GUIDES_PROMPT', + name: 'THREAT_INVESTIGATION_GUIDES_TITLE', + content: 'THREAT_INVESTIGATION_GUIDES_PROMPT', categories: ['PROMPT_CONTEXT_EVENT_CATEGORY'], color: '#F3D371', isDefault: true, + id: 'THREAT_INVESTIGATION_GUIDES_TITLE', + promptType: PromptTypeEnum.quick, }, { - title: 'SPL_QUERY_CONVERSION_TITLE', - prompt: 'SPL_QUERY_CONVERSION_PROMPT', + name: 'SPL_QUERY_CONVERSION_TITLE', + content: 'SPL_QUERY_CONVERSION_PROMPT', color: '#BADA55', isDefault: true, + id: 'SPL_QUERY_CONVERSION_TITLE', + promptType: PromptTypeEnum.quick, }, { - title: 'AUTOMATION_TITLE', - prompt: 'AUTOMATION_PROMPT', + name: 'AUTOMATION_TITLE', + content: 'AUTOMATION_PROMPT', color: '#FFA500', isDefault: true, + id: 'AUTOMATION_TITLE', + promptType: PromptTypeEnum.quick, }, { - title: 'A_CUSTOM_OPTION', - prompt: 'quickly prompt please', + name: 'A_CUSTOM_OPTION', + content: 'quickly prompt please', color: '#D36086', categories: [], + id: 'A_CUSTOM_OPTION', + promptType: PromptTypeEnum.quick, }, ]; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts index de23052d1556..04b027cbfe57 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/system_prompt/index.ts @@ -5,35 +5,47 @@ * 2.0. */ -import { Prompt } from '../../assistant/types'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; -export const mockSystemPrompt: Prompt = { +export const mockSystemPrompt: PromptResponse = { id: 'mock-system-prompt-1', content: 'You are a helpful, expert assistant who answers questions about Elastic Security.', name: 'Mock system prompt', + consumer: 'securitySolutionUI', promptType: 'system', - isFlyoutMode: false, }; -export const mockSuperheroSystemPrompt: Prompt = { +export const mockSuperheroSystemPrompt: PromptResponse = { id: 'mock-superhero-system-prompt-1', content: `You are a helpful, expert assistant who answers questions about Elastic Security. You have the personality of a mutant superhero who says "bub" a lot.`, name: 'Mock superhero system prompt', + consumer: 'securitySolutionUI', promptType: 'system', }; -export const defaultSystemPrompt: Prompt = { +export const defaultSystemPrompt: PromptResponse = { id: 'default-system-prompt', content: 'You are a helpful, expert assistant who answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.\nIf you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.\nUse the following context to answer questions:', name: 'Default system prompt', promptType: 'system', + consumer: 'securitySolutionUI', isDefault: true, isNewConversationDefault: true, }; -export const mockSystemPrompts: Prompt[] = [ +export const defaultQuickPrompt: PromptResponse = { + id: 'default-system-prompt', + content: + 'You are a helpful, expert assistant who answers questions about Elastic Security. Do not answer questions unrelated to Elastic Security.\nIf you answer a question related to KQL or EQL, it should be immediately usable within an Elastic Security timeline; please always format the output correctly with back ticks. Any answer provided for Query DSL should also be usable in a security timeline. This means you should only ever include the "filter" portion of the query.\nUse the following context to answer questions:', + name: 'Default system prompt', + promptType: 'quick', + consumer: 'securitySolutionUI', + color: 'red', +}; + +export const mockSystemPrompts: PromptResponse[] = [ mockSystemPrompt, mockSuperheroSystemPrompt, defaultSystemPrompt, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx index 17e977fdbf80..13e543a02b3b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx @@ -81,6 +81,7 @@ export const TestProvidersComponent: React.FC = ({ baseConversations={{}} navigateToApp={mockNavigateToApp} {...providerContext} + currentAppId={'test'} > {children} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts index 5bc23b0d680e..1f7c96126bc1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/user_prompt/index.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { Prompt } from '../../assistant/types'; +import { PromptResponse } from '@kbn/elastic-assistant-common'; -export const mockUserPrompt: Prompt = { +export const mockUserPrompt: PromptResponse = { id: 'mock-user-prompt-1', content: `Explain the meaning from the context above, then summarize a list of suggested Elasticsearch KQL and EQL queries. Finally, suggest an investigation guide, and format it as markdown.`, name: 'Mock user prompt', - promptType: 'user', + promptType: 'quick', }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx index 730c88413833..09b186903c0b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx @@ -88,4 +88,34 @@ describe('NewChat', () => { expect(mockUseAssistantOverlay.showAssistantOverlay).toHaveBeenCalledWith(true); }); + + it('renders new chat as link', () => { + render(); + + const newChatLink = screen.getByTestId('newChatLink'); + + expect(newChatLink).toBeInTheDocument(); + }); + + it('calls onShowOverlay callback on click', () => { + const onShowOverlaySpy = jest.fn(); + render(); + + const newChatButton = screen.getByTestId('newChat'); + + userEvent.click(newChatButton); + + expect(onShowOverlaySpy).toHaveBeenCalled(); + }); + + it('calls onShowOverlay callback on click for link', () => { + const onShowOverlaySpy = jest.fn(); + render(); + + const newChatLink = screen.getByTestId('newChatLink'); + + userEvent.click(newChatLink); + + expect(onShowOverlaySpy).toHaveBeenCalled(); + }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx index a4793dfd25a9..d45f94b7d0b4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiLink } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { PromptContext } from '../assistant/prompt_context/types'; @@ -17,7 +17,7 @@ export type Props = Omit & { children?: React.ReactNode; /** Optionally automatically add this context to a conversation when the assistant is shown */ conversationId?: string; - /** Defaults to `discuss`. If null, the button will not have an icon */ + /** Defaults to `discuss`. If null, the button will not have an icon. Not available for link */ iconType?: string | null; /** Optionally specify a well known ID, or default to a UUID */ promptContextId?: string; @@ -25,6 +25,10 @@ export type Props = Omit & { color?: 'text' | 'accent' | 'primary' | 'success' | 'warning' | 'danger'; /** Required to identify the availability of the Assistant for the current license level */ isAssistantEnabled: boolean; + /** Optionally render new chat as a link */ + asLink?: boolean; + /** Optional callback when overlay shows */ + onShowOverlay?: () => void; }; const NewChatComponent: React.FC = ({ @@ -39,6 +43,8 @@ const NewChatComponent: React.FC = ({ suggestedUserPrompt, tooltip, isAssistantEnabled, + asLink = false, + onShowOverlay, }) => { const { showAssistantOverlay } = useAssistantOverlay( category, @@ -53,7 +59,8 @@ const NewChatComponent: React.FC = ({ const showOverlay = useCallback(() => { showAssistantOverlay(true); - }, [showAssistantOverlay]); + onShowOverlay?.(); + }, [showAssistantOverlay, onShowOverlay]); const icon = useMemo(() => { if (iconType === null) { @@ -64,12 +71,22 @@ const NewChatComponent: React.FC = ({ }, [iconType]); return useMemo( - () => ( - - {children} - - ), - [children, icon, showOverlay, color] + () => + asLink ? ( + + {children} + + ) : ( + + {children} + + ), + [children, icon, showOverlay, color, asLink] ); }; diff --git a/x-pack/packages/kbn-elastic-assistant/index.ts b/x-pack/packages/kbn-elastic-assistant/index.ts index df0ba1e8db0f..7cd882cd633b 100644 --- a/x-pack/packages/kbn-elastic-assistant/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/index.ts @@ -90,12 +90,6 @@ export { WELCOME_CONVERSATION_TITLE, } from './impl/assistant/use_conversation/translations'; -/** i18n translations of system prompts */ -export * as SYSTEM_PROMPTS from './impl/content/prompts/system/translations'; - -/** i18n translations of user prompts */ -export * as USER_PROMPTS from './impl/content/prompts/user/translations'; - export type { /** for rendering results in a code block */ CodeBlockDetails, @@ -114,9 +108,6 @@ export type { ClientMessage, } from './impl/assistant_context/types'; -/** Interface for defining system/user prompts */ -export type { Prompt } from './impl/assistant/types'; - /** * This interface is used to pass context to the assistant, * for the purpose of building prompts. Examples of context include: @@ -139,12 +130,6 @@ export type { PromptContext } from './impl/assistant/prompt_context/types'; */ export type { PromptContextTemplate } from './impl/assistant/prompt_context/types'; -/** - * This interface is used to pass a default or base set of Quick Prompts to the Elastic Assistant that - * can be displayed when corresponding PromptContext's are registered. - */ -export type { QuickPrompt } from './impl/assistant/quick_prompts/types'; - export { useFetchCurrentUserConversations } from './impl/assistant/api/conversations/use_fetch_current_user_conversations'; export * from './impl/assistant/api/conversations/bulk_update_actions_conversations'; export { getConversationById } from './impl/assistant/api/conversations/conversations'; @@ -152,4 +137,4 @@ export { getConversationById } from './impl/assistant/api/conversations/conversa export { mergeBaseWithPersistedConversations } from './impl/assistant/helpers'; export { UpgradeButtons } from './impl/upgrade/upgrade_buttons'; -export { getUserConversations } from './impl/assistant/api'; +export { getUserConversations, getPrompts, bulkUpdatePrompts } from './impl/assistant/api'; 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 b2df63e93d71..b0d4b7247d12 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/common.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/common.ts @@ -8,17 +8,8 @@ import { z } from 'zod'; import moment from 'moment'; -export enum EntityType { - service = 'service', - host = 'host', - pod = 'pod', - node = 'node', -} - export const arrayOfStringsSchema = z.array(z.string()); -export const entityTypeSchema = z.nativeEnum(EntityType); - export enum BasicAggregations { avg = 'avg', max = 'max', 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 8e5c411c2489..58a9c011091b 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/entity.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/entity.ts @@ -13,7 +13,6 @@ const entitySchema = z.object({ id: z.string(), identityFields: arrayOfStringsSchema, displayName: z.string(), - spaceId: z.string(), metrics: z.record(z.string(), z.number()), }), }); 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 3ccc9a1ba2ee..15f3e98582c9 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 @@ -8,7 +8,6 @@ import { z } from 'zod'; import { arrayOfStringsSchema, - entityTypeSchema, keyMetricSchema, metadataSchema, filterSchema, @@ -20,7 +19,7 @@ export const entityDefinitionSchema = z.object({ id: z.string().regex(/^[\w-]+$/), name: z.string(), description: z.optional(z.string()), - type: entityTypeSchema, + type: z.string(), filter: filterSchema, indexPatterns: arrayOfStringsSchema, identityFields: z.array(identityFieldsSchema), diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_openai.test.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_openai.test.ts index 5c6d389a4ccc..7d01468b4d6f 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/chat_openai.test.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_openai.test.ts @@ -7,10 +7,10 @@ import type OpenAI from 'openai'; import { Stream } from 'openai/streaming'; -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { loggerMock } from '@kbn/logging-mocks'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; -import { ActionsClientChatOpenAI, ActionsClientChatOpenAIParams } from './chat_openai'; +import { ActionsClientChatOpenAI } from './chat_openai'; import { mockActionResponse, mockChatCompletion } from './mocks'; const connectorId = 'mock-connector-id'; @@ -19,11 +19,8 @@ const mockExecute = jest.fn(); const mockLogger = loggerMock.create(); -const mockActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: mockExecute, - })), -} as unknown as ActionsPluginStart; +const actionsClient = actionsClientMock.create(); + const chunk = { object: 'chat.completion.chunk', choices: [ @@ -40,30 +37,15 @@ export async function* asyncGenerator() { yield chunk; } const mockStreamExecute = jest.fn(); -const mockStreamActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: mockStreamExecute, - })), -} as unknown as ActionsPluginStart; const prompt = 'Do you know my name?'; const { signal } = new AbortController(); -const mockRequest = { - params: { connectorId }, - body: { - message: prompt, - subAction: 'invokeAI', - isEnabledKnowledgeBase: true, - }, -} as ActionsClientChatOpenAIParams['request']; - const defaultArgs = { - actions: mockActions, + actionsClient, connectorId, logger: mockLogger, - request: mockRequest, streaming: false, signal, timeout: 999999, @@ -77,6 +59,7 @@ describe('ActionsClientChatOpenAI', () => { data: mockChatCompletion, status: 'ok', })); + actionsClient.execute.mockImplementation(mockExecute); }); describe('_llmType', () => { @@ -116,10 +99,11 @@ describe('ActionsClientChatOpenAI', () => { functions: [jest.fn()], }; it('returns the expected data', async () => { + actionsClient.execute.mockImplementation(mockStreamExecute); const actionsClientChatOpenAI = new ActionsClientChatOpenAI({ ...defaultArgs, streaming: true, - actions: mockStreamActions, + actionsClient, }); const result: AsyncIterable = @@ -178,16 +162,11 @@ describe('ActionsClientChatOpenAI', () => { serviceMessage: 'action-result-service-message', status: 'error', // <-- error status })); - - const badActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: hasErrorStatus, - })), - } as unknown as ActionsPluginStart; + actionsClient.execute.mockRejectedValueOnce(hasErrorStatus); const actionsClientChatOpenAI = new ActionsClientChatOpenAI({ ...defaultArgs, - actions: badActions, + actionsClient, }); expect(actionsClientChatOpenAI.completionWithRetry(defaultNonStreamingArgs)) diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_openai.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_openai.ts index c2dada0dafa3..391609db2156 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/chat_openai.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_openai.ts @@ -6,24 +6,24 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { KibanaRequest, Logger } from '@kbn/core/server'; -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { Logger } from '@kbn/core/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; import { get } from 'lodash/fp'; import { ChatOpenAI } from '@langchain/openai'; import { Stream } from 'openai/streaming'; import type OpenAI from 'openai'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { DEFAULT_OPEN_AI_MODEL, DEFAULT_TIMEOUT } from './constants'; import { InvokeAIActionParamsSchema, RunActionParamsSchema } from './types'; const LLM_TYPE = 'ActionsClientChatOpenAI'; export interface ActionsClientChatOpenAIParams { - actions: ActionsPluginStart; + actionsClient: PublicMethodsOf; connectorId: string; llmType?: string; logger: Logger; - request: KibanaRequest; streaming?: boolean; traceId?: string; maxRetries?: number; @@ -54,22 +54,20 @@ export class ActionsClientChatOpenAI extends ChatOpenAI { #temperature?: number; // Kibana variables - #actions: ActionsPluginStart; + #actionsClient: PublicMethodsOf; #connectorId: string; #logger: Logger; - #request: KibanaRequest; #actionResultData: string; #traceId: string; #signal?: AbortSignal; #timeout?: number; constructor({ - actions, + actionsClient, connectorId, traceId = uuidv4(), llmType, logger, - request, maxRetries, model, signal, @@ -92,12 +90,11 @@ export class ActionsClientChatOpenAI extends ChatOpenAI { azureOpenAIApiVersion: 'nothing', openAIApiKey: '', }); - this.#actions = actions; + this.#actionsClient = actionsClient; this.#connectorId = connectorId; this.#traceId = traceId; this.llmType = llmType ?? LLM_TYPE; this.#logger = logger; - this.#request = request; this.#timeout = timeout; this.#actionResultData = ''; this.streaming = streaming; @@ -146,10 +143,7 @@ export class ActionsClientChatOpenAI extends ChatOpenAI { )} ` ); - // create an actions client from the authenticated request context: - const actionsClient = await this.#actions.getActionsClientWithRequest(this.#request); - - const actionResult = await actionsClient.execute(requestBody); + const actionResult = await this.#actionsClient.execute(requestBody); if (actionResult.status === 'error') { throw new Error(`${LLM_TYPE}: ${actionResult?.message} - ${actionResult?.serviceMessage}`); diff --git a/x-pack/packages/kbn-langchain/server/language_models/llm.test.ts b/x-pack/packages/kbn-langchain/server/language_models/llm.test.ts index e0f7b764b625..aa33bbf7a6d4 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/llm.test.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/llm.test.ts @@ -5,39 +5,27 @@ * 2.0. */ -import { KibanaRequest } from '@kbn/core/server'; -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { loggerMock } from '@kbn/logging-mocks'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; import { ActionsClientLlm } from './llm'; import { mockActionResponse } from './mocks'; const connectorId = 'mock-connector-id'; -const mockExecute = jest.fn().mockImplementation(() => ({ - data: mockActionResponse, - status: 'ok', -})); +const actionsClient = actionsClientMock.create(); -const mockLogger = loggerMock.create(); +actionsClient.execute.mockImplementation( + jest.fn().mockImplementation(() => ({ + data: mockActionResponse, + status: 'ok', + })) +); -const mockActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: mockExecute, - })), -} as unknown as ActionsPluginStart; +const mockLogger = loggerMock.create(); const prompt = 'Do you know my name?'; -const mockRequest: KibanaRequest = { - params: { connectorId }, - body: { - message: prompt, - subAction: 'invokeAI', - isEnabledKnowledgeBase: true, - }, -} as KibanaRequest; - describe('ActionsClientLlm', () => { beforeEach(() => { jest.clearAllMocks(); @@ -46,10 +34,9 @@ describe('ActionsClientLlm', () => { describe('getActionResultData', () => { it('returns the expected data', async () => { const actionsClientLlm = new ActionsClientLlm({ - actions: mockActions, + actionsClient, connectorId, logger: mockLogger, - request: mockRequest, }); const result = await actionsClientLlm._call(prompt); // ignore the result @@ -61,10 +48,9 @@ describe('ActionsClientLlm', () => { describe('_llmType', () => { it('returns the expected LLM type', () => { const actionsClientLlm = new ActionsClientLlm({ - actions: mockActions, + actionsClient, connectorId, logger: mockLogger, - request: mockRequest, }); expect(actionsClientLlm._llmType()).toEqual('ActionsClientLlm'); @@ -72,11 +58,10 @@ describe('ActionsClientLlm', () => { it('returns the expected LLM type when overridden', () => { const actionsClientLlm = new ActionsClientLlm({ - actions: mockActions, + actionsClient, connectorId, llmType: 'special-llm-type', logger: mockLogger, - request: mockRequest, }); expect(actionsClientLlm._llmType()).toEqual('special-llm-type'); @@ -86,10 +71,9 @@ describe('ActionsClientLlm', () => { describe('_call', () => { it('returns the expected content when _call is invoked', async () => { const actionsClientLlm = new ActionsClientLlm({ - actions: mockActions, + actionsClient, connectorId, logger: mockLogger, - request: mockRequest, }); const result = await actionsClientLlm._call(prompt); @@ -98,23 +82,15 @@ describe('ActionsClientLlm', () => { }); it('rejects with the expected error when the action result status is error', async () => { - const hasErrorStatus = jest.fn().mockImplementation(() => ({ - message: 'action-result-message', - serviceMessage: 'action-result-service-message', - status: 'error', // <-- error status - })); - - const badActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: hasErrorStatus, - })), - } as unknown as ActionsPluginStart; - + actionsClient.execute.mockImplementation(() => { + throw new Error( + 'ActionsClientLlm: action result status is error: action-result-message - action-result-service-message' + ); + }); const actionsClientLlm = new ActionsClientLlm({ - actions: badActions, + actionsClient, connectorId, logger: mockLogger, - request: mockRequest, }); await expect(actionsClientLlm._call(prompt)).rejects.toThrowError( @@ -125,16 +101,17 @@ describe('ActionsClientLlm', () => { it('rejects with the expected error the message has invalid content', async () => { const invalidContent = { message: 1234 }; - mockExecute.mockImplementation(() => ({ - data: invalidContent, - status: 'ok', - })); + actionsClient.execute.mockImplementation( + jest.fn().mockResolvedValue({ + data: invalidContent, + status: 'ok', + }) + ); const actionsClientLlm = new ActionsClientLlm({ - actions: mockActions, + actionsClient, connectorId, logger: mockLogger, - request: mockRequest, }); await expect(actionsClientLlm._call(prompt)).rejects.toThrowError( diff --git a/x-pack/packages/kbn-langchain/server/language_models/llm.ts b/x-pack/packages/kbn-langchain/server/language_models/llm.ts index 9708a8d3a5d7..bad538821ff1 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/llm.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/llm.ts @@ -5,11 +5,12 @@ * 2.0. */ -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; -import { KibanaRequest, Logger } from '@kbn/core/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import { Logger } from '@kbn/core/server'; import { LLM } from '@langchain/core/language_models/llms'; import { get } from 'lodash/fp'; import { v4 as uuidv4 } from 'uuid'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { DEFAULT_TIMEOUT, getDefaultArguments } from './constants'; import { getMessageContentAndRole } from './helpers'; @@ -18,11 +19,10 @@ import { TraceOptions } from './types'; const LLM_TYPE = 'ActionsClientLlm'; interface ActionsClientLlmParams { - actions: ActionsPluginStart; + actionsClient: PublicMethodsOf; connectorId: string; llmType?: string; logger: Logger; - request: KibanaRequest; model?: string; temperature?: number; timeout?: number; @@ -31,10 +31,9 @@ interface ActionsClientLlmParams { } export class ActionsClientLlm extends LLM { - #actions: ActionsPluginStart; + #actionsClient: PublicMethodsOf; #connectorId: string; #logger: Logger; - #request: KibanaRequest; #traceId: string; #timeout?: number; @@ -46,13 +45,12 @@ export class ActionsClientLlm extends LLM { temperature?: number; constructor({ - actions, + actionsClient, connectorId, traceId = uuidv4(), llmType, logger, model, - request, temperature, timeout, traceOptions, @@ -61,12 +59,11 @@ export class ActionsClientLlm extends LLM { callbacks: [...(traceOptions?.tracers ?? [])], }); - this.#actions = actions; + this.#actionsClient = actionsClient; this.#connectorId = connectorId; this.#traceId = traceId; this.llmType = llmType ?? LLM_TYPE; this.#logger = logger; - this.#request = request; this.#timeout = timeout; this.model = model; this.temperature = temperature; @@ -107,10 +104,7 @@ export class ActionsClientLlm extends LLM { }, }; - // create an actions client from the authenticated request context: - const actionsClient = await this.#actions.getActionsClientWithRequest(this.#request); - - const actionResult = await actionsClient.execute(requestBody); + const actionResult = await this.#actionsClient.execute(requestBody); if (actionResult.status === 'error') { throw new Error( diff --git a/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.test.ts b/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.test.ts index 7ec9f1e77334..98da9a4e81b5 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.test.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.test.ts @@ -6,10 +6,10 @@ */ import { PassThrough } from 'stream'; -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { loggerMock } from '@kbn/logging-mocks'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; -import { ActionsClientSimpleChatModel, CustomChatModelInput } from './simple_chat_model'; +import { ActionsClientSimpleChatModel } from './simple_chat_model'; import { mockActionResponse } from './mocks'; import { BaseMessage } from '@langchain/core/messages'; import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'; @@ -19,26 +19,15 @@ import { parseGeminiStream } from '../utils/gemini'; const connectorId = 'mock-connector-id'; const mockExecute = jest.fn(); +const actionsClient = actionsClientMock.create(); const mockLogger = loggerMock.create(); -const mockActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: mockExecute, - })), -} as unknown as ActionsPluginStart; - const mockStreamExecute = jest.fn().mockImplementation(() => ({ data: new PassThrough(), status: 'ok', })); -const mockStreamActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: mockStreamExecute, - })), -} as unknown as ActionsPluginStart; -const prompt = 'Do you know my name?'; const callMessages = [ { lc_serializable: true, @@ -78,20 +67,10 @@ const callRunManager = { handleLLMNewToken, } as unknown as CallbackManagerForLLMRun; -const mockRequest: CustomChatModelInput['request'] = { - params: { connectorId }, - body: { - message: prompt, - subAction: 'invokeAI', - isEnabledKnowledgeBase: true, - }, -} as CustomChatModelInput['request']; - const defaultArgs = { - actions: mockActions, + actionsClient, connectorId, logger: mockLogger, - request: mockRequest, streaming: false, }; jest.mock('../utils/bedrock'); @@ -100,6 +79,12 @@ jest.mock('../utils/gemini'); describe('ActionsClientSimpleChatModel', () => { beforeEach(() => { jest.clearAllMocks(); + actionsClient.execute.mockImplementation( + jest.fn().mockImplementation(() => ({ + data: mockActionResponse, + status: 'ok', + })) + ); mockExecute.mockImplementation(() => ({ data: mockActionResponse, status: 'ok', @@ -146,28 +131,24 @@ describe('ActionsClientSimpleChatModel', () => { callOptions, callRunManager ); - const subAction = mockExecute.mock.calls[0][0].params.subAction; + const subAction = actionsClient.execute.mock.calls[0][0].params.subAction; expect(subAction).toEqual('invokeAI'); expect(result).toEqual(mockActionResponse.message); }); it('rejects with the expected error when the action result status is error', async () => { - const hasErrorStatus = jest.fn().mockImplementation(() => ({ - message: 'action-result-message', - serviceMessage: 'action-result-service-message', - status: 'error', // <-- error status - })); + const hasErrorStatus = jest.fn().mockImplementation(() => { + throw Error( + 'ActionsClientSimpleChatModel: action result status is error: action-result-message - action-result-service-message' + ); + }); - const badActions = { - getActionsClientWithRequest: jest.fn().mockImplementation(() => ({ - execute: hasErrorStatus, - })), - } as unknown as ActionsPluginStart; + actionsClient.execute.mockRejectedValueOnce(hasErrorStatus); const actionsClientSimpleChatModel = new ActionsClientSimpleChatModel({ ...defaultArgs, - actions: badActions, + actionsClient, }); await expect( @@ -180,10 +161,12 @@ describe('ActionsClientSimpleChatModel', () => { it('rejects with the expected error the message has invalid content', async () => { const invalidContent = { message: 1234 }; - mockExecute.mockImplementation(() => ({ - data: invalidContent, - status: 'ok', - })); + actionsClient.execute.mockImplementation( + jest.fn().mockResolvedValue({ + data: invalidContent, + status: 'ok', + }) + ); const actionsClientSimpleChatModel = new ActionsClientSimpleChatModel(defaultArgs); @@ -221,9 +204,10 @@ describe('ActionsClientSimpleChatModel', () => { (parseGeminiStream as jest.Mock).mockResolvedValue(mockActionResponse.message); }); it('returns the expected content when _call is invoked with streaming and llmType is Bedrock', async () => { + actionsClient.execute.mockImplementationOnce(mockStreamExecute); const actionsClientSimpleChatModel = new ActionsClientSimpleChatModel({ ...defaultArgs, - actions: mockStreamActions, + actionsClient, llmType: 'bedrock', streaming: true, maxTokens: 333, @@ -248,9 +232,10 @@ describe('ActionsClientSimpleChatModel', () => { expect(result).toEqual(mockActionResponse.message); }); it('returns the expected content when _call is invoked with streaming and llmType is Gemini', async () => { + actionsClient.execute.mockImplementationOnce(mockStreamExecute); const actionsClientSimpleChatModel = new ActionsClientSimpleChatModel({ ...defaultArgs, - actions: mockStreamActions, + actionsClient, llmType: 'gemini', streaming: true, maxTokens: 333, @@ -283,9 +268,11 @@ describe('ActionsClientSimpleChatModel', () => { handleToken(`, "action_input": "`); handleToken('token6'); }); + actionsClient.execute.mockImplementationOnce(mockStreamExecute); + const actionsClientSimpleChatModel = new ActionsClientSimpleChatModel({ ...defaultArgs, - actions: mockStreamActions, + actionsClient, llmType: 'bedrock', streaming: true, }); @@ -303,9 +290,10 @@ describe('ActionsClientSimpleChatModel', () => { handleToken('"'); handleToken('token7'); }); + actionsClient.execute.mockImplementationOnce(mockStreamExecute); const actionsClientSimpleChatModel = new ActionsClientSimpleChatModel({ ...defaultArgs, - actions: mockStreamActions, + actionsClient, llmType: 'bedrock', streaming: true, }); diff --git a/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.ts b/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.ts index 97f7c20cc110..ed3872399387 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/simple_chat_model.ts @@ -11,12 +11,12 @@ import { type BaseChatModelParams, } from '@langchain/core/language_models/chat_models'; import { type BaseMessage } from '@langchain/core/messages'; -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; import { Logger } from '@kbn/logging'; -import { KibanaRequest } from '@kbn/core-http-server'; import { v4 as uuidv4 } from 'uuid'; import { get } from 'lodash/fp'; import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { parseGeminiStream } from '../utils/gemini'; import { parseBedrockStream } from '../utils/bedrock'; import { getDefaultArguments } from './constants'; @@ -27,23 +27,21 @@ export const getMessageContentAndRole = (prompt: string, role = 'user') => ({ }); export interface CustomChatModelInput extends BaseChatModelParams { - actions: ActionsPluginStart; + actionsClient: PublicMethodsOf; connectorId: string; logger: Logger; llmType?: string; signal?: AbortSignal; model?: string; temperature?: number; - request: KibanaRequest; streaming: boolean; maxTokens?: number; } export class ActionsClientSimpleChatModel extends SimpleChatModel { - #actions: ActionsPluginStart; + #actionsClient: PublicMethodsOf; #connectorId: string; #logger: Logger; - #request: KibanaRequest; #traceId: string; #signal?: AbortSignal; #maxTokens?: number; @@ -53,12 +51,11 @@ export class ActionsClientSimpleChatModel extends SimpleChatModel { temperature?: number; constructor({ - actions, + actionsClient, connectorId, llmType, logger, model, - request, temperature, signal, streaming, @@ -66,12 +63,11 @@ export class ActionsClientSimpleChatModel extends SimpleChatModel { }: CustomChatModelInput) { super({}); - this.#actions = actions; + this.#actionsClient = actionsClient; this.#connectorId = connectorId; this.#traceId = uuidv4(); this.#logger = logger; this.#signal = signal; - this.#request = request; this.#maxTokens = maxTokens; this.llmType = llmType ?? 'ActionsClientSimpleChatModel'; this.model = model; @@ -122,10 +118,8 @@ export class ActionsClientSimpleChatModel extends SimpleChatModel { }, }, }; - // create an actions client from the authenticated request context: - const actionsClient = await this.#actions.getActionsClientWithRequest(this.#request); - const actionResult = await actionsClient.execute(requestBody); + const actionResult = await this.#actionsClient.execute(requestBody); if (actionResult.status === 'error') { throw new Error( diff --git a/x-pack/packages/kbn-langchain/tsconfig.json b/x-pack/packages/kbn-langchain/tsconfig.json index 92dc5ebd3391..949aca47794e 100644 --- a/x-pack/packages/kbn-langchain/tsconfig.json +++ b/x-pack/packages/kbn-langchain/tsconfig.json @@ -18,6 +18,6 @@ "@kbn/logging", "@kbn/actions-plugin", "@kbn/logging-mocks", - "@kbn/core-http-server" + "@kbn/utility-types" ] } diff --git a/x-pack/packages/ml/query_utils/src/build_base_filter_criteria.ts b/x-pack/packages/ml/query_utils/src/build_base_filter_criteria.ts index 3dd079aab33d..9ec8ba8dccc3 100644 --- a/x-pack/packages/ml/query_utils/src/build_base_filter_criteria.ts +++ b/x-pack/packages/ml/query_utils/src/build_base_filter_criteria.ts @@ -23,9 +23,10 @@ import { isFilterBasedDefaultQuery } from './filter_based_default_query'; */ export function buildBaseFilterCriteria( timeFieldName?: string, - earliestMs?: number, - latestMs?: number, - query?: Query['query'] + earliestMs?: number | string, + latestMs?: number | string, + query?: Query['query'], + timeFormat = 'epoch_millis' ): estypes.QueryDslQueryContainer[] { const filterCriteria = []; @@ -35,7 +36,7 @@ export function buildBaseFilterCriteria( [timeFieldName]: { gte: earliestMs, lte: latestMs, - format: 'epoch_millis', + format: timeFormat, }, }, }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx index a4e6d39e720e..b4579dd4bd50 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx @@ -73,6 +73,7 @@ export const TestProvidersComponent: React.FC = ({ children, isILMAvailab http={mockHttp} baseConversations={{}} navigateToApp={mockNavigateToApp} + currentAppId={'securitySolutionUI'} > ( +
+ {storyFn()} +
+ ), + ], +}; + +const mockCore = { + application: { + navigateToApp: () => {}, + getUrlForApp: () => '#', + }, +} as unknown as CoreStart; + +export const LandingLinksImageCards = (params: LandingLinksImagesProps) => ( +
+ +
+ {items.map((item) => { + const { id } = item; + return ; + })} +
+
+
+); + +LandingLinksImageCards.argTypes = { + items: { + control: 'object', + defaultValue: items, + }, +}; + +LandingLinksImageCards.parameters = { + layout: 'fullscreen', +}; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_image_card.test.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_image_card.test.tsx new file mode 100644 index 000000000000..b55527146bba --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_image_card.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; 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 { SecurityPageName } from '../constants'; +import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation'; +import { LandingLinksImageCard } from './landing_links_image_card'; +import { BETA } from './beta_badge'; + +jest.mock('../navigation'); + +mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`); +const mockOnLinkClick = jest.fn(); + +const DEFAULT_NAV_ITEM = { + id: SecurityPageName.overview, + title: 'TEST LABEL', + description: 'TEST DESCRIPTION', + landingImage: 'TEST_IMAGE.png', +}; + +describe('LandingLinksImageCard', () => { + it('should render', () => { + const title = 'test label'; + + const { queryByText } = render( + + ); + + expect(queryByText(title)).toBeInTheDocument(); + }); + + it('should render landingImage', () => { + const landingImage = 'test_image.jpeg'; + const title = 'TEST_LABEL'; + + const { getByTestId } = render( + + ); + + expect(getByTestId('LandingImageCard-image')).toHaveStyle({ + backgroundImage: `url(${landingImage})`, + }); + }); + + it('should render beta tag when isBeta is true', () => { + const { queryByText } = render( + + ); + expect(queryByText(BETA)).toBeInTheDocument(); + }); + + it('should not render beta tag when isBeta is false', () => { + const { queryByText } = render(); + expect(queryByText(BETA)).not.toBeInTheDocument(); + }); + + it('should navigate link', () => { + const id = SecurityPageName.administration; + const title = 'test label 2'; + + const { getByText } = render( + + ); + + getByText(title).click(); + + expect(mockGetAppUrl).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.administration, + absolute: false, + path: '', + }); + expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' }); + }); + + it('should call onLinkClick', () => { + const id = SecurityPageName.administration; + const title = 'myTestLabel'; + + const { getByText } = render( + + ); + + getByText(title).click(); + + expect(mockOnLinkClick).toHaveBeenCalledWith(id); + }); +}); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_image_card.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_image_card.tsx new file mode 100644 index 000000000000..fb03cf538611 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_image_card.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { css } from '@emotion/react'; +import { withLink } from '../links'; +import type { NavigationLink } from '../types'; +import { BetaBadge } from './beta_badge'; +import { getKibanaLinkProps } from './utils'; + +export interface LandingLinksImageCardProps { + item: NavigationLink; + urlState?: string; + onLinkClick?: (id: string) => void; +} + +const CARD_HEIGHT = 116; +const CARD_WIDTH = 370; +const CARD_HEIGHT_IMAGE = 98; + +const useStyles = () => { + const { euiTheme } = useEuiTheme(); + return { + card: css` + height: ${CARD_HEIGHT}px; + max-width: ${CARD_WIDTH}px; + `, + cardWrapper: css` + height: 100%; + `, + titleContainer: css` + height: ${euiTheme.size.l}; + `, + title: css` + color: ${euiTheme.colors.primaryText}; + font-weight: ${euiTheme.font.weight.semiBold}; + `, + getImageContainer: (imageUrl: string | undefined) => css` + height: ${CARD_HEIGHT_IMAGE}px; + width: ${CARD_HEIGHT_IMAGE}px; + background-position: center center; + background-repeat: no-repeat; + background-image: url(${imageUrl ?? ''}); + background-size: auto 98px; + `, + }; +}; + +const EuiPanelWithLink = withLink(EuiPanel); + +export const LandingLinksImageCard: React.FC = React.memo( + function LandingLinksImageCard({ item, urlState, onLinkClick }) { + const styles = useStyles(); + + const linkProps = getKibanaLinkProps({ item, urlState, onLinkClick }); + const { landingImage, title, description, isBeta, betaOptions } = item; + + const imageBackground = useMemo( + () => styles.getImageContainer(landingImage), + [landingImage, styles] + ); + + return ( + + + + + {landingImage && ( + + )} + + + + + + + +

{title}

+
+
+ {isBeta && } +
+
+ + {description} + +
+
+
+
+
+ ); + } +); + +// eslint-disable-next-line import/no-default-export +export default LandingLinksImageCard; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.test.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.test.tsx index e614c99a500d..690739576c92 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.test.tsx +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.test.tsx @@ -8,14 +8,12 @@ import React from 'react'; import { render } from '@testing-library/react'; import { SecurityPageName } from '../constants'; -import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation'; +import { mockGetAppUrl } from '../../mocks/navigation'; import { LandingLinksImageCards } from './landing_links_images_cards'; -import { BETA } from './beta_badge'; jest.mock('../navigation'); mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`); -const mockOnLinkClick = jest.fn(); const DEFAULT_NAV_ITEM = { id: SecurityPageName.overview, @@ -25,70 +23,19 @@ const DEFAULT_NAV_ITEM = { }; describe('LandingLinksImageCards', () => { - it('should render', () => { - const title = 'test label'; + it('should render accordion', () => { + const landingLinksCardsAccordionTestId = 'LandingImageCards-accordion'; - const { queryByText } = render( - - ); + const { queryByTestId } = render(); - expect(queryByText(title)).toBeInTheDocument(); + expect(queryByTestId(landingLinksCardsAccordionTestId)).toBeInTheDocument(); }); - it('should render landingImage', () => { - const landingImage = 'test_image.jpeg'; - const title = 'TEST_LABEL'; + it('should render LandingLinksImageCard item', () => { + const landingLinksCardTestId = 'LandingImageCard-item'; - const { getByTestId } = render( - - ); + const { queryByTestId } = render(); - expect(getByTestId('LandingImageCard-image')).toHaveAttribute('src', landingImage); - }); - - it('should render beta tag when isBeta is true', () => { - const { queryByText } = render( - - ); - expect(queryByText(BETA)).toBeInTheDocument(); - }); - - it('should not render beta tag when isBeta is false', () => { - const { queryByText } = render(); - expect(queryByText(BETA)).not.toBeInTheDocument(); - }); - - it('should navigate link', () => { - const id = SecurityPageName.administration; - const title = 'test label 2'; - - const { getByText } = render( - - ); - - getByText(title).click(); - - expect(mockGetAppUrl).toHaveBeenCalledWith({ - deepLinkId: SecurityPageName.administration, - absolute: false, - path: '', - }); - expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' }); - }); - - it('should call onLinkClick', () => { - const id = SecurityPageName.administration; - const title = 'myTestLabel'; - - const { getByText } = render( - - ); - - getByText(title).click(); - - expect(mockOnLinkClick).toHaveBeenCalledWith(id); + expect(queryByTestId(landingLinksCardTestId)).toBeInTheDocument(); }); }); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx index ac8598c42702..6ba9b63dc7ca 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx @@ -4,13 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiImage, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; import React from 'react'; import { css } from '@emotion/react'; -import { withLink } from '../links'; +import * as i18n from './translations'; import type { NavigationLink } from '../types'; -import { BetaBadge } from './beta_badge'; -import { getKibanaLinkProps } from './utils'; +import LandingLinksImageCard from './landing_links_image_card'; export interface LandingLinksImagesProps { items: NavigationLink[]; @@ -18,83 +26,64 @@ export interface LandingLinksImagesProps { onLinkClick?: (id: string) => void; } -const CARD_WIDTH = 320; - const useStyles = () => { - const { euiTheme } = useEuiTheme(); return { - container: css` - max-width: ${CARD_WIDTH}px; - `, - card: css` - // Needed to use the primary color in the title underlining on hover - .euiCard__title { - color: ${euiTheme.colors.primaryText}; + accordion: css` + .euiAccordion__childWrapper { + overflow: visible; } `, - titleContainer: css` - display: flex; - align-items: center; - `, - title: css` - color: ${euiTheme.colors.primaryText}; - `, - description: css` - padding-top: ${euiTheme.size.xs}; - max-width: 550px; - `, }; }; -const EuiCardWithLink = withLink(EuiCard); - export const LandingLinksImageCards: React.FC = React.memo( function LandingLinksImageCards({ items, urlState, onLinkClick }) { + const landingLinksAccordionId = useGeneratedHtmlId({ prefix: 'landingLinksAccordion' }); const styles = useStyles(); + return ( - - {items.map((item) => { - const linkProps = getKibanaLinkProps({ item, urlState, onLinkClick }); - const { id, landingImage, title, description, isBeta, betaOptions } = item; - return ( - + - - ) - } - title={ -
- -

{title}

-
- {isBeta && } -
- } - titleElement="span" - description={{description}} - /> -
- ); - })} -
+ + + + + + +

{i18n.LANDING_LINKS_ACCORDION_HEADER}

+
+
+ + } + > + + + {items.map((item) => { + const { id } = item; + return ( + + ); + })} + + + ); } ); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/translations.ts b/x-pack/packages/security-solution/navigation/src/landing_links/translations.ts new file mode 100644 index 000000000000..d9409b46534d --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const LANDING_LINKS_ACCORDION_HEADER = i18n.translate( + 'securitySolutionPackages.navigation.landingLinks', + { + defaultMessage: 'Security views', + } +); diff --git a/x-pack/packages/security-solution/upselling/service/types.ts b/x-pack/packages/security-solution/upselling/service/types.ts index fdbd840429fe..83a6e2f6bb12 100644 --- a/x-pack/packages/security-solution/upselling/service/types.ts +++ b/x-pack/packages/security-solution/upselling/service/types.ts @@ -18,7 +18,8 @@ export type UpsellingSectionId = | 'endpoint_protection_updates' | 'endpoint_agent_tamper_protection' | 'cloud_security_posture_integration_installation' - | 'ruleDetailsEndpointExceptions'; + | 'ruleDetailsEndpointExceptions' + | 'integration_assistant'; export type UpsellingMessageId = | 'investigation_guide' diff --git a/x-pack/packages/security/plugin_types_common/src/licensing/license.ts b/x-pack/packages/security/plugin_types_common/src/licensing/license.ts index 0a7e8e3b87c6..349395ee63fd 100644 --- a/x-pack/packages/security/plugin_types_common/src/licensing/license.ts +++ b/x-pack/packages/security/plugin_types_common/src/licensing/license.ts @@ -13,6 +13,7 @@ import type { SecurityLicenseFeatures } from './license_features'; export interface SecurityLicense { isLicenseAvailable(): boolean; + getLicenseType(): string | undefined; getUnavailableReason: () => string | undefined; isEnabled(): boolean; getFeatures(): SecurityLicenseFeatures; diff --git a/x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts b/x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts index 58fb081a5760..68dc87b0f577 100644 --- a/x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts +++ b/x-pack/packages/security/plugin_types_common/src/licensing/license_features.ts @@ -83,4 +83,10 @@ export interface SecurityLicenseFeatures { * Describes the layout of the login form if it's displayed. */ readonly layout?: LoginLayout; + + /** + * Indicates whether we allow FIPS mode + */ + + readonly allowFips: boolean; } diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts index 06a74c0f011f..41eab4fbc2e4 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts @@ -12,7 +12,6 @@ import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, } from '../constants/saved_objects'; -import { AuthenticatedUser } from '@kbn/security-plugin/server'; import { AuthorizationMode } from './get_authorization_mode_by_source'; import { CONNECTORS_ADVANCED_EXECUTE_PRIVILEGE_API_TAG, @@ -29,7 +28,6 @@ const ADVANCED_EXECUTE_AUTHZ = `api:${CONNECTORS_ADVANCED_EXECUTE_PRIVILEGE_API_ function mockSecurity() { const security = securityMock.createSetup(); const authorization = security.authz; - const authentication = security.authc; // typescript is having trouble inferring jest's automocking ( authorization.actions.savedObject.get as jest.MockedFunction< @@ -37,7 +35,7 @@ function mockSecurity() { > ).mockImplementation(mockAuthorizationAction); authorization.mode.useRbacForRequest.mockReturnValue(true); - return { authorization, authentication }; + return { authorization }; } beforeEach(() => { @@ -167,7 +165,7 @@ describe('ensureAuthorized', () => { }); test('exempts users from requiring privileges to execute actions when authorizationMode is Legacy', async () => { - const { authorization, authentication } = mockSecurity(); + const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType > = jest.fn(); @@ -175,14 +173,9 @@ describe('ensureAuthorized', () => { const actionsAuthorization = new ActionsAuthorization({ request, authorization, - authentication, authorizationMode: AuthorizationMode.Legacy, }); - authentication.getCurrentUser.mockReturnValueOnce({ - username: 'some-user', - } as unknown as AuthenticatedUser); - await actionsAuthorization.ensureAuthorized({ operation: 'execute', actionTypeId: 'myType' }); expect(authorization.actions.savedObject.get).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.ts index fd4477b051cf..5739af64050e 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.ts @@ -18,7 +18,6 @@ import { AuthorizationMode } from './get_authorization_mode_by_source'; export interface ConstructorOptions { request: KibanaRequest; authorization?: SecurityPluginSetup['authz']; - authentication?: SecurityPluginSetup['authc']; // In order to support legacy Alerts which predate the introduction of the // Actions feature in Kibana we need a way of "dialing down" the level of // authorization for certain opearations. @@ -49,7 +48,6 @@ export class ActionsAuthorization { constructor({ request, authorization, - authentication, authorizationMode = AuthorizationMode.RBAC, }: ConstructorOptions) { this.request = request; diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index f1c55958c928..e1366f7f9c57 100644 --- a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -75,11 +75,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -109,11 +104,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -193,11 +183,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -268,11 +253,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -393,11 +373,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -468,11 +443,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -593,11 +563,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -643,11 +608,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -691,11 +651,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -742,11 +697,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1001,11 +951,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "timeWindow": Object { @@ -1031,11 +976,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1050,11 +990,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1069,11 +1004,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1120,11 +1050,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1718,11 +1643,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1928,11 +1848,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -2031,11 +1946,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -2318,27 +2228,12 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -2381,11 +2276,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2527,11 +2417,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2570,11 +2455,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2604,11 +2484,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2652,11 +2527,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2703,11 +2573,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2786,11 +2651,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2869,11 +2729,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2903,11 +2758,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2937,11 +2787,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2988,11 +2833,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -3272,11 +3112,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -3406,11 +3241,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -3517,11 +3347,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "message": Object { @@ -3616,11 +3441,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -3743,11 +3563,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -3777,11 +3592,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -3904,11 +3714,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4028,11 +3833,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4152,11 +3952,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4230,11 +4025,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4264,11 +4054,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4315,11 +4100,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4395,11 +4175,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4412,11 +4187,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4528,11 +4298,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4576,11 +4341,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4624,11 +4384,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -4668,19 +4423,9 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4728,19 +4473,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4773,19 +4508,9 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4869,11 +4594,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -5241,27 +4961,12 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5294,19 +4999,9 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5354,19 +5049,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5414,19 +5099,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5474,19 +5149,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5505,11 +5170,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -5524,11 +5184,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -5657,11 +5312,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "connector": Object { @@ -5804,19 +5454,9 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -6251,11 +5891,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -6340,11 +5975,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -6383,11 +6013,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -6567,11 +6192,6 @@ Object { "type": "any", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -6610,11 +6230,6 @@ Object { "type": "any", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -6653,11 +6268,6 @@ Object { "type": "any", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -6696,11 +6306,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -6724,11 +6329,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -6848,11 +6448,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -6882,11 +6477,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -6916,11 +6506,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -6967,11 +6552,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7025,11 +6605,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7059,11 +6634,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7315,11 +6885,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -7466,11 +7031,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -7544,11 +7104,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -7739,19 +7294,9 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7766,11 +7311,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7785,11 +7325,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7804,11 +7339,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7852,11 +7382,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7900,11 +7425,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -7951,11 +7471,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -8047,11 +7562,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -8151,11 +7661,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -8221,11 +7726,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -8900,11 +8400,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -14647,11 +14142,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -20359,11 +19849,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26071,11 +25556,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26190,11 +25670,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "script": Object { @@ -26408,11 +25883,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "requiresApproval": Object { @@ -26540,19 +26010,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26582,11 +26042,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26616,11 +26071,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26667,11 +26117,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26684,11 +26129,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26701,11 +26141,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26817,11 +26252,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -26981,11 +26411,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -27191,11 +26616,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -27235,19 +26655,9 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -27295,19 +26705,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -27340,19 +26740,9 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -27436,11 +26826,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -27909,27 +27294,12 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -27986,19 +27356,9 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -28119,27 +27479,12 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -28295,11 +27640,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -28505,11 +27845,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -28970,19 +28305,9 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -29039,19 +28364,9 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -29215,11 +28530,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -29425,11 +28735,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -29469,19 +28774,9 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -29529,19 +28824,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -29574,19 +28859,9 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -29670,11 +28945,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -30463,27 +29733,12 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -30540,19 +29795,9 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -30569,11 +29814,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -30603,11 +29843,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -30648,11 +29883,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -30734,11 +29964,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -30758,11 +29983,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -30803,11 +30023,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -30869,19 +30084,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31016,19 +30221,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31158,19 +30353,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31344,11 +30529,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31441,11 +30621,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31538,11 +30713,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31635,11 +30805,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31732,11 +30897,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31829,11 +30989,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31926,11 +31081,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -31950,19 +31100,9 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -31992,11 +31132,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32087,11 +31222,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -32389,27 +31519,12 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -32426,11 +31541,6 @@ Object { "presence": "optional", }, "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32460,11 +31570,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32505,11 +31610,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32531,11 +31631,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32631,11 +31726,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "webhookUrl": Object { @@ -32660,11 +31750,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32760,11 +31845,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "webhookUrl": Object { @@ -32789,11 +31869,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32823,11 +31898,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32871,11 +31941,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32922,11 +31987,6 @@ Object { "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32956,11 +32016,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -32990,11 +32045,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -33024,11 +32074,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -33332,11 +32377,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -33542,11 +32582,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -33591,11 +32626,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -33657,11 +32687,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -33791,11 +32816,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -33951,11 +32971,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index dfb0ccda5c45..bfcedc821368 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -10,7 +10,12 @@ import { schema } from '@kbn/config-schema'; import { ActionExecutor } from './action_executor'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; -import { httpServerMock, loggingSystemMock, analyticsServiceMock } from '@kbn/core/server/mocks'; +import { + httpServerMock, + loggingSystemMock, + analyticsServiceMock, + securityServiceMock, +} from '@kbn/core/server/mocks'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; import { spacesServiceMock } from '@kbn/spaces-plugin/server/spaces_service/spaces_service.mock'; import { ActionType as ConnectorType } from '../types'; @@ -20,7 +25,6 @@ import { asHttpRequestExecutionSource, asSavedObjectExecutionSource, } from './action_execution_source'; -import { securityMock } from '@kbn/security-plugin/server/mocks'; import { finished } from 'stream/promises'; import { PassThrough } from 'stream'; import { SecurityConnectorFeatureId } from '../../common'; @@ -58,7 +62,7 @@ const executeParams = { const spacesMock = spacesServiceMock.createStartContract(); const loggerMock: ReturnType = loggingSystemMock.createLogger(); -const securityMockStart = securityMock.createStart(); +const securityMockStart = securityServiceMock.createStart(); const authorizationMock = actionsAuthorizationMock.create(); const getActionsAuthorizationWithRequest = jest.fn(); diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 5ca37f6ee871..f8f8f32547c1 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -7,6 +7,8 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { + type AuthenticatedUser, + type SecurityServiceStart, AnalyticsServiceStart, KibanaRequest, Logger, @@ -18,7 +20,6 @@ import { withSpan } from '@kbn/apm-utils'; import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import { SpacesServiceStart } from '@kbn/spaces-plugin/server'; import { IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; -import { AuthenticatedUser, SecurityPluginStart } from '@kbn/security-plugin/server'; import { createTaskRunError, TaskErrorSource } from '@kbn/task-manager-plugin/server'; import { getErrorSource } from '@kbn/task-manager-plugin/server/task_running'; import { GEN_AI_TOKEN_COUNT_EVENT } from './event_based_telemetry'; @@ -59,7 +60,7 @@ const Millis2Nanos = 1000 * 1000; export interface ActionExecutorContext { logger: Logger; spaces?: SpacesServiceStart; - security?: SecurityPluginStart; + security: SecurityServiceStart; getServices: GetServicesFunction; getUnsecuredServices: GetUnsecuredServicesFunction; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index c5f10f728065..3dd86bdcf148 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -18,6 +18,7 @@ import { httpServiceMock, savedObjectsRepositoryMock, analyticsServiceMock, + securityServiceMock, } from '@kbn/core/server/mocks'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; import { ActionTypeDisabledError } from './errors'; @@ -98,6 +99,7 @@ const actionExecutorInitializerParams = { eventLogger, inMemoryConnectors: [], analyticsService: analyticsServiceMock.createAnalyticsServiceStart(), + security: securityServiceMock.createStart(), }; const taskRunnerFactoryInitializerParams = { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 111e0509f81a..3112124850bf 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -553,7 +553,7 @@ export class ActionsPlugin implements Plugin = ({ dataView, savedSearch, appDependencies, + showContextualInsights = false, showFrozenDataTierChoice = true, }) => { if (!dataView) return null; @@ -69,7 +72,7 @@ export const LogRateAnalysisAppState: FC = ({ - + diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx index fcb5fa7e3a87..acaa2fb27d99 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx @@ -6,12 +6,14 @@ */ import type { FC } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; -import { isEqual } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { isEqual, orderBy } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { EuiFlexGroup, EuiFlexItem, EuiPageBody, EuiPageSection, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { Message } from '@kbn/observability-ai-assistant-plugin/public'; import type { Filter, Query } from '@kbn/es-query'; import { FilterStateStore } from '@kbn/es-query'; import { useUrlState, usePageUrlState } from '@kbn/ml-url-state'; @@ -25,6 +27,10 @@ import { setInitialAnalysisStart, setDocumentCountChartData, } from '@kbn/aiops-log-rate-analysis/state'; +import { + LOG_RATE_ANALYSIS_TYPE, + type LogRateAnalysisType, +} from '@kbn/aiops-log-rate-analysis/log_rate_analysis_type'; import { useDataSource } from '../../hooks/use_data_source'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; @@ -40,10 +46,26 @@ import { import { SearchPanel } from '../search_panel'; import { PageHeader } from '../page_header'; +import type { LogRateAnalysisResultsData } from './log_rate_analysis_results'; + import { LogRateAnalysisContent } from './log_rate_analysis_content/log_rate_analysis_content'; -export const LogRateAnalysisPage: FC = () => { - const { data: dataService } = useAiopsAppContext(); +interface SignificantFieldValue { + field: string; + value: string | number; + docCount: number; + pValue: number | null; +} + +interface LogRateAnalysisPageProps { + showContextualInsights?: boolean; +} + +export const LogRateAnalysisPage: FC = ({ + showContextualInsights = false, +}) => { + const aiopsAppContext = useAiopsAppContext(); + const { data: dataService, observabilityAIAssistant } = aiopsAppContext; const { dataView, savedSearch } = useDataSource(); const currentSelectedGroup = useCurrentSelectedGroup(); @@ -58,6 +80,15 @@ export const LogRateAnalysisPage: FC = () => { const [selectedSavedSearch, setSelectedSavedSearch] = useState(savedSearch); + // Used to store analysis results to be passed on to the AI Assistant. + const [logRateAnalysisParams, setLogRateAnalysisParams] = useState< + | { + logRateAnalysisType: LogRateAnalysisType; + significantFieldValues: SignificantFieldValue[]; + } + | undefined + >(); + useEffect(() => { if (savedSearch) { setSelectedSavedSearch(savedSearch); @@ -182,6 +213,88 @@ export const LogRateAnalysisPage: FC = () => { } }; + const onAnalysisCompleted = (analysisResults: LogRateAnalysisResultsData | undefined) => { + const significantFieldValues = orderBy( + analysisResults?.significantItems?.map((item) => ({ + field: item.fieldName, + value: item.fieldValue, + docCount: item.doc_count, + pValue: item.pValue, + })), + ['pValue', 'docCount'], + ['asc', 'asc'] + ).slice(0, 50); + + const logRateAnalysisType = analysisResults?.analysisType; + setLogRateAnalysisParams( + significantFieldValues && logRateAnalysisType + ? { logRateAnalysisType, significantFieldValues } + : undefined + ); + }; + + const messages = useMemo(() => { + const hasLogRateAnalysisParams = + logRateAnalysisParams && logRateAnalysisParams.significantFieldValues?.length > 0; + + if (!hasLogRateAnalysisParams || !observabilityAIAssistant) { + return undefined; + } + + const { logRateAnalysisType } = logRateAnalysisParams; + + const header = 'Field name,Field value,Doc count,p-value'; + const rows = logRateAnalysisParams.significantFieldValues + .map((item) => Object.values(item).join(',')) + .join('\n'); + + return observabilityAIAssistant.getContextualInsightMessages({ + message: + 'Can you identify possible causes and remediations for these log rate analysis results', + instructions: `You are an AIOps expert using Elastic's Kibana on call being consulted about a log rate change that got triggered by a ${logRateAnalysisType} in log messages. Your job is to take immediate action and proceed with both urgency and precision. + "Log Rate Analysis" is an AIOps feature that uses advanced statistical methods to identify reasons for increases and decreases in log rates. It makes it easy to find and investigate causes of unusual spikes or dips by using the analysis workflow view. + You are using "Log Rate Analysis" and ran the statistical analysis on the log messages which occured during the alert. + You received the following analysis results from "Log Rate Analysis" which list statistically significant co-occuring field/value combinations sorted from most significant (lower p-values) to least significant (higher p-values) that ${ + logRateAnalysisType === LOG_RATE_ANALYSIS_TYPE.SPIKE + ? 'contribute to the log rate spike' + : 'are less or not present in the log rate dip' + }: + + ${ + logRateAnalysisType === LOG_RATE_ANALYSIS_TYPE.SPIKE + ? 'The median log rate in the selected deviation time range is higher than the baseline. Therefore, the results shows statistically significant items within the deviation time range that are contributors to the spike. The "doc count" column refers to the amount of documents in the deviation time range.' + : 'The median log rate in the selected deviation time range is lower than the baseline. Therefore, the analysis results table shows statistically significant items within the baseline time range that are less in number or missing within the deviation time range. The "doc count" column refers to the amount of documents in the baseline time range.' + } + + ${header} + ${rows} + + Based on the above analysis results and your observability expert knowledge, output the following: + Analyse the type of these logs and explain their usual purpose (1 paragraph). + ${ + logRateAnalysisType === LOG_RATE_ANALYSIS_TYPE.SPIKE + ? 'Based on the type of these logs do a root cause analysis on why the field and value combinations from the analysis results are causing this log rate spike (2 parapraphs)' + : 'Based on the type of these logs explain why the statistically significant field and value combinations are less in number or missing from the log rate dip with concrete examples based on the analysis results data which contains items that are present in the baseline time range and are missing or less in number in the deviation time range (2 paragraphs)' + }. + ${ + logRateAnalysisType === LOG_RATE_ANALYSIS_TYPE.SPIKE + ? 'Recommend concrete remediations to resolve the root cause (3 bullet points).' + : '' + } + + Do not mention individual p-values from the analysis results. + Do not repeat the full list of field names and field values back to the user. + Do not repeat the given instructions in your output.`, + }); + }, [logRateAnalysisParams, observabilityAIAssistant]); + + const logRateAnalysisTitle = i18n.translate( + 'xpack.aiops.observabilityAIAssistantContextualInsight.logRateAnalysisTitle', + { + defaultMessage: 'Possible causes and remediations', + } + ); + return ( @@ -196,11 +309,24 @@ export const LogRateAnalysisPage: FC = () => { setSearchParams={setSearchParams} /> - + + + + {showContextualInsights && + observabilityAIAssistant?.ObservabilityAIAssistantContextualInsight && + messages ? ( + + + + ) : null} diff --git a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts index ae803d312e59..b263457c11d0 100644 --- a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts +++ b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts @@ -7,6 +7,7 @@ import { createContext, type FC, type PropsWithChildren, useContext } from 'react'; +import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; @@ -134,6 +135,8 @@ export interface AiopsAppDependencies { isServerless?: boolean; /** Identifier to indicate the plugin utilizing the component */ embeddingOrigin?: string; + /** Observability AI Assistant */ + observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; } /** diff --git a/x-pack/plugins/aiops/public/types/index.ts b/x-pack/plugins/aiops/public/types/index.ts index 5c71a63bba35..9150c9b983f8 100644 --- a/x-pack/plugins/aiops/public/types/index.ts +++ b/x-pack/plugins/aiops/public/types/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { ExecutionContextStart } from '@kbn/core-execution-context-browser'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -43,6 +44,7 @@ export interface AiopsPluginStartDeps { licensing: LicensingPluginStart; executionContext: ExecutionContextStart; embeddable: EmbeddableStart; + observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json index c4e7af826c0c..dde05c2b6ef9 100644 --- a/x-pack/plugins/aiops/tsconfig.json +++ b/x-pack/plugins/aiops/tsconfig.json @@ -77,6 +77,7 @@ "@kbn/unified-search-plugin", "@kbn/usage-collection-plugin", "@kbn/utility-types", + "@kbn/observability-ai-assistant-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 6c072654cf3b..e0c5d7fee528 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -23,6 +23,7 @@ export type { RuleTaskState, RuleTaskParams, } from '@kbn/alerting-state-types'; +export type { AlertingFrameworkHealth } from '@kbn/alerting-types'; export * from './alert_summary'; export * from './builtin_action_groups'; export * from './bulk_edit'; diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index d7f2c6920f4f..4981fc6dea26 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -52,6 +52,7 @@ export { RuleLastRunOutcomeValues, RuleExecutionStatusErrorReasons, RuleExecutionStatusWarningReasons, + HealthStatus, } from '@kbn/alerting-types'; export type RuleTypeState = Record; diff --git a/x-pack/plugins/alerting/common/rule_circuit_breaker_error_message.ts b/x-pack/plugins/alerting/common/rule_circuit_breaker_error_message.ts index 01d12cd90332..7779b1a5dd60 100644 --- a/x-pack/plugins/alerting/common/rule_circuit_breaker_error_message.ts +++ b/x-pack/plugins/alerting/common/rule_circuit_breaker_error_message.ts @@ -6,8 +6,7 @@ */ import { i18n } from '@kbn/i18n'; - -const errorMessageHeader = 'Error validating circuit breaker'; +import { errorMessageHeader } from '@kbn/alerting-types'; const getCreateRuleErrorSummary = (name: string) => { return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.createSummary', { diff --git a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap index c0827083779f..932daa1fed69 100644 --- a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap +++ b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap @@ -183,11 +183,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -457,11 +452,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -814,11 +804,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -1129,11 +1114,6 @@ Object { "type": "any", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1556,11 +1536,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -1784,11 +1759,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -1968,11 +1938,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -1981,11 +1946,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "serviceName": Object { @@ -2049,11 +2009,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2256,11 +2211,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -2269,11 +2219,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "serviceName": Object { @@ -2379,11 +2324,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2542,11 +2482,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -2555,11 +2490,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "serviceName": Object { @@ -2665,11 +2595,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -2824,11 +2749,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "metric": Object { @@ -2936,11 +2856,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -3225,11 +3140,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -3302,11 +3212,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -3352,11 +3257,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -3535,11 +3435,6 @@ Object { "type": "record", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -3712,11 +3607,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -3803,11 +3693,6 @@ Object { "type": "any", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4021,11 +3906,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "name": Object { @@ -4184,11 +4064,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "popularity": Object { @@ -4239,11 +4114,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "type": Object { @@ -4339,11 +4209,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4498,11 +4363,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "popularity": Object { @@ -4619,11 +4479,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4659,11 +4514,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "type": Object { @@ -4677,11 +4527,6 @@ Object { "type": "any", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4796,11 +4641,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "nested": Object { @@ -4830,11 +4670,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -4843,11 +4678,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "type": Object { @@ -5110,11 +4940,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "popularity": Object { @@ -5165,11 +4990,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "type": Object { @@ -5265,11 +5085,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5424,11 +5239,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "popularity": Object { @@ -5545,11 +5355,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5585,11 +5390,6 @@ Object { "x-oas-optional": true, }, ], - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "type": Object { @@ -5603,11 +5403,6 @@ Object { "type": "any", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5690,11 +5485,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -5803,11 +5593,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -5858,19 +5643,9 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -7352,6 +7127,52 @@ Object { }, Object { "properties": Object { + "alertSuppression": Object { + "additionalProperties": false, + "properties": Object { + "duration": Object { + "additionalProperties": false, + "properties": Object { + "unit": Object { + "enum": Array [ + "s", + "m", + "h", + ], + "type": "string", + }, + "value": Object { + "minimum": 1, + "type": "integer", + }, + }, + "required": Array [ + "value", + "unit", + ], + "type": "object", + }, + "groupBy": Object { + "items": Object { + "type": "string", + }, + "maxItems": 3, + "minItems": 1, + "type": "array", + }, + "missingFieldsStrategy": Object { + "enum": Array [ + "doNotSuppress", + "suppress", + ], + "type": "string", + }, + }, + "required": Array [ + "groupBy", + ], + "type": "object", + }, "anomalyThreshold": Object { "minimum": 0, "type": "integer", @@ -7909,11 +7730,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -9928,11 +9744,6 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], @@ -10035,11 +9846,6 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, "maxBurnRateThreshold": Object { @@ -10102,30 +9908,15 @@ Object { "type": "number", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, ], "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -10255,11 +10046,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -10304,11 +10090,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -10353,11 +10134,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -10377,11 +10153,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -10401,11 +10172,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -10488,11 +10254,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -10644,11 +10405,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; @@ -10731,11 +10487,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -10822,11 +10573,6 @@ Object { "type": "array", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "rules": Array [ Object { "args": Object { @@ -10918,11 +10664,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -10967,11 +10708,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -11092,11 +10828,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -11141,11 +10872,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -11190,11 +10916,6 @@ Object { "type": "boolean", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -11214,11 +10935,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", }, }, @@ -11238,11 +10954,6 @@ Object { "type": "alternatives", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, "type": "object", } `; diff --git a/x-pack/plugins/alerting/server/maintenance_window_client_factory.test.ts b/x-pack/plugins/alerting/server/maintenance_window_client_factory.test.ts index e25fcc34a46c..85ac758da173 100644 --- a/x-pack/plugins/alerting/server/maintenance_window_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/maintenance_window_client_factory.test.ts @@ -14,9 +14,9 @@ import { savedObjectsServiceMock, loggingSystemMock, uiSettingsServiceMock, + securityServiceMock, } from '@kbn/core/server/mocks'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { securityMock } from '@kbn/security-plugin/server/mocks'; import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '../common'; @@ -24,12 +24,13 @@ jest.mock('./maintenance_window_client'); const savedObjectsClient = savedObjectsClientMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); -const securityPluginStart = securityMock.createStart(); +const securityService = securityServiceMock.createStart(); const uiSettings = uiSettingsServiceMock.createStartContract(); const maintenanceWindowClientFactoryParams: jest.Mocked = { logger: loggingSystemMock.create().get(), savedObjectsService, + securityService, uiSettings, }; @@ -39,10 +40,7 @@ beforeEach(() => { test('creates a maintenance window client with proper constructor arguments when security is enabled', async () => { const factory = new MaintenanceWindowClientFactory(); - factory.initialize({ - securityPluginStart, - ...maintenanceWindowClientFactoryParams, - }); + factory.initialize(maintenanceWindowClientFactoryParams); const request = mockRouter.createKibanaRequest(); savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); @@ -86,10 +84,7 @@ test('creates a maintenance window client with proper constructor arguments', as test('creates an unauthorized maintenance window client', async () => { const factory = new MaintenanceWindowClientFactory(); - factory.initialize({ - securityPluginStart, - ...maintenanceWindowClientFactoryParams, - }); + factory.initialize(maintenanceWindowClientFactoryParams); const request = mockRouter.createKibanaRequest(); savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); @@ -125,10 +120,7 @@ test('getUserName() returns null when security is disabled', async () => { test('getUserName() returns a name when security is enabled', async () => { const factory = new MaintenanceWindowClientFactory(); - factory.initialize({ - securityPluginStart, - ...maintenanceWindowClientFactoryParams, - }); + factory.initialize(maintenanceWindowClientFactoryParams); const request = mockRouter.createKibanaRequest(); factory.createWithAuthorization(request); @@ -136,7 +128,7 @@ test('getUserName() returns a name when security is enabled', async () => { const constructorCall = jest.requireMock('./maintenance_window_client').MaintenanceWindowClient .mock.calls[0][0]; - securityPluginStart.authc.getCurrentUser.mockReturnValueOnce({ + securityService.authc.getCurrentUser.mockReturnValueOnce({ username: 'testname', } as unknown as AuthenticatedUser); const userNameResult = await constructorCall.getUserName(); diff --git a/x-pack/plugins/alerting/server/maintenance_window_client_factory.ts b/x-pack/plugins/alerting/server/maintenance_window_client_factory.ts index 4e040f50ec3c..5cd013784637 100644 --- a/x-pack/plugins/alerting/server/maintenance_window_client_factory.ts +++ b/x-pack/plugins/alerting/server/maintenance_window_client_factory.ts @@ -10,16 +10,16 @@ import { Logger, SavedObjectsServiceStart, SECURITY_EXTENSION_ID, + SecurityServiceStart, UiSettingsServiceStart, } from '@kbn/core/server'; -import { SecurityPluginStart } from '@kbn/security-plugin/server'; import { MaintenanceWindowClient } from './maintenance_window_client'; import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '../common'; export interface MaintenanceWindowClientFactoryOpts { logger: Logger; savedObjectsService: SavedObjectsServiceStart; - securityPluginStart?: SecurityPluginStart; + securityService: SecurityServiceStart; uiSettings: UiSettingsServiceStart; } @@ -27,7 +27,7 @@ export class MaintenanceWindowClientFactory { private isInitialized = false; private logger!: Logger; private savedObjectsService!: SavedObjectsServiceStart; - private securityPluginStart?: SecurityPluginStart; + private securityService!: SecurityServiceStart; private uiSettings!: UiSettingsServiceStart; public initialize(options: MaintenanceWindowClientFactoryOpts) { @@ -37,12 +37,12 @@ export class MaintenanceWindowClientFactory { this.isInitialized = true; this.logger = options.logger; this.savedObjectsService = options.savedObjectsService; - this.securityPluginStart = options.securityPluginStart; + this.securityService = options.securityService; this.uiSettings = options.uiSettings; } private createMaintenanceWindowClient(request: KibanaRequest, withAuth: boolean) { - const { securityPluginStart } = this; + const { securityService } = this; const savedObjectsClient = this.savedObjectsService.getScopedClient(request, { includedHiddenTypes: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE], ...(withAuth ? {} : { excludedExtensions: [SECURITY_EXTENSION_ID] }), @@ -55,11 +55,8 @@ export class MaintenanceWindowClientFactory { savedObjectsClient, uiSettings: uiSettingClient, async getUserName() { - if (!securityPluginStart || !request) { - return null; - } - const user = securityPluginStart.authc.getCurrentUser(request); - return user ? user.username : null; + const user = securityService.authc.getCurrentUser(request); + return user?.username ?? null; }, }); } diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 1eb130f523c9..3c996c532acf 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -541,19 +541,20 @@ export class AlertingPlugin { backfillClient: this.backfillClient!, connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: core.uiSettings, + securityService: core.security, }); rulesSettingsClientFactory.initialize({ logger: this.logger, savedObjectsService: core.savedObjects, - securityPluginStart: plugins.security, + securityService: core.security, isServerless: !!plugins.serverless, }); maintenanceWindowClientFactory.initialize({ logger: this.logger, savedObjectsService: core.savedObjects, - securityPluginStart: plugins.security, + securityService: core.security, uiSettings: core.uiSettings, }); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_client_factory.test.ts index 9be3e17e6371..4cd7ffbcf0c6 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -14,6 +14,7 @@ import { loggingSystemMock, savedObjectsRepositoryMock, uiSettingsServiceMock, + securityServiceMock, } from '@kbn/core/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; @@ -43,6 +44,7 @@ const savedObjectsService = savedObjectsServiceMock.createInternalStartContract( const securityPluginSetup = securityMock.createSetup(); const securityPluginStart = securityMock.createStart(); +const securityService = securityServiceMock.createStart(); const alertingAuthorization = alertingAuthorizationMock.create(); const alertingAuthorizationClientFactory = alertingAuthorizationClientFactoryMock.createFactory(); @@ -67,6 +69,7 @@ const rulesClientFactoryParams: jest.Mocked = { backfillClient, connectorAdapterRegistry: new ConnectorAdapterRegistry(), uiSettings: uiSettingsServiceMock.createStartContract(), + securityService: securityServiceMock.createStart(), getAlertIndicesAlias: jest.fn(), alertsService: null, }; @@ -87,7 +90,11 @@ beforeEach(() => { test('creates a rules client with proper constructor arguments when security is enabled', async () => { const factory = new RulesClientFactory(); - factory.initialize({ securityPluginSetup, securityPluginStart, ...rulesClientFactoryParams }); + factory.initialize({ + securityPluginSetup, + securityPluginStart, + ...rulesClientFactoryParams, + }); const request = mockRouter.createKibanaRequest(); savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); @@ -207,13 +214,12 @@ test('getUserName() returns a name when security is enabled', async () => { const factory = new RulesClientFactory(); factory.initialize({ ...rulesClientFactoryParams, - securityPluginSetup, - securityPluginStart, + securityService, }); factory.create(mockRouter.createKibanaRequest(), savedObjectsService); const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - securityPluginStart.authc.getCurrentUser.mockReturnValueOnce({ + securityService.authc.getCurrentUser.mockReturnValueOnce({ username: 'bob', } as unknown as AuthenticatedUser); const userNameResult = await constructorCall.getUserName(); @@ -255,6 +261,7 @@ test('createAPIKey() returns an API key when security is enabled', async () => { const factory = new RulesClientFactory(); factory.initialize({ ...rulesClientFactoryParams, + securityService, securityPluginSetup, securityPluginStart, }); @@ -285,6 +292,7 @@ test('createAPIKey() throws when security plugin createAPIKey throws an error', const factory = new RulesClientFactory(); factory.initialize({ ...rulesClientFactoryParams, + securityService, securityPluginSetup, securityPluginStart, }); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index 50a11dd178c3..f28170b277ac 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -58,6 +58,7 @@ export interface RulesClientFactoryOpts { backfillClient: BackfillClient; connectorAdapterRegistry: ConnectorAdapterRegistry; uiSettings: CoreStart['uiSettings']; + securityService: CoreStart['security']; } export class RulesClientFactory { @@ -83,6 +84,7 @@ export class RulesClientFactory { private backfillClient!: BackfillClient; private connectorAdapterRegistry!: ConnectorAdapterRegistry; private uiSettings!: CoreStart['uiSettings']; + private securityService!: CoreStart['security']; public initialize(options: RulesClientFactoryOpts) { if (this.isInitialized) { @@ -110,10 +112,11 @@ export class RulesClientFactory { this.backfillClient = options.backfillClient; this.connectorAdapterRegistry = options.connectorAdapterRegistry; this.uiSettings = options.uiSettings; + this.securityService = options.securityService; } public create(request: KibanaRequest, savedObjects: SavedObjectsServiceStart): RulesClient { - const { securityPluginSetup, securityPluginStart, actions, eventLog } = this; + const { securityPluginSetup, securityService, securityPluginStart, actions, eventLog } = this; const spaceId = this.getSpaceId(request); if (!this.authorization) { @@ -149,11 +152,8 @@ export class RulesClientFactory { uiSettings: this.uiSettings, async getUserName() { - if (!securityPluginStart) { - return null; - } - const user = await securityPluginStart.authc.getCurrentUser(request); - return user ? user.username : null; + const user = securityService.authc.getCurrentUser(request); + return user?.username ?? null; }, async createAPIKey(name: string) { if (!securityPluginStart) { @@ -185,7 +185,7 @@ export class RulesClientFactory { if (!securityPluginStart) { return false; } - const user = securityPluginStart.authc.getCurrentUser(request); + const user = securityService.authc.getCurrentUser(request); return user && user.authentication_type ? user.authentication_type === 'api_key' : false; }, getAuthenticationAPIKey(name: string) { diff --git a/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts index 6f713f20530e..f107fbac3a54 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts @@ -14,9 +14,9 @@ import { savedObjectsClientMock, savedObjectsServiceMock, loggingSystemMock, + securityServiceMock, } from '@kbn/core/server/mocks'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { securityMock } from '@kbn/security-plugin/server/mocks'; import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common'; @@ -25,11 +25,12 @@ jest.mock('./rules_settings_client'); const savedObjectsClient = savedObjectsClientMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); -const securityPluginStart = securityMock.createStart(); +const securityService = securityServiceMock.createStart(); const rulesSettingsClientFactoryParams: jest.Mocked = { logger: loggingSystemMock.create().get(), savedObjectsService, + securityService, isServerless: false, }; @@ -39,10 +40,7 @@ beforeEach(() => { test('creates a rules settings client with proper constructor arguments when security is enabled', async () => { const factory = new RulesSettingsClientFactory(); - factory.initialize({ - securityPluginStart, - ...rulesSettingsClientFactoryParams, - }); + factory.initialize(rulesSettingsClientFactoryParams); const request = mockRouter.createKibanaRequest(); savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); @@ -88,10 +86,7 @@ test('creates a rules settings client with proper constructor arguments', async test('creates an unauthorized rules settings client', async () => { const factory = new RulesSettingsClientFactory(); - factory.initialize({ - securityPluginStart, - ...rulesSettingsClientFactoryParams, - }); + factory.initialize(rulesSettingsClientFactoryParams); const request = mockRouter.createKibanaRequest(); savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); @@ -128,10 +123,7 @@ test('getUserName() returns null when security is disabled', async () => { test('getUserName() returns a name when security is enabled', async () => { const factory = new RulesSettingsClientFactory(); - factory.initialize({ - securityPluginStart, - ...rulesSettingsClientFactoryParams, - }); + factory.initialize(rulesSettingsClientFactoryParams); const request = mockRouter.createKibanaRequest(); factory.createWithAuthorization(request); @@ -139,7 +131,7 @@ test('getUserName() returns a name when security is enabled', async () => { const constructorCall = jest.requireMock('./rules_settings_client').RulesSettingsClient.mock.calls[0][0]; - securityPluginStart.authc.getCurrentUser.mockReturnValueOnce({ + securityService.authc.getCurrentUser.mockReturnValueOnce({ username: 'testname', } as unknown as AuthenticatedUser); const userNameResult = await constructorCall.getUserName(); diff --git a/x-pack/plugins/alerting/server/rules_settings_client_factory.ts b/x-pack/plugins/alerting/server/rules_settings_client_factory.ts index f69068ee3cb6..707c38b508c1 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client_factory.ts @@ -10,8 +10,8 @@ import { Logger, SavedObjectsServiceStart, SECURITY_EXTENSION_ID, + SecurityServiceStart, } from '@kbn/core/server'; -import { SecurityPluginStart } from '@kbn/security-plugin/server'; import { RulesSettingsClient } from './rules_settings_client'; import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common'; @@ -19,14 +19,14 @@ export interface RulesSettingsClientFactoryOpts { logger: Logger; savedObjectsService: SavedObjectsServiceStart; isServerless: boolean; - securityPluginStart?: SecurityPluginStart; + securityService: SecurityServiceStart; } export class RulesSettingsClientFactory { private isInitialized = false; private logger!: Logger; private savedObjectsService!: SavedObjectsServiceStart; - private securityPluginStart?: SecurityPluginStart; + private securityService!: SecurityServiceStart; private isServerless = false; public initialize(options: RulesSettingsClientFactoryOpts) { @@ -36,12 +36,12 @@ export class RulesSettingsClientFactory { this.isInitialized = true; this.logger = options.logger; this.savedObjectsService = options.savedObjectsService; - this.securityPluginStart = options.securityPluginStart; + this.securityService = options.securityService; this.isServerless = options.isServerless; } private createRulesSettingsClient(request: KibanaRequest, withAuth: boolean) { - const { securityPluginStart } = this; + const { securityService } = this; const savedObjectsClient = this.savedObjectsService.getScopedClient(request, { includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE], ...(withAuth ? {} : { excludedExtensions: [SECURITY_EXTENSION_ID] }), @@ -51,11 +51,8 @@ export class RulesSettingsClientFactory { logger: this.logger, savedObjectsClient, async getUserName() { - if (!securityPluginStart || !request) { - return null; - } - const user = securityPluginStart.authc.getCurrentUser(request); - return user ? user.username : null; + const user = securityService.authc.getCurrentUser(request); + return user?.username ?? null; }, isServerless: this.isServerless, }); diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index a8868010d231..557899e322ae 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -133,6 +133,12 @@ export const MAX_CUSTOM_FIELDS_PER_CASE = 10 as const; export const MAX_CUSTOM_FIELD_KEY_LENGTH = 36 as const; // uuidv4 length export const MAX_CUSTOM_FIELD_LABEL_LENGTH = 50 as const; export const MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH = 160 as const; +export const MAX_TEMPLATE_KEY_LENGTH = 36 as const; // uuidv4 length +export const MAX_TEMPLATE_NAME_LENGTH = 50 as const; +export const MAX_TEMPLATE_DESCRIPTION_LENGTH = 1000 as const; +export const MAX_TEMPLATES_LENGTH = 10 as const; +export const MAX_TEMPLATE_TAG_LENGTH = 50 as const; +export const MAX_TAGS_PER_TEMPLATE = 10 as const; /** * Cases features diff --git a/x-pack/plugins/cases/common/constants/owners.ts b/x-pack/plugins/cases/common/constants/owners.ts index 8ac7164ef75c..a7628628a7dc 100644 --- a/x-pack/plugins/cases/common/constants/owners.ts +++ b/x-pack/plugins/cases/common/constants/owners.ts @@ -56,8 +56,8 @@ export const OWNER_INFO: Record = { [GENERAL_CASES_OWNER]: { id: GENERAL_CASES_OWNER, appId: 'management', - label: 'Stack', - iconType: 'casesApp', + label: 'Management', + iconType: 'managementApp', appRoute: '/app/management/insightsAndAlerting', validRuleConsumers: [AlertConsumers.ML, AlertConsumers.STACK_ALERTS, AlertConsumers.EXAMPLE], }, diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index 0dff1cac0d95..7a45f92fa466 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -58,6 +58,81 @@ export const CaseRequestCustomFieldsRt = limitedArraySchema({ max: MAX_CUSTOM_FIELDS_PER_CASE, }); +export const CaseBaseOptionalFieldsRequestRt = rt.exact( + rt.partial({ + /** + * The description of the case + */ + description: limitedStringSchema({ + fieldName: 'description', + min: 1, + max: MAX_DESCRIPTION_LENGTH, + }), + /** + * The identifying strings for filter a case + */ + tags: limitedArraySchema({ + codec: limitedStringSchema({ fieldName: 'tag', min: 1, max: MAX_LENGTH_PER_TAG }), + min: 0, + max: MAX_TAGS_PER_CASE, + fieldName: 'tags', + }), + /** + * The title of a case + */ + title: limitedStringSchema({ fieldName: 'title', min: 1, max: MAX_TITLE_LENGTH }), + /** + * The external system that the case can be synced with + */ + connector: CaseConnectorRt, + /** + * The severity of the case + */ + severity: CaseSeverityRt, + /** + * The users assigned to this case + */ + assignees: limitedArraySchema({ + codec: CaseUserProfileRt, + fieldName: 'assignees', + min: 0, + max: MAX_ASSIGNEES_PER_CASE, + }), + /** + * The category of the case. + */ + category: rt.union([ + limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), + rt.null, + ]), + /** + * Custom fields of the case + */ + customFields: CaseRequestCustomFieldsRt, + /** + * The alert sync settings + */ + settings: CaseSettingsRt, + }) +); + +export const CaseRequestFieldsRt = rt.intersection([ + CaseBaseOptionalFieldsRequestRt, + rt.exact( + rt.partial({ + /** + * The current status of the case (open, closed, in-progress) + */ + status: CaseStatusRt, + + /** + * The plugin owner of the case + */ + owner: rt.string, + }) + ), +]); + /** * Create case */ @@ -356,71 +431,7 @@ export const CasesBulkGetResponseRt = rt.strict({ * Update cases */ export const CasePatchRequestRt = rt.intersection([ - rt.exact( - rt.partial({ - /** - * The description of the case - */ - description: limitedStringSchema({ - fieldName: 'description', - min: 1, - max: MAX_DESCRIPTION_LENGTH, - }), - /** - * The current status of the case (open, closed, in-progress) - */ - status: CaseStatusRt, - /** - * The identifying strings for filter a case - */ - tags: limitedArraySchema({ - codec: limitedStringSchema({ fieldName: 'tag', min: 1, max: MAX_LENGTH_PER_TAG }), - min: 0, - max: MAX_TAGS_PER_CASE, - fieldName: 'tags', - }), - /** - * The title of a case - */ - title: limitedStringSchema({ fieldName: 'title', min: 1, max: MAX_TITLE_LENGTH }), - /** - * The external system that the case can be synced with - */ - connector: CaseConnectorRt, - /** - * The alert sync settings - */ - settings: CaseSettingsRt, - /** - * The plugin owner of the case - */ - owner: rt.string, - /** - * The severity of the case - */ - severity: CaseSeverityRt, - /** - * The users assigned to this case - */ - assignees: limitedArraySchema({ - codec: CaseUserProfileRt, - fieldName: 'assignees', - min: 0, - max: MAX_ASSIGNEES_PER_CASE, - }), - /** - * The category of the case. - */ - category: rt.union([ - limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), - rt.null, - ]), - /** - * Custom fields of the case - */ - customFields: CaseRequestCustomFieldsRt, - }) - ), + CaseRequestFieldsRt, /** * The saved object ID and version */ diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts index 3369cb8473c0..c16dfbc60eaf 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts @@ -8,11 +8,24 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { v4 as uuidv4 } from 'uuid'; import { + MAX_ASSIGNEES_PER_CASE, + MAX_CATEGORY_LENGTH, MAX_CUSTOM_FIELDS_PER_CASE, MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_DESCRIPTION_LENGTH, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATES_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_KEY_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_TAG_LENGTH, + MAX_TITLE_LENGTH, } from '../../../constants'; +import { CaseSeverity } from '../../domain'; import { ConnectorTypes } from '../../domain/connector/v1'; import { CustomFieldTypes } from '../../domain/custom_field/v1'; import { @@ -23,6 +36,7 @@ import { CustomFieldConfigurationWithoutTypeRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, + TemplateConfigurationRt, } from './v1'; describe('configure', () => { @@ -90,6 +104,51 @@ describe('configure', () => { ); }); + it('has expected attributes in request with templates', () => { + const request = { + ...defaultRequest, + templates: [ + { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: ['foo', 'bar'], + caseFields: { + title: 'case using sample template', + }, + }, + { + key: 'template_key_2', + name: 'Template 2', + description: 'this is second template', + tags: [], + caseFields: null, + }, + ], + }; + const query = ConfigurationRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits templates to ${MAX_TEMPLATES_LENGTH}`, () => { + const templates = new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + caseFields: { + title: 'case using sample template', + }, + }); + + expect( + PathReporter.report(ConfigurationRequestRt.decode({ ...defaultRequest, templates }))[0] + ).toContain(`The length of the field templates is too long. Array must be of length <= 10.`); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -159,6 +218,51 @@ describe('configure', () => { ); }); + it('has expected attributes in request with templates', () => { + const request = { + ...defaultRequest, + templates: [ + { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: ['foo', 'bar'], + caseFields: { + title: 'case using sample template', + }, + }, + { + key: 'template_key_2', + name: 'Template 2', + description: 'this is second template', + caseFields: null, + }, + ], + }; + const query = ConfigurationPatchRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits templates to ${MAX_TEMPLATES_LENGTH}`, () => { + const templates = new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: [], + caseFields: { + title: 'case using sample template', + }, + }); + + expect( + PathReporter.report(ConfigurationPatchRequestRt.decode({ ...defaultRequest, templates }))[0] + ).toContain(`The length of the field templates is too long. Array must be of length <= 10.`); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -407,4 +511,325 @@ describe('configure', () => { ).toContain('Invalid value "foobar" supplied'); }); }); + + describe('TemplateConfigurationRt', () => { + const defaultRequest = { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: ['foo', 'bar'], + caseFields: { + title: 'case using sample template', + }, + }; + + it('has expected attributes in request', () => { + const query = TemplateConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TemplateConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('limits key to 36 characters', () => { + const longKey = 'x'.repeat(MAX_TEMPLATE_KEY_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key: longKey })) + ).toContain('The length of the key is too long. The maximum length is 36.'); + }); + + it('return error if key is empty', () => { + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key: '' })) + ).toContain('The key field cannot be an empty string.'); + }); + + it('returns an error if they key is not in the expected format', () => { + const key = 'Not a proper key'; + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key })) + ).toContain(`Key must be lower case, a-z, 0-9, '_', and '-' are allowed`); + }); + + it('accepts a uuid as an key', () => { + const key = uuidv4(); + + const query = TemplateConfigurationRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('accepts a slug as an key', () => { + const key = 'abc_key-1'; + + const query = TemplateConfigurationRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('does not throw when there is no description or tags', () => { + const newRequest = { + key: 'template_key_1', + name: 'Template 1', + caseFields: null, + }; + + expect(PathReporter.report(TemplateConfigurationRt.decode({ ...newRequest }))).toContain( + 'No errors!' + ); + }); + + it('limits name to 50 characters', () => { + const longName = 'x'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, name: longName })) + ).toContain('The length of the name is too long. The maximum length is 50.'); + }); + + it('limits description to 1000 characters', () => { + const longDesc = 'x'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ ...defaultRequest, description: longDesc }) + ) + ).toContain('The length of the description is too long. The maximum length is 1000.'); + }); + + it(`throws an error when there are more than ${MAX_TAGS_PER_TEMPLATE} tags`, async () => { + const tags = Array(MAX_TAGS_PER_TEMPLATE + 1).fill('foobar'); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, tags })) + ).toContain( + `The length of the field template's tags is too long. Array must be of length <= 10.` + ); + }); + + it(`throws an error when the a tag is more than ${MAX_TEMPLATE_TAG_LENGTH} characters`, async () => { + const tag = 'a'.repeat(MAX_TEMPLATE_TAG_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, tags: [tag] })) + ).toContain(`The length of the template's tag is too long. The maximum length is 50.`); + }); + + it(`throws an error when the a tag is empty string`, async () => { + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, tags: [''] })) + ).toContain(`The template's tag field cannot be an empty string.`); + }); + + describe('caseFields', () => { + it('removes foo:bar attributes from caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('accepts caseFields as null', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: null, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: null }, + }); + }); + + it('accepts caseFields as {}', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: {}, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: {} }, + }); + }); + + it('accepts caseFields with all fields', () => { + const caseFieldsAll = { + title: 'Case with sample template 1', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-1'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }; + + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: caseFieldsAll, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: caseFieldsAll }, + }); + }); + + it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => { + const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' }); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, assignees }, + }) + ) + ).toContain( + 'The length of the field assignees is too long. Array must be of length <= 10.' + ); + }); + + it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { + const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, description }, + }) + ) + ).toContain('The length of the description is too long. The maximum length is 30000.'); + }); + + it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => { + const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar'); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, tags }, + }) + ) + ).toContain('The length of the field tags is too long. Array must be of length <= 200.'); + }); + + it(`throws an error when the tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => { + const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, tags: [tag] }, + }) + ) + ).toContain('The length of the tag is too long. The maximum length is 256.'); + }); + + it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => { + const title = 'a'.repeat(MAX_TITLE_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, title }, + }) + ) + ).toContain('The length of the title is too long. The maximum length is 160.'); + }); + + it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => { + const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, category }, + }) + ) + ).toContain('The length of the category is too long. The maximum length is 50.'); + }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, customFields }, + }) + ) + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => { + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { + ...defaultRequest.caseFields, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: '#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1), + }, + ], + }, + }) + ) + ).toContain( + `The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.` + ); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.ts b/x-pack/plugins/cases/common/types/api/configure/v1.ts index 8e986677ae8a..bd2e1f5c11af 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -10,12 +10,19 @@ import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATES_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_KEY_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_TAG_LENGTH, } from '../../../constants'; import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema'; import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; import { CaseConnectorRt } from '../../domain/connector/v1'; +import { CaseBaseOptionalFieldsRequestRt } from '../case/v1'; import { CaseCustomFieldTextWithValidationValueRt } from '../custom_field/v1'; export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ @@ -64,6 +71,59 @@ export const CustomFieldsConfigurationRt = limitedArraySchema({ fieldName: 'customFields', }); +export const TemplateConfigurationRt = rt.intersection([ + rt.strict({ + /** + * key of template + */ + key: regexStringRt({ + codec: limitedStringSchema({ fieldName: 'key', min: 1, max: MAX_TEMPLATE_KEY_LENGTH }), + pattern: '^[a-z0-9_-]+$', + message: `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`, + }), + /** + * name of template + */ + name: limitedStringSchema({ fieldName: 'name', min: 1, max: MAX_TEMPLATE_NAME_LENGTH }), + /** + * case fields + */ + caseFields: rt.union([rt.null, CaseBaseOptionalFieldsRequestRt]), + }), + rt.exact( + rt.partial({ + /** + * description of templates + */ + description: limitedStringSchema({ + fieldName: 'description', + min: 0, + max: MAX_TEMPLATE_DESCRIPTION_LENGTH, + }), + /** + * tags of templates + */ + tags: limitedArraySchema({ + codec: limitedStringSchema({ + fieldName: `template's tag`, + min: 1, + max: MAX_TEMPLATE_TAG_LENGTH, + }), + min: 0, + max: MAX_TAGS_PER_TEMPLATE, + fieldName: `template's tags`, + }), + }) + ), +]); + +export const TemplatesConfigurationRt = limitedArraySchema({ + codec: TemplateConfigurationRt, + min: 0, + max: MAX_TEMPLATES_LENGTH, + fieldName: 'templates', +}); + export const ConfigurationRequestRt = rt.intersection([ rt.strict({ /** @@ -82,6 +142,7 @@ export const ConfigurationRequestRt = rt.intersection([ rt.exact( rt.partial({ customFields: CustomFieldsConfigurationRt, + templates: TemplatesConfigurationRt, }) ), ]); @@ -106,6 +167,7 @@ export const ConfigurationPatchRequestRt = rt.intersection([ closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type, connector: ConfigurationBasicWithoutOwnerRt.type.props.connector, customFields: CustomFieldsConfigurationRt, + templates: TemplatesConfigurationRt, }) ), rt.strict({ version: rt.string }), diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.ts b/x-pack/plugins/cases/common/types/domain/case/v1.ts index d8da843e46a0..83d48df363bd 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.ts @@ -52,15 +52,11 @@ export const CaseSettingsRt = rt.strict({ syncAlerts: rt.boolean, }); -const CaseBasicRt = rt.strict({ +const CaseBaseFields = { /** * The description of the case */ description: rt.string, - /** - * The current status of the case (open, closed, in-progress) - */ - status: CaseStatusRt, /** * The identifying strings for filter a case */ @@ -73,14 +69,6 @@ const CaseBasicRt = rt.strict({ * The external system that the case can be synced with */ connector: CaseConnectorRt, - /** - * The alert sync settings - */ - settings: CaseSettingsRt, - /** - * The plugin owner of the case - */ - owner: rt.string, /** * The severity of the case */ @@ -98,6 +86,28 @@ const CaseBasicRt = rt.strict({ * user-configured custom fields. */ customFields: CaseCustomFieldsRt, + /** + * The alert sync settings + */ + settings: CaseSettingsRt, +}; + +export const CaseBaseOptionalFieldsRt = rt.exact( + rt.partial({ + ...CaseBaseFields, + }) +); + +const CaseBasicRt = rt.strict({ + /** + * The current status of the case (open, closed, in-progress) + */ + status: CaseStatusRt, + /** + * The plugin owner of the case + */ + owner: rt.string, + ...CaseBaseFields, }); export const CaseAttributesRt = rt.intersection([ @@ -151,3 +161,4 @@ export type CaseAttributes = rt.TypeOf; export type CaseSettings = rt.TypeOf; export type RelatedCase = rt.TypeOf; export type AttachmentTotals = rt.TypeOf; +export type CaseBaseOptionalFields = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts index 400d69700fe1..13637fb4d8c6 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts @@ -6,12 +6,14 @@ */ import { PathReporter } from 'io-ts/lib/PathReporter'; +import { CaseSeverity } from '../case/v1'; import { ConnectorTypes } from '../connector/v1'; import { CustomFieldTypes } from '../custom_field/v1'; import { ConfigurationAttributesRt, ConfigurationRt, CustomFieldConfigurationWithoutTypeRt, + TemplateConfigurationRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, } from './v1'; @@ -45,11 +47,59 @@ describe('configure', () => { required: false, }; + const templateWithAllCaseFields = { + key: 'template_sample_1', + name: 'Sample template 1', + description: 'this is first sample template', + tags: ['foo', 'bar', 'foobar'], + caseFields: { + title: 'Case with sample template 1', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-1'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }, + }; + + const templateWithFewCaseFields = { + key: 'template_sample_2', + name: 'Sample template 2', + tags: [], + caseFields: { + title: 'Case with sample template 2', + tags: ['sample-2'], + }, + }; + + const templateWithNoCaseFields = { + key: 'template_sample_3', + name: 'Sample template 3', + caseFields: null, + }; + describe('ConfigurationAttributesRt', () => { const defaultRequest = { connector: resilient, closure_type: 'close-by-user', customFields: [textCustomField, toggleCustomField], + templates: [], owner: 'cases', created_at: '2020-02-19T23:06:33.798Z', created_by: { @@ -110,6 +160,7 @@ describe('configure', () => { connector: serviceNow, closure_type: 'close-by-user', customFields: [], + templates: [templateWithAllCaseFields, templateWithFewCaseFields, templateWithNoCaseFields], created_at: '2020-02-19T23:06:33.798Z', created_by: { full_name: 'Leslie Knope', @@ -299,4 +350,71 @@ describe('configure', () => { }); }); }); + + describe('TemplateConfigurationRt', () => { + const defaultRequest = templateWithAllCaseFields; + + it('has expected attributes in request ', () => { + const query = TemplateConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TemplateConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...templateWithAllCaseFields.caseFields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('accepts few caseFields', () => { + const query = TemplateConfigurationRt.decode(templateWithFewCaseFields); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...templateWithFewCaseFields }, + }); + }); + + it('accepts null for caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: null, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: null }, + }); + }); + + it('accepts {} for caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: {}, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: {} }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.ts index 65882ad40753..1e4e30c95e38 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -9,6 +9,7 @@ import * as rt from 'io-ts'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1'; import { UserRt } from '../user/v1'; import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1'; +import { CaseBaseOptionalFieldsRt } from '../case/v1'; export const ClosureTypeRt = rt.union([ rt.literal('close-by-user'), @@ -57,6 +58,37 @@ export const CustomFieldConfigurationRt = rt.union([ export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); +export const TemplateConfigurationRt = rt.intersection([ + rt.strict({ + /** + * key of template + */ + key: rt.string, + /** + * name of template + */ + name: rt.string, + /** + * case fields of template + */ + caseFields: rt.union([rt.null, CaseBaseOptionalFieldsRt]), + }), + rt.exact( + rt.partial({ + /** + * description of template + */ + description: rt.string, + /** + * tags of template + */ + tags: rt.array(rt.string), + }) + ), +]); + +export const TemplatesConfigurationRt = rt.array(TemplateConfigurationRt); + export const ConfigurationBasicWithoutOwnerRt = rt.strict({ /** * The external connector @@ -70,6 +102,10 @@ export const ConfigurationBasicWithoutOwnerRt = rt.strict({ * The custom fields configured for the case */ customFields: CustomFieldsConfigurationRt, + /** + * Templates configured for the case + */ + templates: TemplatesConfigurationRt, }); export const CasesConfigureBasicRt = rt.intersection([ @@ -109,6 +145,8 @@ export const ConfigurationsRt = rt.array(ConfigurationRt); export type CustomFieldsConfiguration = rt.TypeOf; export type CustomFieldConfiguration = rt.TypeOf; +export type TemplatesConfiguration = rt.TypeOf; +export type TemplateConfiguration = rt.TypeOf; export type ClosureType = rt.TypeOf; export type ConfigurationAttributes = rt.TypeOf; export type Configuration = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 3854c14c79de..6d75b30dd119 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -119,10 +119,18 @@ export interface ResolvedCase { export type CasesConfigurationUI = Pick< SnakeToCamelCase, - 'closureType' | 'connector' | 'mappings' | 'customFields' | 'id' | 'version' | 'owner' + | 'closureType' + | 'connector' + | 'mappings' + | 'customFields' + | 'templates' + | 'id' + | 'version' + | 'owner' >; export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; +export type CasesConfigurationUITemplate = CasesConfigurationUI['templates'][number]; export type SortOrder = 'asc' | 'desc'; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 56fc3a68f487..242fc3260b7e 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -816,7 +816,8 @@ describe('AllCasesListGeneric', () => { }); }); - describe('Row actions', () => { + // FLAKY: https://github.com/elastic/kibana/issues/148095 + describe.skip('Row actions', () => { const statusTests = [ [CaseStatuses.open], [CaseStatuses['in-progress']], diff --git a/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx index 50ea3a82974b..10bdd185ef9f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/multi_select_filter.test.tsx @@ -10,7 +10,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; -describe('multi select filter', () => { +// FLAKY: https://github.com/elastic/kibana/issues/183663 +describe.skip('multi select filter', () => { it('should render the amount of options available', async () => { const onChange = jest.fn(); const props = { diff --git a/x-pack/plugins/cases/public/components/create/assignees.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx similarity index 56% rename from x-pack/plugins/cases/public/components/create/assignees.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx index 83b7802ce4a1..f0b73cb8bf99 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx @@ -15,7 +15,6 @@ import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_l import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { userProfiles } from '../../containers/user_profiles/api.mock'; import { Assignees } from './assignees'; -import type { FormProps } from './schema'; import { act, waitFor, screen } from '@testing-library/react'; import * as api from '../../containers/user_profiles/api'; import type { UserProfile } from '@kbn/user-profile-components'; @@ -29,7 +28,7 @@ describe('Assignees', () => { let appMockRender: AppMockRenderer; const MockHookWrapperComponent: FC> = ({ children }) => { - const { form } = useForm(); + const { form } = useForm(); globalForm = form; return
{children}
; @@ -41,113 +40,99 @@ describe('Assignees', () => { }); it('renders', async () => { - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); it('does not render the assign yourself link when the current user profile is undefined', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(undefined as unknown as UserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - expect(result.queryByTestId('create-case-assign-yourself-link')).not.toBeInTheDocument(); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(screen.queryByTestId('create-case-assign-yourself-link')).not.toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); it('selects the current user correctly', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(result.getByTestId('create-case-assign-yourself-link')); - }); + userEvent.click(await screen.findByTestId('create-case-assign-yourself-link')); - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); - }); + expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); }); it('disables the assign yourself button if the current user is already selected', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(result.getByTestId('create-case-assign-yourself-link')); - }); + userEvent.click(await screen.findByTestId('create-case-assign-yourself-link')); await waitFor(() => { expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); }); - expect(result.getByTestId('create-case-assign-yourself-link')).toBeDisabled(); + expect(await screen.findByTestId('create-case-assign-yourself-link')).toBeDisabled(); }); it('assignees users correctly', async () => { - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - await act(async () => { - await userEvent.type(result.getByTestId('comboBoxSearchInput'), 'dr', { delay: 1 }); - }); + await userEvent.type(await screen.findByTestId('comboBoxSearchInput'), 'dr', { delay: 1 }); - await waitFor(() => { - expect( - result.getByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') - ).toBeInTheDocument(); - }); + expect( + await screen.findByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') + ).toBeInTheDocument(); - await waitFor(async () => { - expect(result.getByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument(); - }); + expect(await screen.findByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument(); - act(() => { - userEvent.click(result.getByText(`${currentUserProfile.user.full_name}`)); - }); + userEvent.click(await screen.findByText(`${currentUserProfile.user.full_name}`)); await waitFor(() => { expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); @@ -186,25 +171,62 @@ describe('Assignees', () => { ); await waitFor(() => { - expect(screen.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); + userEvent.click(await screen.findByTestId('comboBoxSearchInput')); + + expect(await screen.findByText('Turtle')).toBeInTheDocument(); + expect(await screen.findByText('turtle')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Turtle'), undefined, { skipPointerEventsCheck: true }); + + // ensure that the similar user is still available for selection + expect(await screen.findByText('turtle')).toBeInTheDocument(); + }); + + it('fetches the unknown user profiles using bulk_get', async () => { + // the profile is not returned by the suggest API + const userProfile = { + uid: 'u_qau3P4T1H-_f1dNHyEOWJzVkGQhLH1gnNMVvYxqmZcs_0', + enabled: true, + data: {}, + user: { + username: 'uncertain_crawdad', + email: 'uncertain_crawdad@profiles.elastic.co', + full_name: 'Uncertain Crawdad', + }, + }; + + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + spyOnBulkGetUserProfiles.mockResolvedValue([userProfile]); + + appMockRender.render( + + + + ); + + expect(screen.queryByText(userProfile.user.full_name)).not.toBeInTheDocument(); + act(() => { - userEvent.click(screen.getByTestId('comboBoxSearchInput')); + globalForm.setFieldValue('assignees', [{ uid: userProfile.uid }]); }); await waitFor(() => { - expect(screen.getByText('Turtle')).toBeInTheDocument(); - expect(screen.getByText('turtle')).toBeInTheDocument(); + expect(globalForm.getFormData()).toEqual({ + assignees: [{ uid: userProfile.uid }], + }); }); - act(() => { - userEvent.click(screen.getByText('Turtle'), undefined, { skipPointerEventsCheck: true }); - }); - - // ensure that the similar user is still available for selection await waitFor(() => { - expect(screen.getByText('turtle')).toBeInTheDocument(); + expect(spyOnBulkGetUserProfiles).toBeCalledTimes(1); + expect(spyOnBulkGetUserProfiles).toHaveBeenCalledWith({ + security: expect.anything(), + uids: [userProfile.uid], + }); }); + + expect(await screen.findByText(userProfile.user.full_name)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx similarity index 61% rename from x-pack/plugins/cases/public/components/create/assignees.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx index 1e8464dc1a2e..6e56e7d154a2 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { isEmpty } from 'lodash'; +import { isEmpty, differenceWith } from 'lodash'; import React, { memo, useCallback, useState } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { @@ -23,31 +23,35 @@ import type { FieldConfig, FieldHook } from '@kbn/es-ui-shared-plugin/static/for import { UseField, getFieldValidityAndErrorMessage, + useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { CaseAssignees } from '../../../common/types/domain'; import { MAX_ASSIGNEES_PER_CASE } from '../../../common/constants'; import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; -import { OptionalFieldLabel } from './optional_field_label'; -import * as i18n from './translations'; +import { OptionalFieldLabel } from '../optional_field_label'; +import * as i18n from '../create/translations'; import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { getAllPermissionsExceptFrom } from '../../utils/permissions'; import { useIsUserTyping } from '../../common/use_is_user_typing'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; + +const FIELD_ID = 'assignees'; interface Props { isLoading: boolean; } +type UserProfileComboBoxOption = EuiComboBoxOptionOption & UserProfileWithAvatar; + interface FieldProps { - field: FieldHook; - options: EuiComboBoxOptionOption[]; + field: FieldHook; + options: UserProfileComboBoxOption[]; isLoading: boolean; isDisabled: boolean; currentUserProfile?: UserProfile; - selectedOptions: EuiComboBoxOptionOption[]; - setSelectedOptions: React.Dispatch>; onSearchComboChange: (value: string) => void; } @@ -73,28 +77,32 @@ const userProfileToComboBoxOption = (userProfile: UserProfileWithAvatar) => ({ data: userProfile.data, }); -const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ uid: option.value }); +const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ + uid: option.value ?? '', +}); const AssigneesFieldComponent: React.FC = React.memo( - ({ - field, - isLoading, - isDisabled, - options, - currentUserProfile, - selectedOptions, - setSelectedOptions, - onSearchComboChange, - }) => { - const { setValue } = field; + ({ field, isLoading, isDisabled, options, currentUserProfile, onSearchComboChange }) => { + const { setValue, value: selectedAssignees } = field; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const selectedOptions: UserProfileComboBoxOption[] = selectedAssignees + .map(({ uid }) => { + const selectedUserProfile = options.find((userProfile) => userProfile.key === uid); + + if (selectedUserProfile) { + return selectedUserProfile; + } + + return null; + }) + .filter((value): value is UserProfileComboBoxOption => value != null); + const onComboChange = useCallback( - (currentOptions: EuiComboBoxOptionOption[]) => { - setSelectedOptions(currentOptions); + (currentOptions: Array>) => { setValue(currentOptions.map((option) => comboBoxOptionToAssignee(option))); }, - [setSelectedOptions, setValue] + [setValue] ); const onSelfAssign = useCallback(() => { @@ -102,62 +110,51 @@ const AssigneesFieldComponent: React.FC = React.memo( return; } - setSelectedOptions((prev) => [ - ...(prev ?? []), - userProfileToComboBoxOption(currentUserProfile), - ]); - - setValue([ - ...(selectedOptions?.map((option) => comboBoxOptionToAssignee(option)) ?? []), - { uid: currentUserProfile.uid }, - ]); - }, [currentUserProfile, selectedOptions, setSelectedOptions, setValue]); + setValue([...selectedAssignees, { uid: currentUserProfile.uid }]); + }, [currentUserProfile, selectedAssignees, setValue]); - const renderOption = useCallback( - (option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => { - const { user, data } = option as EuiComboBoxOptionOption & UserProfileWithAvatar; + const renderOption = useCallback((option, searchValue: string, contentClassName: string) => { + const { user, data } = option as UserProfileComboBoxOption; - const displayName = getUserDisplayName(user); + const displayName = getUserDisplayName(user); - return ( + return ( + + + + - - + + + {displayName} + - - - - {displayName} - + {user.email && user.email !== displayName ? ( + + + + {user.email} + + - {user.email && user.email !== displayName ? ( - - - - {user.email} - - - - ) : null} - + ) : null} - ); - }, - [] - ); + + ); + }, []); const isCurrentUserSelected = Boolean( - selectedOptions?.find((option) => option.value === currentUserProfile?.uid) + selectedAssignees?.find((assignee) => assignee.uid === currentUserProfile?.uid) ); return ( @@ -179,6 +176,7 @@ const AssigneesFieldComponent: React.FC = React.memo( } isInvalid={isInvalid} error={errorMessage} + data-test-subj="caseAssignees" > = ({ isLoading: isLoadingForm }) => { const { owner: owners } = useCasesContext(); + const [{ assignees }] = useFormData<{ assignees?: CaseAssignees }>({ watch: [FIELD_ID] }); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const [searchTerm, setSearchTerm] = useState(''); - const [selectedOptions, setSelectedOptions] = useState(); const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const hasOwners = owners.length > 0; @@ -212,7 +210,7 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { useGetCurrentUserProfile(); const { - data: userProfiles, + data: userProfiles = [], isLoading: isLoadingSuggest, isFetching: isFetchingSuggest, } = useSuggestUserProfiles({ @@ -221,10 +219,22 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { onDebounce, }); + const assigneesWithoutProfiles = differenceWith( + assignees ?? [], + userProfiles ?? [], + (assignee, userProfile) => assignee.uid === userProfile.uid + ); + + const { data: bulkUserProfiles = new Map(), isFetching: isLoadingBulkGetUserProfiles } = + useBulkGetUserProfiles({ uids: assigneesWithoutProfiles.map((assignee) => assignee.uid) }); + + const bulkUserProfilesAsArray = Array.from(bulkUserProfiles).map(([_, profile]) => profile); + const options = - bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles)?.map((userProfile) => - userProfileToComboBoxOption(userProfile) - ) ?? []; + bringCurrentUserToFrontAndSort(currentUserProfile, [ + ...userProfiles, + ...bulkUserProfilesAsArray, + ])?.map((userProfile) => userProfileToComboBoxOption(userProfile)) ?? []; const onSearchComboChange = (value: string) => { if (!isEmpty(value)) { @@ -237,22 +247,21 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { const isLoading = isLoadingForm || isLoadingCurrentUserProfile || + isLoadingBulkGetUserProfiles || isLoadingSuggest || isFetchingSuggest || isUserTyping; - const isDisabled = isLoadingForm || isLoadingCurrentUserProfile; + const isDisabled = isLoadingForm || isLoadingCurrentUserProfile || isLoadingBulkGetUserProfiles; return ( { const onSubmit = jest.fn(); const FormComponent: FC> = ({ children }) => { - const { form } = useForm({ onSubmit }); + const { form } = useForm({ onSubmit }); return (
diff --git a/x-pack/plugins/cases/public/components/create/category.tsx b/x-pack/plugins/cases/public/components/case_form_fields/category.tsx similarity index 93% rename from x-pack/plugins/cases/public/components/create/category.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/category.tsx index 879a8dfb9bbe..d5df6118094e 100644 --- a/x-pack/plugins/cases/public/components/create/category.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/category.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { useGetCategories } from '../../containers/use_get_categories'; import { CategoryFormField } from '../category/category_form_field'; -import { OptionalFieldLabel } from './optional_field_label'; +import { OptionalFieldLabel } from '../optional_field_label'; interface Props { isLoading: boolean; diff --git a/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx new file mode 100644 index 000000000000..0f80652c9ac0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { connectorsMock } from '../../containers/mock'; +import { Connector } from './connector'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { incidentTypes, severity, choices } from '../connectors/mock'; +import { noConnectorsCasePermission, createAppMockRenderer } from '../../common/mock'; + +import { FormTestComponent } from '../../common/test_utils'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; + +const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes, +}; + +const useGetSeverityResponse = { + isLoading: false, + severity, +}; + +const useGetChoicesResponse = { + isLoading: false, + choices, +}; + +const defaultProps = { + connectors: connectorsMock, + isLoading: false, + isLoadingConnectors: false, +}; + +describe('Connector', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(screen.queryByTestId('connector-fields')).not.toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByLabelText('Loading')).toBeInTheDocument(); + expect(await screen.findByTestId('dropdown-connectors')).toBeDisabled(); + }); + + it('renders default connector correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByText('Jira')).toBeInTheDocument(); + + expect(await screen.findByTestId('connector-fields-jira')).toBeInTheDocument(); + }); + + it('shows all connectors in dropdown', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('dropdown-connectors')); + + await waitForEuiPopoverOpen(); + + expect( + await screen.findByTestId(`dropdown-connector-${connectorsMock[0].id}`) + ).toBeInTheDocument(); + expect( + await screen.findByTestId(`dropdown-connector-${connectorsMock[1].id}`) + ).toBeInTheDocument(); + }); + + it('changes connector correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('dropdown-connectors')); + + await waitForEuiPopoverOpen(); + + userEvent.click(await screen.findByTestId('dropdown-connector-resilient-2')); + + expect(await screen.findByTestId('connector-fields-resilient')).toBeInTheDocument(); + }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + appMockRender.render( + + + + ); + expect( + await screen.findByTestId('create-case-connector-permissions-error-msg') + ).toBeInTheDocument(); + expect(screen.queryByTestId('caseConnectors')).not.toBeInTheDocument(); + }); + + it('shows the actions permission message if the user does not have access to case connector', async () => { + appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); + + appMockRender.render( + + + + ); + expect(screen.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); + expect(screen.queryByTestId('caseConnectors')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/case_form_fields/connector.tsx similarity index 62% rename from x-pack/plugins/cases/public/components/create/connector.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/connector.tsx index 39e04f7bc0be..5ed37c262ec1 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/connector.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiFormRow } from '@elastic/eui'; import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; @@ -14,7 +14,6 @@ import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; import { schema } from './schema'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { useApplicationCapabilities } from '../../common/lib/kibana'; import * as i18n from '../../common/translations'; @@ -29,21 +28,10 @@ interface Props { const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingConnectors }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const connector = getConnectorById(connectorId, connectors) ?? null; - - const { - data: { connector: configurationConnector }, - } = useGetCaseConfiguration(); - const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; - const defaultConnectorId = useMemo(() => { - return connectors.some((c) => c.id === configurationConnector.id) - ? configurationConnector.id - : 'none'; - }, [configurationConnector.id, connectors]); - const connectorIdConfig = getConnectorsFormValidators({ config: schema.connectorId as FieldConfig, connectors, @@ -58,26 +46,27 @@ const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingC } return ( - - - - - - - - + + + + + + + + + + ); }; diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx similarity index 67% rename from x-pack/plugins/cases/public/components/create/custom_fields.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx index 8ab517c497cd..95f7ef1aaa09 100644 --- a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx @@ -15,40 +15,32 @@ import { FormTestComponent } from '../../common/test_utils'; import { customFieldsConfigurationMock } from '../../containers/mock'; import { CustomFields } from './custom_fields'; import * as i18n from './translations'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; -import { useGetAllCaseConfigurationsResponse } from '../configure_cases/__mock__'; - -jest.mock('../../containers/configure/use_get_all_case_configurations'); - -const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; describe('CustomFields', () => { let appMockRender: AppMockRenderer; const onSubmit = jest.fn(); + const defaultProps = { + configurationCustomFields: customFieldsConfigurationMock, + isLoading: false, + setCustomFieldsOptional: false, + isEditMode: false, + }; + beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: customFieldsConfigurationMock, - }, - ], - })); }); it('renders correctly', async () => { appMockRender.render( - + ); expect(await screen.findByText(i18n.ADDITIONAL_FIELDS)).toBeInTheDocument(); - expect(await screen.findByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); for (const item of customFieldsConfigurationMock) { expect( @@ -58,19 +50,13 @@ describe('CustomFields', () => { }); it('should not show the custom fields if the configuration is empty', async () => { - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: [], - }, - ], - })); - appMockRender.render( - + ); @@ -78,26 +64,51 @@ describe('CustomFields', () => { expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0); }); + it('should render as optional fields for text custom fields', async () => { + appMockRender.render( + + + + ); + + expect(screen.getAllByTestId('form-optional-field-label')).toHaveLength(2); + }); + + it('should not set default value when in edit mode', async () => { + appMockRender.render( + + + + ); + + expect( + screen.queryByText(`${customFieldsConfigurationMock[0].defaultValue}`) + ).not.toBeInTheDocument(); + }); + it('should sort the custom fields correctly', async () => { const reversedCustomFieldsConfiguration = [...customFieldsConfigurationMock].reverse(); - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: reversedCustomFieldsConfiguration, - }, - ], - })); - appMockRender.render( - + ); - const customFieldsWrapper = await screen.findByTestId('create-case-custom-fields'); + const customFieldsWrapper = await screen.findByTestId('caseCustomFields'); const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); @@ -110,11 +121,9 @@ describe('CustomFields', () => { }); it('should update the custom fields', async () => { - appMockRender = createAppMockRenderer(); - appMockRender.render( - + ); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx new file mode 100644 index 000000000000..f2b39b352a96 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { sortBy } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiFormRow } from '@elastic/eui'; + +import type { CasesConfigurationUI } from '../../../common/ui'; +import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; +import * as i18n from './translations'; + +interface Props { + isLoading: boolean; + configurationCustomFields: CasesConfigurationUI['customFields']; + setCustomFieldsOptional?: boolean; + isEditMode?: boolean; +} + +const CustomFieldsComponent: React.FC = ({ + isLoading, + setCustomFieldsOptional = false, + configurationCustomFields, + isEditMode, +}) => { + const sortedCustomFields = useMemo( + () => sortCustomFieldsByLabel(configurationCustomFields), + [configurationCustomFields] + ); + + const customFieldsComponents = sortedCustomFields.map( + (customField: CasesConfigurationUI['customFields'][number]) => { + const customFieldFactory = customFieldsBuilderMap[customField.type]; + const customFieldType = customFieldFactory().build(); + + const CreateComponent = customFieldType.Create; + + return ( + + ); + } + ); + + if (!configurationCustomFields.length) { + return null; + } + + return ( + + + +

{i18n.ADDITIONAL_FIELDS}

+
+ + {customFieldsComponents} +
+
+ ); +}; + +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); + +const sortCustomFieldsByLabel = (configCustomFields: CasesConfigurationUI['customFields']) => { + return sortBy(configCustomFields, (configCustomField) => { + return configCustomField.label; + }); +}; diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx similarity index 98% rename from x-pack/plugins/cases/public/components/create/description.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx index 5acd5a3b4f5c..8d841da78b36 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx @@ -10,7 +10,7 @@ import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Description } from './description'; -import { schema } from './schema'; +import { schema } from '../create/schema'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { MAX_DESCRIPTION_LENGTH } from '../../../common/constants'; diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/case_form_fields/description.tsx similarity index 98% rename from x-pack/plugins/cases/public/components/create/description.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/description.tsx index 5c512e701c12..881ea13c19c3 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/description.tsx @@ -12,7 +12,7 @@ import { ID as LensPluginId } from '../markdown_editor/plugins/lens/constants'; interface Props { isLoading: boolean; - draftStorageKey: string; + draftStorageKey?: string; } export const fieldName = 'description'; diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx new file mode 100644 index 000000000000..e095a8a915b7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx @@ -0,0 +1,330 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; + +import { CaseFormFields } from '.'; +import userEvent from '@testing-library/user-event'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +jest.mock('../../containers/user_profiles/api'); + +describe('CaseFormFields', () => { + let appMock: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { tags: [] }; + const defaultProps = { + isLoading: false, + configurationCustomFields: [], + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + appMock.render( + + + + ); + + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + }); + + it('renders case fields correctly', async () => { + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + }); + + it('does not render customFields when empty', () => { + appMock.render( + + + + ); + + expect(screen.queryByTestId('caseCustomFields')).not.toBeInTheDocument(); + }); + + it('renders customFields when not empty', async () => { + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + }); + + it('does not render assignees when no platinum license', () => { + appMock.render( + + + + ); + + expect(screen.queryByTestId('createCaseAssigneesComboBox')).not.toBeInTheDocument(); + }); + + it('renders assignees when platinum license', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + + + + ); + + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + }); + + it('calls onSubmit with case fields', async () => { + appMock.render( + + + + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + }, + true + ); + }); + }); + + it('calls onSubmit with existing case fields', async () => { + appMock.render( + + + + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: ['case-tag-1', 'case-tag-2'], + description: 'This is a case description', + title: 'Case with Template 1', + }, + true + ); + }); + }); + + it('calls onSubmit with custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, + }, + }, + true + ); + }); + }); + + it('calls onSubmit with existing custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + customFields: { + test_key_1: 'Test custom filed value', + test_key_2: true, + test_key_4: false, + }, + }, + true + ); + }); + }); + + it('calls onSubmit with assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + + + + ); + + const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); + + userEvent.click(await within(assigneesComboBox).findByTestId('comboBoxToggleListButton')); + + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByText(`${userProfiles[0].user.full_name}`)); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + assignees: [{ uid: userProfiles[0].uid }], + }, + true + ); + }); + }); + + it('calls onSubmit with existing assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + + + + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + assignees: [{ uid: userProfiles[1].uid }], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx new file mode 100644 index 000000000000..5232529e59ce --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { EuiFlexGroup } from '@elastic/eui'; +import { Title } from './title'; +import { Tags } from './tags'; +import { Category } from './category'; +import { Severity } from './severity'; +import { Description } from './description'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { Assignees } from './assignees'; +import { CustomFields } from './custom_fields'; +import type { CasesConfigurationUI } from '../../containers/types'; + +interface Props { + isLoading: boolean; + configurationCustomFields: CasesConfigurationUI['customFields']; + setCustomFieldsOptional?: boolean; + isEditMode?: boolean; + draftStorageKey?: string; +} + +const CaseFormFieldsComponent: React.FC = ({ + isLoading, + configurationCustomFields, + setCustomFieldsOptional = false, + isEditMode, + draftStorageKey, +}) => { + const { caseAssignmentAuthorized } = useCasesFeatures(); + + return ( + + + {caseAssignmentAuthorized ? <Assignees isLoading={isLoading} /> : null} + <Tags isLoading={isLoading} /> + <Category isLoading={isLoading} /> + <Severity isLoading={isLoading} /> + <Description isLoading={isLoading} draftStorageKey={draftStorageKey} /> + <CustomFields + isLoading={isLoading} + setCustomFieldsOptional={setCustomFieldsOptional} + configurationCustomFields={configurationCustomFields} + isEditMode={isEditMode} + /> + </EuiFlexGroup> + ); +}; + +CaseFormFieldsComponent.displayName = 'CaseFormFields'; + +export const CaseFormFields = memo(CaseFormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx new file mode 100644 index 000000000000..9c501dafff88 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { CasePostRequest } from '../../../common'; +import { + MAX_DESCRIPTION_LENGTH, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TITLE_LENGTH, +} from '../../../common/constants'; +import { SEVERITY_TITLE } from '../severity/translations'; +import type { ConnectorTypeFields } from '../../../common/types/domain'; +import * as i18n from './translations'; +import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; +import { OptionalFieldLabel } from '../optional_field_label'; + +const { maxLengthField } = fieldValidators; + +export type CaseFormFieldsSchemaProps = Omit< + CasePostRequest, + 'connector' | 'settings' | 'owner' | 'customFields' +> & { + connectorId: string; + fields: ConnectorTypeFields['fields']; + syncAlerts: boolean; + customFields: Record<string, string | boolean>; +}; + +export const schema: FormSchema<CaseFormFieldsSchemaProps> = { + title: { + label: i18n.NAME, + validations: [ + { + validator: maxLengthField({ + length: MAX_TITLE_LENGTH, + message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), + }), + }, + ], + }, + description: { + label: i18n.DESCRIPTION, + validations: [ + { + validator: maxLengthField({ + length: MAX_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), + }), + }, + ], + }, + tags: { + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), + limit: MAX_LENGTH_PER_TAG, + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), + limit: MAX_TAGS_PER_CASE, + }), + }, + ], + }, + severity: { + label: SEVERITY_TITLE, + }, + assignees: { labelAppend: OptionalFieldLabel }, + category: { + labelAppend: OptionalFieldLabel, + }, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + defaultValue: true, + }, + customFields: {}, + connectorId: { + label: i18n.CONNECTORS, + defaultValue: 'none', + }, + fields: { + defaultValue: null, + }, +}; diff --git a/x-pack/plugins/cases/public/components/create/severity.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/severity.test.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/severity.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/severity.test.tsx diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/case_form_fields/severity.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/severity.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/severity.tsx diff --git a/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx new file mode 100644 index 000000000000..959dcba6d4e7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SyncAlertsToggle } from './sync_alerts_toggle'; +import { schema } from '../create/schema'; +import { FormTestComponent } from '../../common/test_utils'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +describe('SyncAlertsToggle', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + const defaultFormProps = { + onSubmit, + formDefaultValue: { syncAlerts: true }, + schema: { + syncAlerts: schema.syncAlerts, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('it renders', async () => { + appMockRender.render( + <FormTestComponent> + <SyncAlertsToggle isLoading={false} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByRole('switch')).toHaveAttribute('aria-checked', 'true'); + expect(await screen.findByText('On')).toBeInTheDocument(); + }); + + it('it toggles the switch', async () => { + appMockRender.render( + <FormTestComponent> + <SyncAlertsToggle isLoading={false} /> + </FormTestComponent> + ); + + const synAlerts = await screen.findByTestId('caseSyncAlerts'); + + userEvent.click(within(synAlerts).getByRole('switch')); + + expect(await screen.findByRole('switch')).toHaveAttribute('aria-checked', 'false'); + expect(await screen.findByText('Off')).toBeInTheDocument(); + }); + + it('calls onSubmit with correct data', async () => { + appMockRender.render( + <FormTestComponent {...defaultFormProps}> + <SyncAlertsToggle isLoading={false} /> + </FormTestComponent> + ); + + const synAlerts = await screen.findByTestId('caseSyncAlerts'); + + userEvent.click(within(synAlerts).getByRole('switch')); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + syncAlerts: false, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx similarity index 76% rename from x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx index 1a189de3e17e..de9395946ffa 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx @@ -6,11 +6,9 @@ */ import React, { memo } from 'react'; -import { getUseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import * as i18n from './translations'; - -const CommonUseField = getUseField({ component: Field }); +import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import * as i18n from '../create/translations'; interface Props { isLoading: boolean; @@ -18,9 +16,12 @@ interface Props { const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading }) => { const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); + return ( - <CommonUseField + <UseField path="syncAlerts" + component={ToggleField} + config={{ defaultValue: true }} componentProps={{ idAria: 'caseSyncAlerts', 'data-test-subj': 'caseSyncAlerts', diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx similarity index 95% rename from x-pack/plugins/cases/public/components/create/tags.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx index ed78d78928f0..78f0cfce49f5 100644 --- a/x-pack/plugins/cases/public/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx @@ -13,12 +13,12 @@ import userEvent from '@testing-library/user-event'; import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Tags } from './tags'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { schema } from '../create/schema'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { MAX_LENGTH_PER_TAG } from '../../../common/constants'; +import type { CaseFormFieldsSchemaProps } from './schema'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/use_get_tags'); @@ -30,7 +30,7 @@ describe('Tags', () => { let appMockRender: AppMockRenderer; const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ + const { form } = useForm<CaseFormFieldsSchemaProps>({ defaultValue: { tags: [] }, schema: { tags: schema.tags, diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/case_form_fields/tags.tsx similarity index 80% rename from x-pack/plugins/cases/public/components/create/tags.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/tags.tsx index f3d4319dfea3..422e89a91afd 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/tags.tsx @@ -7,13 +7,10 @@ import React, { memo, useMemo } from 'react'; -import { getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { useGetTags } from '../../containers/use_get_tags'; -import * as i18n from './translations'; - -const CommonUseField = getUseField({ component: Field }); - +import * as i18n from '../create/translations'; interface Props { isLoading: boolean; } @@ -29,8 +26,9 @@ const TagsComponent: React.FC<Props> = ({ isLoading }) => { ); return ( - <CommonUseField + <UseField path="tags" + component={ComboBoxField} componentProps={{ idAria: 'caseTags', 'data-test-subj': 'caseTags', diff --git a/x-pack/plugins/cases/public/components/create/title.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx similarity index 88% rename from x-pack/plugins/cases/public/components/create/title.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx index 382ee67cc494..3caf90433f8f 100644 --- a/x-pack/plugins/cases/public/components/create/title.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx @@ -13,14 +13,15 @@ import { act } from '@testing-library/react'; import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Title } from './title'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { schema } from '../create/schema'; +import type { CaseFormFieldsSchemaProps } from './schema'; -describe('Title', () => { +// FLAKY: https://github.com/elastic/kibana/issues/187364 +describe.skip('Title', () => { let globalForm: FormHook; const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ + const { form } = useForm<CaseFormFieldsSchemaProps>({ defaultValue: { title: 'My title' }, schema: { title: schema.title, diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/case_form_fields/title.tsx similarity index 87% rename from x-pack/plugins/cases/public/components/create/title.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/title.tsx index 35de4c7a41cc..8727a3cc0196 100644 --- a/x-pack/plugins/cases/public/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/title.tsx @@ -12,16 +12,17 @@ const CommonUseField = getUseField({ component: Field }); interface Props { isLoading: boolean; + autoFocus?: boolean; } -const TitleComponent: React.FC<Props> = ({ isLoading }) => ( +const TitleComponent: React.FC<Props> = ({ isLoading, autoFocus = false }) => ( <CommonUseField path="title" componentProps={{ idAria: 'caseTitle', 'data-test-subj': 'caseTitle', euiFieldProps: { - autoFocus: true, + autoFocus, fullWidth: true, disabled: isLoading, }, diff --git a/x-pack/plugins/cases/public/components/case_form_fields/translations.ts b/x-pack/plugins/cases/public/components/case_form_fields/translations.ts new file mode 100644 index 000000000000..b8359958025b --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const ADDITIONAL_FIELDS = i18n.translate('xpack.cases.additionalFields', { + defaultMessage: 'Additional fields', +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts b/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts new file mode 100644 index 000000000000..a8a948d88a15 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; +import * as i18n from './translations'; + +describe('utils', () => { + describe('validateEmptyTags', () => { + const message = i18n.TAGS_EMPTY_ERROR; + it('returns no error for non empty tags', () => { + expect(validateEmptyTags({ value: ['coke', 'pepsi'], message })).toBeUndefined(); + }); + + it('returns no error for non empty tag', () => { + expect(validateEmptyTags({ value: 'coke', message })).toBeUndefined(); + }); + + it('returns error for empty tags', () => { + expect(validateEmptyTags({ value: [' ', 'pepsi'], message })).toEqual({ message }); + }); + + it('returns error for empty tag', () => { + expect(validateEmptyTags({ value: ' ', message })).toEqual({ message }); + }); + }); + + describe('validateMaxLength', () => { + const limit = 5; + const message = i18n.MAX_LENGTH_ERROR('tag', limit); + + it('returns error for tags exceeding length', () => { + expect( + validateMaxLength({ + value: ['coke', 'pepsi!'], + message, + limit, + }) + ).toEqual({ message }); + }); + + it('returns error for tag exceeding length', () => { + expect( + validateMaxLength({ + value: 'Hello!', + message, + limit, + }) + ).toEqual({ message }); + }); + + it('returns no error for tags not exceeding length', () => { + expect( + validateMaxLength({ + value: ['coke', 'pepsi'], + message, + limit, + }) + ).toBeUndefined(); + }); + + it('returns no error for tag not exceeding length', () => { + expect( + validateMaxLength({ + value: 'Hello', + message, + limit, + }) + ).toBeUndefined(); + }); + }); + + describe('validateMaxTagsLength', () => { + const limit = 2; + const message = i18n.MAX_TAGS_ERROR(limit); + + it('returns error when tags exceed length', () => { + expect(validateMaxTagsLength({ value: ['coke', 'pepsi', 'fanta'], message, limit })).toEqual({ + message, + }); + }); + + it('returns no error when tags do not exceed length', () => { + expect(validateMaxTagsLength({ value: ['coke', 'pepsi'], message, limit })).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/utils.ts b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts new file mode 100644 index 000000000000..1fde95ff5408 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const isInvalidTag = (value: string) => value.trim() === ''; + +const isTagCharactersInLimit = (value: string, limit: number) => value.trim().length > limit; + +export const validateEmptyTags = ({ + value, + message, +}: { + value: string | string[]; + message: string; +}) => { + if ( + (!Array.isArray(value) && isInvalidTag(value)) || + (Array.isArray(value) && value.length > 0 && value.find((item) => isInvalidTag(item))) + ) { + return { + message, + }; + } +}; + +export const validateMaxLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if ( + (!Array.isArray(value) && isTagCharactersInLimit(value, limit)) || + (Array.isArray(value) && + value.length > 0 && + value.some((item) => isTagCharactersInLimit(item, limit))) + ) { + return { + message, + }; + } +}; + +export const validateMaxTagsLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if (Array.isArray(value) && value.length > limit) { + return { + message, + }; + } +}; diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx index 52a40ca06521..d8c98e42f2e3 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx @@ -18,17 +18,17 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import * as i18n from '../../tags/translations'; import { useGetTags } from '../../../containers/use_get_tags'; import { Tags } from '../../tags/tags'; import { useCasesContext } from '../../cases_context/use_cases_context'; -import { schemaTags } from '../../create/schema'; +import { schema as createCaseSchema } from '../../create/schema'; -export const schema: FormSchema = { - tags: schemaTags, +export const schema = { + tags: createCaseSchema.tags as FieldConfig<string[]>, }; export interface EditTagsProps { diff --git a/x-pack/plugins/cases/public/components/category/category_component.test.tsx b/x-pack/plugins/cases/public/components/category/category_component.test.tsx index 6eb95600cd58..e5be97ec2058 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.test.tsx @@ -54,9 +54,9 @@ describe('Category ', () => { render(<CategoryComponent {...defaultProps} />); userEvent.type(screen.getByRole('combobox'), 'new{enter}'); - - expect(onChange).toBeCalledWith('new'); - expect(screen.getByRole('combobox')).toHaveValue('new'); + await waitFor(() => { + expect(onChange).toBeCalledWith('new'); + }); }); it('renders current option list', async () => { @@ -74,7 +74,6 @@ describe('Category ', () => { userEvent.click(screen.getByText('foo')); expect(onChange).toHaveBeenCalledWith('foo'); - expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('foo'); }); it('should call onChange when adding new category', async () => { @@ -84,7 +83,6 @@ describe('Category ', () => { await waitFor(() => { expect(onChange).toHaveBeenCalledWith('hi'); - expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('hi'); }); }); @@ -100,7 +98,7 @@ describe('Category ', () => { userEvent.type(screen.getByRole('combobox'), ' there{enter}'); await waitFor(() => { - expect(onChange).toHaveBeenCalledWith('hi there'); + expect(onChange).toHaveBeenCalledWith('there'); }); }); }); diff --git a/x-pack/plugins/cases/public/components/category/category_component.tsx b/x-pack/plugins/cases/public/components/category/category_component.tsx index ee6f84a24406..f57ba7b36a5a 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.tsx @@ -5,10 +5,13 @@ * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox } from '@elastic/eui'; import { ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX } from './translations'; +import type { CaseUI } from '../../../common/ui'; + +export type CategoryField = CaseUI['category'] | undefined; export interface CategoryComponentProps { isLoading: boolean; @@ -26,15 +29,11 @@ export const CategoryComponent: React.FC<CategoryComponentProps> = React.memo( })); }, [availableCategories]); - const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( - category != null ? [{ label: category }] : [] - ); + const selectedOptions = category != null ? [{ label: category }] : []; const onComboChange = useCallback( (currentOptions: Array<EuiComboBoxOptionOption<string>>) => { const value = currentOptions[0]?.label; - - setSelectedOptions(currentOptions); onChange(value); }, [onChange] diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.tsx index 060e0928b898..f8bb2221ce7d 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.tsx @@ -15,7 +15,7 @@ import { import { isEmpty } from 'lodash'; import React, { memo } from 'react'; import { MAX_CATEGORY_LENGTH } from '../../../common/constants'; -import type { CaseUI } from '../../../common/ui'; +import type { CategoryField } from './category_component'; import { CategoryComponent } from './category_component'; import { CATEGORY, EMPTY_CATEGORY_VALIDATION_MSG, MAX_LENGTH_ERROR } from './translations'; @@ -25,8 +25,6 @@ interface Props { formRowProps?: Partial<EuiFormRowProps>; } -type CategoryField = CaseUI['category'] | undefined; - const getCategoryConfig = (): FieldConfig<CategoryField> => ({ defaultValue: null, validations: [ @@ -65,7 +63,7 @@ const CategoryFormFieldComponent: React.FC<Props> = ({ formRowProps, }) => { return ( - <UseField<CategoryField> path={'category'} config={getCategoryConfig()}> + <UseField<CategoryField> path="category" config={getCategoryConfig()}> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -79,7 +77,7 @@ const CategoryFormFieldComponent: React.FC<Props> = ({ label={CATEGORY} error={errorMessage} isInvalid={isInvalid} - data-test-subj="case-create-form-category" + data-test-subj="caseCategory" fullWidth > <CategoryComponent diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index e0161e437e70..bf1ace60ced9 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -19,7 +19,7 @@ export const searchURL = '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; const mockConfigurationData = { - closureType: 'close-by-user', + closureType: 'close-by-user' as const, connector: { fields: null, id: 'none', @@ -27,6 +27,7 @@ const mockConfigurationData = { type: ConnectorTypes.none, }, customFields: [], + templates: [], mappings: [], version: '', id: '', diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 73e6c60a9005..71df212399bc 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { Suspense, useMemo } from 'react'; import type { EuiThemeComputed } from '@elastic/eui'; import { EuiFlexGroup, @@ -14,6 +14,7 @@ import { EuiIconTip, EuiSuperSelect, useEuiTheme, + EuiLoadingSpinner, } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -31,6 +32,15 @@ export interface Props { appendAddConnectorButton?: boolean; } +const suspendedComponentWithProps = (ComponentToSuspend: React.ComponentType) => { + // eslint-disable-next-line react/display-name + return (props: Record<string, unknown>) => ( + <Suspense fallback={<EuiLoadingSpinner size={'m'} />}> + <ComponentToSuspend {...props} /> + </Suspense> + ); +}; + const ICON_SIZE = 'm'; const noConnectorOption = { @@ -90,6 +100,8 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({ const connectorsAsOptions = useMemo(() => { const connectorsFormatted = connectors.reduce( (acc, connector) => { + const iconClass = getConnectorIcon(triggersActionsUi, connector.actionTypeId); + return [ ...acc, { @@ -102,7 +114,11 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({ margin-right: ${euiTheme.size.m}; margin-bottom: 0 !important; `} - type={getConnectorIcon(triggersActionsUi, connector.actionTypeId)} + type={ + typeof iconClass === 'string' + ? iconClass + : suspendedComponentWithProps(iconClass) + } size={ICON_SIZE} /> </EuiFlexItem> diff --git a/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx new file mode 100644 index 000000000000..ce46d368a5d2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { DeleteConfirmationModal } from './delete_confirmation_modal'; + +describe('DeleteConfirmationModal', () => { + let appMock: AppMockRenderer; + const props = { + title: 'My custom field', + message: 'This is a sample message', + onCancel: jest.fn(), + onConfirm: jest.fn(), + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByTestId('confirm-delete-modal')).toBeInTheDocument(); + expect(result.getByText('Delete')).toBeInTheDocument(); + expect(result.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onConfirm', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByText('Delete')).toBeInTheDocument(); + userEvent.click(result.getByText('Delete')); + + expect(props.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByText('Cancel')).toBeInTheDocument(); + userEvent.click(result.getByText('Cancel')); + + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx new file mode 100644 index 000000000000..a994c8720cc1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from '../custom_fields/translations'; + +interface ConfirmDeleteCaseModalProps { + title: string; + message: string; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteConfirmationModalComponent: React.FC<ConfirmDeleteCaseModalProps> = ({ + title, + message, + onCancel, + onConfirm, +}) => { + return ( + <EuiConfirmModal + buttonColor="danger" + cancelButtonText={i18n.CANCEL} + data-test-subj="confirm-delete-modal" + defaultFocusedButton="confirm" + onCancel={onCancel} + onConfirm={onConfirm} + title={title} + confirmButtonText={i18n.DELETE} + > + {message} + </EuiConfirmModal> + ); +}; +DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal'; + +export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx new file mode 100644 index 000000000000..555f5e6f553b --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx @@ -0,0 +1,798 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; +import { + MAX_CUSTOM_FIELD_LABEL_LENGTH, + MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, +} from '../../../common/constants'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; +import { FIELD_LABEL, DEFAULT_VALUE } from '../custom_fields/translations'; +import { CustomFieldsForm } from '../custom_fields/form'; +import { TemplateForm } from '../templates/form'; +import * as i18n from './translations'; +import type { FlyOutBodyProps } from './flyout'; +import { CommonFlyout } from './flyout'; +import type { TemplateFormProps } from '../templates/types'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; + +jest.mock('../connectors/servicenow/use_get_choices'); +jest.mock('../../containers/user_profiles/api'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +describe('CommonFlyout ', () => { + let appMockRender: AppMockRenderer; + + const props = { + onCloseFlyout: jest.fn(), + onSaveField: jest.fn(), + isLoading: false, + disabled: false, + renderHeader: () => <div>{`Flyout header`}</div>, + }; + + const children = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={null} /> + ); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders flyout correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-header')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-cancel')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-save')).toBeInTheDocument(); + }); + + it('renders flyout header correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + expect(await screen.findByText('Flyout header')); + }); + + it('renders loading state correctly', async () => { + appMockRender.render( + <CommonFlyout {...{ ...props, isLoading: true }}>{children}</CommonFlyout> + ); + + expect(await screen.findAllByRole('progressbar')).toHaveLength(2); + }); + + it('renders disable state correctly', async () => { + appMockRender.render(<CommonFlyout {...{ ...props, disabled: true }}>{children}</CommonFlyout>); + + expect(await screen.findByTestId('common-flyout-cancel')).toBeDisabled(); + expect(await screen.findByTestId('common-flyout-save')).toBeDisabled(); + }); + + it('calls onCloseFlyout on cancel', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + userEvent.click(await screen.findByTestId('common-flyout-cancel')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('calls onCloseFlyout on close', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('does not call onSaveField when not valid data', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + expect(props.onSaveField).not.toBeCalled(); + }); + + describe('CustomFieldsFlyout', () => { + const renderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={null} /> + ); + + it('should render custom field form in flyout', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + expect(await screen.findByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(await screen.findByTestId('custom-field-type-selector')).toBeInTheDocument(); + expect(await screen.findByTestId('text-custom-field-required-wrapper')).toBeInTheDocument(); + expect(await screen.findByTestId('text-custom-field-default-value')).toBeInTheDocument(); + }); + + it('calls onSaveField form correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('shows error if field label is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + + userEvent.type(await screen.findByTestId('custom-field-label-input'), message); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR(FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) + ) + ).toBeInTheDocument(); + }); + + describe('Text custom field', () => { + it('calls onSaveField with correct params when a custom field is NOT required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'Default value' + ); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + defaultValue: 'Default value', + }); + }); + }); + + it('calls onSaveField with the correct params when a custom field is required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'Default value' + ); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TEXT, + defaultValue: 'Default value', + }); + }); + }); + + it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('renders flyout with the correct data when an initial customField value exists', async () => { + const newRenderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[0]} /> + ); + + const modifiedProps = { + ...props, + data: customFieldsConfigurationMock[0], + }; + + appMockRender.render(<CommonFlyout {...modifiedProps}>{newRenderBody}</CommonFlyout>); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].label + ); + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + expect(await screen.findByTestId('text-custom-field-required')).toHaveAttribute('checked'); + expect(await screen.findByTestId('text-custom-field-default-value')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].defaultValue + ); + }); + + it('shows an error if default value is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'z'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) + ); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR(DEFAULT_VALUE.toLowerCase(), MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH) + ) + ).toBeInTheDocument(); + }); + }); + + describe('Toggle custom field', () => { + it('calls onSaveField with correct params when a custom field is NOT required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TOGGLE, + defaultValue: false, + }); + }); + }); + + it('calls onSaveField with the correct default value when a custom field is required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('toggle-custom-field-required')); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TOGGLE, + defaultValue: false, + }); + }); + }); + + it('renders flyout with the correct data when an initial customField value exists', async () => { + const newRenderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[1]} /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[1].label + ); + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + expect(await screen.findByTestId('toggle-custom-field-required')).toHaveAttribute( + 'checked' + ); + expect(await screen.findByTestId('toggle-custom-field-default-value')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + }); + }); + + describe('TemplateFlyout', () => { + const currentConfiguration = { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }; + + const renderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={null} + connectors={connectorsMock} + currentConfiguration={currentConfiguration} + onChange={onChange} + /> + ); + + it('should render template form in flyout', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('should render all fields with details', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + const newConfiguration = { + ...currentConfiguration, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'First custom field', + required: true, + }, + ], + }; + + appMockRender = createAppMockRenderer({ license }); + + appMockRender.render( + <CommonFlyout {...props}> + {({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={templatesConfigurationMock[3]} + connectors={[]} + currentConfiguration={newConfiguration} + onChange={onChange} + /> + )} + </CommonFlyout> + ); + + // template fields + expect(await screen.findByTestId('template-name-input')).toHaveValue('Fourth test template'); + expect(await screen.findByTestId('template-description-input')).toHaveTextContent( + 'This is a fourth test template' + ); + + const templateTags = await screen.findByTestId('template-tags'); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent('foo'); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent('bar'); + + const caseTitle = await screen.findByTestId('caseTitle'); + expect(within(caseTitle).getByTestId('input')).toHaveValue('Case with sample template 4'); + + const caseDescription = await screen.findByTestId('caseDescription'); + expect(within(caseDescription).getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent( + 'case desc' + ); + + const caseCategory = await screen.findByTestId('caseCategory'); + expect(within(caseCategory).getByRole('combobox')).toHaveTextContent(''); + + const caseTags = await screen.findByTestId('caseTags'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('sample-4'); + + expect(await screen.findByTestId('case-severity-selection-low')).toBeInTheDocument(); + + const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); + + expect(await within(assigneesComboBox).findByTestId('comboBoxInput')).toHaveTextContent( + 'Damaged Raccoon' + ); + + // custom fields + expect( + await screen.findByTestId('first_custom_field_key-text-create-custom-field') + ).toHaveValue('this is a text field value'); + + // connector + expect(await screen.findByTestId('dropdown-connector-no-connector')).toBeInTheDocument(); + }); + + it('calls onSaveField form correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + const templateTags = await screen.findByTestId('template-tags'); + userEvent.paste(within(templateTags).getByRole('combobox'), 'foo'); + userEvent.keyboard('{enter}'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'Template description', + name: 'Template name', + tags: ['foo'], + }); + }); + }); + + it('calls onSaveField with case fields correctly', async () => { + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={{ + key: 'random_key', + name: 'Template 1', + description: 'test description', + caseFields: null, + }} + connectors={[]} + currentConfiguration={currentConfiguration} + onChange={onChange} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + description: 'test description', + tags: [], + caseFields: { + title: 'Case using template', + description: 'This is a case description', + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + }); + }); + }); + + it('calls onSaveField form with custom fields correctly', async () => { + const newConfig = { ...currentConfiguration, customFields: customFieldsConfigurationMock }; + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={{ + key: 'random_key', + name: 'Template 1', + description: 'test description', + caseFields: null, + }} + connectors={[]} + currentConfiguration={newConfig} + onChange={onChange} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + const textCustomField = await screen.findByTestId( + `${customFieldsConfigurationMock[0].key}-text-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'this is a sample text!'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + description: 'test description', + tags: [], + caseFields: { + connector: { + id: 'none', + name: 'none', + type: '.none', + fields: null, + }, + settings: { + syncAlerts: true, + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'this is a sample text!', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: false, + }, + ], + }, + }); + }); + }); + + it('calls onSaveField form with connector fields correctly', async () => { + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + + const connector = { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }; + + const newConfig = { + ...currentConfiguration, + connector, + }; + + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={{ + key: 'random_key', + name: 'Template 1', + description: 'test description', + caseFields: { connector }, + }} + connectors={connectorsMock} + currentConfiguration={newConfig} + onChange={onChange} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '1'); + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + description: 'test description', + tags: [], + caseFields: { + customFields: [], + connector: { + ...connector, + fields: { + urgency: '1', + severity: null, + impact: null, + category: 'software', + subcategory: null, + }, + }, + settings: { + syncAlerts: true, + }, + }, + }); + }); + }); + + it('calls onSaveField with edited fields correctly', async () => { + const newConfig = { + ...currentConfiguration, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'First custom field', + required: true, + }, + ], + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }; + + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={templatesConfigurationMock[3]} + connectors={connectorsMock} + currentConfiguration={newConfig} + onChange={onChange} + isEditMode={true} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + userEvent.clear(await screen.findByTestId('template-name-input')); + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.clear(within(caseTitle).getByTestId('input')); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Updated case using template'); + + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + userEvent.clear(customField); + userEvent.paste(customField, 'Updated custom field value'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'Updated custom field value', + }, + ], + description: 'case desc', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: ['sample-4'], + title: 'Updated case using template', + }, + description: 'This is a fourth test template', + key: 'test_template_4', + name: 'Template name', + tags: ['foo', 'bar'], + }); + }); + }); + + it('shows error when template name is empty', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).not.toHaveBeenCalled(); + }); + + expect(await screen.findByText('A Template name is required.')).toBeInTheDocument(); + }); + + it('shows error if template name is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + const message = 'z'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-name-input'), message); + + expect( + await screen.findByText(i18n.MAX_LENGTH_ERROR('template name', MAX_TEMPLATE_NAME_LENGTH)) + ).toBeInTheDocument(); + }); + + it('shows error if template description is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + const message = 'z'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-description-input'), message); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR('template description', MAX_TEMPLATE_DESCRIPTION_LENGTH) + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx new file mode 100644 index 000000000000..37d16d01e568 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import type { FormHook, FormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types'; + +import * as i18n from './translations'; + +export interface FormState<T extends FormData = FormData, I extends FormData = T> { + isValid: boolean | undefined; + submit: FormHook<T, I>['submit']; +} + +export interface FlyOutBodyProps<T extends FormData = FormData, I extends FormData = T> { + onChange: (state: FormState<T, I>) => void; +} + +export interface FlyoutProps<T extends FormData = FormData, I extends FormData = T> { + disabled: boolean; + isLoading: boolean; + onCloseFlyout: () => void; + onSaveField: (data: I) => void; + renderHeader: () => React.ReactNode; + children: ({ onChange }: FlyOutBodyProps<T, I>) => React.ReactNode; +} + +export const CommonFlyout = <T extends FormData = FormData, I extends FormData = T>({ + onCloseFlyout, + onSaveField, + isLoading, + disabled, + renderHeader, + children, +}: FlyoutProps<T, I>) => { + const [formState, setFormState] = useState<FormState<T, I>>({ + isValid: undefined, + submit: async () => ({ + isValid: false, + data: {} as T, + }), + }); + + const { submit } = formState; + + const handleSaveField = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid) { + /** + * The serializer transforms the data + * from the form format to the backend + * format. The I generic is the correct + * format of the data. + */ + onSaveField(data as unknown as I); + } + }, [onSaveField, submit]); + + /** + * The children will call setFormState which in turn will make the parent + * to rerender which in turn will rerender the children etc. + * To avoid an infinitive loop we need to memoize the children. + */ + const memoizedChildren = useMemo( + () => + children({ + onChange: setFormState, + }), + [children] + ); + + return ( + <EuiFlyout onClose={onCloseFlyout} data-test-subj="common-flyout"> + <EuiFlyoutHeader hasBorder data-test-subj="common-flyout-header"> + <EuiTitle size="s"> + <h3 id="flyoutTitle">{renderHeader()}</h3> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody>{memoizedChildren}</EuiFlyoutBody> + <EuiFlyoutFooter data-test-subj={'common-flyout-footer'}> + <EuiFlexGroup justifyContent="flexStart"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + onClick={onCloseFlyout} + data-test-subj={'common-flyout-cancel'} + disabled={disabled} + isLoading={isLoading} + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexGroup justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButton + fill + onClick={handleSaveField} + data-test-subj={'common-flyout-save'} + disabled={disabled} + isLoading={isLoading} + > + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexGroup> + </EuiFlyoutFooter> + </EuiFlyout> + ); +}; + +CommonFlyout.displayName = 'CommonFlyout'; diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index ba3e7850533c..b424b2ca62fc 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event'; import { ConfigureCases } from '.'; import { noUpdateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; -import { customFieldsConfigurationMock } from '../../containers/mock'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../../containers/mock'; import type { AppMockRenderer } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; @@ -36,6 +36,7 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/a import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useLicense } from '../../common/use_license'; +import * as i18n from './translations'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); @@ -78,7 +79,11 @@ describe('ConfigureCases', () => { beforeEach(() => { useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); usePersistConfigurationMock.mockImplementation(() => usePersistConfigurationMockResponse); - useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); + useGetConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + data: [], + isLoading: false, + })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(<ConfigureCases />, { @@ -126,7 +131,11 @@ describe('ConfigureCases', () => { }, })); - useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); + useGetConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + data: [], + isLoading: false, + })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(<ConfigureCases />, { wrappingComponent: TestProviders, @@ -425,6 +434,7 @@ describe('ConfigureCases', () => { }, closureType: 'close-by-user', customFields: [], + templates: [], id: '', version: '', }); @@ -521,6 +531,7 @@ describe('ConfigureCases', () => { }, closureType: 'close-by-pushing', customFields: [], + templates: [], id: '', version: '', }); @@ -688,7 +699,7 @@ describe('ConfigureCases', () => { within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(screen.getByText('Delete')); @@ -706,6 +717,7 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, ], + templates: [], id: '', version: '', }); @@ -729,11 +741,11 @@ describe('ConfigureCases', () => { within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) ); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); userEvent.click(screen.getByTestId('text-custom-field-required')); - userEvent.click(screen.getByTestId('custom-field-flyout-save')); + userEvent.click(screen.getByTestId('common-flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -756,6 +768,7 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, ], + templates: [], id: '', version: '', }); @@ -767,7 +780,7 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); }); it('closes fly out for when click on cancel', async () => { @@ -775,12 +788,12 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('custom-field-flyout-cancel')); + userEvent.click(screen.getByTestId('common-flyout-cancel')); expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); }); it('closes fly out for when click on save field', async () => { @@ -788,11 +801,11 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(screen.getByTestId('custom-field-flyout-save')); + userEvent.click(screen.getByTestId('common-flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -812,20 +825,237 @@ describe('ConfigureCases', () => { required: false, }, ], + templates: [], id: '', version: '', }); }); expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); + }); + }); + + describe('templates', () => { + let appMockRender: AppMockRenderer; + const persistCaseConfigure = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false, isAtLeastGold: () => true }); + }); + + it('should render template section', async () => { + appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + }); + + it('should render template form in flyout', async () => { + appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-header')).toHaveTextContent( + i18n.CREATE_TEMPLATE + ); + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('should add template', async () => { + appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); + + userEvent.click(screen.getByTestId('common-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: customFieldsConfigurationMock, + templates: [ + { + key: expect.anything(), + name: 'Template name', + description: 'Template description', + tags: [], + caseFields: { + title: 'Case using template', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + customFields: [ + { + key: customFieldsConfigurationMock[0].key, + type: customFieldsConfigurationMock[0].type, + value: customFieldsConfigurationMock[0].defaultValue, + }, + { + key: customFieldsConfigurationMock[1].key, + type: customFieldsConfigurationMock[1].type, + value: customFieldsConfigurationMock[1].defaultValue, + }, + { + key: customFieldsConfigurationMock[3].key, + type: customFieldsConfigurationMock[3].type, + value: false, // when no default value for toggle, we set it to false + }, + ], + }, + }, + ], + id: '', + version: '', + }); + }); + + expect(screen.getByTestId('templates-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); + }); + + it('should delete a template', async () => { + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); + + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + templates: templatesConfigurationMock, + }, + })); + + appMockRender.render(<ConfigureCases />); + + const list = screen.getByTestId('templates-list'); + + userEvent.click( + within(list).getByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [], + templates: [ + { ...templatesConfigurationMock[1] }, + { ...templatesConfigurationMock[2] }, + { ...templatesConfigurationMock[3] }, + { ...templatesConfigurationMock[4] }, + ], + id: '', + version: '', + }); + }); + }); + + it('should update a template', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + templates: [templatesConfigurationMock[0], templatesConfigurationMock[3]], + }, + })); + + appMockRender.render(<ConfigureCases />); + + const list = screen.getByTestId('templates-list'); + + userEvent.click( + within(list).getByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + userEvent.clear(await screen.findByTestId('template-name-input')); + userEvent.paste(await screen.findByTestId('template-name-input'), 'Updated template name'); + + userEvent.click(screen.getByTestId('common-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [], + templates: [ + { + ...templatesConfigurationMock[0], + name: 'Updated template name', + tags: [], + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + }, + { ...templatesConfigurationMock[3] }, + ], + id: '', + version: '', + }); + }); }); }); describe('rendering with license limitations', () => { let appMockRender: AppMockRenderer; let persistCaseConfigure: jest.Mock; - beforeEach(() => { // Default setup jest.clearAllMocks(); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index d33726d7ccdf..1003a10646e8 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable complexity */ + import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/react'; @@ -22,7 +24,7 @@ import { import type { ActionConnectorTableItem } from '@kbn/triggers-actions-ui-plugin/public/types'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; -import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import type { CustomFieldConfiguration, TemplateConfiguration } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; @@ -32,17 +34,20 @@ import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils'; import * as i18n from './translations'; -import { getConnectorById } from '../utils'; +import { getConnectorById, addOrReplaceField } from '../utils'; import { HeaderPage } from '../header_page'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesBreadcrumbs } from '../use_breadcrumbs'; import { CasesDeepLinkId } from '../../common/navigation'; import { CustomFields } from '../custom_fields'; -import { CustomFieldFlyout } from '../custom_fields/flyout'; +import { CommonFlyout } from './flyout'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; -import { addOrReplaceCustomField } from '../custom_fields/utils'; import { useLicense } from '../../common/use_license'; +import { Templates } from '../templates'; +import type { TemplateFormProps } from '../templates/types'; +import { CustomFieldsForm } from '../custom_fields/form'; +import { TemplateForm } from '../templates/form'; const sectionWrapperCss = css` box-sizing: content-box; @@ -58,6 +63,11 @@ const getFormWrapperCss = (euiTheme: EuiThemeComputed<{}>) => css` } `; +interface Flyout { + type: 'addConnector' | 'editConnector' | 'customField' | 'template'; + visible: boolean; +} + export const ConfigureCases: React.FC = React.memo(() => { const { permissions } = useCasesContext(); const { triggersActionsUi } = useKibana().services; @@ -66,28 +76,30 @@ export const ConfigureCases: React.FC = React.memo(() => { const hasMinimumLicensePermissions = license.isAtLeastGold(); const [connectorIsValid, setConnectorIsValid] = useState(true); - const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false); - const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false); + const [flyOutVisibility, setFlyOutVisibility] = useState<Flyout | null>(null); const [editedConnectorItem, setEditedConnectorItem] = useState<ActionConnectorTableItem | null>( null ); - const [customFieldFlyoutVisible, setCustomFieldFlyoutVisibility] = useState<boolean>(false); const [customFieldToEdit, setCustomFieldToEdit] = useState<CustomFieldConfiguration | null>(null); + const [templateToEdit, setTemplateToEdit] = useState<TemplateConfiguration | null>(null); const { euiTheme } = useEuiTheme(); const { - data: { - id: configurationId, - version: configurationVersion, - closureType, - connector, - mappings, - customFields, - }, + data: currentConfiguration, isLoading: loadingCaseConfigure, refetch: refetchCaseConfigure, } = useGetCaseConfiguration(); + const { + id: configurationId, + version: configurationVersion, + closureType, + connector, + mappings, + customFields, + templates, + } = currentConfiguration; + const { mutate: persistCaseConfigure, mutateAsync: persistCaseConfigureAsync, @@ -95,7 +107,6 @@ export const ConfigureCases: React.FC = React.memo(() => { } = usePersistConfiguration(); const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration; - const { isLoading: isLoadingConnectors, data: connectors = [], @@ -125,6 +136,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connector: caseConnector, closureType, customFields, + templates, id: configurationId, version: configurationVersion, }); @@ -135,6 +147,7 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigureAsync, closureType, customFields, + templates, configurationId, configurationVersion, onConnectorUpdated, @@ -148,20 +161,23 @@ export const ConfigureCases: React.FC = React.memo(() => { isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { - setEditFlyoutVisibility(true); + setFlyOutVisibility({ type: 'editConnector', visible: true }); }, []); const onCloseAddFlyout = useCallback( - () => setAddFlyoutVisibility(false), - [setAddFlyoutVisibility] + () => setFlyOutVisibility({ type: 'addConnector', visible: false }), + [setFlyOutVisibility] ); - const onCloseEditFlyout = useCallback(() => setEditFlyoutVisibility(false), []); + const onCloseEditFlyout = useCallback( + () => setFlyOutVisibility({ type: 'editConnector', visible: false }), + [] + ); const onChangeConnector = useCallback( (id: string) => { if (id === 'add-connector') { - setAddFlyoutVisibility(true); + setFlyOutVisibility({ type: 'addConnector', visible: true }); return; } @@ -173,6 +189,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connector: caseConnector, closureType, customFields, + templates, id: configurationId, version: configurationVersion, }); @@ -182,6 +199,7 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure, closureType, customFields, + templates, configurationId, configurationVersion, ] @@ -192,12 +210,20 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure({ connector, customFields, + templates, id: configurationId, version: configurationVersion, closureType: type, }); }, - [configurationId, configurationVersion, connector, customFields, persistCaseConfigure] + [ + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] ); useEffect(() => { @@ -225,7 +251,7 @@ export const ConfigureCases: React.FC = React.memo(() => { const ConnectorAddFlyout = useMemo( () => - addFlyoutVisible + flyOutVisibility?.type === 'addConnector' && flyOutVisibility?.visible ? triggersActionsUi.getAddConnectorFlyout({ onClose: onCloseAddFlyout, featureId: CasesConnectorFeatureId, @@ -233,12 +259,12 @@ export const ConfigureCases: React.FC = React.memo(() => { }) : null, // eslint-disable-next-line react-hooks/exhaustive-deps - [addFlyoutVisible] + [flyOutVisibility] ); const ConnectorEditFlyout = useMemo( () => - editedConnectorItem && editFlyoutVisible + editedConnectorItem && flyOutVisibility?.type === 'editConnector' && flyOutVisibility?.visible ? triggersActionsUi.getEditConnectorFlyout({ connector: editedConnectorItem, onClose: onCloseEditFlyout, @@ -246,20 +272,31 @@ export const ConfigureCases: React.FC = React.memo(() => { }) : null, // eslint-disable-next-line react-hooks/exhaustive-deps - [connector.id, editedConnectorItem, editFlyoutVisible] + [connector.id, editedConnectorItem, flyOutVisibility] ); - const onAddCustomFields = useCallback(() => { - setCustomFieldFlyoutVisibility(true); - }, [setCustomFieldFlyoutVisibility]); - const onDeleteCustomField = useCallback( (key: string) => { const remainingCustomFields = customFields.filter((field) => field.key !== key); + // delete the same custom field from each template as well + const templatesWithRemainingCustomFields = templates.map((template) => { + const templateCustomFields = + template.caseFields?.customFields?.filter((field) => field.key !== key) ?? []; + + return { + ...template, + caseFields: { + ...template.caseFields, + customFields: [...templateCustomFields], + }, + }; + }); + persistCaseConfigure({ connector, customFields: [...remainingCustomFields], + templates: [...templatesWithRemainingCustomFields], id: configurationId, version: configurationVersion, closureType, @@ -271,6 +308,7 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationVersion, connector, customFields, + templates, persistCaseConfigure, ] ); @@ -282,28 +320,30 @@ export const ConfigureCases: React.FC = React.memo(() => { if (selectedCustomField) { setCustomFieldToEdit(selectedCustomField); } - setCustomFieldFlyoutVisibility(true); + setFlyOutVisibility({ type: 'customField', visible: true }); }, - [setCustomFieldFlyoutVisibility, setCustomFieldToEdit, customFields] + [setFlyOutVisibility, setCustomFieldToEdit, customFields] ); - const onCloseAddFieldFlyout = useCallback(() => { - setCustomFieldFlyoutVisibility(false); + const onCloseCustomFieldFlyout = useCallback(() => { + setFlyOutVisibility({ type: 'customField', visible: false }); setCustomFieldToEdit(null); - }, [setCustomFieldFlyoutVisibility, setCustomFieldToEdit]); + }, [setFlyOutVisibility, setCustomFieldToEdit]); + + const onCustomFieldSave = useCallback( + (data: CustomFieldConfiguration) => { + const updatedCustomFields = addOrReplaceField(customFields, data); - const onSaveCustomField = useCallback( - (customFieldData: CustomFieldConfiguration) => { - const updatedFields = addOrReplaceCustomField(customFields, customFieldData); persistCaseConfigure({ connector, - customFields: updatedFields, + customFields: updatedCustomFields, + templates, id: configurationId, version: configurationVersion, closureType, }); - setCustomFieldFlyoutVisibility(false); + setFlyOutVisibility({ type: 'customField', visible: false }); setCustomFieldToEdit(null); }, [ @@ -312,24 +352,124 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationVersion, connector, customFields, + templates, persistCaseConfigure, ] ); - const CustomFieldAddFlyout = customFieldFlyoutVisible ? ( - <CustomFieldFlyout - isLoading={loadingCaseConfigure || isPersistingConfiguration} - disabled={ - !permissions.create || - !permissions.update || - loadingCaseConfigure || - isPersistingConfiguration + const onDeleteTemplate = useCallback( + (key: string) => { + const remainingTemplates = templates.filter((field) => field.key !== key); + + persistCaseConfigure({ + connector, + customFields, + templates: [...remainingTemplates], + id: configurationId, + version: configurationVersion, + closureType, + }); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] + ); + + const onEditTemplate = useCallback( + (key: string) => { + const selectedTemplate = templates.find((item) => item.key === key); + + if (selectedTemplate) { + setTemplateToEdit(selectedTemplate); } - customField={customFieldToEdit} - onCloseFlyout={onCloseAddFieldFlyout} - onSaveField={onSaveCustomField} - /> - ) : null; + setFlyOutVisibility({ type: 'template', visible: true }); + }, + [setFlyOutVisibility, setTemplateToEdit, templates] + ); + + const onCloseTemplateFlyout = useCallback(() => { + setFlyOutVisibility({ type: 'template', visible: false }); + setTemplateToEdit(null); + }, [setFlyOutVisibility, setTemplateToEdit]); + + const onTemplateSave = useCallback( + (data: TemplateConfiguration) => { + const updatedTemplates = addOrReplaceField(templates, data); + + persistCaseConfigure({ + connector, + customFields, + templates: updatedTemplates, + id: configurationId, + version: configurationVersion, + closureType, + }); + + setFlyOutVisibility({ type: 'template', visible: false }); + setTemplateToEdit(null); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] + ); + + const AddOrEditCustomFieldFlyout = + flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? ( + <CommonFlyout<CustomFieldConfiguration> + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={ + !permissions.create || + !permissions.update || + loadingCaseConfigure || + isPersistingConfiguration + } + onCloseFlyout={onCloseCustomFieldFlyout} + onSaveField={onCustomFieldSave} + renderHeader={() => <span>{i18n.ADD_CUSTOM_FIELD}</span>} + > + {({ onChange }) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldToEdit} /> + )} + </CommonFlyout> + ) : null; + + const AddOrEditTemplateFlyout = + flyOutVisibility?.type === 'template' && flyOutVisibility?.visible ? ( + <CommonFlyout<TemplateFormProps, TemplateConfiguration> + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={ + !permissions.create || + !permissions.update || + loadingCaseConfigure || + isPersistingConfiguration + } + onCloseFlyout={onCloseTemplateFlyout} + onSaveField={onTemplateSave} + renderHeader={() => <span>{i18n.CREATE_TEMPLATE}</span>} + > + {({ onChange }) => ( + <TemplateForm + initialValue={templateToEdit} + connectors={connectors ?? []} + currentConfiguration={currentConfiguration} + isEditMode={Boolean(templateToEdit)} + onChange={onChange} + /> + )} + </CommonFlyout> + ) : null; return ( <EuiPageSection restrictWidth={true}> @@ -397,16 +537,34 @@ export const ConfigureCases: React.FC = React.memo(() => { customFields={customFields} isLoading={isLoadingCaseConfiguration} disabled={isLoadingCaseConfiguration} - handleAddCustomField={onAddCustomFields} + handleAddCustomField={() => + setFlyOutVisibility({ type: 'customField', visible: true }) + } handleDeleteCustomField={onDeleteCustomField} handleEditCustomField={onEditCustomField} /> </EuiFlexItem> </div> + + <EuiSpacer size="xl" /> + + <div css={sectionWrapperCss}> + <EuiFlexItem grow={false}> + <Templates + templates={templates} + isLoading={isLoadingCaseConfiguration} + disabled={isLoadingCaseConfiguration} + onAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} + onEditTemplate={onEditTemplate} + onDeleteTemplate={onDeleteTemplate} + /> + </EuiFlexItem> + </div> <EuiSpacer size="xl" /> {ConnectorAddFlyout} {ConnectorEditFlyout} - {CustomFieldAddFlyout} + {AddOrEditCustomFieldFlyout} + {AddOrEditTemplateFlyout} </div> </EuiPageBody> </EuiPageSection> diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index e10f6fcad2fb..08c83c9564f1 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -160,3 +160,14 @@ export const CASES_WEBHOOK_MAPPINGS = i18n.translate( 'Webhook - Case Management field mappings are configured in the connector settings in the third-party REST API JSON.', } ); + +export const ADD_CUSTOM_FIELD = i18n.translate( + 'xpack.cases.configureCases.customFields.addCustomField', + { + defaultMessage: 'Add field', + } +); + +export const CREATE_TEMPLATE = i18n.translate('xpack.cases.configureCases.templates.flyoutTitle', { + defaultMessage: 'Create template', +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/utils.ts b/x-pack/plugins/cases/public/components/configure_cases/utils.ts index 2177ea7af81d..a46b85f75694 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/utils.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/utils.ts @@ -51,7 +51,7 @@ export const setThirdPartyToMapping = ( export const getNoneConnector = (): CaseConnector => ({ id: 'none', name: 'none', - type: ConnectorTypes.none, + type: ConnectorTypes.none as const, fields: null, }); diff --git a/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx index 9cf77466a225..70e91af61885 100644 --- a/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx +++ b/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx @@ -6,48 +6,90 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { UseField, Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { screen } from '@testing-library/react'; import { ConnectorSelector } from './form'; -import { connectorsMock } from '../../containers/mock'; -import { getFormMock } from '../__mock__/form'; import { useKibana } from '../../common/lib/kibana'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { FormTestComponent } from '../../common/test_utils'; +import { connectorsMock } from '../../containers/mock'; -jest.mock('@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/hooks/use_form'); jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; -const useFormMock = useForm as jest.Mock; describe('ConnectorSelector', () => { - const formHookMock = getFormMock({ connectorId: connectorsMock[0].id }); + const handleChange = jest.fn(); + const defaultProps = { + connectors: [], + handleChange, + dataTestSubj: 'connectors', + disabled: false, + idAria: 'connectors', + isLoading: false, + }; + + let appMock: AppMockRenderer; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); beforeEach(() => { - useFormMock.mockImplementation(() => ({ form: formHookMock })); useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ actionTypeTitle: 'test', iconClass: 'logoSecurity', }); }); - it('it should render', async () => { - const wrapper = mount( - <Form form={formHookMock as unknown as FormHook}> + it('should render', async () => { + appMock.render( + <FormTestComponent> + <UseField + path="connectorId" + component={ConnectorSelector} + componentProps={{ + ...defaultProps, + }} + /> + </FormTestComponent> + ); + + expect(await screen.findByTestId(defaultProps.dataTestSubj)); + }); + + it('should set the selected connector to none if the connector is not available', async () => { + appMock.render( + <FormTestComponent formDefaultValue={{ connectorId: 'foo' }}> + <UseField + path="connectorId" + component={ConnectorSelector} + componentProps={{ + ...defaultProps, + }} + /> + </FormTestComponent> + ); + + expect(await screen.findByText('No connector selected')); + }); + + it('should set the selected connector correctly', async () => { + appMock.render( + <FormTestComponent formDefaultValue={{ connectorId: connectorsMock[0].id }}> <UseField path="connectorId" component={ConnectorSelector} componentProps={{ + ...defaultProps, connectors: connectorsMock, - dataTestSubj: 'caseConnectors', - disabled: false, - idAria: 'caseConnectors', - isLoading: false, }} /> - </Form> + </FormTestComponent> ); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); + expect(await screen.findByText(connectorsMock[0].name)); }); }); diff --git a/x-pack/plugins/cases/public/components/connector_selector/form.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.tsx index 37221edf9048..fa991bc5b987 100644 --- a/x-pack/plugins/cases/public/components/connector_selector/form.tsx +++ b/x-pack/plugins/cases/public/components/connector_selector/form.tsx @@ -45,6 +45,10 @@ export const ConnectorSelector = ({ [handleChange, field] ); + const isConnectorAvailable = Boolean( + connectors.find((connector) => connector.id === field.value) + ); + return ( <EuiFormRow css={css` @@ -66,7 +70,7 @@ export const ConnectorSelector = ({ disabled={disabled} isLoading={isLoading} onChange={onChange} - selectedConnector={isEmpty(field.value) ? 'none' : field.value} + selectedConnector={isEmpty(field.value) || !isConnectorAvailable ? 'none' : field.value} /> </EuiFormRow> ); diff --git a/x-pack/plugins/cases/public/components/connectors/constants.ts b/x-pack/plugins/cases/public/components/connectors/constants.ts index 486698330d86..1443b6ae49b0 100644 --- a/x-pack/plugins/cases/public/components/connectors/constants.ts +++ b/x-pack/plugins/cases/public/components/connectors/constants.ts @@ -15,6 +15,8 @@ export const connectorsQueriesKeys = { [...connectorsQueriesKeys.jira, connectorId, 'getIssueType'] as const, jiraGetIssues: (connectorId: string, query: string) => [...connectorsQueriesKeys.jira, connectorId, 'getIssues', query] as const, + jiraGetIssue: (connectorId: string, id: string) => + [...connectorsQueriesKeys.jira, connectorId, 'getIssue', id] as const, resilientGetIncidentTypes: (connectorId: string) => [...connectorsQueriesKeys.resilient, connectorId, 'getIncidentTypes'] as const, resilientGetSeverity: (connectorId: string) => diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx index 743ecac4cdc9..5d172539ea29 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx @@ -13,6 +13,7 @@ import userEvent from '@testing-library/user-event'; import { connector, issues } from '../mock'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import { useGetIssue } from './use_get_issue'; import Fields from './case_fields'; import { useGetIssues } from './use_get_issues'; import type { AppMockRenderer } from '../../../common/mock'; @@ -22,11 +23,13 @@ import { MockFormWrapperComponent } from '../test_utils'; jest.mock('./use_get_issue_types'); jest.mock('./use_get_fields_by_issue_type'); jest.mock('./use_get_issues'); +jest.mock('./use_get_issue'); jest.mock('../../../common/lib/kibana'); const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const useGetIssuesMock = useGetIssues as jest.Mock; +const useGetIssueMock = useGetIssue as jest.Mock; describe('Jira Fields', () => { const useGetIssueTypesResponse = { @@ -84,6 +87,12 @@ describe('Jira Fields', () => { data: { data: issues }, }; + const useGetIssueResponse = { + isLoading: false, + isFetching: false, + data: { data: issues[0] }, + }; + let appMockRenderer: AppMockRenderer; beforeEach(() => { @@ -91,6 +100,7 @@ describe('Jira Fields', () => { useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); useGetIssuesMock.mockReturnValue(useGetIssuesResponse); + useGetIssueMock.mockReturnValue(useGetIssueResponse); jest.clearAllMocks(); }); @@ -237,6 +247,38 @@ describe('Jira Fields', () => { expect(await screen.findByTestId('prioritySelect')).toHaveValue('Low'); }); + it('sets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + <MockFormWrapperComponent fields={newFields}> + <Fields connector={connector} /> + </MockFormWrapperComponent> + ); + + expect(await screen.findByText('Person Task')).toBeInTheDocument(); + }); + + it('resets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + <MockFormWrapperComponent fields={newFields}> + <Fields connector={connector} /> + </MockFormWrapperComponent> + ); + + const checkbox = within(await screen.findByTestId('search-parent-issues')).getByTestId( + 'comboBoxSearchInput' + ); + + expect(await screen.findByText('Person Task')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('comboBoxClearButton')); + + expect(checkbox).toHaveValue(''); + }); + it('should submit Jira connector', async () => { appMockRenderer.render( <MockFormWrapperComponent fields={fields}> diff --git a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx index 27df975ac586..c4089c7f14c6 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx @@ -6,83 +6,140 @@ */ import React, { useState, memo } from 'react'; +import { isEmpty } from 'lodash'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox, EuiFormRow } from '@elastic/eui'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { getFieldValidityAndErrorMessage, UseField, + useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { useIsUserTyping } from '../../../common/use_is_user_typing'; import { useKibana } from '../../../common/lib/kibana'; import type { ActionConnector } from '../../../../common/types/domain'; import { useGetIssues } from './use_get_issues'; import * as i18n from './translations'; +import { useGetIssue } from './use_get_issue'; + +interface FieldProps { + field: FieldHook<string>; + options: Array<EuiComboBoxOptionOption<string>>; + isLoading: boolean; + onSearchComboChange: (value: string) => void; +} interface Props { actionConnector?: ActionConnector; } -const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { - const [query, setQuery] = useState<string | null>(null); - const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( - [] +const SearchIssuesFieldComponent: React.FC<FieldProps> = ({ + field, + options, + isLoading, + onSearchComboChange, +}) => { + const { value: parent } = field; + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + const selectedOptions = [parent] + .map((currentParent: string) => { + const selectedParent = options.find((issue) => issue.value === currentParent); + + if (selectedParent) { + return selectedParent; + } + + return null; + }) + .filter((value): value is EuiComboBoxOptionOption<string> => value != null); + + const onChangeComboBox = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => { + field.setValue(changedOptions.length ? changedOptions[0].value ?? '' : ''); + }; + + return ( + <EuiFormRow + id="indexConnectorSelectSearchBox" + fullWidth + label={i18n.PARENT_ISSUE} + isInvalid={isInvalid} + error={errorMessage} + > + <EuiComboBox + fullWidth + singleSelection + async + placeholder={i18n.SEARCH_ISSUES_PLACEHOLDER} + aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL} + isLoading={isLoading} + isInvalid={isInvalid} + noSuggestions={!options.length} + options={options} + data-test-subj="search-parent-issues" + data-testid="search-parent-issues" + selectedOptions={selectedOptions} + onChange={onChangeComboBox} + onSearchChange={onSearchComboChange} + /> + </EuiFormRow> ); +}; +SearchIssuesFieldComponent.displayName = 'SearchIssuesField'; + +const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { const { http } = useKibana().services; + const [{ fields }] = useFormData<{ fields?: { parent: string } }>({ + watch: ['fields.parent'], + }); + + const [query, setQuery] = useState<string | null>(null); + const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const { isFetching: isLoadingIssues, data: issuesData } = useGetIssues({ http, actionConnector, query, + onDebounce, + }); + + const { isFetching: isLoadingIssue, data: issueData } = useGetIssue({ + http, + actionConnector, + id: fields?.parent ?? '', }); const issues = issuesData?.data ?? []; + const issue = issueData?.data ? [issueData.data] : []; + + const onSearchComboChange = (value: string) => { + if (!isEmpty(value)) { + setQuery(value); + } - const options = issues.map((issue) => ({ label: issue.title, value: issue.key })); + onContentChange(value); + }; + + const isLoading = isUserTyping || isLoadingIssues || isLoadingIssue; + const options = [...issues, ...issue].map((_issue) => ({ + label: _issue.title, + value: _issue.key, + })); return ( - <UseField path="fields.parent"> - {(field) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - - const onSearchChange = (searchVal: string) => { - setQuery(searchVal); - }; - - const onChangeComboBox = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => { - setSelectedOptions(changedOptions); - field.setValue(changedOptions[0].value ?? ''); - }; - - return ( - <EuiFormRow - id="indexConnectorSelectSearchBox" - fullWidth - label={i18n.PARENT_ISSUE} - isInvalid={isInvalid} - error={errorMessage} - > - <EuiComboBox - fullWidth - singleSelection - async - placeholder={i18n.SEARCH_ISSUES_PLACEHOLDER} - aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL} - isLoading={isLoadingIssues} - isInvalid={isInvalid} - noSuggestions={!options.length} - options={options} - data-test-subj="search-parent-issues" - data-testid="search-parent-issues" - selectedOptions={selectedOptions} - onChange={onChangeComboBox} - onSearchChange={onSearchChange} - /> - </EuiFormRow> - ); + <UseField<string> + path="fields.parent" + component={SearchIssuesFieldComponent} + componentProps={{ + isLoading, + onSearchComboChange, + options, }} - </UseField> + /> ); }; + SearchIssuesComponent.displayName = 'SearchIssues'; export const SearchIssues = memo(SearchIssuesComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx new file mode 100644 index 000000000000..876738025e6a --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useKibana, useToasts } from '../../../common/lib/kibana'; +import { connector as actionConnector } from '../mock'; +import { useGetIssue } from './use_get_issue'; +import * as api from './api'; +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; + +describe('useGetIssue', () => { + const { http } = useKibanaMock().services; + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const spy = jest.spyOn(api, 'getIssue'); + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(spy).toHaveBeenCalledWith({ + http, + signal: expect.anything(), + connectorId: actionConnector.id, + id: 'RJ-107', + }); + }); + + it('does not call the api when the connector is missing', async () => { + const spy = jest.spyOn(api, 'getIssue'); + renderHook( + () => + useGetIssue({ + http, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalledWith(); + }); + + it('does not call the api when the id is missing', async () => { + const spy = jest.spyOn(api, 'getIssue'); + renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: '', + }), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalledWith(); + }); + + it('calls addError when the getIssue api throws an error', async () => { + const spyOnGetCases = jest.spyOn(api, 'getIssue'); + spyOnGetCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess: jest.fn(), addError }); + + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isError); + + expect(addError).toHaveBeenCalled(); + }); + + it('calls addError when the getIssue api returns successfully but contains an error', async () => { + const spyOnGetCases = jest.spyOn(api, 'getIssue'); + spyOnGetCases.mockResolvedValue({ + status: 'error', + message: 'Error message', + actionId: 'test', + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess: jest.fn(), addError }); + + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx new file mode 100644 index 000000000000..ed3bfcf61f2f --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup } from '@kbn/core/public'; +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import { useQuery } from '@tanstack/react-query'; +import { isEmpty } from 'lodash'; +import type { ActionConnector } from '../../../../common/types/domain'; +import { getIssue } from './api'; +import type { Issue } from './types'; +import * as i18n from './translations'; +import { useCasesToast } from '../../../common/use_cases_toast'; +import type { ServerError } from '../../../types'; +import { connectorsQueriesKeys } from '../constants'; + +interface Props { + http: HttpSetup; + id: string; + actionConnector?: ActionConnector; +} + +export const useGetIssue = ({ http, actionConnector, id }: Props) => { + const { showErrorToast } = useCasesToast(); + return useQuery<ActionTypeExecutorResult<Issue>, ServerError>( + connectorsQueriesKeys.jiraGetIssue(actionConnector?.id ?? '', id), + ({ signal }) => { + return getIssue({ + http, + signal, + connectorId: actionConnector?.id ?? '', + id, + }); + }, + { + enabled: Boolean(actionConnector && !isEmpty(id)), + staleTime: 60 * 1000, // one minute + onSuccess: (res) => { + if (res.status && res.status === 'error') { + showErrorToast(new Error(i18n.GET_ISSUE_API_ERROR(id)), { + title: i18n.GET_ISSUE_API_ERROR(id), + toastMessage: `${res.serviceMessage ?? res.message}`, + }); + } + }, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.GET_ISSUE_API_ERROR(id) }); + }, + } + ); +}; + +export type UseGetIssueTypes = ReturnType<typeof useGetIssue>; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx index 037fcc6bb8d8..01f4ad0a3edb 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx @@ -10,7 +10,8 @@ import useDebounce from 'react-use/lib/useDebounce'; import type { HttpSetup } from '@kbn/core/public'; import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; import { useQuery } from '@tanstack/react-query'; -import { isEmpty } from 'lodash'; +import { isEmpty, noop } from 'lodash'; +import { SEARCH_DEBOUNCE_MS } from '../../../../common/constants'; import type { ActionConnector } from '../../../../common/types/domain'; import { getIssues } from './api'; import type { Issues } from './types'; @@ -23,16 +24,16 @@ interface Props { http: HttpSetup; query: string | null; actionConnector?: ActionConnector; + onDebounce?: () => void; } -const SEARCH_DEBOUNCE_MS = 500; - -export const useGetIssues = ({ http, actionConnector, query }: Props) => { +export const useGetIssues = ({ http, actionConnector, query, onDebounce = noop }: Props) => { const [debouncedQuery, setDebouncedQuery] = useState(query); useDebounce( () => { setDebouncedQuery(query); + onDebounce(); }, SEARCH_DEBOUNCE_MS, [query] diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx index e8260a69a330..ee7538543ec4 100644 --- a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx @@ -77,12 +77,15 @@ const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = field.setValue(changedOptions.map((option) => option.value as string)); }; - const selectedOptions = (field.value ?? []).map((incidentType) => ({ - value: incidentType, - label: - (allIncidentTypes ?? []).find((type) => incidentType === type.id.toString())?.name ?? - '', - })); + const selectedOptions = + field.value && allIncidentTypes?.length + ? field.value.map((incidentType) => ({ + value: incidentType, + label: + allIncidentTypes.find((type) => incidentType === type.id.toString())?.name ?? + '', + })) + : []; return ( <EuiFormRow diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx deleted file mode 100644 index 1cf7c8207513..000000000000 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { mount } from 'enzyme'; -import { act, waitFor } from '@testing-library/react'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiComboBox } from '@elastic/eui'; - -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { connectorsMock } from '../../containers/mock'; -import { Connector } from './connector'; -import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; -import { useGetSeverity } from '../connectors/resilient/use_get_severity'; -import { useGetChoices } from '../connectors/servicenow/use_get_choices'; -import { incidentTypes, severity, choices } from '../connectors/mock'; -import type { FormProps } from './schema'; -import { schema } from './schema'; -import type { AppMockRenderer } from '../../common/mock'; -import { - noConnectorsCasePermission, - createAppMockRenderer, - TestProviders, -} from '../../common/mock'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; -import { useCaseConfigureResponse } from '../configure_cases/__mock__'; - -jest.mock('../connectors/resilient/use_get_incident_types'); -jest.mock('../connectors/resilient/use_get_severity'); -jest.mock('../connectors/servicenow/use_get_choices'); -jest.mock('../../containers/configure/use_get_case_configuration'); - -const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; -const useGetSeverityMock = useGetSeverity as jest.Mock; -const useGetChoicesMock = useGetChoices as jest.Mock; -const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; - -const useGetIncidentTypesResponse = { - isLoading: false, - incidentTypes, -}; - -const useGetSeverityResponse = { - isLoading: false, - severity, -}; - -const useGetChoicesResponse = { - isLoading: false, - choices, -}; - -const defaultProps = { - connectors: connectorsMock, - isLoading: false, - isLoadingConnectors: false, -}; - -describe('Connector', () => { - let appMockRender: AppMockRenderer; - let globalForm: FormHook; - - const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ - defaultValue: { connectorId: connectorsMock[0].id, fields: null }, - schema: { - connectorId: schema.connectorId, - fields: schema.fields, - }, - }); - - globalForm = form; - - return <Form form={form}>{children}</Form>; - }; - - beforeEach(() => { - jest.clearAllMocks(); - appMockRender = createAppMockRenderer(); - useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); - useGetSeverityMock.mockReturnValue(useGetSeverityResponse); - useGetChoicesMock.mockReturnValue(useGetChoicesResponse); - useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); - }); - - it('it renders', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - // Selected connector is set to none so no fields should be displayed - expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeFalsy(); - }); - - it('it is disabled and loading when isLoadingConnectors=true', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...{ ...defaultProps, isLoadingConnectors: true }} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') - ).toEqual(true); - - expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( - true - ); - }); - - it('it is disabled and loading when isLoading=true', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...{ ...defaultProps, isLoading: true }} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') - ).toEqual(true); - expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( - true - ); - }); - - it(`it should change connector`, async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); - }); - - act(() => { - ( - wrapper.find(EuiComboBox).props() as unknown as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - } - ).onChange([{ value: '19', label: 'Denial of Service' }]); - }); - - act(() => { - wrapper - .find('select[data-test-subj="severitySelect"]') - .first() - .simulate('change', { - target: { value: '4' }, - }); - }); - - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ - connectorId: 'resilient-2', - fields: { incidentTypes: ['19'], severityCode: '4' }, - }); - }); - }); - - it('shows the actions permission message if the user does not have read access to actions', async () => { - appMockRender.coreStart.application.capabilities = { - ...appMockRender.coreStart.application.capabilities, - actions: { save: false, show: false }, - }; - - const result = appMockRender.render( - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - ); - expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('caseConnectors')).toBe(null); - }); - - it('shows the actions permission message if the user does not have access to case connector', async () => { - appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); - - const result = appMockRender.render( - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - ); - expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('caseConnectors')).toBe(null); - }); -}); diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.tsx deleted file mode 100644 index 28cebde65db2..000000000000 --- a/x-pack/plugins/cases/public/components/create/custom_fields.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; -import { sortBy } from 'lodash'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; - -import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { CasesConfigurationUI } from '../../../common/ui'; -import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; -import * as i18n from './translations'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; -import { getConfigurationByOwner } from '../../containers/configure/utils'; - -interface Props { - isLoading: boolean; -} - -const CustomFieldsComponent: React.FC<Props> = ({ isLoading }) => { - const { owner } = useCasesContext(); - const [{ selectedOwner }] = useFormData<{ selectedOwner: string }>({ watch: ['selectedOwner'] }); - const { data: configurations, isLoading: isLoadingCaseConfiguration } = - useGetAllCaseConfigurations(); - - const configurationOwner: string | undefined = selectedOwner ? selectedOwner : owner[0]; - const customFieldsConfiguration = useMemo( - () => - getConfigurationByOwner({ - configurations, - owner: configurationOwner, - }).customFields ?? [], - [configurations, configurationOwner] - ); - - const sortedCustomFields = useMemo( - () => sortCustomFieldsByLabel(customFieldsConfiguration), - [customFieldsConfiguration] - ); - - const customFieldsComponents = sortedCustomFields.map( - (customField: CasesConfigurationUI['customFields'][number]) => { - const customFieldFactory = customFieldsBuilderMap[customField.type]; - const customFieldType = customFieldFactory().build(); - - const CreateComponent = customFieldType.Create; - - return ( - <CreateComponent - isLoading={isLoading || isLoadingCaseConfiguration} - customFieldConfiguration={customField} - key={customField.key} - /> - ); - } - ); - - if (!customFieldsConfiguration.length) { - return null; - } - - return ( - <EuiFlexGroup direction="column" gutterSize="s"> - <EuiText size="m"> - <h3>{i18n.ADDITIONAL_FIELDS}</h3> - </EuiText> - <EuiSpacer size="xs" /> - <EuiFlexItem data-test-subj="create-case-custom-fields">{customFieldsComponents}</EuiFlexItem> - </EuiFlexGroup> - ); -}; - -CustomFieldsComponent.displayName = 'CustomFields'; - -export const CustomFields = React.memo(CustomFieldsComponent); - -const sortCustomFieldsByLabel = (configCustomFields: CasesConfigurationUI['customFields']) => { - return sortBy(configCustomFields, (configCustomField) => { - return configCustomField.label; - }); -}; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index b5b3f7bf7b67..885e25e959ac 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -5,48 +5,44 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; import React from 'react'; -import { mount } from 'enzyme'; -import { act, render, within, fireEvent, waitFor } from '@testing-library/react'; +import { within, fireEvent, waitFor, screen } from '@testing-library/react'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; -import { NONE_CONNECTOR_ID } from '../../../common/constants'; -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; import type { CreateCaseFormProps } from './form'; import { CreateCaseForm } from './form'; import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useGetAllCaseConfigurationsResponse } from '../configure_cases/__mock__'; -import { TestProviders } from '../../common/mock'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useGetTags } from '../../containers/use_get_tags'; import { useAvailableCasesOwners } from '../app/use_available_owners'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); jest.mock('../../containers/configure/use_get_all_case_configurations'); +jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); +jest.mock('../../containers/user_profiles/use_get_current_user_profile'); jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); jest.mock('../app/use_available_owners'); const useGetTagsMock = useGetTags as jest.Mock; -const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; +const useGetSupportedActionConnectorsMock = useGetSupportedActionConnectors as jest.Mock; const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; const useAvailableOwnersMock = useAvailableCasesOwners as jest.Mock; - -const initialCaseValue: FormProps = { - description: '', - tags: [], - title: '', - connectorId: NONE_CONNECTOR_ID, - fields: null, - syncAlerts: true, - assignees: [], - customFields: {}, -}; +const useSuggestUserProfilesMock = useSuggestUserProfiles as jest.Mock; +const useGetCurrentUserProfileMock = useGetCurrentUserProfile as jest.Mock; const casesFormProps: CreateCaseFormProps = { onCancel: jest.fn(), @@ -54,36 +50,18 @@ const casesFormProps: CreateCaseFormProps = { }; describe('CreateCaseForm', () => { - let globalForm: FormHook; - const draftStorageKey = `cases.caseView.createCase.description.markdownEditor`; - - const MockHookWrapperComponent: FC< - PropsWithChildren<{ - testProviderProps?: unknown; - }> - > = ({ children, testProviderProps = {} }) => { - const { form } = useForm<FormProps>({ - defaultValue: initialCaseValue, - options: { stripEmptyFields: false }, - schema, - }); - - globalForm = form; - - return ( - // @ts-expect-error ts upgrade v4.7.4 - <TestProviders {...testProviderProps}> - <Form form={form}>{children}</Form> - </TestProviders> - ); - }; + const draftStorageKey = 'cases.caseView.createCase.description.markdownEditor'; + let appMockRenderer: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); useAvailableOwnersMock.mockReturnValue(['securitySolution', 'observability']); useGetTagsMock.mockReturnValue({ data: ['test'] }); - useGetConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); + useGetSupportedActionConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); useGetAllCaseConfigurationsMock.mockImplementation(() => useGetAllCaseConfigurationsResponse); + useSuggestUserProfilesMock.mockReturnValue({ data: userProfiles, isLoading: false }); + useGetCurrentUserProfileMock.mockReturnValue({ data: userProfiles[0], isLoading: false }); }); afterEach(() => { @@ -91,136 +69,86 @@ describe('CreateCaseForm', () => { }); it('renders with steps', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeTruthy(); + expect(await screen.findByTestId('case-creation-form-steps')).toBeInTheDocument(); }); it('renders without steps', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} withSteps={false} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} withSteps={false} />); - expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeFalsy(); + expect(screen.queryByText('case-creation-form-steps')).not.toBeInTheDocument(); }); it('renders all form fields except case selection', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="categories-list"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeFalsy(); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('categories-list')).toBeInTheDocument(); + expect(screen.queryByText('caseOwnerSelector')).not.toBeInTheDocument(); }); it('renders all form fields including case selection if has permissions and no owner', async () => { - const wrapper = mount( - <MockHookWrapperComponent testProviderProps={{ owner: [] }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="categories-list"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeTruthy(); + appMockRenderer = createAppMockRenderer({ owner: [] }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('categories-list')).toBeInTheDocument(); + expect(await screen.findByTestId('caseOwnerSelector')).toBeInTheDocument(); }); it('does not render solution picker when only one owner is available', async () => { useAvailableOwnersMock.mockReturnValue(['securitySolution']); - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeFalsy(); + expect(screen.queryByTestId('caseOwnerSelector')).not.toBeInTheDocument(); }); - it('hides the sync alerts toggle', () => { - const { queryByText } = render( - <MockHookWrapperComponent testProviderProps={{ features: { alerts: { sync: false } } }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + it('hides the sync alerts toggle', async () => { + appMockRenderer = createAppMockRenderer({ features: { alerts: { sync: false } } }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(queryByText('Sync alert')).not.toBeInTheDocument(); - }); - - it('should render spinner when loading', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); - - await act(async () => { - globalForm.setFieldValue('title', 'title'); - globalForm.setFieldValue('description', 'description'); - await wrapper.find(`button[data-test-subj="create-case-submit"]`).simulate('click'); - wrapper.update(); - }); - - expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy(); + expect(screen.queryByText('Sync alert')).not.toBeInTheDocument(); }); it('should not render the assignees on basic license', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(result.queryByTestId('createCaseAssigneesComboBox')).toBeNull(); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + expect(screen.queryByTestId('createCaseAssigneesComboBox')).not.toBeInTheDocument(); }); - it('should render the assignees on platinum license', () => { + it('should render the assignees on platinum license', async () => { const license = licensingMock.createLicense({ license: { type: 'platinum' }, }); - const result = render( - <MockHookWrapperComponent testProviderProps={{ license }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); - it('should not prefill the form when no initialValue provided', () => { - const { getByTestId } = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> + it('should not prefill the form when no initialValue provided', async () => { + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + const titleInput = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByRole( + 'textbox' ); - const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); - const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); expect(titleInput).toHaveValue(''); expect(descriptionInput).toHaveValue(''); }); - it('should render custom fields when available', () => { + it('should render custom fields when available', async () => { useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, data: [ @@ -231,70 +159,62 @@ describe('CreateCaseForm', () => { ], })); - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(result.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); for (const item of customFieldsConfigurationMock) { expect( - result.getByTestId(`${item.key}-${item.type}-create-custom-field`) + await screen.findByTestId(`${item.key}-${item.type}-create-custom-field`) ).toBeInTheDocument(); } }); - it('should prefill the form when provided with initialValue', () => { - const { getByTestId } = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should prefill the form when provided with initialValue', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); - const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); + const titleInput = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByRole( + 'textbox' + ); expect(titleInput).toHaveValue('title'); expect(descriptionInput).toHaveValue('description'); }); describe('draft comment ', () => { - it('should clear session storage key on cancel', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should clear session storage key on cancel', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const cancelBtn = result.getByTestId('create-case-cancel'); + const cancelBtn = await screen.findByTestId('create-case-cancel'); fireEvent.click(cancelBtn); - fireEvent.click(result.getByTestId('confirmModalConfirmButton')); + fireEvent.click(await screen.findByTestId('confirmModalConfirmButton')); expect(casesFormProps.onCancel).toHaveBeenCalled(); expect(sessionStorage.getItem(draftStorageKey)).toBe(null); }); - it('should clear session storage key on submit', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should clear session storage key on submit', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const submitBtn = result.getByTestId('create-case-submit'); + const submitBtn = await screen.findByTestId('create-case-submit'); fireEvent.click(submitBtn); @@ -304,4 +224,115 @@ describe('CreateCaseForm', () => { }); }); }); + + describe('templates', () => { + beforeEach(() => { + useGetAllCaseConfigurationsMock.mockReturnValue({ + ...useGetAllCaseConfigurationsResponse, + data: [ + { + ...useGetAllCaseConfigurationsResponse.data[0], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + required: false, + label: 'My test label 1', + }, + ], + templates: templatesConfigurationMock, + }, + ], + }); + }); + + it('should populate the cases fields correctly when selecting a case template', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const selectedTemplate = templatesConfigurationMock[4]; + + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.name + ); + + const title = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const description = within(await screen.findByTestId('caseDescription')).getByRole('textbox'); + const tags = within(await screen.findByTestId('caseTags')).getByTestId('comboBoxInput'); + const category = within(await screen.findByTestId('caseCategory')).getByTestId( + 'comboBoxSearchInput' + ); + const severity = await screen.findByTestId('case-severity-selection'); + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + + expect(title).toHaveValue(selectedTemplate.caseFields?.title); + expect(description).toHaveValue(selectedTemplate.caseFields?.description); + expect(tags).toHaveTextContent(selectedTemplate.caseFields?.tags?.[0]!); + expect(category).toHaveValue(selectedTemplate.caseFields?.category); + expect(severity).toHaveTextContent('High'); + expect(customField).toHaveValue('this is a text field value'); + expect(await screen.findByText('Damaged Raccoon')).toBeInTheDocument(); + + expect(await screen.findByText('Jira')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields-jira')).toBeInTheDocument(); + }); + + it('changes templates correctly', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const firstTemplate = templatesConfigurationMock[4]; + const secondTemplate = templatesConfigurationMock[2]; + + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + firstTemplate.name + ); + + const title = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const description = within(await screen.findByTestId('caseDescription')).getByRole('textbox'); + const tags = within(await screen.findByTestId('caseTags')).getByTestId('comboBoxInput'); + const category = within(await screen.findByTestId('caseCategory')).getByTestId( + 'comboBoxSearchInput' + ); + const assignees = within(await screen.findByTestId('caseAssignees')).getByTestId( + 'comboBoxSearchInput' + ); + const severity = await screen.findByTestId('case-severity-selection'); + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + + expect(title).toHaveValue(firstTemplate.caseFields?.title); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + secondTemplate.name + ); + + expect(title).toHaveValue(secondTemplate.caseFields?.title); + expect(description).not.toHaveValue(); + expect(tags).toHaveTextContent(secondTemplate.caseFields?.tags?.[0]!); + expect(tags).toHaveTextContent(secondTemplate.caseFields?.tags?.[1]!); + expect(category).not.toHaveValue(); + expect(severity).toHaveTextContent('Medium'); + expect(customField).not.toHaveValue(); + expect(assignees).not.toHaveValue(); + + expect(screen.queryByText('Damaged Raccoon')).not.toBeInTheDocument(); + expect(screen.queryByText('Jira')).not.toBeInTheDocument(); + expect(screen.queryByTestId('connector-fields-jira')).not.toBeInTheDocument(); + + expect(await screen.findByText('No connector selected')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 4c95b6e11a11..db6df19308e5 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -5,30 +5,13 @@ * 2.0. */ -import React, { useMemo } from 'react'; -import type { EuiThemeComputed } from '@elastic/eui'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiSteps, - useEuiTheme, - logicalCSS, -} from '@elastic/eui'; -import { css } from '@emotion/react'; - +import React, { useCallback, useState, useMemo } from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; - -import type { ActionConnector } from '../../../common/types/domain'; import type { CasePostRequest } from '../../../common/types/api'; -import { Title } from './title'; -import { Description, fieldName as descriptionFieldName } from './description'; -import { Tags } from './tags'; -import { Connector } from './connector'; +import { fieldName as descriptionFieldName } from '../case_form_fields/description'; import * as i18n from './translations'; -import { SyncAlertsToggle } from './sync_alerts_toggle'; -import type { CaseUI } from '../../containers/types'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; import type { CasesTimelineIntegration } from '../timeline_context'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { InsertTimeline } from '../insert_timeline'; @@ -37,33 +20,19 @@ import type { UseCreateAttachments } from '../../containers/use_create_attachmen import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; import { SubmitCaseButton } from './submit_button'; import { FormContext } from './form_context'; -import { useCasesFeatures } from '../../common/use_cases_features'; -import { CreateCaseOwnerSelector } from './owner_selector'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CaseAttachmentsWithoutOwner } from '../../types'; -import { Severity } from './severity'; -import { Assignees } from './assignees'; import { useCancelCreationAction } from './use_cancel_creation_action'; import { CancelCreationConfirmationModal } from './cancel_creation_confirmation_modal'; -import { Category } from './category'; -import { CustomFields } from './custom_fields'; - -const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) => - big - ? css` - ${logicalCSS('margin-top', euiTheme.size.xl)}; - ` - : css` - ${logicalCSS('margin-top', euiTheme.size.base)}; - `; +import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; +import type { CreateCaseFormFieldsProps } from './form_fields'; +import { CreateCaseFormFields } from './form_fields'; +import { getConfigurationByOwner } from '../../containers/configure/utils'; +import { CreateCaseOwnerSelector } from './owner_selector'; +import { useAvailableCasesOwners } from '../app/use_available_owners'; +import { getInitialCaseValue, getOwnerDefaultValue } from './utils'; -export interface CreateCaseFormFieldsProps { - connectors: ActionConnector[]; - isLoadingConnectors: boolean; - withSteps: boolean; - draftStorageKey: string; -} export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsProps>, 'withSteps'> { onCancel: () => void; onSuccess: (theCase: CaseUI) => void; @@ -76,130 +45,70 @@ export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsPr initialValue?: Pick<CasePostRequest, 'title' | 'description'>; } -const empty: ActionConnector[] = []; -export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo( - ({ connectors, isLoadingConnectors, withSteps, draftStorageKey }) => { +type FormFieldsWithFormContextProps = Pick< + CreateCaseFormFieldsProps, + 'withSteps' | 'draftStorageKey' +> & { + isLoadingCaseConfiguration: boolean; + currentConfiguration: CasesConfigurationUI; + selectedOwner: string; + onSelectedOwner: (owner: string) => void; +}; + +export const FormFieldsWithFormContext: React.FC<FormFieldsWithFormContextProps> = React.memo( + ({ + currentConfiguration, + isLoadingCaseConfiguration, + withSteps, + draftStorageKey, + selectedOwner, + onSelectedOwner, + }) => { const { owner } = useCasesContext(); - const { isSubmitting } = useFormContext(); - const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); - const { euiTheme } = useEuiTheme(); const availableOwners = useAvailableCasesOwners(); - const canShowCaseSolutionSelection = !owner.length && availableOwners.length > 1; - - const firstStep = useMemo( - () => ({ - title: i18n.STEP_ONE_TITLE, - children: ( - <> - <Title isLoading={isSubmitting} /> - {caseAssignmentAuthorized ? ( - <div css={containerCss(euiTheme)}> - <Assignees isLoading={isSubmitting} /> - </div> - ) : null} - <div css={containerCss(euiTheme)}> - <Tags isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)}> - <Category isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)}> - <Severity isLoading={isSubmitting} /> - </div> - {canShowCaseSolutionSelection && ( - <div css={containerCss(euiTheme, true)}> - <CreateCaseOwnerSelector - availableOwners={availableOwners} - isLoading={isSubmitting} - /> - </div> - )} - <div css={containerCss(euiTheme, true)}> - <Description isLoading={isSubmitting} draftStorageKey={draftStorageKey} /> - </div> - <div css={containerCss(euiTheme)}> - <CustomFields isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)} /> - </> - ), - }), - [ - isSubmitting, - euiTheme, - caseAssignmentAuthorized, - canShowCaseSolutionSelection, - availableOwners, - draftStorageKey, - ] - ); - - const secondStep = useMemo( - () => ({ - title: i18n.STEP_TWO_TITLE, - children: ( - <div> - <SyncAlertsToggle isLoading={isSubmitting} /> - </div> - ), - }), - [isSubmitting] - ); - - const thirdStep = useMemo( - () => ({ - title: i18n.STEP_THREE_TITLE, - children: ( - <div> - <Connector - connectors={connectors} - isLoadingConnectors={isLoadingConnectors} - isLoading={isSubmitting} - /> - </div> - ), - }), - [connectors, isLoadingConnectors, isSubmitting] - ); - - const allSteps = useMemo( - () => [firstStep, ...(isSyncAlertsEnabled ? [secondStep] : []), thirdStep], - [isSyncAlertsEnabled, firstStep, secondStep, thirdStep] + const shouldShowOwnerSelector = Boolean(!owner.length && availableOwners.length > 1); + const { reset } = useFormContext(); + + const { data: connectors = [], isLoading: isLoadingConnectors } = + useGetSupportedActionConnectors(); + + const onOwnerChange = useCallback( + (newOwner: string) => { + onSelectedOwner(newOwner); + reset({ + resetValues: true, + defaultValue: getInitialCaseValue({ + owner: newOwner, + connector: currentConfiguration.connector, + }), + }); + }, + [currentConfiguration.connector, onSelectedOwner, reset] ); return ( <> - {isSubmitting && ( - <EuiLoadingSpinner - css={css` - position: absolute; - top: 50%; - left: 50%; - z-index: 99; - `} - data-test-subj="create-case-loading-spinner" - size="xl" - /> - )} - {withSteps ? ( - <EuiSteps - headingElement="h2" - steps={allSteps} - data-test-subj={'case-creation-form-steps'} + {shouldShowOwnerSelector && ( + <CreateCaseOwnerSelector + selectedOwner={selectedOwner} + availableOwners={availableOwners} + isLoading={isLoadingCaseConfiguration} + onOwnerChange={onOwnerChange} /> - ) : ( - <> - {firstStep.children} - {isSyncAlertsEnabled && secondStep.children} - {thirdStep.children} - </> )} + <CreateCaseFormFields + connectors={connectors} + isLoading={isLoadingConnectors || isLoadingCaseConfiguration} + withSteps={withSteps} + draftStorageKey={draftStorageKey} + configuration={currentConfiguration} + /> </> ); } ); -CreateCaseFormFields.displayName = 'CreateCaseFormFields'; +FormFieldsWithFormContext.displayName = 'FormFieldsWithFormContext'; export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( ({ @@ -212,6 +121,13 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( initialValue, }) => { const { owner } = useCasesContext(); + const availableOwners = useAvailableCasesOwners(); + const defaultOwnerValue = owner[0] ?? getOwnerDefaultValue(availableOwners); + const [selectedOwner, onSelectedOwner] = useState<string>(defaultOwnerValue); + + const { data: configurations, isLoading: isLoadingCaseConfiguration } = + useGetAllCaseConfigurations(); + const draftStorageKey = getMarkdownEditorStorageKey({ appId: owner[0], caseId: 'createCase', @@ -233,6 +149,15 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( return onSuccess(theCase); }; + const currentConfiguration = useMemo( + () => + getConfigurationByOwner({ + configurations, + owner: selectedOwner, + }), + [configurations, selectedOwner] + ); + return ( <CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}> <FormContext @@ -240,14 +165,18 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( onSuccess={handleOnSuccess} attachments={attachments} initialValue={initialValue} + currentConfiguration={currentConfiguration} + selectedOwner={selectedOwner} > - <CreateCaseFormFields - connectors={empty} - isLoadingConnectors={false} + <FormFieldsWithFormContext withSteps={withSteps} draftStorageKey={draftStorageKey} + selectedOwner={selectedOwner} + onSelectedOwner={onSelectedOwner} + isLoadingCaseConfiguration={isLoadingCaseConfiguration} + currentConfiguration={currentConfiguration} /> - <div> + <EuiFormRow fullWidth> <EuiFlexGroup alignItems="center" justifyContent="flexEnd" @@ -275,7 +204,7 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( <SubmitCaseButton /> </EuiFlexItem> </EuiFlexGroup> - </div> + </EuiFormRow> <InsertTimeline fieldName={descriptionFieldName} /> </FormContext> </CasesTimelineIntegrationProvider> diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 4c8991f0cb59..5417807edf16 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -16,7 +16,6 @@ import { createAppMockRenderer } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useCreateAttachments } from '../../containers/use_create_attachments'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; @@ -39,8 +38,6 @@ import { useGetChoicesResponse, } from './mock'; import { FormContext } from './form_context'; -import type { CreateCaseFormFieldsProps } from './form'; -import { CreateCaseFormFields } from './form'; import { SubmitCaseButton } from './submit_button'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import userEvent from '@testing-library/user-event'; @@ -60,13 +57,15 @@ import { CustomFieldTypes, } from '../../../common/types/domain'; import { useAvailableCasesOwners } from '../app/use_available_owners'; +import type { CreateCaseFormFieldsProps } from './form_fields'; +import { CreateCaseFormFields } from './form_fields'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../containers/use_post_case'); jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/configure/use_get_case_configuration'); jest.mock('../../containers/configure/use_get_all_case_configurations'); jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); @@ -81,7 +80,6 @@ jest.mock('../../containers/use_get_categories'); jest.mock('../app/use_available_owners'); const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; const usePostCaseMock = usePostCase as jest.Mock; const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; @@ -106,8 +104,11 @@ const defaultPostCase = { mutateAsync: postCase, }; +const currentConfiguration = useGetAllCaseConfigurationsResponse.data[0]; + const defaultCreateCaseForm: CreateCaseFormFieldsProps = { - isLoadingConnectors: false, + configuration: currentConfiguration, + isLoading: false, connectors: [], withSteps: true, draftStorageKey: 'cases.kibana.createCase.description.markdownEditor', @@ -205,7 +206,6 @@ describe('Create case', () => { useCreateAttachmentsMock.mockImplementation(() => ({ mutateAsync: createAttachments })); usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useGetConnectorsMock.mockReturnValue(sampleConnectorData); - useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); useGetAllCaseConfigurationsMock.mockImplementation(() => useGetAllCaseConfigurationsResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); @@ -244,7 +244,11 @@ describe('Create case', () => { describe('Step 1 - Case Fields', () => { it('renders correctly', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -269,7 +273,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -294,7 +302,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -328,7 +340,11 @@ describe('Create case', () => { const newCategory = 'First '; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -373,7 +389,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -408,7 +428,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -431,7 +455,11 @@ describe('Create case', () => { it('should select LOW as the default severity', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -446,27 +474,28 @@ describe('Create case', () => { }); it('should submit form with custom fields', async () => { - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: [ - ...customFieldsConfigurationMock, - { - key: 'my_custom_field_key', - type: CustomFieldTypes.TEXT, - label: 'my custom field label', - required: false, - }, - ], - }, - ], - })); + const configurations = [ + { + ...useGetAllCaseConfigurationsResponse.data[0], + customFields: [ + ...customFieldsConfigurationMock, + { + key: 'my_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'my custom field label', + required: false, + }, + ], + }, + ]; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={configurations[0]} + > + <CreateCaseFormFields {...defaultCreateCaseForm} configuration={configurations[0]} /> <SubmitCaseButton /> </FormContext> ); @@ -477,7 +506,7 @@ describe('Create case', () => { const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[1]; - expect(await screen.findByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); const textCustomField = await screen.findByTestId( `${textField.key}-${textField.type}-create-custom-field` @@ -512,147 +541,20 @@ describe('Create case', () => { }); }); - it('should change custom fields based on the selected owner', async () => { - appMockRender = createAppMockRenderer({ owner: [] }); - - const securityCustomField = { - key: 'security_custom_field', - type: CustomFieldTypes.TEXT, - label: 'security custom field', - required: false, - }; - const o11yCustomField = { - key: 'o11y_field_key', - type: CustomFieldTypes.TEXT, - label: 'observability custom field', - required: false, - }; - const stackCustomField = { - key: 'stack_field_key', - type: CustomFieldTypes.TEXT, - label: 'stack custom field', - required: false, - }; - - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'securitySolution', - customFields: [securityCustomField], - }, - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'observability', - customFields: [o11yCustomField], - }, - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'cases', - customFields: [stackCustomField], - }, - ], - })); - - appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - ); - - await waitForFormToRender(screen); - await fillFormReactTestingLib({ renderer: screen }); - - const createCaseCustomFields = await screen.findByTestId('create-case-custom-fields'); - - // the default selectedOwner is securitySolution - // only the security custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - - const caseOwnerSelector = await screen.findByTestId('caseOwnerSelector'); - - userEvent.click(await within(caseOwnerSelector).findByLabelText('Observability')); - - // only the o11y custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - - userEvent.click(await within(caseOwnerSelector).findByLabelText('Stack')); - - // only the stack custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - }); - it('should select the default connector set in the configuration', async () => { - useGetCaseConfigurationMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - data: { - ...useCaseConfigureResponse.data, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + const configuration = { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, }, - })); + }; useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - }, - ], + data: [configuration], })); useGetConnectorsMock.mockReturnValue({ @@ -661,8 +563,16 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields + {...defaultCreateCaseForm} + configuration={configuration} + connectors={connectorsMock} + /> <SubmitCaseButton /> </FormContext> ); @@ -694,32 +604,19 @@ describe('Create case', () => { }); it('should default to none if the default connector does not exist in connectors', async () => { - useGetCaseConfigurationMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - data: { - ...useCaseConfigureResponse.data, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + const configuration = { + ...useCaseConfigureResponse.data, + connector: { + id: 'not-exist', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, }, - })); + }; useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - }, - ], + data: [configuration], })); useGetConnectorsMock.mockReturnValue({ @@ -728,8 +625,16 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields + {...defaultCreateCaseForm} + configuration={configuration} + connectors={connectorsMock} + /> <SubmitCaseButton /> </FormContext> ); @@ -757,7 +662,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -788,8 +697,12 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -861,8 +774,12 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectors} /> <SubmitCaseButton /> </FormContext> ); @@ -914,8 +831,13 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + afterCaseCreated={afterCaseCreated} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -977,7 +899,12 @@ describe('Create case', () => { ]; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + attachments={attachments} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1008,7 +935,12 @@ describe('Create case', () => { const attachments: CaseAttachments = []; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + attachments={attachments} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1044,11 +976,13 @@ describe('Create case', () => { appMockRender.render( <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + currentConfiguration={currentConfiguration} onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated} attachments={attachments} > - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -1098,7 +1032,11 @@ describe('Create case', () => { }; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1129,7 +1067,11 @@ describe('Create case', () => { it('should submit assignees', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1168,7 +1110,11 @@ describe('Create case', () => { useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1193,7 +1139,11 @@ describe('Create case', () => { it('should have session storage value same as draft comment', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1221,14 +1171,18 @@ describe('Create case', () => { it('should have session storage value same as draft comment', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> ); await waitForFormToRender(screen); - const descriptionInput = within(screen.getByTestId('caseDescription')).getByTestId( + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByTestId( 'euiMarkdownEditorTextArea' ); diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 04a327868418..54198f8510e5 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -5,46 +5,22 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { NONE_CONNECTOR_ID } from '../../../common/constants'; -import { CaseSeverity } from '../../../common/types/domain'; -import type { FormProps } from './schema'; import { schema } from './schema'; -import { getNoneConnector, normalizeActionConnector } from '../configure_cases/utils'; import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import type { CasesConfigurationUI, CaseUI, CaseUICustomField } from '../../containers/types'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; import type { CasePostRequest } from '../../../common/types/api'; import type { UseCreateAttachments } from '../../containers/use_create_attachments'; import { useCreateAttachments } from '../../containers/use_create_attachments'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { useCasesFeatures } from '../../common/use_cases_features'; -import { - getConnectorById, - getConnectorsFormDeserializer, - getConnectorsFormSerializer, - convertCustomFieldValue, -} from '../utils'; -import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CaseAttachmentsWithoutOwner } from '../../types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useCreateCaseWithAttachmentsTransaction } from '../../common/apm/use_cases_transactions'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useApplication } from '../../common/lib/kibana/use_application'; - -const initialCaseValue: FormProps = { - description: '', - tags: [], - title: '', - severity: CaseSeverity.LOW, - connectorId: NONE_CONNECTOR_ID, - fields: null, - syncAlerts: true, - assignees: [], - customFields: {}, -}; +import { createFormSerializer, createFormDeserializer, getInitialCaseValue } from './utils'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; interface Props { afterCaseCreated?: ( @@ -55,6 +31,8 @@ interface Props { onSuccess?: (theCase: CaseUI) => void; attachments?: CaseAttachmentsWithoutOwner; initialValue?: Pick<CasePostRequest, 'title' | 'description'>; + currentConfiguration: CasesConfigurationUI; + selectedOwner: string; } export const FormContext: React.FC<Props> = ({ @@ -63,111 +41,23 @@ export const FormContext: React.FC<Props> = ({ onSuccess, attachments, initialValue, + currentConfiguration, + selectedOwner, }) => { - const { data: connectors = [], isLoading: isLoadingConnectors } = - useGetSupportedActionConnectors(); - const { data: allConfigurations } = useGetAllCaseConfigurations(); - const { owner } = useCasesContext(); const { appId } = useApplication(); - const { isSyncAlertsEnabled } = useCasesFeatures(); + const { data: connectors = [] } = useGetSupportedActionConnectors(); const { mutateAsync: postCase } = usePostCase(); const { mutateAsync: createAttachments } = useCreateAttachments(); const { mutateAsync: pushCaseToExternalService } = usePostPushToService(); const { startTransaction } = useCreateCaseWithAttachmentsTransaction(); - const availableOwners = useAvailableCasesOwners(); - - const trimUserFormData = (userFormData: CaseUI) => { - let formData = { - ...userFormData, - title: userFormData.title.trim(), - description: userFormData.description.trim(), - }; - - if (userFormData.category) { - formData = { ...formData, category: userFormData.category.trim() }; - } - - if (userFormData.tags) { - formData = { ...formData, tags: userFormData.tags.map((tag: string) => tag.trim()) }; - } - - return formData; - }; - - const transformCustomFieldsData = useCallback( - ( - customFields: Record<string, string | boolean>, - selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] - ) => { - const transformedCustomFields: CaseUI['customFields'] = []; - - if (!customFields || !selectedCustomFieldsConfiguration.length) { - return []; - } - - for (const [key, value] of Object.entries(customFields)) { - const configCustomField = selectedCustomFieldsConfiguration.find( - (item) => item.key === key - ); - if (configCustomField) { - transformedCustomFields.push({ - key: configCustomField.key, - type: configCustomField.type, - value: convertCustomFieldValue(value), - } as CaseUICustomField); - } - } - - return transformedCustomFields; - }, - [] - ); const submitCase = useCallback( - async ( - { - connectorId: dataConnectorId, - fields, - syncAlerts = isSyncAlertsEnabled, - ...dataWithoutConnectorId - }, - isValid - ) => { + async (data: CasePostRequest, isValid) => { if (isValid) { - const { selectedOwner, customFields, ...userFormData } = dataWithoutConnectorId; - const caseConnector = getConnectorById(dataConnectorId, connectors); - const defaultOwner = owner[0] ?? availableOwners[0]; - startTransaction({ appId, attachments }); - const connectorToUpdate = caseConnector - ? normalizeActionConnector(caseConnector, fields) - : getNoneConnector(); - - const configurationOwner: string | undefined = selectedOwner ? selectedOwner : owner[0]; - const selectedConfiguration = allConfigurations.find( - (element: CasesConfigurationUI) => element.owner === configurationOwner - ); - - const customFieldsConfiguration = selectedConfiguration - ? selectedConfiguration.customFields - : []; - - const transformedCustomFields = transformCustomFieldsData( - customFields, - customFieldsConfiguration ?? [] - ); - - const trimmedData = trimUserFormData(userFormData); - const theCase = await postCase({ - request: { - ...trimmedData, - connector: connectorToUpdate, - settings: { syncAlerts }, - owner: selectedOwner ?? defaultOwner, - customFields: transformedCustomFields, - }, + request: data, }); // add attachments to the case @@ -183,10 +73,10 @@ export const FormContext: React.FC<Props> = ({ await afterCaseCreated(theCase, createAttachments); } - if (theCase?.id && connectorToUpdate.id !== 'none') { + if (theCase?.id && data.connector.id !== 'none') { await pushCaseToExternalService({ caseId: theCase.id, - connector: connectorToUpdate, + connector: data.connector, }); } @@ -196,15 +86,9 @@ export const FormContext: React.FC<Props> = ({ } }, [ - isSyncAlertsEnabled, - connectors, - owner, - availableOwners, startTransaction, appId, attachments, - transformCustomFieldsData, - allConfigurations, postCase, afterCaseCreated, onSuccess, @@ -213,27 +97,34 @@ export const FormContext: React.FC<Props> = ({ ] ); - const { form } = useForm<FormProps>({ - defaultValue: { ...initialCaseValue, ...initialValue }, + const { form } = useForm({ + defaultValue: { + /** + * This is needed to initiate the connector + * with the one set in the configuration + * when creating a case. + */ + ...getInitialCaseValue({ + owner: selectedOwner, + connector: currentConfiguration.connector, + }), + ...initialValue, + }, options: { stripEmptyFields: false }, schema, onSubmit: submitCase, - serializer: getConnectorsFormSerializer, - deserializer: getConnectorsFormDeserializer, + serializer: (data: CaseFormFieldsSchemaProps) => + createFormSerializer( + connectors, + { + ...currentConfiguration, + owner: selectedOwner, + }, + data + ), + deserializer: createFormDeserializer, }); - const childrenWithExtraProp = useMemo( - () => - children != null - ? React.Children.map(children, (child: React.ReactElement) => - React.cloneElement(child, { - connectors, - isLoadingConnectors, - }) - ) - : null, - [children, connectors, isLoadingConnectors] - ); return ( <Form onKeyDown={(e: KeyboardEvent) => { @@ -245,7 +136,7 @@ export const FormContext: React.FC<Props> = ({ }} form={form} > - {childrenWithExtraProp} + {children} </Form> ); }; diff --git a/x-pack/plugins/cases/public/components/create/form_fields.tsx b/x-pack/plugins/cases/public/components/create/form_fields.tsx new file mode 100644 index 000000000000..26189e33b7f1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/form_fields.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useEffect } from 'react'; +import { + EuiLoadingSpinner, + EuiSteps, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +import type { CasePostRequest } from '../../../common'; +import type { ActionConnector } from '../../../common/types/domain'; +import { Connector } from '../case_form_fields/connector'; +import * as i18n from './translations'; +import { SyncAlertsToggle } from '../case_form_fields/sync_alerts_toggle'; +import type { CasesConfigurationUI, CasesConfigurationUITemplate } from '../../containers/types'; +import { removeEmptyFields } from '../utils'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { TemplateSelector } from './templates'; +import { getInitialCaseValue } from './utils'; +import { CaseFormFields } from '../case_form_fields'; + +export interface CreateCaseFormFieldsProps { + configuration: CasesConfigurationUI; + connectors: ActionConnector[]; + isLoading: boolean; + withSteps: boolean; + draftStorageKey: string; +} + +const transformTemplateCaseFieldsToCaseFormFields = ( + owner: string, + caseTemplateFields: CasesConfigurationUITemplate['caseFields'] +): CasePostRequest => { + const caseFields = removeEmptyFields(caseTemplateFields ?? {}); + return getInitialCaseValue({ owner, ...caseFields }); +}; + +export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo( + ({ configuration, connectors, isLoading, withSteps, draftStorageKey }) => { + const { reset, updateFieldValues, isSubmitting, setFieldValue } = useFormContext(); + const { isSyncAlertsEnabled } = useCasesFeatures(); + const configurationOwner = configuration.owner; + + /** + * Changes the selected connector + * when the user selects a solution. + * Each solution has its own configuration + * so the connector has to change. + */ + useEffect(() => { + setFieldValue('connectorId', configuration.connector.id); + }, [configuration.connector.id, setFieldValue]); + + const onTemplateChange = useCallback( + (caseFields: CasesConfigurationUITemplate['caseFields']) => { + const caseFormFields = transformTemplateCaseFieldsToCaseFormFields( + configurationOwner, + caseFields + ); + + reset({ + resetValues: true, + defaultValue: getInitialCaseValue({ owner: configurationOwner }), + }); + updateFieldValues(caseFormFields); + }, + [configurationOwner, reset, updateFieldValues] + ); + + const firstStep = useMemo( + () => ({ + title: i18n.STEP_ONE_TITLE, + children: ( + <TemplateSelector + isLoading={isSubmitting || isLoading} + templates={configuration.templates} + onTemplateChange={onTemplateChange} + /> + ), + }), + [configuration.templates, isLoading, isSubmitting, onTemplateChange] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.STEP_TWO_TITLE, + children: ( + <CaseFormFields + configurationCustomFields={configuration.customFields} + isLoading={isSubmitting} + setCustomFieldsOptional={false} + isEditMode={false} + draftStorageKey={draftStorageKey} + /> + ), + }), + [configuration.customFields, draftStorageKey, isSubmitting] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, + children: <SyncAlertsToggle isLoading={isSubmitting} />, + }), + [isSubmitting] + ); + + const fourthStep = useMemo( + () => ({ + title: i18n.STEP_FOUR_TITLE, + children: ( + <Connector + connectors={connectors} + isLoadingConnectors={isLoading} + isLoading={isSubmitting} + key={configuration.id} + /> + ), + }), + [configuration.id, connectors, isLoading, isSubmitting] + ); + + const allSteps = useMemo( + () => [firstStep, secondStep, ...(isSyncAlertsEnabled ? [thirdStep] : []), fourthStep], + [firstStep, secondStep, isSyncAlertsEnabled, thirdStep, fourthStep] + ); + + return ( + <> + {isSubmitting && ( + <EuiLoadingSpinner + css={css` + position: absolute; + top: 50%; + left: 50%; + z-index: 99; + `} + data-test-subj="create-case-loading-spinner" + size="xl" + /> + )} + {withSteps ? ( + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'case-creation-form-steps'} + /> + ) : ( + <> + <EuiSpacer size="l" /> + <EuiFlexGroup direction="column"> + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_ONE_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{firstStep.children}</EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_TWO_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{secondStep.children}</EuiFlexItem> + </EuiFlexGroup> + {isSyncAlertsEnabled && ( + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_THREE_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{thirdStep.children}</EuiFlexItem> + </EuiFlexGroup> + )} + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_FOUR_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{fourthStep.children}</EuiFlexItem> + </EuiFlexGroup> + </EuiFlexGroup> + </> + )} + </> + ); + } +); + +CreateCaseFormFields.displayName = 'CreateCaseFormFields'; diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx index 451207b080df..c61dd83dea42 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx @@ -11,13 +11,14 @@ import { waitFor, screen } from '@testing-library/react'; import { SECURITY_SOLUTION_OWNER } from '../../../common'; import { OBSERVABILITY_OWNER, OWNER_INFO } from '../../../common/constants'; import { CreateCaseOwnerSelector } from './owner_selector'; -import { FormTestComponent } from '../../common/test_utils'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import userEvent from '@testing-library/user-event'; describe('Case Owner Selection', () => { - const onSubmit = jest.fn(); + const onOwnerChange = jest.fn(); + const selectedOwner = SECURITY_SOLUTION_OWNER; + let appMockRender: AppMockRenderer; beforeEach(() => { @@ -25,92 +26,66 @@ describe('Case Owner Selection', () => { appMockRender = createAppMockRenderer(); }); - it('renders', async () => { + it('renders all options', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[SECURITY_SOLUTION_OWNER]} isLoading={false} /> - </FormTestComponent> + <CreateCaseOwnerSelector + availableOwners={[SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]} + isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={selectedOwner} + /> ); expect(await screen.findByTestId('caseOwnerSelector')).toBeInTheDocument(); - }); - it.each([ - [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER], - [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER], - ])('disables %s button if user only has %j', async (disabledButton, permission) => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[permission]} isLoading={false} /> - </FormTestComponent> - ); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); - expect(await screen.findByLabelText(OWNER_INFO[disabledButton].label)).toBeDisabled(); - expect(await screen.findByLabelText(OWNER_INFO[permission].label)).not.toBeDisabled(); + const options = await screen.findAllByRole('option'); + expect(options[0]).toHaveTextContent(OWNER_INFO[SECURITY_SOLUTION_OWNER].label); + expect(options[1]).toHaveTextContent(OWNER_INFO[OBSERVABILITY_OWNER].label); }); - it('defaults to security Solution', async () => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> + it.each([[SECURITY_SOLUTION_OWNER], [OBSERVABILITY_OWNER]])( + 'only displays %s option if available', + async (available) => { + appMockRender.render( <CreateCaseOwnerSelector - availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} + availableOwners={[available]} isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={available} /> - </FormTestComponent> - ); - - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); - expect(await screen.findByLabelText('Security')).toBeChecked(); - - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); - - await waitFor(() => { - // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); - }); - }); + ); - it('defaults to security Solution with empty owners', async () => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[]} isLoading={false} /> - </FormTestComponent> - ); + expect(await screen.findByText(OWNER_INFO[available].label)).toBeInTheDocument(); - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); - expect(await screen.findByLabelText('Security')).toBeChecked(); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); - - await waitFor(() => { - // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); - }); - }); + expect((await screen.findAllByRole('option')).length).toBe(1); + } + ); it('changes the selection', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector - availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} - isLoading={false} - /> - </FormTestComponent> + <CreateCaseOwnerSelector + availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} + isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={selectedOwner} + /> ); - expect(await screen.findByLabelText('Security')).toBeChecked(); - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); + expect(await screen.findByText('Security')).toBeInTheDocument(); + expect(screen.queryByText('Observability')).not.toBeInTheDocument(); - userEvent.click(await screen.findByLabelText('Observability')); - - expect(await screen.findByLabelText('Observability')).toBeChecked(); - expect(await screen.findByLabelText('Security')).not.toBeChecked(); - - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); + userEvent.click(await screen.findByText('Observability'), undefined, { + skipPointerEventsCheck: true, + }); await waitFor(() => { // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'observability' }, true); + expect(onOwnerChange).toBeCalledWith('observability'); }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.tsx index 00dd4a03f266..314bbaefc95c 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.tsx @@ -5,113 +5,72 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIcon, - EuiKeyPadMenu, - EuiKeyPadMenuItem, - useGeneratedHtmlId, -} from '@elastic/eui'; -import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { - getFieldValidityAndErrorMessage, - UseField, -} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { SECURITY_SOLUTION_OWNER } from '../../../common'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import { OWNER_INFO } from '../../../common/constants'; import * as i18n from './translations'; -interface OwnerSelectorProps { - field: FieldHook<string>; - isLoading: boolean; - availableOwners: string[]; -} - interface Props { + selectedOwner: string; availableOwners: string[]; isLoading: boolean; + onOwnerChange: (owner: string) => void; } -const DEFAULT_SELECTABLE_OWNERS = Object.keys(OWNER_INFO) as Array<keyof typeof OWNER_INFO>; - -const FIELD_NAME = 'selectedOwner'; - -const FullWidthKeyPadMenu = euiStyled(EuiKeyPadMenu)` - width: 100%; -`; - -const FullWidthKeyPadItem = euiStyled(EuiKeyPadMenuItem)` - - width: 100%; -`; - -const OwnerSelector = ({ +const CaseOwnerSelector: React.FC<Props> = ({ availableOwners, - field, - isLoading = false, -}: OwnerSelectorProps): JSX.Element => { - const { errorMessage, isInvalid } = getFieldValidityAndErrorMessage(field); - const radioGroupName = useGeneratedHtmlId({ prefix: 'caseOwnerRadioGroup' }); - - const onChange = useCallback((val: string) => field.setValue(val), [field]); + isLoading, + onOwnerChange, + selectedOwner, +}) => { + const onChange = (owner: string) => { + onOwnerChange(owner); + }; + + const options = Object.entries(OWNER_INFO) + .filter(([owner]) => availableOwners.includes(owner)) + .map(([owner, definition]) => ({ + value: owner, + inputDisplay: ( + <EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}> + <EuiFlexItem grow={false}> + <EuiIcon + type={definition.iconType} + size="m" + title={definition.label} + className="eui-alignMiddle" + /> + </EuiFlexItem> + <EuiFlexItem> + <small>{definition.label}</small> + </EuiFlexItem> + </EuiFlexGroup> + ), + 'data-test-subj': `${definition.id}OwnerOption`, + })); return ( <EuiFormRow + display="columnCompressed" + label={i18n.SOLUTION_SELECTOR_LABEL} data-test-subj="caseOwnerSelector" fullWidth - isInvalid={isInvalid} - error={errorMessage} - helpText={field.helpText} - label={field.label} - labelAppend={field.labelAppend} > - <FullWidthKeyPadMenu checkable={{ ariaLegend: i18n.ARIA_KEYPAD_LEGEND }}> - <EuiFlexGroup> - {DEFAULT_SELECTABLE_OWNERS.map((owner) => ( - <EuiFlexItem key={owner}> - <FullWidthKeyPadItem - data-test-subj={`${owner}RadioButton`} - onChange={onChange} - checkable="single" - name={radioGroupName} - id={owner} - label={OWNER_INFO[owner].label} - isSelected={field.value === owner} - isDisabled={isLoading || !availableOwners.includes(owner)} - > - <EuiIcon type={OWNER_INFO[owner].iconType} size="xl" /> - </FullWidthKeyPadItem> - </EuiFlexItem> - ))} - </EuiFlexGroup> - </FullWidthKeyPadMenu> + <EuiSuperSelect + data-test-subj="caseOwnerSuperSelect" + options={options} + isLoading={isLoading} + fullWidth + valueOfSelected={selectedOwner} + onChange={(owner) => onChange(owner)} + compressed + /> </EuiFormRow> ); }; -OwnerSelector.displayName = 'OwnerSelector'; - -const CaseOwnerSelector: React.FC<Props> = ({ availableOwners, isLoading }) => { - const defaultValue = availableOwners.includes(SECURITY_SOLUTION_OWNER) - ? SECURITY_SOLUTION_OWNER - : availableOwners[0] ?? SECURITY_SOLUTION_OWNER; - - return ( - <UseField - path={FIELD_NAME} - config={{ defaultValue }} - component={OwnerSelector} - componentProps={{ availableOwners, isLoading }} - /> - ); -}; - CaseOwnerSelector.displayName = 'CaseOwnerSelectionComponent'; export const CreateCaseOwnerSelector = memo(CaseOwnerSelector); diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 9d07efbf3611..2f92857930d9 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -5,140 +5,34 @@ * 2.0. */ -import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { FIELD_TYPES, VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import type { ConnectorTypeFields } from '../../../common/types/domain'; -import type { CasePostRequest } from '../../../common/types/api'; -import { - MAX_TITLE_LENGTH, - MAX_DESCRIPTION_LENGTH, - MAX_LENGTH_PER_TAG, - MAX_TAGS_PER_CASE, -} from '../../../common/constants'; import * as i18n from './translations'; -import { OptionalFieldLabel } from './optional_field_label'; -import { SEVERITY_TITLE } from '../severity/translations'; -const { emptyField, maxLengthField } = fieldValidators; +const { emptyField } = fieldValidators; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; +import { schema as caseFormFieldsSchema } from '../case_form_fields/schema'; -const isInvalidTag = (value: string) => value.trim() === ''; +const caseFormFieldsSchemaTyped = caseFormFieldsSchema as Record<string, FieldConfig<string>>; -const isTagCharactersInLimit = (value: string) => value.trim().length > MAX_LENGTH_PER_TAG; - -export const schemaTags = { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.TAGS, - helpText: i18n.TAGS_HELP, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isInvalidTag(value)) || - (Array.isArray(value) && value.length > 0 && value.find(isInvalidTag)) - ) { - return { - message: i18n.TAGS_EMPTY_ERROR, - }; - } - }, - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isTagCharactersInLimit(value)) || - (Array.isArray(value) && value.length > 0 && value.some(isTagCharactersInLimit)) - ) { - return { - message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), - }; - } - }, - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string[] }) => { - if (Array.isArray(value) && value.length > MAX_TAGS_PER_CASE) { - return { - message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), - }; - } - }, - }, - ], -}; - -export type FormProps = Omit< - CasePostRequest, - 'connector' | 'settings' | 'owner' | 'customFields' -> & { - connectorId: string; - fields: ConnectorTypeFields['fields']; - syncAlerts: boolean; - selectedOwner?: string | null; - customFields: Record<string, string | boolean>; -}; - -export const schema: FormSchema<FormProps> = { +export const schema: FormSchema<CaseFormFieldsSchemaProps> = { + ...caseFormFieldsSchema, title: { - type: FIELD_TYPES.TEXT, - label: i18n.NAME, + ...caseFormFieldsSchemaTyped.title, validations: [ { validator: emptyField(i18n.TITLE_REQUIRED), }, - { - validator: maxLengthField({ - length: MAX_TITLE_LENGTH, - message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), - }), - }, + ...(caseFormFieldsSchemaTyped.title.validations ?? []), ], }, description: { - label: i18n.DESCRIPTION, + ...caseFormFieldsSchemaTyped.description, validations: [ { validator: emptyField(i18n.DESCRIPTION_REQUIRED), }, - { - validator: maxLengthField({ - length: MAX_DESCRIPTION_LENGTH, - message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), - }), - }, - ], - }, - selectedOwner: { - label: i18n.SOLUTION, - type: FIELD_TYPES.RADIO_GROUP, - validations: [ - { - validator: emptyField(i18n.SOLUTION_REQUIRED), - }, + ...(caseFormFieldsSchemaTyped.description.validations ?? []), ], }, - tags: schemaTags, - severity: { - label: SEVERITY_TITLE, - }, - connectorId: { - type: FIELD_TYPES.SUPER_SELECT, - label: i18n.CONNECTORS, - defaultValue: 'none', - }, - fields: { - defaultValue: null, - }, - syncAlerts: { - helpText: i18n.SYNC_ALERTS_HELP, - type: FIELD_TYPES.TOGGLE, - defaultValue: true, - }, - assignees: {}, - category: {}, }; diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx deleted file mode 100644 index 9ac765854772..000000000000 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { SyncAlertsToggle } from './sync_alerts_toggle'; -import type { FormProps } from './schema'; -import { schema } from './schema'; - -describe('SyncAlertsToggle', () => { - let globalForm: FormHook; - - const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ - defaultValue: { syncAlerts: true }, - schema: { - syncAlerts: schema.syncAlerts, - }, - }); - - globalForm = form; - - return <Form form={form}>{children}</Form>; - }; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('it renders', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - }); - - it('it toggles the switch', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> - ); - - wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); - - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ syncAlerts: false }); - }); - }); - - it('it shows the correct labels', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text()).toBe( - 'On' - ); - - wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); - - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text() - ).toBe('Off'); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/create/template.test.tsx b/x-pack/plugins/cases/public/components/create/template.test.tsx new file mode 100644 index 000000000000..d3b1c59b7125 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/template.test.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 from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { templatesConfigurationMock } from '../../containers/mock'; +import { TemplateSelector } from './templates'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + const onTemplateChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + expect(await screen.findByText('Template name')).toBeInTheDocument(); + expect(await screen.findByTestId('create-case-template-select')).toBeInTheDocument(); + }); + + it('selects a template correctly', async () => { + const selectedTemplate = templatesConfigurationMock[2]; + + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.key + ); + + await waitFor(() => { + expect(onTemplateChange).toHaveBeenCalledWith(selectedTemplate.caseFields); + }); + }); + + it('shows the selected option correctly', async () => { + const selectedTemplate = templatesConfigurationMock[2]; + + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.key + ); + + expect( + (await screen.findByRole<HTMLOptionElement>('option', { name: selectedTemplate.name })) + .selected + ).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/templates.tsx b/x-pack/plugins/cases/public/components/create/templates.tsx new file mode 100644 index 000000000000..612a7d8a24a7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/templates.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { EuiSelectOption } from '@elastic/eui'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import type { CasesConfigurationUI, CasesConfigurationUITemplate } from '../../containers/types'; +import { OptionalFieldLabel } from '../optional_field_label'; +import { TEMPLATE_HELP_TEXT, TEMPLATE_LABEL } from './translations'; + +interface Props { + isLoading: boolean; + templates: CasesConfigurationUI['templates']; + onTemplateChange: (caseFields: CasesConfigurationUITemplate['caseFields']) => void; +} + +export const TemplateSelectorComponent: React.FC<Props> = ({ + isLoading, + templates, + onTemplateChange, +}) => { + const [selectedTemplate, onSelectTemplate] = useState<string>(); + + const options: EuiSelectOption[] = templates.map((template) => ({ + text: template.name, + value: template.key, + })); + + const onChange: React.ChangeEventHandler<HTMLSelectElement> = useCallback( + (e) => { + const selectedTemplated = templates.find((template) => template.key === e.target.value); + + if (selectedTemplated) { + onSelectTemplate(selectedTemplated.key); + onTemplateChange(selectedTemplated.caseFields); + } + }, + [onTemplateChange, templates] + ); + + return ( + <EuiFormRow + id="createCaseTemplate" + fullWidth + label={TEMPLATE_LABEL} + labelAppend={OptionalFieldLabel} + helpText={TEMPLATE_HELP_TEXT} + > + <EuiSelect + onChange={onChange} + options={options} + disabled={isLoading} + isLoading={isLoading} + data-test-subj="create-case-template-select" + fullWidth + hasNoInitialSelection + value={selectedTemplate} + /> + </EuiFormRow> + ); +}; + +TemplateSelectorComponent.displayName = 'TemplateSelector'; + +export const TemplateSelector = React.memo(TemplateSelectorComponent); diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts index 473cc40a6a3f..aef9c7c525ac 100644 --- a/x-pack/plugins/cases/public/components/create/translations.ts +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -11,14 +11,18 @@ export * from '../../common/translations'; export * from '../user_profiles/translations'; export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', { - defaultMessage: 'Case fields', + defaultMessage: 'Select template', }); export const STEP_TWO_TITLE = i18n.translate('xpack.cases.create.stepTwoTitle', { - defaultMessage: 'Case settings', + defaultMessage: 'Case fields', }); export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitle', { + defaultMessage: 'Case settings', +}); + +export const STEP_FOUR_TITLE = i18n.translate('xpack.cases.create.stepFourTitle', { defaultMessage: 'External Connector Fields', }); @@ -45,3 +49,15 @@ export const CANCEL_MODAL_BUTTON = i18n.translate('xpack.cases.create.cancelModa export const CONFIRM_MODAL_BUTTON = i18n.translate('xpack.cases.create.confirmModalButton', { defaultMessage: 'Exit without saving', }); + +export const TEMPLATE_LABEL = i18n.translate('xpack.cases.create.templateLabel', { + defaultMessage: 'Template name', +}); + +export const TEMPLATE_HELP_TEXT = i18n.translate('xpack.cases.create.templateHelpText', { + defaultMessage: 'Selecting a template will pre-fill certain case fields below', +}); + +export const SOLUTION_SELECTOR_LABEL = i18n.translate('xpack.cases.create.solutionSelectorLabel', { + defaultMessage: 'Create case under:', +}); diff --git a/x-pack/plugins/cases/public/components/create/utils.test.ts b/x-pack/plugins/cases/public/components/create/utils.test.ts new file mode 100644 index 000000000000..6b8c9c9017fc --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/utils.test.ts @@ -0,0 +1,383 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getInitialCaseValue, + trimUserFormData, + getOwnerDefaultValue, + createFormDeserializer, + createFormSerializer, +} from './utils'; +import { ConnectorTypes, CaseSeverity, CustomFieldTypes } from '../../../common/types/domain'; +import { GENERAL_CASES_OWNER } from '../../../common'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; + +describe('utils', () => { + describe('getInitialCaseValue', () => { + it('returns expected initial values', () => { + const params = { + owner: 'foobar', + connector: { + id: 'foo', + name: 'bar', + type: ConnectorTypes.jira as const, + fields: null, + }, + }; + expect(getInitialCaseValue(params)).toEqual({ + assignees: [], + category: undefined, + customFields: [], + description: '', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + ...params, + }); + }); + + it('returns none connector when none is specified', () => { + expect(getInitialCaseValue({ owner: 'foobar' })).toEqual({ + assignees: [], + category: undefined, + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: '', + owner: 'foobar', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + }); + }); + + it('returns extra fields', () => { + const extraFields = { + owner: 'foobar', + title: 'my title', + assignees: [ + { + uid: 'uid', + }, + ], + tags: ['my tag'], + category: 'categorty', + severity: CaseSeverity.HIGH as const, + description: 'Cool description', + settings: { syncAlerts: false }, + customFields: [{ key: 'key', type: CustomFieldTypes.TEXT as const, value: 'text' }], + }; + + expect(getInitialCaseValue(extraFields)).toEqual({ + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + ...extraFields, + }); + }); + }); + + describe('trimUserFormData', () => { + it('trims applicable fields in the user form data', () => { + const userFormData = { + title: ' title ', + description: ' description ', + category: ' category ', + tags: [' tag 1 ', ' tag 2 '], + }; + + expect(trimUserFormData(userFormData)).toEqual({ + title: userFormData.title.trim(), + description: userFormData.description.trim(), + category: userFormData.category.trim(), + tags: ['tag 1', 'tag 2'], + }); + }); + + it('ignores category and tags if they are missing', () => { + const userFormData = { + title: ' title ', + description: ' description ', + tags: [], + }; + + expect(trimUserFormData(userFormData)).toEqual({ + title: userFormData.title.trim(), + description: userFormData.description.trim(), + tags: [], + }); + }); + }); + + describe('getOwnerDefaultValue', () => { + it('returns the general cases owner if it exists', () => { + expect(getOwnerDefaultValue(['foobar', GENERAL_CASES_OWNER])).toEqual(GENERAL_CASES_OWNER); + }); + + it('returns the first available owner if the general cases owner is not available', () => { + expect(getOwnerDefaultValue(['foo', 'bar'])).toEqual('foo'); + }); + + it('returns the general cases owner if no owner is available', () => { + expect(getOwnerDefaultValue([])).toEqual(GENERAL_CASES_OWNER); + }); + }); + + describe('createFormSerializer', () => { + const dataToSerialize = { + title: 'title', + description: 'description', + tags: [], + connectorId: '', + fields: { incidentTypes: null, severityCode: null }, + customFields: {}, + syncAlerts: false, + }; + const serializedFormData = { + title: 'title', + description: 'description', + customFields: [], + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + owner: casesConfigurationsMock.owner, + }; + + it('returns empty values with owner and connector from configuration when data is empty', () => { + // @ts-ignore: this is what we are trying to test + expect(createFormSerializer([], casesConfigurationsMock, {})).toEqual({ + assignees: [], + category: undefined, + customFields: [], + description: '', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + connector: casesConfigurationsMock.connector, + owner: casesConfigurationsMock.owner, + }); + }); + + it('normalizes action connectors', () => { + expect( + createFormSerializer( + [ + { + id: 'test', + actionTypeId: '.test', + name: 'My connector', + isDeprecated: false, + isPreconfigured: false, + config: { foo: 'bar' }, + isMissingSecrets: false, + isSystemAction: false, + }, + ], + casesConfigurationsMock, + { + ...dataToSerialize, + connectorId: 'test', + fields: { + issueType: '1', + priority: 'test', + parent: null, + }, + } + ) + ).toEqual({ + ...serializedFormData, + connector: { + id: 'test', + name: 'My connector', + type: '.test', + fields: { + issueType: '1', + priority: 'test', + parent: null, + }, + }, + }); + }); + + it('transforms custom fields', () => { + expect( + createFormSerializer([], casesConfigurationsMock, { + ...dataToSerialize, + customFields: { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }, + }) + ).toEqual({ + ...serializedFormData, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'first value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_3', + type: 'text', + value: 'second value', + }, + ], + }); + }); + + it('trims form data', () => { + const untrimmedData = { + title: ' title ', + description: ' description ', + category: ' category ', + tags: [' tag 1 ', ' tag 2 '], + }; + + expect( + // @ts-ignore: expected incomplete form data + createFormSerializer([], casesConfigurationsMock, { ...dataToSerialize, ...untrimmedData }) + ).toEqual({ + ...serializedFormData, + title: untrimmedData.title.trim(), + description: untrimmedData.description.trim(), + category: untrimmedData.category.trim(), + tags: ['tag 1', 'tag 2'], + }); + }); + }); + + describe('createFormDeserializer', () => { + it('deserializes data as expected', () => { + expect( + createFormDeserializer({ + title: 'title', + description: 'description', + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + id: 'foobar', + name: 'none', + type: ConnectorTypes.swimlane as const, + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + }, + owner: casesConfigurationsMock.owner, + customFields: [], + }) + ).toEqual({ + title: 'title', + description: 'description', + syncAlerts: false, + tags: [], + owner: casesConfigurationsMock.owner, + connectorId: 'foobar', + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + customFields: {}, + }); + }); + + it('deserializes customFields as expected', () => { + expect( + createFormDeserializer({ + title: 'title', + description: 'description', + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + id: 'foobar', + name: 'none', + type: ConnectorTypes.swimlane as const, + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + }, + owner: casesConfigurationsMock.owner, + customFields: [ + { + key: 'test_key_1', + type: CustomFieldTypes.TEXT, + value: 'first value', + }, + { + key: 'test_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'test_key_3', + type: CustomFieldTypes.TEXT, + value: 'second value', + }, + ], + }) + ).toEqual({ + title: 'title', + description: 'description', + syncAlerts: false, + tags: [], + owner: casesConfigurationsMock.owner, + connectorId: 'foobar', + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + customFields: { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/utils.ts b/x-pack/plugins/cases/public/components/create/utils.ts new file mode 100644 index 000000000000..daeac67066c9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/utils.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import type { CasePostRequest } from '../../../common'; +import { GENERAL_CASES_OWNER } from '../../../common'; +import type { ActionConnector } from '../../../common/types/domain'; +import { CaseSeverity } from '../../../common/types/domain'; +import type { CasesConfigurationUI } from '../../containers/types'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; +import { + customFieldsFormDeserializer, + customFieldsFormSerializer, + getConnectorById, + getConnectorsFormSerializer, +} from '../utils'; + +type GetInitialCaseValueArgs = Partial<Omit<CasePostRequest, 'owner'>> & + Pick<CasePostRequest, 'owner'>; + +export const getInitialCaseValue = ({ + owner, + connector, + ...restFields +}: GetInitialCaseValueArgs): CasePostRequest => ({ + title: '', + assignees: [], + tags: [], + category: undefined, + severity: CaseSeverity.LOW as const, + description: '', + settings: { syncAlerts: true }, + customFields: [], + ...restFields, + connector: connector ?? getNoneConnector(), + owner, +}); + +export const trimUserFormData = ( + userFormData: Omit< + CaseFormFieldsSchemaProps, + 'connectorId' | 'fields' | 'syncAlerts' | 'customFields' + > +) => { + let formData = { + ...userFormData, + title: userFormData.title.trim(), + description: userFormData.description.trim(), + }; + + if (userFormData.category) { + formData = { ...formData, category: userFormData.category.trim() }; + } + + if (userFormData.tags) { + formData = { ...formData, tags: userFormData.tags.map((tag: string) => tag.trim()) }; + } + + return formData; +}; + +export const createFormDeserializer = (data: CasePostRequest): CaseFormFieldsSchemaProps => { + const { connector, settings, customFields, ...restData } = data; + + return { + ...restData, + connectorId: connector.id, + fields: connector.fields, + syncAlerts: settings.syncAlerts, + customFields: customFieldsFormDeserializer(customFields) ?? {}, + }; +}; + +export const createFormSerializer = ( + connectors: ActionConnector[], + currentConfiguration: CasesConfigurationUI, + data: CaseFormFieldsSchemaProps +): CasePostRequest => { + if (data == null || isEmpty(data)) { + return getInitialCaseValue({ + owner: currentConfiguration.owner, + connector: currentConfiguration.connector, + }); + } + + const { connectorId: dataConnectorId, fields, syncAlerts, customFields, ...restData } = data; + + const serializedConnectorFields = getConnectorsFormSerializer({ fields }); + const caseConnector = getConnectorById(dataConnectorId, connectors); + const connectorToUpdate = caseConnector + ? normalizeActionConnector(caseConnector, serializedConnectorFields.fields) + : getNoneConnector(); + + const transformedCustomFields = customFieldsFormSerializer( + customFields, + currentConfiguration.customFields + ); + + const trimmedData = trimUserFormData(restData); + + return { + ...trimmedData, + connector: connectorToUpdate, + settings: { syncAlerts: syncAlerts ?? false }, + owner: currentConfiguration.owner, + customFields: transformedCustomFields, + }; +}; + +export const getOwnerDefaultValue = (availableOwners: string[]) => + availableOwners.includes(GENERAL_CASES_OWNER) + ? GENERAL_CASES_OWNER + : availableOwners[0] ?? GENERAL_CASES_OWNER; diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx index 002d3e65b4e6..fab80347300d 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx @@ -99,7 +99,7 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); }); it('calls onDeleteCustomField when confirm', async () => { @@ -113,12 +113,12 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(await screen.findByText('Delete')); await waitFor(() => { - expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); expect(props.onDeleteCustomField).toHaveBeenCalledWith( customFieldsConfigurationMock[0].key ); @@ -136,12 +136,12 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(await screen.findByText('Cancel')); await waitFor(() => { - expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); expect(props.onDeleteCustomField).not.toHaveBeenCalledWith(); }); }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx index cfccb53e48db..f8475a90b94a 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx @@ -20,7 +20,7 @@ import * as i18n from '../translations'; import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain'; import { builderMap } from '../builder'; -import { DeleteConfirmationModal } from '../delete_confirmation_modal'; +import { DeleteConfirmationModal } from '../../configure_cases/delete_confirmation_modal'; export interface Props { customFields: CustomFieldsConfiguration; @@ -111,7 +111,8 @@ const CustomFieldsListComponent: React.FC<Props> = (props) => { </EuiFlexItem> {showModal && selectedItem ? ( <DeleteConfirmationModal - label={selectedItem.label} + title={i18n.DELETE_FIELD_TITLE(selectedItem.label)} + message={i18n.DELETE_FIELD_DESCRIPTION} onCancel={onCancel} onConfirm={onConfirm} /> diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx deleted file mode 100644 index 508f124a7746..000000000000 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; -import { CustomFieldFlyout } from './flyout'; -import { customFieldsConfigurationMock } from '../../containers/mock'; -import { - MAX_CUSTOM_FIELD_LABEL_LENGTH, - MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, -} from '../../../common/constants'; -import { CustomFieldTypes } from '../../../common/types/domain'; - -import * as i18n from './translations'; - -describe('CustomFieldFlyout ', () => { - let appMockRender: AppMockRenderer; - - const props = { - onCloseFlyout: jest.fn(), - onSaveField: jest.fn(), - isLoading: false, - disabled: false, - customField: null, - }; - - beforeEach(() => { - jest.clearAllMocks(); - appMockRender = createAppMockRenderer(); - }); - - it('renders correctly', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - expect(await screen.findByTestId('custom-field-flyout-header')).toBeInTheDocument(); - expect(await screen.findByTestId('custom-field-flyout-cancel')).toBeInTheDocument(); - expect(await screen.findByTestId('custom-field-flyout-save')).toBeInTheDocument(); - }); - - it('shows error if field label is too long', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); - - userEvent.type(await screen.findByTestId('custom-field-label-input'), message); - - expect( - await screen.findByText( - i18n.MAX_LENGTH_ERROR(i18n.FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) - ) - ).toBeInTheDocument(); - }); - - it('does not call onSaveField when error', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - expect( - await screen.findByText(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL.toLocaleLowerCase())) - ).toBeInTheDocument(); - - expect(props.onSaveField).not.toBeCalled(); - }); - - it('calls onCloseFlyout on cancel', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('custom-field-flyout-cancel')); - - await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); - }); - }); - - it('calls onCloseFlyout on close', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); - - await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); - }); - }); - - describe('Text custom field', () => { - it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TEXT, - }); - }); - }); - - it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'Default value' - ); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TEXT, - defaultValue: 'Default value', - }); - }); - }); - - it('calls onSaveField with the correct params when a custom field is required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'Default value' - ); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TEXT, - defaultValue: 'Default value', - }); - }); - }); - - it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TEXT, - }); - }); - }); - - it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CustomFieldFlyout {...{ ...props, customField: customFieldsConfigurationMock[0] }} /> - ); - - expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[0].label - ); - expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); - expect(await screen.findByTestId('text-custom-field-required')).toHaveAttribute('checked'); - expect(await screen.findByTestId('text-custom-field-default-value')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[0].defaultValue - ); - }); - - it('shows an error if default value is too long', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'z'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) - ); - - expect( - await screen.findByText( - i18n.MAX_LENGTH_ERROR( - i18n.DEFAULT_VALUE.toLowerCase(), - MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH - ) - ) - ).toBeInTheDocument(); - }); - }); - - describe('Toggle custom field', () => { - it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { - target: { value: CustomFieldTypes.TOGGLE }, - }); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TOGGLE, - defaultValue: false, - }); - }); - }); - - it('calls onSaveField with the correct default value when a custom field is required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { - target: { value: CustomFieldTypes.TOGGLE }, - }); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('toggle-custom-field-required')); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TOGGLE, - defaultValue: false, - }); - }); - }); - - it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CustomFieldFlyout {...{ ...props, customField: customFieldsConfigurationMock[1] }} /> - ); - - expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[1].label - ); - expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); - expect(await screen.findByTestId('toggle-custom-field-required')).toHaveAttribute('checked'); - expect(await screen.findByTestId('toggle-custom-field-default-value')).toHaveAttribute( - 'aria-checked', - 'true' - ); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx deleted file mode 100644 index 0be2c4ea43bc..000000000000 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useState } from 'react'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, -} from '@elastic/eui'; -import type { CustomFieldFormState } from './form'; -import { CustomFieldsForm } from './form'; -import type { CustomFieldConfiguration } from '../../../common/types/domain'; -import { CustomFieldTypes } from '../../../common/types/domain'; - -import * as i18n from './translations'; - -export interface CustomFieldFlyoutProps { - disabled: boolean; - isLoading: boolean; - onCloseFlyout: () => void; - onSaveField: (data: CustomFieldConfiguration) => void; - customField: CustomFieldConfiguration | null; -} - -const CustomFieldFlyoutComponent: React.FC<CustomFieldFlyoutProps> = ({ - onCloseFlyout, - onSaveField, - isLoading, - disabled, - customField, -}) => { - const dataTestSubj = 'custom-field-flyout'; - - const [formState, setFormState] = useState<CustomFieldFormState>({ - isValid: undefined, - submit: async () => ({ - isValid: false, - data: { key: '', label: '', type: CustomFieldTypes.TEXT, required: false }, - }), - }); - - const { submit } = formState; - - const handleSaveField = useCallback(async () => { - const { isValid, data } = await submit(); - - if (isValid) { - onSaveField(data); - } - }, [onSaveField, submit]); - - return ( - <EuiFlyout onClose={onCloseFlyout} data-test-subj={dataTestSubj}> - <EuiFlyoutHeader hasBorder data-test-subj={`${dataTestSubj}-header`}> - <EuiTitle size="s"> - <h3 id="flyoutTitle">{i18n.ADD_CUSTOM_FIELD}</h3> - </EuiTitle> - </EuiFlyoutHeader> - <EuiFlyoutBody> - <CustomFieldsForm initialValue={customField} onChange={setFormState} /> - </EuiFlyoutBody> - <EuiFlyoutFooter data-test-subj={`${dataTestSubj}-footer`}> - <EuiFlexGroup justifyContent="flexStart"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - onClick={onCloseFlyout} - data-test-subj={`${dataTestSubj}-cancel`} - disabled={disabled} - isLoading={isLoading} - > - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButton - fill - onClick={handleSaveField} - data-test-subj={`${dataTestSubj}-save`} - disabled={disabled} - isLoading={isLoading} - > - {i18n.SAVE_FIELD} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexGroup> - </EuiFlyoutFooter> - </EuiFlyout> - ); -}; - -CustomFieldFlyoutComponent.displayName = 'CustomFieldFlyout'; - -export const CustomFieldFlyout = React.memo(CustomFieldFlyoutComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx index ef2cbac45867..89fdca73fefb 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx @@ -10,13 +10,13 @@ import { screen, fireEvent, waitFor, act } from '@testing-library/react'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import type { CustomFieldFormState } from './form'; import { CustomFieldsForm } from './form'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import * as i18n from './translations'; import userEvent from '@testing-library/user-event'; import { customFieldsConfigurationMock } from '../../containers/mock'; +import type { FormState } from '../configure_cases/flyout'; describe('CustomFieldsForm ', () => { let appMockRender: AppMockRenderer; @@ -68,9 +68,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -96,9 +96,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected and the text default value is not filled', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -122,9 +122,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected and the text default value is an empty string', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -149,9 +149,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if the initial default value is null', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); const initialValue = { required: true, @@ -190,9 +190,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is not selected', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -215,9 +215,9 @@ describe('CustomFieldsForm ', () => { }); it('deserializes the "type: text" custom field data correctly', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render( <CustomFieldsForm onChange={onChangeState} initialValue={customFieldsConfigurationMock[0]} /> @@ -247,9 +247,9 @@ describe('CustomFieldsForm ', () => { }); it('deserializes the "type: toggle" custom field data correctly', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render( <CustomFieldsForm onChange={onChangeState} initialValue={customFieldsConfigurationMock[1]} /> diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.tsx index 230b947db854..2a2c675aac31 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/form.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/form.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import React, { useEffect, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; @@ -15,14 +14,10 @@ import { FormFields } from './form_fields'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import { customFieldSerializer } from './utils'; - -export interface CustomFieldFormState { - isValid: boolean | undefined; - submit: FormHook<CustomFieldConfiguration>['submit']; -} +import type { FormState } from '../configure_cases/flyout'; interface Props { - onChange: (state: CustomFieldFormState) => void; + onChange: (state: FormState<CustomFieldConfiguration>) => void; initialValue: CustomFieldConfiguration | null; } diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx index 9db854199305..0b62466fa685 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx @@ -53,6 +53,22 @@ describe('Create ', () => { ); }); + it('does not render default value when setDefaultValue is false', async () => { + render( + <FormTestComponent onSubmit={onSubmit}> + <Create + isLoading={false} + customFieldConfiguration={customFieldConfiguration} + setDefaultValue={false} + /> + </FormTestComponent> + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toHaveValue(''); + }); + it('renders loading state correctly', async () => { render( <FormTestComponent onSubmit={onSubmit}> diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx index aaab2043fb33..f735a4034f02 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx @@ -11,16 +11,19 @@ import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import type { CaseCustomFieldText } from '../../../../common/types/domain'; import type { CustomFieldType } from '../types'; import { getTextFieldConfig } from './config'; +import { OptionalFieldLabel } from '../../optional_field_label'; const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, isLoading, + setAsOptional, + setDefaultValue = true, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ - required, + required: setAsOptional ? false : required, label, - ...(defaultValue && { defaultValue: String(defaultValue) }), + ...(defaultValue && setDefaultValue && { defaultValue: String(defaultValue) }), }); return ( @@ -30,6 +33,7 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ component={TextField} label={label} componentProps={{ + labelAppend: setAsOptional ? OptionalFieldLabel : null, euiFieldProps: { 'data-test-subj': `${key}-text-create-custom-field`, fullWidth: true, diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx index 9672b3c8bb6b..8eb7c5030084 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx @@ -36,6 +36,20 @@ describe('Create ', () => { expect(await screen.findByRole('switch')).toBeChecked(); // defaultValue true }); + it('does not render default value when setDefaultValue is false', async () => { + render( + <FormTestComponent onSubmit={onSubmit}> + <Create + isLoading={false} + customFieldConfiguration={customFieldConfiguration} + setDefaultValue={false} + /> + </FormTestComponent> + ); + + expect(await screen.findByRole('switch')).not.toBeChecked(); + }); + it('updates the value correctly', async () => { render( <FormTestComponent onSubmit={onSubmit}> diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx index 2d3f51bc4f67..eb3ad2b114e5 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx @@ -14,6 +14,7 @@ import type { CustomFieldType } from '../types'; const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ customFieldConfiguration, isLoading, + setDefaultValue = true, }) => { const { key, label, defaultValue } = customFieldConfiguration; @@ -21,7 +22,7 @@ const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ <UseField path={`customFields.${key}`} component={ToggleField} - config={{ defaultValue: defaultValue ? defaultValue : false }} + config={{ defaultValue: defaultValue && setDefaultValue ? defaultValue : false }} key={key} label={label} componentProps={{ diff --git a/x-pack/plugins/cases/public/components/custom_fields/types.ts b/x-pack/plugins/cases/public/components/custom_fields/types.ts index 856ff7e9e1c6..a1dcffaec6b9 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -30,6 +30,8 @@ export interface CustomFieldType<T extends CaseUICustomField> { Create: React.FC<{ customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; + setAsOptional?: boolean; + setDefaultValue?: boolean; }>; } diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts index ba629a6ea10a..5a2131964583 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -5,202 +5,11 @@ * 2.0. */ -import { addOrReplaceCustomField, customFieldSerializer } from './utils'; -import { customFieldsConfigurationMock, customFieldsMock } from '../../containers/mock'; +import { customFieldSerializer } from './utils'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; -import type { CaseUICustomField } from '../../../common/ui'; describe('utils ', () => { - describe('addOrReplaceCustomField ', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('adds new custom field correctly', async () => { - const fieldToAdd: CaseUICustomField = { - key: 'my_test_key', - type: CustomFieldTypes.TEXT, - value: 'my_test_value', - }; - const res = addOrReplaceCustomField(customFieldsMock, fieldToAdd); - expect(res).toMatchInlineSnapshot( - [...customFieldsMock, fieldToAdd], - ` - Array [ - Object { - "key": "test_key_1", - "type": "text", - "value": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - Object { - "key": "my_test_key", - "type": "text", - "value": "my_test_value", - }, - ] - ` - ); - }); - - it('updates existing custom field correctly', async () => { - const fieldToUpdate = { - ...customFieldsMock[0], - field: { value: ['My text test value 1!!!'] }, - }; - - const res = addOrReplaceCustomField(customFieldsMock, fieldToUpdate as CaseUICustomField); - expect(res).toMatchInlineSnapshot( - [ - { ...fieldToUpdate }, - { ...customFieldsMock[1] }, - { ...customFieldsMock[2] }, - { ...customFieldsMock[3] }, - ], - ` - Array [ - Object { - "field": Object { - "value": Array [ - "My text test value 1!!!", - ], - }, - "key": "test_key_1", - "type": "text", - "value": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - ] - ` - ); - }); - - it('adds new custom field configuration correctly', async () => { - const fieldToAdd = { - key: 'my_test_key', - type: CustomFieldTypes.TEXT, - label: 'my_test_label', - required: true, - }; - const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToAdd); - expect(res).toMatchInlineSnapshot( - [...customFieldsConfigurationMock, fieldToAdd], - ` - Array [ - Object { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "required": false, - "type": "toggle", - }, - Object { - "key": "my_test_key", - "label": "my_test_label", - "required": true, - "type": "text", - }, - ] - ` - ); - }); - - it('updates existing custom field config correctly', async () => { - const fieldToUpdate = { - ...customFieldsConfigurationMock[0], - label: `${customFieldsConfigurationMock[0].label}!!!`, - }; - - const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToUpdate); - expect(res).toMatchInlineSnapshot( - [ - { ...fieldToUpdate }, - { ...customFieldsConfigurationMock[1] }, - { ...customFieldsConfigurationMock[2] }, - { ...customFieldsConfigurationMock[3] }, - ], - ` - Array [ - Object { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1!!!", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "required": false, - "type": "toggle", - }, - ] - ` - ); - }); - }); - describe('customFieldSerializer ', () => { it('serializes the data correctly if the default value is a normal string', async () => { const customField = { diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.ts index bea01a3761bd..3842b75b5a7e 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.ts @@ -9,27 +9,6 @@ import { isEmptyString } from '@kbn/es-ui-shared-plugin/static/validators/string import { isString } from 'lodash'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; -export const addOrReplaceCustomField = <T extends { key: string }>( - customFields: T[], - customFieldToAdd: T -): T[] => { - const foundCustomFieldIndex = customFields.findIndex( - (customField) => customField.key === customFieldToAdd.key - ); - - if (foundCustomFieldIndex === -1) { - return [...customFields, customFieldToAdd]; - } - - return customFields.map((customField) => { - if (customField.key !== customFieldToAdd.key) { - return customField; - } - - return customFieldToAdd; - }); -}; - export const customFieldSerializer = ( field: CustomFieldConfiguration ): CustomFieldConfiguration => { diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx index c939feda42e4..b1437e2e2a25 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -34,7 +34,7 @@ type MarkdownEditorFormProps = EuiMarkdownEditorProps & { bottomRightContent?: React.ReactNode; caseTitle?: string; caseTags?: string[]; - draftStorageKey: string; + draftStorageKey?: string; disabledUiPlugins?: string[]; initialValue?: string; }; @@ -59,7 +59,7 @@ export const MarkdownEditorForm = React.memo( const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const { hasConflicts } = useMarkdownSessionStorage({ field, - sessionKey: draftStorageKey, + sessionKey: draftStorageKey ?? '', initialValue, }); const { euiTheme } = useEuiTheme(); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx index 7de2e83cf234..e4ce68ed4523 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx @@ -54,6 +54,17 @@ describe('useMarkdownSessionStorage', () => { }); }); + it('should return hasConflicts as false when sessionKey is empty', async () => { + const { result, waitFor } = renderHook(() => + useMarkdownSessionStorage({ field, sessionKey: '', initialValue }) + ); + + await waitFor(() => { + expect(field.setValue).not.toHaveBeenCalled(); + expect(result.current.hasConflicts).toBe(false); + }); + }); + it('should update the session value with field value when it is first render', async () => { const { waitFor } = renderHook<SessionStorageType, { hasConflicts: boolean }>( (props) => { diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx index e33fed672985..0a82d43cc093 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx @@ -30,7 +30,7 @@ export const useMarkdownSessionStorage = ({ const [sessionValue, setSessionValue] = useSessionStorage(sessionKey, '', true); - if (!isEmpty(sessionValue) && isFirstRender.current) { + if (!isEmpty(sessionValue) && !isEmpty(sessionKey) && isFirstRender.current) { field.setValue(sessionValue); } @@ -45,7 +45,9 @@ export const useMarkdownSessionStorage = ({ useDebounce( () => { - setSessionValue(field.value); + if (!isEmpty(sessionKey)) { + setSessionValue(field.value); + } }, STORAGE_DEBOUNCE_TIME, [field.value] diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx b/x-pack/plugins/cases/public/components/optional_field_label/index.test.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx rename to x-pack/plugins/cases/public/components/optional_field_label/index.test.tsx diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx b/x-pack/plugins/cases/public/components/optional_field_label/index.tsx similarity index 89% rename from x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx rename to x-pack/plugins/cases/public/components/optional_field_label/index.tsx index ea994b221996..98c101440116 100644 --- a/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx +++ b/x-pack/plugins/cases/public/components/optional_field_label/index.tsx @@ -8,7 +8,7 @@ import { EuiText } from '@elastic/eui'; import React from 'react'; -import * as i18n from '../../../common/translations'; +import * as i18n from '../../common/translations'; export const OptionalFieldLabel = ( <EuiText color="subdued" size="xs" data-test-subj="form-optional-field-label"> diff --git a/x-pack/plugins/cases/public/components/templates/form.test.tsx b/x-pack/plugins/cases/public/components/templates/form.test.tsx new file mode 100644 index 000000000000..a01aa25132cb --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -0,0 +1,790 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_TAG_LENGTH, +} from '../../../common/constants'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; +import type { FormState } from '../configure_cases/flyout'; +import { TemplateForm } from './form'; +import type { TemplateFormProps } from './types'; + +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +describe('TemplateForm', () => { + let appMockRenderer: AppMockRenderer; + const defaultProps = { + connectors: connectorsMock, + currentConfiguration: { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }, + onChange: jest.fn(), + initialValue: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('renders all default fields', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders all fields as per initialValue', async () => { + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_key_1', + name: 'Template 1', + description: 'Sample description', + caseFields: null, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + expect(await screen.findByTestId('template-name-input')).toHaveValue('Template 1'); + expect(await screen.findByTestId('template-description-input')).toHaveValue( + 'Sample description' + ); + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders case fields as per initialValue', async () => { + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_key_1', + name: 'Template 1', + description: 'Sample description', + caseFields: { + title: 'Case with template 1', + description: 'case description', + }, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + expect(await within(await screen.findByTestId('caseTitle')).findByTestId('input')).toHaveValue( + 'Case with template 1' + ); + expect( + await within(await screen.findByTestId('caseDescription')).findByTestId( + 'euiMarkdownEditorTextArea' + ) + ).toHaveValue('case description'); + }); + + it('renders case fields as optional', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + const title = await screen.findByTestId('caseTitle'); + const tags = await screen.findByTestId('caseTags'); + const category = await screen.findByTestId('caseCategory'); + const description = await screen.findByTestId('caseDescription'); + + expect(await within(title).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(tags).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(category).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(description).findByTestId('form-optional-field-label')).toBeInTheDocument(); + }); + + it('serializes the template field data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), 'foo'); + userEvent.keyboard('{enter}'); + userEvent.paste(within(templateTags).getByRole('combobox'), 'bar'); + userEvent.keyboard('{enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'this is a first template', + name: 'Template 1', + tags: ['foo', 'bar'], + }); + }); + }); + + it('serializes the template field data correctly with existing fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { ...templatesConfigurationMock[0], tags: ['foo', 'bar'] }, + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'This is a first test template', + name: 'First test template', + tags: ['foo', 'bar'], + }); + }); + }); + + it('serializes the case field data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + initialValue: { key: 'template_1_key', name: 'Template 1', caseFields: null }, + onChange: onChangeState, + }} + /> + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: 'This is a case description', + settings: { + syncAlerts: true, + }, + tags: ['template-1'], + title: 'Case with Template 1', + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the case field data correctly with existing fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: templatesConfigurationMock[3], + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: 'case desc', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: ['sample-4'], + title: 'Case with sample template 4', + }, + description: 'This is a fourth test template', + name: 'Fourth test template', + tags: ['foo', 'bar'], + }); + }); + }); + + it('serializes the connector fields data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + initialValue: { key: 'template_1_key', name: 'Template 1', caseFields: null }, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, + onChange: onChangeState, + }} + /> + ); + + await screen.findByTestId('caseConnectors'); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the connector fields data correctly with existing connector', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: { + connector: { + id: 'servicenow-1', + type: ConnectorTypes.serviceNowITSM, + name: 'my-SN-connector', + fields: null, + }, + }, + }, + connectors: connectorsMock, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'resilient-2', + name: 'My Resilient connector', + type: ConnectorTypes.resilient, + fields: null, + }, + }, + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['Denial of Service']); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: { + category: 'Denial of Service', + impact: null, + severity: null, + subcategory: null, + urgency: null, + }, + id: 'servicenow-1', + name: 'My SN connector', + type: '.servicenow', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the custom fields data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: null, + }, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + onChange: onChangeState, + }} + /> + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const customFieldsElement = await screen.findByTestId('caseCustomFields'); + + expect( + await within(customFieldsElement).findAllByTestId('form-optional-field-label') + ).toHaveLength( + customFieldsConfigurationMock.filter((field) => field.type === CustomFieldTypes.TEXT).length + ); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[3]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My text test value 1', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: true, + }, + ], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the custom fields data correctly with existing custom fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: { + customFields: [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: 'this is my first custom field value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + value: false, + }, + ], + }, + }, + onChange: onChangeState, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const toggleField = customFieldsConfigurationMock[1]; + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'this is my first custom field value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: false, + }, + ], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('shows form state as invalid when template name missing', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), ''); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template name is too long', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const name = 'a'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-name-input'), name); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template description is too long', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const description = 'a'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-description-input'), description); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template tags are more than 10', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const tagsArray = Array(MAX_TAGS_PER_TEMPLATE + 1).fill('foo'); + + const templateTags = await screen.findByTestId('template-tags'); + + tagsArray.forEach((tag) => { + userEvent.paste(within(templateTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template tag is more than 50 characters', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const x = 'a'.repeat(MAX_TEMPLATE_TAG_LENGTH + 1); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), x); + userEvent.keyboard('{enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx new file mode 100644 index 000000000000..acd6855fe470 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useEffect, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import type { ActionConnector, TemplateConfiguration } from '../../../common/types/domain'; +import type { FormState } from '../configure_cases/flyout'; +import { schema } from './schema'; +import { FormFields } from './form_fields'; +import { templateDeserializer, templateSerializer } from './utils'; +import type { TemplateFormProps } from './types'; +import type { CasesConfigurationUI } from '../../containers/types'; + +interface Props { + onChange: (state: FormState<TemplateConfiguration, TemplateFormProps>) => void; + initialValue: TemplateConfiguration | null; + connectors: ActionConnector[]; + currentConfiguration: CasesConfigurationUI; + isEditMode?: boolean; +} + +const FormComponent: React.FC<Props> = ({ + onChange, + initialValue, + connectors, + currentConfiguration, + isEditMode = false, +}) => { + const keyDefaultValue = useMemo(() => uuidv4(), []); + + const { form } = useForm({ + defaultValue: initialValue ?? { + key: keyDefaultValue, + name: '', + description: '', + tags: [], + caseFields: { + connector: currentConfiguration.connector, + }, + }, + options: { stripEmptyFields: false }, + schema, + deserializer: templateDeserializer, + serializer: (data: TemplateFormProps) => + templateSerializer(connectors, currentConfiguration, data), + }); + + const { submit, isValid, isSubmitting } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid, submit }); + } + }, [onChange, isValid, submit]); + + return ( + <Form form={form}> + <FormFields + isSubmitting={isSubmitting} + connectors={connectors} + currentConfiguration={currentConfiguration} + isEditMode={isEditMode} + /> + </Form> + ); +}; + +FormComponent.displayName = 'TemplateForm'; + +export const TemplateForm = React.memo(FormComponent); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx new file mode 100644 index 000000000000..cdd9ca2814f0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx @@ -0,0 +1,442 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { CaseSeverity, ConnectorTypes } from '../../../common/types/domain'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import { TEMPLATE_FIELDS, CASE_FIELDS, CONNECTOR_FIELDS, CASE_SETTINGS } from './translations'; +import { FormFields } from './form_fields'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +describe('form fields', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { tags: [], templateTags: [] }; + const defaultProps = { + connectors: connectorsMock, + currentConfiguration: { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('renders all steps', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByText(TEMPLATE_FIELDS)).toBeInTheDocument(); + expect(await screen.findByText(CASE_FIELDS)).toBeInTheDocument(); + expect(await screen.findByText(CASE_SETTINGS)).toBeInTheDocument(); + expect(await screen.findByText(CONNECTOR_FIELDS)).toBeInTheDocument(); + }); + + it('renders template fields correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + }); + + it('renders case fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + }); + + it('renders case fields with existing value', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + title: 'Case title', + description: 'case description', + tags: ['case-1', 'case-2'], + category: 'new', + severity: CaseSeverity.MEDIUM, + templateTags: [], + }} + onSubmit={onSubmit} + > + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await within(await screen.findByTestId('caseTitle')).findByTestId('input')).toHaveValue( + 'Case title' + ); + + const caseTags = await screen.findByTestId('caseTags'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('case-1'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('case-2'); + + const category = await screen.findByTestId('caseCategory'); + expect(await within(category).findByTestId('comboBoxSearchInput')).toHaveValue('new'); + expect(await screen.findByTestId('case-severity-selection-medium')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toHaveTextContent('case description'); + }); + + it('renders sync alerts correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + }); + + it('renders custom fields correctly', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + }; + + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + }); + + it('renders default connector correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders connector and its fields correctly', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, + }; + + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ ...formDefaultValue, connectorId: 'servicenow-1' }} + onSubmit={onSubmit} + > + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + }); + + it('does not render sync alerts when feature is not enabled', () => { + appMockRenderer = createAppMockRenderer({ + features: { alerts: { sync: false, enabled: true } }, + }); + + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(screen.queryByTestId('caseSyncAlerts')).not.toBeInTheDocument(); + }); + + it('calls onSubmit with template fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + connectorId: 'none', + tags: [], + syncAlerts: true, + name: 'Template 1', + templateDescription: 'this is a first template', + templateTags: ['first'], + }, + true + ); + }); + }); + + it('calls onSubmit with case fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + connectorId: 'none', + syncAlerts: true, + templateTags: [], + }, + true + ); + }); + }); + + it('calls onSubmit with custom fields', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + }; + + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + connectorId: 'none', + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, + }, + syncAlerts: true, + templateTags: [], + }, + true + ); + }); + }); + + it('calls onSubmit with connector fields', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, + }; + + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ ...formDefaultValue, connectorId: 'servicenow-1' }} + onSubmit={onSubmit} + > + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('severitySelect'), '3'); + + userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '2'); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + tags: [], + category: null, + connectorId: 'servicenow-1', + fields: { + category: 'software', + severity: '3', + urgency: '2', + subcategory: null, + }, + syncAlerts: true, + templateTags: [], + }, + true + ); + }); + }); + + it('does not render duplicate template tags', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + templates: [ + { + key: 'test_template_1', + name: 'Test', + tags: ['one', 'two'], + caseFields: {}, + }, + { + key: 'test_template_2', + name: 'Test 2', + tags: ['one', 'three'], + caseFields: {}, + }, + ], + }, + }; + + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit} formDefaultValue={formDefaultValue}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + const caseTags = await screen.findByTestId('template-tags'); + + userEvent.click(within(caseTags).getByTestId('comboBoxToggleListButton')); + await waitForEuiPopoverOpen(); + + /** + * RTL will throw an error if there are more that one + * element matching the text. This ensures that duplicated + * tags are removed. Docs: https://testing-library.com/docs/queries/about + */ + expect(await screen.findByText('one')); + expect(await screen.findByText('two')); + expect(await screen.findByText('three')); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx new file mode 100644 index 000000000000..9a69d1c4590d --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiSteps } from '@elastic/eui'; +import { uniq } from 'lodash'; +import { CaseFormFields } from '../case_form_fields'; +import * as i18n from './translations'; +import type { ActionConnector } from '../../containers/configure/types'; +import type { CasesConfigurationUI } from '../../containers/types'; +import { TemplateFields } from './template_fields'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { SyncAlertsToggle } from '../case_form_fields/sync_alerts_toggle'; +import { Connector } from '../case_form_fields/connector'; + +interface FormFieldsProps { + isSubmitting?: boolean; + connectors: ActionConnector[]; + currentConfiguration: CasesConfigurationUI; + isEditMode?: boolean; +} + +const FormFieldsComponent: React.FC<FormFieldsProps> = ({ + isSubmitting = false, + connectors, + currentConfiguration, + isEditMode, +}) => { + const { isSyncAlertsEnabled } = useCasesFeatures(); + const { customFields: configurationCustomFields, templates } = currentConfiguration; + const configurationTemplateTags = getTemplateTags(templates); + + const firstStep = useMemo( + () => ({ + title: i18n.TEMPLATE_FIELDS, + children: ( + <TemplateFields + isLoading={isSubmitting} + configurationTemplateTags={configurationTemplateTags} + /> + ), + }), + [isSubmitting, configurationTemplateTags] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.CASE_FIELDS, + children: ( + <CaseFormFields + configurationCustomFields={configurationCustomFields} + isLoading={isSubmitting} + setCustomFieldsOptional={true} + isEditMode={isEditMode} + /> + ), + }), + [isSubmitting, configurationCustomFields, isEditMode] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.CASE_SETTINGS, + children: <SyncAlertsToggle isLoading={isSubmitting} />, + }), + [isSubmitting] + ); + + const fourthStep = useMemo( + () => ({ + title: i18n.CONNECTOR_FIELDS, + children: ( + <Connector connectors={connectors} isLoading={isSubmitting} isLoadingConnectors={false} /> + ), + }), + [connectors, isSubmitting] + ); + + const allSteps = useMemo( + () => [firstStep, secondStep, ...(isSyncAlertsEnabled ? [thirdStep] : []), fourthStep], + [firstStep, secondStep, thirdStep, fourthStep, isSyncAlertsEnabled] + ); + + return ( + <> + <UseField path="key" component={HiddenField} /> + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'template-creation-form-steps'} + /> + </> + ); +}; + +FormFieldsComponent.displayName = 'FormFields'; + +export const FormFields = memo(FormFieldsComponent); + +const getTemplateTags = (templates: CasesConfigurationUI['templates']) => + uniq(templates.map((template) => (template?.tags?.length ? template.tags : [])).flat()); diff --git a/x-pack/plugins/cases/public/components/templates/index.test.tsx b/x-pack/plugins/cases/public/components/templates/index.test.tsx new file mode 100644 index 000000000000..ca4cb4c3caf8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/index.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 userEvent from '@testing-library/user-event'; +import { screen, waitFor, within } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { MAX_TEMPLATES_LENGTH } from '../../../common/constants'; +import { Templates } from '.'; +import * as i18n from './translations'; +import { templatesConfigurationMock } from '../../containers/mock'; + +describe('Templates', () => { + let appMockRender: AppMockRenderer; + + const props = { + disabled: false, + isLoading: false, + templates: [], + onAddTemplate: jest.fn(), + onEditTemplate: jest.fn(), + onDeleteTemplate: jest.fn(), + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + appMockRender.render(<Templates {...props} />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + }); + + it('renders empty templates correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: [] }} />); + + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + expect(await screen.findByTestId('empty-templates')).toBeInTheDocument(); + expect(await screen.queryByTestId('templates-list')).not.toBeInTheDocument(); + }); + + it('renders templates correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + expect(await screen.findByTestId('templates-list')).toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + appMockRender.render(<Templates {...{ ...props, isLoading: true }} />); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders disabled state correctly', async () => { + appMockRender.render(<Templates {...{ ...props, disabled: true }} />); + + expect(await screen.findByTestId('add-template')).toHaveAttribute('disabled'); + }); + + it('calls onChange on add option click', async () => { + appMockRender.render(<Templates {...props} />); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(props.onAddTemplate).toBeCalled(); + }); + + it('calls onEditTemplate correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + const list = await screen.findByTestId('templates-list'); + + expect(list).toBeInTheDocument(); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + await waitFor(() => { + expect(props.onEditTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('calls onDeleteTemplate correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Delete')); + + await waitFor(() => { + expect(props.onDeleteTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('shows the experimental badge', async () => { + appMockRender.render(<Templates {...props} />); + + expect(await screen.findByTestId('case-experimental-badge')).toBeInTheDocument(); + }); + + it('shows error when templates reaches the limit', async () => { + const mockTemplates = []; + + for (let i = 0; i < MAX_TEMPLATES_LENGTH; i++) { + mockTemplates.push({ + key: `field_key_${i + 1}`, + name: `template_${i + 1}`, + description: 'random foobar', + caseFields: null, + }); + } + + appMockRender.render(<Templates {...{ ...props, templates: mockTemplates }} />); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByText(i18n.MAX_TEMPLATE_LIMIT(MAX_TEMPLATES_LENGTH))); + expect(await screen.findByTestId('add-template')).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx new file mode 100644 index 000000000000..9671b9aee855 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiButtonEmpty, + EuiPanel, + EuiDescribedFormGroup, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { MAX_TEMPLATES_LENGTH } from '../../../common/constants'; +import type { CasesConfigurationUITemplate } from '../../../common/ui'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; +import * as i18n from './translations'; +import { TemplatesList } from './templates_list'; + +interface Props { + disabled: boolean; + isLoading: boolean; + templates: CasesConfigurationUITemplate[]; + onAddTemplate: () => void; + onEditTemplate: (key: string) => void; + onDeleteTemplate: (key: string) => void; +} + +const TemplatesComponent: React.FC<Props> = ({ + disabled, + isLoading, + templates, + onAddTemplate, + onEditTemplate, + onDeleteTemplate, +}) => { + const { permissions } = useCasesContext(); + const canAddTemplates = permissions.create && permissions.update; + const [error, setError] = useState<boolean>(false); + + const handleAddTemplate = useCallback(() => { + if (templates.length === MAX_TEMPLATES_LENGTH && !error) { + setError(true); + return; + } + + onAddTemplate(); + setError(false); + }, [onAddTemplate, error, templates]); + + const handleEditTemplate = useCallback( + (key: string) => { + setError(false); + onEditTemplate(key); + }, + [setError, onEditTemplate] + ); + + const handleDeleteTemplate = useCallback( + (key: string) => { + setError(false); + onDeleteTemplate(key); + }, + [setError, onDeleteTemplate] + ); + + return ( + <EuiDescribedFormGroup + fullWidth + title={ + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem grow={false}>{i18n.TEMPLATE_TITLE}</EuiFlexItem> + <EuiFlexItem grow={false}> + <ExperimentalBadge /> + </EuiFlexItem> + </EuiFlexGroup> + } + description={<p>{i18n.TEMPLATE_DESCRIPTION}</p>} + data-test-subj="templates-form-group" + > + <EuiPanel paddingSize="s" color="subdued" hasBorder={false} hasShadow={false}> + {templates.length ? ( + <> + <TemplatesList + templates={templates} + onEditTemplate={handleEditTemplate} + onDeleteTemplate={handleDeleteTemplate} + /> + {error ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiText color="danger">{i18n.MAX_TEMPLATE_LIMIT(MAX_TEMPLATES_LENGTH)}</EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + </> + ) : null} + <EuiSpacer size="m" /> + {!templates.length ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false} data-test-subj="empty-templates"> + {i18n.NO_TEMPLATES} + <EuiSpacer size="m" /> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + {canAddTemplates ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + isLoading={isLoading} + isDisabled={disabled || error} + size="s" + onClick={handleAddTemplate} + iconType="plusInCircle" + data-test-subj="add-template" + > + {i18n.ADD_TEMPLATE} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + </EuiPanel> + </EuiDescribedFormGroup> + ); +}; + +TemplatesComponent.displayName = 'Templates'; + +export const Templates = React.memo(TemplatesComponent); diff --git a/x-pack/plugins/cases/public/components/templates/schema.test.tsx b/x-pack/plugins/cases/public/components/templates/schema.test.tsx new file mode 100644 index 000000000000..3e572068b5fd --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/schema.test.tsx @@ -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 { caseFormFieldsSchemaWithOptionalLabel } from './schema'; + +describe('Template schema', () => { + describe('caseFormFieldsSchemaWithOptionalLabel', () => { + it('has label append for each field', () => { + expect(caseFormFieldsSchemaWithOptionalLabel).toMatchInlineSnapshot(` + Object { + "assignees": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "category": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "connectorId": Object { + "defaultValue": "none", + "label": "External incident management system", + }, + "customFields": Object {}, + "description": Object { + "label": "Description", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + "fields": Object { + "defaultValue": null, + }, + "severity": Object { + "label": "Severity", + }, + "syncAlerts": Object { + "defaultValue": true, + "helpText": "Enabling this option will sync the alert statuses with the case status.", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "tags": Object { + "helpText": "Separate tags with a line break.", + "label": "Tags", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "validator": [Function], + }, + ], + }, + "title": Object { + "label": "Name", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx new file mode 100644 index 000000000000..2c51bc8827b3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/schema.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 { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATE_TAG_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, +} from '../../../common/constants'; +import { OptionalFieldLabel } from '../optional_field_label'; +import * as i18n from './translations'; +import type { TemplateFormProps } from './types'; +import { + validateEmptyTags, + validateMaxLength, + validateMaxTagsLength, +} from '../case_form_fields/utils'; +import { schema as caseFormFieldsSchema } from '../case_form_fields/schema'; +const { emptyField, maxLengthField } = fieldValidators; + +const nonOptionalFields = ['connectorId', 'fields', 'severity', 'customFields']; + +// add optional label to all case form fields +export const caseFormFieldsSchemaWithOptionalLabel = Object.fromEntries( + Object.entries(caseFormFieldsSchema).map(([key, value]) => { + if (typeof value === 'object' && !nonOptionalFields.includes(key)) { + const updatedValue = { ...value, labelAppend: OptionalFieldLabel }; + return [key, updatedValue]; + } + + return [key, value]; + }) +); + +export const schema: FormSchema<TemplateFormProps> = { + key: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD('key')), + }, + ], + }, + name: { + label: i18n.TEMPLATE_NAME, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.TEMPLATE_NAME)), + }, + { + validator: maxLengthField({ + length: MAX_TEMPLATE_NAME_LENGTH, + message: i18n.MAX_LENGTH_ERROR('template name', MAX_TEMPLATE_NAME_LENGTH), + }), + }, + ], + }, + templateDescription: { + label: i18n.DESCRIPTION, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: maxLengthField({ + length: MAX_TEMPLATE_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('template description', MAX_TEMPLATE_DESCRIPTION_LENGTH), + }), + }, + ], + }, + templateTags: { + label: i18n.TAGS, + helpText: i18n.TEMPLATE_TAGS_HELP, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_TEMPLATE_TAG_LENGTH), + limit: MAX_TEMPLATE_TAG_LENGTH, + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_TEMPLATE), + limit: MAX_TAGS_PER_TEMPLATE, + }), + }, + ], + }, + ...caseFormFieldsSchemaWithOptionalLabel, +}; diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx new file mode 100644 index 000000000000..8073c2e25fb4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx @@ -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 React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { TemplateFields } from './template_fields'; + +describe('Template fields', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { templateTags: [] }; + const defaultProps = { + isLoading: false, + configurationTemplateTags: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders template fields correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + }); + + it('renders template fields with existing value', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + name: 'Sample template', + templateDescription: 'This is a template description', + templateTags: ['template-1', 'template-2'], + }} + onSubmit={onSubmit} + > + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-name-input')).toHaveValue('Sample template'); + + const templateTags = await screen.findByTestId('template-tags'); + + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent( + 'template-1' + ); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent( + 'template-2' + ); + + expect(await screen.findByTestId('template-description-input')).toHaveTextContent( + 'This is a template description' + ); + }); + + it('calls onSubmit with template fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(await within(templateTags).findByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + name: 'Template 1', + templateDescription: 'this is a first template', + templateTags: ['first'], + }, + true + ); + }); + }); + + it('calls onSubmit with updated template fields', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + name: 'Sample template', + templateDescription: 'This is a template description', + templateTags: ['template-1', 'template-2'], + }} + onSubmit={onSubmit} + > + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), '!!'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(await within(templateTags).findByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste(await screen.findByTestId('template-description-input'), '..'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + name: 'Sample template!!', + templateDescription: 'This is a template description..', + templateTags: ['template-1', 'template-2', 'first'], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.tsx new file mode 100644 index 000000000000..2f989201437c --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_fields.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField, TextAreaField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiFlexGroup } from '@elastic/eui'; +import { OptionalFieldLabel } from '../optional_field_label'; +import { TemplateTags } from './template_tags'; + +const TemplateFieldsComponent: React.FC<{ + isLoading: boolean; + configurationTemplateTags: string[]; +}> = ({ isLoading = false, configurationTemplateTags }) => ( + <EuiFlexGroup data-test-subj="template-fields" direction="column" gutterSize="none"> + <UseField + path="name" + component={TextField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': 'template-name-input', + fullWidth: true, + autoFocus: true, + isLoading, + }, + }} + /> + <TemplateTags isLoading={isLoading} tagOptions={configurationTemplateTags} /> + <UseField + path="templateDescription" + component={TextAreaField} + componentProps={{ + labelAppend: OptionalFieldLabel, + euiFieldProps: { + 'data-test-subj': 'template-description-input', + fullWidth: true, + isLoading, + }, + }} + /> + </EuiFlexGroup> +); + +TemplateFieldsComponent.displayName = 'TemplateFields'; + +export const TemplateFields = memo(TemplateFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx new file mode 100644 index 000000000000..6a99321bb772 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { TemplateTags } from './template_tags'; +import { showEuiComboBoxOptions } from '@elastic/eui/lib/test/rtl'; + +describe('TemplateTags', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { templateTags: [] }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders template tags', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + }); + + it('renders loading state', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={true} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByLabelText('Loading')).toBeInTheDocument(); + }); + + it('shows template tags options', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={['foo', 'bar', 'test']} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + await showEuiComboBoxOptions(); + + expect(await screen.findByText('foo')).toBeInTheDocument(); + }); + + it('shows template tags with current values', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={{ templateTags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + expect(await screen.findByText('foo')).toBeInTheDocument(); + + expect(await screen.findByText('bar')).toBeInTheDocument(); + }); + + it('adds template tag ', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + const comboBoxEle = await screen.findByRole('combobox'); + userEvent.paste(comboBoxEle, 'test'); + userEvent.keyboard('{enter}'); + userEvent.paste(comboBoxEle, 'template'); + userEvent.keyboard('{enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + templateTags: ['test', 'template'], + }, + true + ); + }); + }); + + it('adds new template tag to existing tags', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={{ templateTags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + const comboBoxEle = await screen.findByRole('combobox'); + userEvent.paste(comboBoxEle, 'test'); + userEvent.keyboard('{enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + templateTags: ['foo', 'bar', 'test'], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.tsx new file mode 100644 index 000000000000..92f141a73eb8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_tags.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import * as i18n from './translations'; +interface Props { + isLoading: boolean; + tagOptions: string[]; +} + +const TemplateTagsComponent: React.FC<Props> = ({ isLoading, tagOptions }) => { + const options = tagOptions.map((label) => ({ + label, + })); + + return ( + <UseField + path="templateTags" + component={ComboBoxField} + componentProps={{ + idAria: 'template-tags', + 'data-test-subj': 'template-tags', + euiFieldProps: { + placeholder: '', + fullWidth: true, + disabled: isLoading, + isLoading, + options, + noSuggestions: false, + customOptionText: i18n.ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX, + }, + }} + /> + ); +}; + +TemplateTagsComponent.displayName = 'TemplateTagsComponent'; + +export const TemplateTags = memo(TemplateTagsComponent); diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx new file mode 100644 index 000000000000..61f855c427c3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { templatesConfigurationMock } from '../../containers/mock'; +import { TemplatesList } from './templates_list'; +import userEvent from '@testing-library/user-event'; + +describe('TemplatesList', () => { + let appMockRender: AppMockRenderer; + const onDeleteTemplate = jest.fn(); + const onEditTemplate = jest.fn(); + + const props = { + templates: templatesConfigurationMock, + onDeleteTemplate, + onEditTemplate, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(<TemplatesList {...props} />); + + expect(screen.getByTestId('templates-list')).toBeInTheDocument(); + }); + + it('renders all templates', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: templatesConfigurationMock }} /> + ); + + expect(await screen.findByTestId('templates-list')).toBeInTheDocument(); + + templatesConfigurationMock.forEach((template) => + expect(screen.getByTestId(`template-${template.key}`)).toBeInTheDocument() + ); + }); + + it('renders template details correctly', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[3]] }} /> + ); + + const list = await screen.findByTestId('templates-list'); + + expect(list).toBeInTheDocument(); + expect( + await screen.findByTestId(`template-${templatesConfigurationMock[3].key}`) + ).toBeInTheDocument(); + expect(await screen.findByText(`${templatesConfigurationMock[3].name}`)).toBeInTheDocument(); + + const tags = templatesConfigurationMock[3].tags; + + tags?.forEach((tag, index) => + expect( + screen.getByTestId(`${templatesConfigurationMock[3].key}-tag-${index}`) + ).toBeInTheDocument() + ); + }); + + it('renders empty state correctly', () => { + appMockRender.render(<TemplatesList {...{ ...props, templates: [] }} />); + + expect(screen.queryAllByTestId(`template-`, { exact: false })).toHaveLength(0); + }); + + it('renders edit button', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ).toBeInTheDocument(); + }); + + it('renders delete button', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ).toBeInTheDocument(); + }); + + it('renders delete modal', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + userEvent.click( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + expect(await screen.findByText('Delete')).toBeInTheDocument(); + expect(await screen.findByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onEditTemplate correctly', async () => { + appMockRender.render(<TemplatesList {...props} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + await waitFor(() => { + expect(props.onEditTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('calls onDeleteTemplate correctly', async () => { + appMockRender.render(<TemplatesList {...props} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Delete')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); + expect(props.onDeleteTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.tsx new file mode 100644 index 000000000000..ceaac643ecab --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiBadge, + useEuiTheme, + EuiButtonIcon, + EuiBadgeGroup, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { TruncatedText } from '../truncated_text'; +import type { TemplateConfiguration, TemplatesConfiguration } from '../../../common/types/domain'; +import { DeleteConfirmationModal } from '../configure_cases/delete_confirmation_modal'; +import * as i18n from './translations'; +export interface Props { + templates: TemplatesConfiguration; + onDeleteTemplate: (key: string) => void; + onEditTemplate: (key: string) => void; +} + +const TemplatesListComponent: React.FC<Props> = (props) => { + const { templates, onEditTemplate, onDeleteTemplate } = props; + const { euiTheme } = useEuiTheme(); + const [itemToBeDeleted, setItemToBeDeleted] = useState<TemplateConfiguration | null>(null); + + const onConfirm = useCallback(() => { + if (itemToBeDeleted) { + onDeleteTemplate(itemToBeDeleted.key); + } + + setItemToBeDeleted(null); + }, [onDeleteTemplate, setItemToBeDeleted, itemToBeDeleted]); + + const onCancel = useCallback(() => { + setItemToBeDeleted(null); + }, []); + + const showModal = Boolean(itemToBeDeleted); + + return templates.length ? ( + <> + <EuiSpacer size="s" /> + <EuiFlexGroup justifyContent="flexStart" data-test-subj="templates-list"> + <EuiFlexItem> + {templates.map((template) => ( + <React.Fragment key={template.key}> + <EuiPanel + paddingSize="s" + data-test-subj={`template-${template.key}`} + hasShadow={false} + > + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={true}> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiText> + <h4> + <TruncatedText text={template.name} /> + </h4> + </EuiText> + </EuiFlexItem> + <EuiBadgeGroup gutterSize="s"> + {template.tags?.length + ? template.tags.map((tag, index) => ( + <EuiBadge + css={css` + max-width: 100px; + `} + key={`${template.key}-tag-${index}`} + data-test-subj={`${template.key}-tag-${index}`} + color={euiTheme.colors.body} + > + {tag} + </EuiBadge> + )) + : null} + </EuiBadgeGroup> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="flexEnd" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${template.key}-template-edit`} + aria-label={`${template.key}-template-edit`} + iconType="pencil" + color="primary" + onClick={() => onEditTemplate(template.key)} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${template.key}-template-delete`} + aria-label={`${template.key}-template-delete`} + iconType="minusInCircle" + color="danger" + onClick={() => setItemToBeDeleted(template)} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + <EuiSpacer size="s" /> + </React.Fragment> + ))} + </EuiFlexItem> + {showModal && itemToBeDeleted ? ( + <DeleteConfirmationModal + title={i18n.DELETE_TITLE(itemToBeDeleted.name)} + message={i18n.DELETE_MESSAGE(itemToBeDeleted.name)} + onCancel={onCancel} + onConfirm={onConfirm} + /> + ) : null} + </EuiFlexGroup> + </> + ) : null; +}; + +TemplatesListComponent.displayName = 'TemplatesList'; + +export const TemplatesList = React.memo(TemplatesListComponent); diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts new file mode 100644 index 000000000000..299307004681 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 * from '../../common/translations'; + +export const TEMPLATE_TITLE = i18n.translate('xpack.cases.templates.title', { + defaultMessage: 'Templates', +}); + +export const TEMPLATE_DESCRIPTION = i18n.translate('xpack.cases.templates.description', { + defaultMessage: + 'Add Case Templates to automatically define the case fields while creating a new case. A user can choose to create an empty case or based on a preset template. Templates allow to auto-populate values when creating new cases.', +}); + +export const NO_TEMPLATES = i18n.translate('xpack.cases.templates.noTemplates', { + defaultMessage: 'You do not have any templates yet', +}); + +export const ADD_TEMPLATE = i18n.translate('xpack.cases.templates.addTemplate', { + defaultMessage: 'Add template', +}); + +export const CREATE_TEMPLATE = i18n.translate('xpack.cases.templates.createTemplate', { + defaultMessage: 'Create template', +}); + +export const REQUIRED = i18n.translate('xpack.cases.templates.required', { + defaultMessage: 'Required', +}); + +export const REQUIRED_FIELD = (fieldName: string): string => + i18n.translate('xpack.cases.templates.requiredField', { + values: { fieldName }, + defaultMessage: 'A {fieldName} is required.', + }); + +export const TEMPLATE_NAME = i18n.translate('xpack.cases.templates.templateName', { + defaultMessage: 'Template name', +}); + +export const TEMPLATE_TAGS_HELP = i18n.translate('xpack.cases.templates.templateTagsHelp', { + defaultMessage: + 'Type one or more custom identifying tags for this template. Please enter after each tag to begin a new one', +}); + +export const TEMPLATE_FIELDS = i18n.translate('xpack.cases.templates.templateFields', { + defaultMessage: 'Template fields', +}); + +export const CASE_FIELDS = i18n.translate('xpack.cases.templates.caseFields', { + defaultMessage: 'Case fields', +}); + +export const CASE_SETTINGS = i18n.translate('xpack.cases.templates.caseSettings', { + defaultMessage: 'Case settings', +}); + +export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorFields', { + defaultMessage: 'External Connector Fields', +}); + +export const DELETE_TITLE = (name: string) => + i18n.translate('xpack.cases.configuration.deleteTitle', { + values: { name }, + defaultMessage: 'Delete {name}?', + }); + +export const DELETE_MESSAGE = (name: string) => + i18n.translate('xpack.cases.configuration.deleteMessage', { + values: { name }, + defaultMessage: 'This action will permanently delete {name}.', + }); + +export const MAX_TEMPLATE_LIMIT = (maxTemplates: number) => + i18n.translate('xpack.cases.templates.maxTemplateLimit', { + values: { maxTemplates }, + defaultMessage: 'Maximum number of {maxTemplates} templates reached.', + }); diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts new file mode 100644 index 000000000000..cf1187ed64e2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TemplateConfiguration } from '../../../common/types/domain'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; + +export type TemplateFormProps = Pick<TemplateConfiguration, 'key' | 'name'> & + Partial<CaseFormFieldsSchemaProps> & { + templateTags?: string[]; + templateDescription?: string; + }; diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts new file mode 100644 index 000000000000..9e3cd70c120a --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -0,0 +1,389 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseSeverity, ConnectorTypes } from '../../../common'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import type { CaseUI } from '../../containers/types'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { + convertTemplateCustomFields, + removeEmptyFields, + templateDeserializer, + templateSerializer, +} from './utils'; + +describe('utils', () => { + describe('getTemplateSerializedData', () => { + it('serializes empty fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + category: null, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: '', + name: '', + tags: [], + }); + }); + + it('serializes connectors fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: '', + name: '', + templateDescription: '', + fields: null, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: '', + name: '', + tags: [], + }); + }); + + it('serializes non empty fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + templateTags: ['sample'], + category: 'new', + }); + + expect(res).toEqual({ + caseFields: { + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: 'description 1', + key: 'key_1', + name: 'template 1', + tags: ['sample'], + }); + }); + + it('serializes custom fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: 'key_1', + name: 'template 1', + templateDescription: '', + customFields: { + custom_field_1: 'foobar', + custom_fields_2: '', + custom_field_3: true, + }, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: 'key_1', + name: 'template 1', + tags: [], + }); + }); + + it('serializes connector fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: 'key_1', + name: 'template 1', + templateDescription: '', + fields: { + impact: 'high', + severity: 'low', + category: null, + urgency: null, + subcategory: null, + }, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: 'key_1', + name: 'template 1', + tags: [], + }); + }); + }); + + describe('removeEmptyFields', () => { + it('removes empty fields', () => { + const res = removeEmptyFields({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + }); + + expect(res).toEqual({}); + }); + + it('does not remove not empty fields', () => { + const res = removeEmptyFields({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + }); + }); + + describe('templateDeserializer', () => { + it('deserialzies initial data correctly', () => { + const res = templateDeserializer({ key: 'temlate_1', name: 'Template 1', caseFields: null }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies template data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + description: 'This is first template', + tags: ['t1', 't2'], + caseFields: null, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: 'This is first template', + templateTags: ['t1', 't2'], + tags: [], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies case fields data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + title: 'Case title', + description: 'This is test case', + category: null, + tags: ['foo', 'bar'], + severity: CaseSeverity.LOW, + assignees: [{ uid: userProfiles[0].uid }], + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + title: 'Case title', + description: 'This is test case', + category: null, + tags: ['foo', 'bar'], + severity: CaseSeverity.LOW, + assignees: [{ uid: userProfiles[0].uid }], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies custom fields data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + customFields: [ + { + key: customFieldsConfigurationMock[0].key, + type: CustomFieldTypes.TEXT, + value: 'this is first custom field value', + }, + { + key: customFieldsConfigurationMock[1].key, + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'none', + customFields: { + [customFieldsConfigurationMock[0].key]: 'this is first custom field value', + [customFieldsConfigurationMock[1].key]: true, + }, + fields: null, + }); + }); + + it('deserialzies connector data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: { + category: 'software', + urgency: '1', + severity: null, + impact: null, + subcategory: null, + }, + }, + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'servicenow-1', + customFields: {}, + fields: { + category: 'software', + impact: undefined, + severity: undefined, + subcategory: undefined, + urgency: '1', + }, + }); + }); + }); + + describe('convertTemplateCustomFields', () => { + it('converts data correctly', () => { + const data = [ + { + key: customFieldsConfigurationMock[0].key, + type: CustomFieldTypes.TEXT, + value: 'this is first custom field value', + }, + { + key: customFieldsConfigurationMock[1].key, + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ] as CaseUI['customFields']; + + const res = convertTemplateCustomFields(data); + + expect(res).toEqual({ + [customFieldsConfigurationMock[0].key]: 'this is first custom field value', + [customFieldsConfigurationMock[1].key]: true, + }); + }); + + it('returns null when customFields empty', () => { + const res = convertTemplateCustomFields([]); + + expect(res).toEqual(null); + }); + + it('returns null when customFields undefined', () => { + const res = convertTemplateCustomFields(undefined); + + expect(res).toEqual(null); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts new file mode 100644 index 000000000000..3ee3002388e2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import type { ActionConnector, TemplateConfiguration } from '../../../common/types/domain'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; +import { + customFieldsFormDeserializer, + customFieldsFormSerializer, + getConnectorById, + getConnectorsFormDeserializer, + getConnectorsFormSerializer, +} from '../utils'; +import type { TemplateFormProps } from './types'; + +export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, value]) => !isEmpty(value) || typeof value === 'boolean') + .map(([key, value]) => [ + key, + value === Object(value) && !Array.isArray(value) + ? removeEmptyFields(value as Record<string, unknown>) + : value, + ]) + ) as T; +} + +export const convertTemplateCustomFields = ( + customFields?: CaseUI['customFields'] +): Record<string, string | boolean> | null => { + if (!customFields || !customFields.length) { + return null; + } + + return customFields.reduce((acc, customField) => { + const initial = { + [customField.key]: customField.value, + }; + + return { ...acc, ...initial }; + }, {}); +}; + +export const templateDeserializer = (data: TemplateConfiguration): TemplateFormProps => { + if (data == null) { + return data; + } + + const { key, name, description, tags: templateTags, caseFields } = data; + const { connector, customFields, settings, tags, ...rest } = caseFields ?? {}; + const connectorFields = getConnectorsFormDeserializer({ fields: connector?.fields ?? null }); + const convertedCustomFields = customFieldsFormDeserializer(customFields); + + return { + key, + name, + templateDescription: description ?? '', + templateTags: templateTags ?? [], + connectorId: connector?.id ?? 'none', + fields: connectorFields.fields ?? null, + customFields: convertedCustomFields ?? {}, + tags: tags ?? [], + ...rest, + }; +}; + +export const templateSerializer = ( + connectors: ActionConnector[], + currentConfiguration: CasesConfigurationUI, + data: TemplateFormProps +): TemplateConfiguration => { + if (data == null) { + return data; + } + + const { fields: connectorFields = null, key, name, ...rest } = data; + + const serializedConnectorFields = getConnectorsFormSerializer({ fields: connectorFields }); + const nonEmptyFields = removeEmptyFields({ ...rest }); + + const { + connectorId, + customFields: templateCustomFields, + syncAlerts = false, + templateTags, + templateDescription, + ...otherCaseFields + } = nonEmptyFields; + + const transformedCustomFields = templateCustomFields + ? customFieldsFormSerializer(templateCustomFields, currentConfiguration.customFields) + : []; + + const templateConnector = connectorId ? getConnectorById(connectorId, connectors) : null; + + const transformedConnector = templateConnector + ? normalizeActionConnector(templateConnector, serializedConnectorFields.fields) + : getNoneConnector(); + + const transformedData: TemplateConfiguration = { + key, + name, + description: templateDescription, + tags: templateTags ?? [], + caseFields: { + ...otherCaseFields, + connector: transformedConnector, + customFields: transformedCustomFields, + settings: { syncAlerts }, + }, + }; + + return transformedData; +}; diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 0e7cd9fb03b3..005f15b78b3d 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -7,7 +7,14 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; -import { elasticUser, getCaseUsersMockResponse } from '../containers/mock'; +import { + customFieldsConfigurationMock, + customFieldsMock, + elasticUser, + getCaseUsersMockResponse, +} from '../containers/mock'; +import type { CaseUICustomField } from '../containers/types'; +import { CustomFieldTypes } from '../../common/types/domain/custom_field/v1'; import { connectorDeprecationValidator, convertEmptyValuesToNull, @@ -21,6 +28,9 @@ import { stringifyToURL, parseCaseUsers, convertCustomFieldValue, + addOrReplaceField, + removeEmptyFields, + customFieldsFormSerializer, } from './utils'; describe('Utils', () => { @@ -528,4 +538,274 @@ describe('Utils', () => { expect(convertCustomFieldValue(false)).toMatchInlineSnapshot('false'); }); }); + + describe('addOrReplaceField ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds new custom field correctly', async () => { + const fieldToAdd: CaseUICustomField = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + value: 'my_test_value', + }; + const res = addOrReplaceField(customFieldsMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsMock, fieldToAdd], + ` + Array [ + Object { + "key": "test_key_1", + "type": "text", + "value": "My text test value 1", + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "test_key_3", + "type": "text", + "value": null, + }, + Object { + "key": "test_key_4", + "type": "toggle", + "value": null, + }, + Object { + "key": "my_test_key", + "type": "text", + "value": "my_test_value", + }, + ] + ` + ); + }); + + it('updates existing custom field correctly', async () => { + const fieldToUpdate = { + ...customFieldsMock[0], + field: { value: ['My text test value 1!!!'] }, + }; + + const res = addOrReplaceField(customFieldsMock, fieldToUpdate as CaseUICustomField); + expect(res).toMatchInlineSnapshot( + [ + { ...fieldToUpdate }, + { ...customFieldsMock[1] }, + { ...customFieldsMock[2] }, + { ...customFieldsMock[3] }, + ], + ` + Array [ + Object { + "field": Object { + "value": Array [ + "My text test value 1!!!", + ], + }, + "key": "test_key_1", + "type": "text", + "value": "My text test value 1", + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "test_key_3", + "type": "text", + "value": null, + }, + Object { + "key": "test_key_4", + "type": "toggle", + "value": null, + }, + ] + ` + ); + }); + + it('adds new custom field configuration correctly', async () => { + const fieldToAdd = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + label: 'my_test_label', + required: true, + }; + const res = addOrReplaceField(customFieldsConfigurationMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsConfigurationMock, fieldToAdd], + ` + Array [ + Object { + "defaultValue": "My default value", + "key": "test_key_1", + "label": "My test label 1", + "required": true, + "type": "text", + }, + Object { + "defaultValue": true, + "key": "test_key_2", + "label": "My test label 2", + "required": true, + "type": "toggle", + }, + Object { + "key": "test_key_3", + "label": "My test label 3", + "required": false, + "type": "text", + }, + Object { + "key": "test_key_4", + "label": "My test label 4", + "required": false, + "type": "toggle", + }, + Object { + "key": "my_test_key", + "label": "my_test_label", + "required": true, + "type": "text", + }, + ] + ` + ); + }); + + it('updates existing custom field config correctly', async () => { + const fieldToUpdate = { + ...customFieldsConfigurationMock[0], + label: `${customFieldsConfigurationMock[0].label}!!!`, + }; + + const res = addOrReplaceField(customFieldsConfigurationMock, fieldToUpdate); + expect(res).toMatchInlineSnapshot( + [ + { ...fieldToUpdate }, + { ...customFieldsConfigurationMock[1] }, + { ...customFieldsConfigurationMock[2] }, + { ...customFieldsConfigurationMock[3] }, + ], + ` + Array [ + Object { + "defaultValue": "My default value", + "key": "test_key_1", + "label": "My test label 1!!!", + "required": true, + "type": "text", + }, + Object { + "defaultValue": true, + "key": "test_key_2", + "label": "My test label 2", + "required": true, + "type": "toggle", + }, + Object { + "key": "test_key_3", + "label": "My test label 3", + "required": false, + "type": "text", + }, + Object { + "key": "test_key_4", + "label": "My test label 4", + "required": false, + "type": "toggle", + }, + ] + ` + ); + }); + }); + + describe('removeEmptyFields', () => { + it('removes empty fields', () => { + const res = removeEmptyFields({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + }); + + expect(res).toEqual({}); + }); + + it('does not remove not empty fields', () => { + const res = removeEmptyFields({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + }); + }); + + describe('customFieldsFormSerializer', () => { + it('transforms customFields correctly', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(customFieldsFormSerializer(customFields, customFieldsConfigurationMock)).toEqual([ + { + key: 'test_key_1', + type: 'text', + value: 'first value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_3', + type: 'text', + value: 'second value', + }, + ]); + }); + + it('returns empty array when custom fields are empty', () => { + expect(customFieldsFormSerializer({}, customFieldsConfigurationMock)).toEqual([]); + }); + + it('returns empty array when not custom fields in the configuration', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(customFieldsFormSerializer(customFields, [])).toEqual([]); + }); + + it('returns empty array when custom fields do not match with configuration', () => { + const customFields = { + random_key: 'first value', + }; + + expect(customFieldsFormSerializer(customFields, customFieldsConfigurationMock)).toEqual([]); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 13bff3b48fdc..7e1aa54554f5 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -17,7 +17,13 @@ import { ConnectorTypes } from '../../common/types/domain'; import type { CasesPublicStartDependencies } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import type { CaseActionConnector } from './types'; -import type { CaseUser, CaseUsers } from '../../common/ui/types'; +import type { + CasesConfigurationUI, + CaseUI, + CaseUICustomField, + CaseUser, + CaseUsers, +} from '../../common/ui/types'; import { convertToCaseUserWithProfileInfo } from './user_profiles/user_converter'; import type { CaseUserWithProfileInfo } from './user_profiles/types'; @@ -235,3 +241,72 @@ export const convertCustomFieldValue = (value: string | boolean) => { return value; }; + +export const addOrReplaceField = <T extends { key: string }>(fields: T[], fieldToAdd: T): T[] => { + const foundFieldIndex = fields.findIndex((field) => field.key === fieldToAdd.key); + + if (foundFieldIndex === -1) { + return [...fields, fieldToAdd]; + } + + return fields.map((field) => { + if (field.key !== fieldToAdd.key) { + return field; + } + + return fieldToAdd; + }); +}; + +export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, value]) => !isEmpty(value) || typeof value === 'boolean') + .map(([key, value]) => [ + key, + value === Object(value) && !Array.isArray(value) + ? removeEmptyFields(value as Record<string, unknown>) + : value, + ]) + ) as T; +} + +export const customFieldsFormDeserializer = ( + customFields?: CaseUI['customFields'] +): Record<string, string | boolean> | null => { + if (!customFields || !customFields.length) { + return null; + } + + return customFields.reduce((acc, customField) => { + const initial = { + [customField.key]: customField.value, + }; + + return { ...acc, ...initial }; + }, {}); +}; + +export const customFieldsFormSerializer = ( + customFields: Record<string, string | boolean>, + selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] +): CaseUI['customFields'] => { + const transformedCustomFields: CaseUI['customFields'] = []; + + if (!customFields || !selectedCustomFieldsConfiguration.length) { + return []; + } + + for (const [key, value] of Object.entries(customFields)) { + const configCustomField = selectedCustomFieldsConfiguration.find((item) => item.key === key); + if (configCustomField) { + transformedCustomFields.push({ + key: configCustomField.key, + type: configCustomField.type, + value: convertCustomFieldValue(value), + } as CaseUICustomField); + } + } + + return transformedCustomFields; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index ae72d839d3ac..b67e8f53f226 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -115,7 +115,8 @@ export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionType const convertConfigureResponseToCasesConfigure = ( configuration: SnakeToCamelCase<Configuration> ): CasesConfigurationUI => { - const { id, version, mappings, customFields, closureType, connector, owner } = configuration; + const { id, version, mappings, customFields, templates, closureType, connector, owner } = + configuration; - return { id, version, mappings, customFields, closureType, connector, owner }; + return { id, version, mappings, customFields, templates, closureType, connector, owner }; }; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index a5946ca31964..1124283e5aa9 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -11,7 +11,7 @@ import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import type { CaseConnectorMapping } from './types'; import type { CasesConfigurationUI } from '../types'; -import { customFieldsConfigurationMock } from '../mock'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; export const mappings: CaseConnectorMapping[] = [ { @@ -49,6 +49,7 @@ export const caseConfigurationResponseMock: Configuration = { owner: SECURITY_SOLUTION_OWNER, version: 'WzHJ12', customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, }; export const caseConfigurationRequest: ConfigurationRequest = { @@ -74,5 +75,6 @@ export const casesConfigurationsMock: CasesConfigurationUI = { mappings: [], version: 'WzHJ12', customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, owner: 'securitySolution', }; diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts index fdd46d640e5f..cd9e44d1bdaa 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts @@ -48,6 +48,7 @@ describe('Use get all case configurations hook', () => { closureType: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], id: '', mappings: [], version: '', @@ -86,6 +87,7 @@ describe('Use get all case configurations hook', () => { closureType: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], id: '', mappings: [], version: '', diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx index e98d63debce4..0fd0ca642baf 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx @@ -27,6 +27,7 @@ export function useGetSupportedActionConnectors() { return getSupportedActionConnectors({ signal }); }, { + staleTime: 60 * 1000, // one minute onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx index 509b0e72cd1f..4fab35fd5ce5 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx @@ -14,13 +14,14 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { ConnectorTypes } from '../../../common'; import { casesQueriesKeys } from '../constants'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; jest.mock('./api'); jest.mock('../../common/lib/kibana'); const useToastMock = useToasts as jest.Mock; -describe('useCreateAttachments', () => { +describe('usePersistConfiguration', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -38,6 +39,7 @@ describe('useCreateAttachments', () => { type: ConnectorTypes.none, }, customFields: [], + templates: [], version: '', id: '', }; @@ -53,7 +55,7 @@ describe('useCreateAttachments', () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -61,22 +63,24 @@ describe('useCreateAttachments', () => { result.current.mutate({ ...request, version: 'test' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + owner: 'securitySolution', + templates: [], + }); + }); expect(spyPatch).not.toHaveBeenCalled(); - expect(spyPost).toHaveBeenCalledWith({ - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: [], - owner: 'securitySolution', - }); }); it('calls postCaseConfigure when the version is empty', async () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -84,14 +88,44 @@ describe('useCreateAttachments', () => { result.current.mutate({ ...request, id: 'test' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + templates: [], + owner: 'securitySolution', + }); + }); expect(spyPatch).not.toHaveBeenCalled(); - expect(spyPost).toHaveBeenCalledWith({ - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: [], - owner: 'securitySolution', + }); + + it('calls postCaseConfigure with correct data', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + const newRequest = { + ...request, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + }; + + act(() => { + result.current.mutate({ ...newRequest, id: 'test-id' }); + }); + + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + owner: 'securitySolution', + }); }); }); @@ -99,7 +133,7 @@ describe('useCreateAttachments', () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -107,20 +141,50 @@ describe('useCreateAttachments', () => { result.current.mutate({ ...request, id: 'test-id', version: 'test-version' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + templates: [], + version: 'test-version', + }); + }); expect(spyPost).not.toHaveBeenCalled(); - expect(spyPatch).toHaveBeenCalledWith('test-id', { - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: [], - version: 'test-version', + }); + + it('calls patchCaseConfigure with correct data', async () => { + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + const newRequest = { + ...request, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + }; + + act(() => { + result.current.mutate({ ...newRequest, id: 'test-id', version: 'test-version' }); + }); + + await waitFor(() => { + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + version: 'test-version', + }); }); }); it('invalidates the queries correctly', async () => { const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -128,13 +192,13 @@ describe('useCreateAttachments', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration({})); + await waitFor(() => { + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration({})); + }); }); it('shows the success toaster', async () => { - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -142,9 +206,9 @@ describe('useCreateAttachments', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(addSuccess).toHaveBeenCalled(); + await waitFor(() => { + expect(addSuccess).toHaveBeenCalled(); + }); }); it('shows a toast error when the api return an error', async () => { @@ -152,7 +216,7 @@ describe('useCreateAttachments', () => { .spyOn(api, 'postCaseConfigure') .mockRejectedValue(new Error('useCreateAttachments: Test error')); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -160,8 +224,8 @@ describe('useCreateAttachments', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(addError).toHaveBeenCalled(); + await waitFor(() => { + expect(addError).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx index 95162d23aa39..dc9bed95d1df 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx @@ -27,12 +27,13 @@ export const usePersistConfiguration = () => { const { showErrorToast, showSuccessToast } = useCasesToast(); return useMutation( - ({ id, version, closureType, customFields, connector }: Request) => { + ({ id, version, closureType, customFields, templates, connector }: Request) => { if (isEmpty(id) || isEmpty(version)) { return postCaseConfigure({ closure_type: closureType, connector, customFields: customFields ?? [], + templates: templates ?? [], owner: owner[0], }); } @@ -42,6 +43,7 @@ export const usePersistConfiguration = () => { closure_type: closureType, connector, customFields: customFields ?? [], + templates: templates ?? [], }); }, { diff --git a/x-pack/plugins/cases/public/containers/configure/utils.ts b/x-pack/plugins/cases/public/containers/configure/utils.ts index 164b9c0f9494..e4416beb5ce5 100644 --- a/x-pack/plugins/cases/public/containers/configure/utils.ts +++ b/x-pack/plugins/cases/public/containers/configure/utils.ts @@ -16,6 +16,7 @@ export const initialConfiguration: CasesConfigurationUI = { type: ConnectorTypes.none, }, customFields: [], + templates: [], mappings: [], version: '', id: '', diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 63fac9c81695..8d2feca6b9be 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -45,6 +45,7 @@ import type { AttachmentUI, CaseUICustomField, CasesConfigurationUICustomField, + CasesConfigurationUITemplate, } from '../../common/ui/types'; import { CaseMetricsFeature } from '../../common/types/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; @@ -1177,3 +1178,84 @@ export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = { type: CustomFieldTypes.TEXT, key: 'test_key_3', label: 'My test label 3', required: false }, { type: CustomFieldTypes.TOGGLE, key: 'test_key_4', label: 'My test label 4', required: false }, ]; + +export const templatesConfigurationMock: CasesConfigurationUITemplate[] = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: [], + caseFields: {}, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template with few case fields', + tags: ['foo'], + caseFields: { + title: 'This is case title using a test template', + severity: CaseSeverity.MEDIUM, + tags: ['third-template', 'medium'], + }, + }, + { + key: 'test_template_4', + name: 'Fourth test template', + description: 'This is a fourth test template', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case with sample template 4', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_5', + name: 'Fifth test template', + description: 'This is a fifth test template', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case with sample template 5', + description: 'case desc', + severity: CaseSeverity.HIGH, + category: 'my category', + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + connector: { + id: 'jira-1', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'Low', parent: null }, + }, + }, + }, +]; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts index 5732085d99c8..4edc105b8d34 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts @@ -114,4 +114,14 @@ describe.skip('useBulkGetUserProfiles', () => { expect(addError).toHaveBeenCalled(); }); + + it('does not call the bulkGetUserProfiles if the array of uids is empty', async () => { + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + + renderHook(() => useBulkGetUserProfiles({ uids: [] }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(spyOnBulkGetUserProfiles).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts index a9e60f3e854a..8b1b9580ca84 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts @@ -34,6 +34,7 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { select: profilesToMap, retry: false, keepPreviousData: true, + staleTime: 60 * 1000, // one minute onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( @@ -44,6 +45,7 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { ); } }, + enabled: uids.length > 0, } ); }; diff --git a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts index 1f6c9b307fe6..c7f047aa6b38 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts @@ -977,7 +977,7 @@ describe('bulkCreate', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to bulk create cases: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to bulk create cases: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index a3fc842dfe3e..0109e6eda880 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -1309,7 +1309,7 @@ describe('update', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index 315ee1483457..8b24c79c530b 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -632,7 +632,7 @@ describe('create', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to create case: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to create case: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/validators.ts b/x-pack/plugins/cases/server/client/cases/validators.ts index eeebbc8c13ca..b2e286d48c4b 100644 --- a/x-pack/plugins/cases/server/client/cases/validators.ts +++ b/x-pack/plugins/cases/server/client/cases/validators.ts @@ -9,7 +9,7 @@ import { differenceWith, intersectionWith, isEmpty } from 'lodash'; import Boom from '@hapi/boom'; import type { CustomFieldsConfiguration } from '../../../common/types/domain'; import type { CaseRequestCustomFields, CasesSearchRequest } from '../../../common/types/api'; -import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; +import { validateDuplicatedKeysInRequest } from '../validators'; import type { ICasesCustomField } from '../../custom_fields'; import { casesCustomFields } from '../../custom_fields'; import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; @@ -20,7 +20,10 @@ interface CustomFieldValidationParams { } export const validateCustomFields = (params: CustomFieldValidationParams) => { - validateDuplicatedCustomFieldKeysInRequest(params); + validateDuplicatedKeysInRequest({ + requestFields: params.requestCustomFields, + fieldName: 'customFields', + }); validateCustomFieldKeysAgainstConfiguration(params); validateRequiredCustomFields(params); validateCustomFieldTypesInRequest(params); diff --git a/x-pack/plugins/cases/server/client/configure/client.test.ts b/x-pack/plugins/cases/server/client/configure/client.test.ts index b5958c44de08..8b312d2d957a 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -15,8 +15,10 @@ import { createCasesClientInternalMock, createCasesClientMockArgs } from '../moc import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_SUPPORTED_CONNECTORS_RETURNED, + MAX_TEMPLATES_LENGTH, } from '../../../common/constants'; import { ConnectorTypes } from '../../../common'; +import type { TemplatesConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import type { ConfigurationRequest } from '../../../common/types/api'; @@ -306,7 +308,7 @@ describe('client', () => { casesClientInternal ) ).rejects.toThrow( - 'Failed to get patch configure in route: Error: Invalid duplicated custom field keys in request: duplicated_key' + 'Failed to get patch configure in route: Error: Invalid duplicated customFields keys in request: duplicated_key' ); }); @@ -346,6 +348,618 @@ describe('client', () => { 'Failed to get patch configure in route: Error: Invalid custom field types in request for the following labels: "text label"' ); }); + + describe('templates', () => { + it(`does not throw error when trying to update templates`, async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'cases', + templates: [], + }, + version: 'test-version', + }); + + clientArgs.services.caseConfigureService.patch.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it(`does not throw error when trying to update to empty templates`, async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'cases', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + }, + version: 'test-version', + }); + + clientArgs.services.caseConfigureService.patch.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + templates: [], + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [], + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it(`throws when trying to update more than ${MAX_TEMPLATES_LENGTH} templates`, async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_1', + name: 'template 1', + caseFields: null, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: The length of the field templates is too long. Array must be of length <= ${MAX_TEMPLATES_LENGTH}.` + ); + }); + + it('throws when there are duplicated template keys in the request', async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: null, + }, + { + key: 'template_1', + name: 'template 2', + tags: [], + caseFields: { + title: 'Case title', + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid duplicated templates keys in request: template_1' + ); + }); + + describe('customFields', () => { + it('throws when there are no customFields in configure and template has customField in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: null, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: No custom fields configured.' + ); + }); + + it('throws when template has duplicated custom field keys in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 2', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: Invalid duplicated templates[0]'s customFields keys in request: custom_field_key_1` + ); + }); + + it('throws when there are invalid customField keys in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_2', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid custom field keys: custom_field_key_2' + ); + }); + + it('throws when template has customField with invalid type in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: The following custom fields have the wrong type in the request: "text label"' + ); + }); + + it('removes deleted custom field from template correctly', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + closure_type: 'close-by-user', + owner: 'cases', + }, + id: 'test-id', + version: 'test-version', + }); + + await update( + 'test-id', + { + version: 'test-version', + customFields: [], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'updated value', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ); + + expect(clientArgs.services.caseConfigureService.patch).toHaveBeenCalledWith({ + configurationId: 'test-id', + originalConfiguration: { + attributes: { + closure_type: 'close-by-user', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + required: false, + type: 'text', + }, + ], + owner: 'cases', + templates: [ + { + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: 'text', + value: 'custom field value 1', + }, + ], + }, + description: 'this is test description', + key: 'template_1', + name: 'template 1', + }, + ], + }, + id: 'test-id', + version: 'test-version', + }, + unsecuredSavedObjectsClient: expect.anything(), + updatedAttributes: { + customFields: [], + templates: [ + { + caseFields: { + customFields: [], + }, + description: 'this is test description', + key: 'template_1', + name: 'template 1', + }, + ], + updated_at: expect.anything(), + updated_by: expect.anything(), + }, + }); + }); + }); + + describe('assignees', () => { + it('throws if the user does not have the correct license while adding assignees in template ', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: ['foo', 'bar'], + caseFields: null, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: ['foo', 'bar'], + caseFields: { + assignees: [{ uid: '1' }], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + }); + }); + }); }); describe('create', () => { @@ -404,8 +1018,334 @@ describe('client', () => { casesClientInternal ) ).rejects.toThrow( - 'Failed to create case configuration: Error: Invalid duplicated custom field keys in request: duplicated_key' + 'Failed to create case configuration: Error: Invalid duplicated customFields keys in request: duplicated_key' ); }); + + describe('templates', () => { + it(`throws when trying to create more than ${MAX_TEMPLATES_LENGTH} templates`, async () => { + await expect( + create( + { + ...baseRequest, + templates: new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: null, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: The length of the field templates is too long. Array must be of length <= ${MAX_TEMPLATES_LENGTH}.` + ); + }); + + it('throws when there are duplicated template keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'duplicated_key', + name: 'template 1', + description: 'test', + caseFields: null, + }, + { + key: 'duplicated_key', + name: 'template 2', + description: 'test', + tags: [], + caseFields: { + title: 'Case title', + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid duplicated templates keys in request: duplicated_key' + ); + }); + + describe('customFields', () => { + it('does not throw error when creating template with correct custom fields', async () => { + const customFields = [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ]; + const templates: TemplatesConfiguration = [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ]; + + clientArgs.services.caseConfigureService.find.mockResolvedValueOnce({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + ...baseRequest, + customFields, + templates, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: null, + updated_by: null, + }, + score: 0, + }, + ], + pit_id: undefined, + }); + + clientArgs.services.caseConfigureService.post.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + ...baseRequest, + customFields, + templates, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: null, + updated_by: null, + }, + }); + + await expect( + create( + { + ...baseRequest, + customFields, + templates, + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it('throws when there are no customFields in configure and template has customField in the request', async () => { + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: ['foo', 'bar'], + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: No custom fields configured.' + ); + }); + + it('throws when template has duplicated custom field keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 2', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: Invalid duplicated templates[0]'s customFields keys in request: custom_field_key_1` + ); + }); + + it('throws when there are invalid customField keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_2', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid custom field keys: custom_field_key_2' + ); + }); + + it('throws when template has customField with invalid type in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: The following custom fields have the wrong type in the request: "custom field 1"' + ); + }); + }); + + describe('assignees', () => { + it('throws if the user does not have the correct license while adding assignees in template ', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: [], + caseFields: { + assignees: [{ uid: '1' }], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + }); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 2fc0cc3e7259..68db617af8bc 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -18,6 +18,8 @@ import type { ConfigurationAttributes, Configurations, ConnectorMappings, + CustomFieldsConfiguration, + TemplatesConfiguration, } from '../../../common/types/domain'; import type { ConfigurationPatchRequest, @@ -42,13 +44,17 @@ import type { CasesClientArgs } from '../types'; import { getMappings } from './get_mappings'; import { Operations } from '../../authorization'; -import { combineAuthorizedAndOwnerFilter } from '../utils'; +import { combineAuthorizedAndOwnerFilter, removeCustomFieldFromTemplates } from '../utils'; import type { MappingsArgs, CreateMappingsArgs, UpdateMappingsArgs } from './types'; import { createMappings } from './create_mappings'; import { updateMappings } from './update_mappings'; import { ConfigurationRt, ConfigurationsRt } from '../../../common/types/domain'; -import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; -import { validateCustomFieldTypesInRequest } from './validators'; +import { validateDuplicatedKeysInRequest } from '../validators'; +import { + validateCustomFieldTypesInRequest, + validateTemplatesCustomFieldsInRequest, +} from './validators'; +import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; /** * Defines the internal helper functions. @@ -91,6 +97,52 @@ export interface ConfigureSubClient { create(configuration: ConfigurationRequest): Promise<Configuration>; } +/** + * validate templates in configuration + */ +const validateTemplates = async ({ + templates, + clientArgs, + customFields, +}: { + templates: TemplatesConfiguration | undefined; + clientArgs: CasesClientArgs; + customFields: CustomFieldsConfiguration | undefined; +}) => { + const { licensingService } = clientArgs.services; + + validateDuplicatedKeysInRequest({ + requestFields: templates, + fieldName: 'templates', + }); + + if (templates && templates.length) { + /** + * Assign users to a template is only available to Platinum+ + */ + const hasAssigneesInTemplate = templates.some((template) => + Boolean(template.caseFields?.assignees && template.caseFields?.assignees.length > 0) + ); + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (hasAssigneesInTemplate && !hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + } + + if (hasAssigneesInTemplate) { + licensingService.notifyUsage(LICENSING_CASE_ASSIGNMENT_FEATURE); + } + + validateTemplatesCustomFieldsInRequest({ + templates, + customFieldsConfiguration: customFields, + }); + } +}; + /** * These functions should not be exposed on the plugin contract. They are for internal use to support the CRUD of * configurations. @@ -251,9 +303,12 @@ export async function update( try { const request = decodeWithExcessOrThrow(ConfigurationPatchRequestRt)(req); - validateDuplicatedCustomFieldKeysInRequest({ requestCustomFields: request.customFields }); + validateDuplicatedKeysInRequest({ + requestFields: request.customFields, + fieldName: 'customFields', + }); - const { version, ...queryWithoutVersion } = request; + const { version, templates, ...queryWithoutVersion } = request; const configuration = await caseConfigureService.get({ unsecuredSavedObjectsClient, @@ -265,6 +320,17 @@ export async function update( originalCustomFields: configuration.attributes.customFields, }); + await validateTemplates({ + templates, + clientArgs, + customFields: configuration.attributes.customFields, + }); + + const updatedTemplates = removeCustomFieldFromTemplates({ + templates, + customFields: request.customFields, + }); + await authorization.ensureAuthorized({ operation: Operations.updateConfiguration, entities: [{ owner: configuration.attributes.owner, id: configuration.id }], @@ -320,6 +386,7 @@ export async function update( configurationId: configuration.id, updatedAttributes: { ...queryWithoutVersionAndConnector, + ...(updatedTemplates && { templates: updatedTemplates }), ...(connector != null && { connector }), updated_at: updateDate, updated_by: user, @@ -364,8 +431,15 @@ export async function create( const validatedConfigurationRequest = decodeWithExcessOrThrow(ConfigurationRequestRt)(configRequest); - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: validatedConfigurationRequest.customFields, + validateDuplicatedKeysInRequest({ + requestFields: validatedConfigurationRequest.customFields, + fieldName: 'customFields', + }); + + await validateTemplates({ + templates: validatedConfigurationRequest.templates, + clientArgs, + customFields: validatedConfigurationRequest.customFields, }); let error = null; @@ -441,6 +515,7 @@ export async function create( attributes: { ...validatedConfigurationRequest, customFields: validatedConfigurationRequest.customFields ?? [], + templates: validatedConfigurationRequest.templates ?? [], connector: validatedConfigurationRequest.connector, created_at: creationDate, created_by: user, diff --git a/x-pack/plugins/cases/server/client/configure/validators.test.ts b/x-pack/plugins/cases/server/client/configure/validators.test.ts index 0f8e20505fb3..ca81926519d3 100644 --- a/x-pack/plugins/cases/server/client/configure/validators.test.ts +++ b/x-pack/plugins/cases/server/client/configure/validators.test.ts @@ -6,10 +6,16 @@ */ import { CustomFieldTypes } from '../../../common/types/domain'; -import { validateCustomFieldTypesInRequest } from './validators'; +import { + validateCustomFieldTypesInRequest, + validateTemplatesCustomFieldsInRequest, +} from './validators'; describe('validators', () => { describe('validateCustomFieldTypesInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('throws an error with the keys of customFields in request that have invalid types', () => { expect(() => validateCustomFieldTypesInRequest({ @@ -69,4 +75,303 @@ describe('validators', () => { ).not.toThrow(); }); }); + + describe('validateTemplatesCustomFieldsInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all custom fields types in request match the configuration', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + { + key: 'template_key_2', + name: 'second template', + description: 'this is a second template value', + caseFields: { + title: 'Case title with template 2', + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if no custom fields are in request', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + customFieldsConfiguration: undefined, + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + tags: ['first-template'], + }, + }, + { + key: 'template_key_2', + name: 'second template', + description: 'this is a second template value', + caseFields: null, + }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if no configuration found but no templates are in request', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + customFieldsConfiguration: undefined, + templates: [], + }) + ).not.toThrow(); + }); + + it('does not throw if the configuration is undefined but no custom fields are in request', () => { + expect(() => validateTemplatesCustomFieldsInRequest({})).not.toThrow(); + }); + + it('throws if configuration is missing and template has custom fields', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + + it('throws for a single invalid type', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'first label', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: \\"first label\\""` + ); + }); + + it('throws for multiple custom fields with invalid types', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + value: 'abc', + }, + ], + }, + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'first label', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + label: 'second label', + required: false, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + label: 'third label', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: \\"first label\\", \\"second label\\", \\"third label\\""` + ); + }); + + it('throws if there are invalid custom field keys', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid custom field keys: invalid_key"`); + }); + + it('throws if template has duplicated custom field keys', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated templates[0]'s customFields keys in request: first_key"` + ); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/configure/validators.ts b/x-pack/plugins/cases/server/client/configure/validators.ts index c5929065c631..1dec647561ab 100644 --- a/x-pack/plugins/cases/server/client/configure/validators.ts +++ b/x-pack/plugins/cases/server/client/configure/validators.ts @@ -6,7 +6,16 @@ */ import Boom from '@hapi/boom'; -import type { CustomFieldTypes } from '../../../common/types/domain'; +import type { + CustomFieldsConfiguration, + CustomFieldTypes, + TemplatesConfiguration, +} from '../../../common/types/domain'; +import { validateDuplicatedKeysInRequest } from '../validators'; +import { + validateCustomFieldKeysAgainstConfiguration, + validateCustomFieldTypesInRequest as validateCaseCustomFieldTypesInRequest, +} from '../cases/validators'; /** * Throws an error if the request tries to change the type of existing custom fields. @@ -38,3 +47,41 @@ export const validateCustomFieldTypesInRequest = ({ ); } }; + +export const validateTemplatesCustomFieldsInRequest = ({ + templates, + customFieldsConfiguration, +}: { + templates?: TemplatesConfiguration; + customFieldsConfiguration?: CustomFieldsConfiguration; +}) => { + if (!Array.isArray(templates) || !templates.length) { + return; + } + + templates.forEach((template, index) => { + if ( + !template.caseFields || + !template.caseFields.customFields || + !template.caseFields.customFields.length + ) { + return; + } + + if (customFieldsConfiguration === undefined) { + throw Boom.badRequest('No custom fields configured.'); + } + + const params = { + requestCustomFields: template.caseFields.customFields, + customFieldsConfiguration, + }; + + validateDuplicatedKeysInRequest({ + requestFields: params.requestCustomFields, + fieldName: `templates[${index}]'s customFields`, + }); + validateCustomFieldKeysAgainstConfiguration(params); + validateCaseCustomFieldTypesInRequest(params); + }); +}; diff --git a/x-pack/plugins/cases/server/client/factory.test.ts b/x-pack/plugins/cases/server/client/factory.test.ts index 69147e888aee..f73e93afd680 100644 --- a/x-pack/plugins/cases/server/client/factory.test.ts +++ b/x-pack/plugins/cases/server/client/factory.test.ts @@ -52,7 +52,7 @@ describe('CasesClientFactory', () => { }); expect(args.securityPluginStart.userProfiles.getCurrent).toHaveBeenCalled(); - expect(args.securityPluginStart.authc.getCurrentUser).not.toHaveBeenCalled(); + expect(args.securityServiceStart.authc.getCurrentUser).not.toHaveBeenCalled(); expect(createCasesClientMocked.mock.calls[0][0].user).toEqual({ username: 'my_user', full_name: 'My user', @@ -63,7 +63,7 @@ describe('CasesClientFactory', () => { it('constructs the user info from the authc service if the user profile is not available', async () => { const scopedClusterClient = coreStart.elasticsearch.client.asScoped(request).asCurrentUser; // @ts-expect-error: not all fields are needed - args.securityPluginStart.authc.getCurrentUser.mockReturnValueOnce({ + args.securityServiceStart.authc.getCurrentUser.mockReturnValueOnce({ username: 'my_user_2', full_name: 'My user 2', email: 'elastic2@elastic.co', @@ -76,7 +76,7 @@ describe('CasesClientFactory', () => { }); expect(args.securityPluginStart.userProfiles.getCurrent).toHaveBeenCalled(); - expect(args.securityPluginStart.authc.getCurrentUser).toHaveBeenCalled(); + expect(args.securityServiceStart.authc.getCurrentUser).toHaveBeenCalled(); expect(createCasesClientMocked.mock.calls[0][0].user).toEqual({ username: 'my_user_2', full_name: 'My user 2', @@ -95,7 +95,7 @@ describe('CasesClientFactory', () => { }); expect(args.securityPluginStart.userProfiles.getCurrent).toHaveBeenCalled(); - expect(args.securityPluginStart.authc.getCurrentUser).toHaveBeenCalled(); + expect(args.securityServiceStart.authc.getCurrentUser).toHaveBeenCalled(); expect(createCasesClientMocked.mock.calls[0][0].user).toEqual({ username: 'elastic/kibana', full_name: null, @@ -113,7 +113,7 @@ describe('CasesClientFactory', () => { }); expect(args.securityPluginStart.userProfiles.getCurrent).toHaveBeenCalled(); - expect(args.securityPluginStart.authc.getCurrentUser).toHaveBeenCalled(); + expect(args.securityServiceStart.authc.getCurrentUser).toHaveBeenCalled(); expect(createCasesClientMocked.mock.calls[0][0].user).toEqual({ username: null, full_name: null, diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index 5bb04c1da9e8..865ee2ff3b68 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -12,6 +12,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract, IBasePath, + SecurityServiceStart, } from '@kbn/core/server'; import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server'; import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; @@ -57,6 +58,7 @@ import { EmailNotificationService } from '../services/notifications/email_notifi interface CasesClientFactoryArgs { securityPluginSetup: SecurityPluginSetup; securityPluginStart: SecurityPluginStart; + securityServiceStart: SecurityServiceStart; spacesPluginStart?: SpacesPluginStart; featuresPluginStart: FeaturesPluginStart; actionsPluginStart: ActionsPluginStart; @@ -257,6 +259,7 @@ export class CasesClientFactory { try { const userProfile = await this.options.securityPluginStart.userProfiles.getCurrent({ + // todo: Access userProfiles from core's UserProfileService contract request, }); @@ -273,7 +276,7 @@ export class CasesClientFactory { } try { - const user = this.options.securityPluginStart.authc.getCurrentUser(request); + const user = this.options.securityServiceStart.authc.getCurrentUser(request); if (user != null) { return { diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 3de350f5a398..74d3c3de46fa 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -6,7 +6,11 @@ */ import type { PublicContract, PublicMethodsOf } from '@kbn/utility-types'; -import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { + loggingSystemMock, + savedObjectsClientMock, + securityServiceMock, +} from '@kbn/core/server/mocks'; import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server'; import { @@ -226,6 +230,7 @@ export const createCasesClientFactoryMockArgs = () => { return { securityPluginSetup: securityMock.createSetup(), securityPluginStart: securityMock.createStart(), + securityServiceStart: securityServiceMock.createStart(), spacesPluginStart: spacesMock.createStart(), featuresPluginStart: featuresPluginMock.createSetup(), actionsPluginStart: actionsMock.createStart(), diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts index 8f9e8648a126..56615189d1d5 100644 --- a/x-pack/plugins/cases/server/client/utils.test.ts +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -19,6 +19,7 @@ import { constructQueryOptions, constructSearch, convertSortField, + removeCustomFieldFromTemplates, } from './utils'; import { CasePersistedSeverity, CasePersistedStatus } from '../common/types/case'; import type { CustomFieldsConfiguration } from '../../common/types/domain'; @@ -1130,4 +1131,289 @@ describe('utils', () => { ); }); }); + + describe('removeCustomFieldFromTemplates', () => { + const customFields = [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + label: 'My test label 1', + required: true, + defaultValue: 'My default value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + label: 'My test label 2', + required: true, + defaultValue: true, + }, + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_3', + label: 'My test label 3', + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: 'My default value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + value: false, + }, + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_3', + value: 'Test custom field', + }, + ], + }, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: [], + caseFields: { + customFields: [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: 'My value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + value: true, + }, + ], + }, + }, + ]; + + it('removes custom field from template correctly', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([ + { + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My default value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: false, + }, + ], + }, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + description: 'This is a second test template', + key: 'test_template_2', + name: 'Second test template', + tags: [], + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + ], + }, + }, + ]); + }); + + it('removes multiple custom fields from template correctly', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: [customFields[0]], + }); + + expect(res).toEqual([ + { + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My default value', + }, + ], + }, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + description: 'This is a second test template', + key: 'test_template_2', + name: 'Second test template', + tags: [], + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My value', + }, + ], + }, + }, + ]); + }); + + it('removes all custom fields from templates when custom fields are empty', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: [], + }); + + expect(res).toEqual([ + { + caseFields: { + customFields: [], + }, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + description: 'This is a second test template', + key: 'test_template_2', + name: 'Second test template', + tags: [], + caseFields: { + customFields: [], + }, + }, + ]); + }); + + it('removes all custom fields from templates when custom fields are undefined', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: undefined, + }); + + expect(res).toEqual([ + { ...templates[0], caseFields: { customFields: [] } }, + { ...templates[1], caseFields: { ...templates[1].caseFields, customFields: [] } }, + ]); + }); + + it('does not remove custom field when templates do not have custom fields', () => { + const res = removeCustomFieldFromTemplates({ + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + title: 'Test title', + description: 'this is test', + }, + }, + ], + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([ + { + caseFields: null, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + description: 'this is test', + title: 'Test title', + }, + }, + ]); + }); + + it('does not remove custom field when templates have empty custom fields', () => { + const res = removeCustomFieldFromTemplates({ + templates: [ + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + title: 'Test title', + description: 'this is test', + customFields: [], + }, + }, + ], + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([ + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + title: 'Test title', + description: 'this is test', + customFields: [], + }, + }, + ]); + }); + + it('does not remove custom field from empty templates', () => { + const res = removeCustomFieldFromTemplates({ + templates: [], + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([]); + }); + + it('returns empty array when templates are undefined', () => { + const res = removeCustomFieldFromTemplates({ + templates: undefined, + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([]); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index 0ce4da8bcc21..258761a563fd 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -21,6 +21,7 @@ import type { CaseStatuses, CustomFieldsConfiguration, ExternalReferenceAttachmentPayload, + TemplatesConfiguration, } from '../../common/types/domain'; import { ActionsAttachmentPayloadRt, @@ -604,3 +605,37 @@ export const constructSearch = ( return { search }; }; + +/** + * remove deleted custom field from template + */ +export const removeCustomFieldFromTemplates = ({ + templates, + customFields, +}: { + templates?: TemplatesConfiguration; + customFields?: CustomFieldsConfiguration; +}): TemplatesConfiguration => { + if (!templates || !templates.length) { + return []; + } + + return templates.map((template) => { + if (!template.caseFields?.customFields || !template.caseFields?.customFields.length) { + return template; + } + + if (!customFields || !customFields?.length) { + return { ...template, caseFields: { ...template.caseFields, customFields: [] } }; + } + + const templateCustomFields = template.caseFields.customFields.filter((templateCustomField) => + customFields?.find((customField) => customField.key === templateCustomField.key) + ); + + return { + ...template, + caseFields: { ...template.caseFields, customFields: templateCustomFields }, + }; + }); +}; diff --git a/x-pack/plugins/cases/server/client/validators.test.ts b/x-pack/plugins/cases/server/client/validators.test.ts index 8d6caa218f93..77867aedbcb4 100644 --- a/x-pack/plugins/cases/server/client/validators.test.ts +++ b/x-pack/plugins/cases/server/client/validators.test.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { validateDuplicatedCustomFieldKeysInRequest } from './validators'; +import { validateDuplicatedKeysInRequest } from './validators'; describe('validators', () => { - describe('validateDuplicatedCustomFieldKeysInRequest', () => { - it('returns customFields in request that have duplicated keys', () => { + describe('validateDuplicatedKeysInRequest', () => { + it('returns fields in request that have duplicated keys', () => { expect(() => - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: [ + validateDuplicatedKeysInRequest({ + requestFields: [ { key: 'triplicated_key', }, @@ -29,16 +29,18 @@ describe('validators', () => { key: 'duplicated_key', }, ], + + fieldName: 'foobar', }) ).toThrowErrorMatchingInlineSnapshot( - `"Invalid duplicated custom field keys in request: triplicated_key,duplicated_key"` + `"Invalid duplicated foobar keys in request: triplicated_key,duplicated_key"` ); }); - it('does not throw if no customFields in request have duplicated keys', () => { + it('does not throw if no fields in request have duplicated keys', () => { expect(() => - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: [ + validateDuplicatedKeysInRequest({ + requestFields: [ { key: '1', }, @@ -46,6 +48,7 @@ describe('validators', () => { key: '2', }, ], + fieldName: 'foobar', }) ).not.toThrow(); }); diff --git a/x-pack/plugins/cases/server/client/validators.ts b/x-pack/plugins/cases/server/client/validators.ts index 88b62640cee8..24527ac81155 100644 --- a/x-pack/plugins/cases/server/client/validators.ts +++ b/x-pack/plugins/cases/server/client/validators.ts @@ -10,15 +10,17 @@ import Boom from '@hapi/boom'; /** * Throws an error if the request has custom fields with duplicated keys. */ -export const validateDuplicatedCustomFieldKeysInRequest = ({ - requestCustomFields = [], +export const validateDuplicatedKeysInRequest = ({ + requestFields = [], + fieldName, }: { - requestCustomFields?: Array<{ key: string }>; + requestFields?: Array<{ key: string }>; + fieldName: string; }) => { const uniqueKeys = new Set<string>(); const duplicatedKeys = new Set<string>(); - requestCustomFields.forEach((item) => { + requestFields.forEach((item) => { if (uniqueKeys.has(item.key)) { duplicatedKeys.add(item.key); } else { @@ -28,7 +30,7 @@ export const validateDuplicatedCustomFieldKeysInRequest = ({ if (duplicatedKeys.size > 0) { throw Boom.badRequest( - `Invalid duplicated custom field keys in request: ${Array.from(duplicatedKeys.values())}` + `Invalid duplicated ${fieldName} keys in request: ${Array.from(duplicatedKeys.values())}` ); } }; diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 94dcaf0a9ce1..faf2517fbe17 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -8,14 +8,19 @@ import * as rt from 'io-ts'; import type { SavedObject } from '@kbn/core/server'; -import type { ConfigurationAttributes } from '../../../common/types/domain'; +import type { + CaseConnector, + CaseCustomFields, + CaseSeverity, + ConfigurationAttributes, +} from '../../../common/types/domain'; import { ConfigurationActivityFieldsRt, ConfigurationAttributesRt, ConfigurationBasicWithoutOwnerRt, } from '../../../common/types/domain'; import type { ConnectorPersisted } from './connectors'; -import type { User } from './user'; +import type { User, UserProfile } from './user'; export interface ConfigurationPersistedAttributes { connector: ConnectorPersisted; @@ -26,6 +31,7 @@ export interface ConfigurationPersistedAttributes { updated_at: string | null; updated_by: User | null; customFields?: PersistedCustomFieldsConfiguration; + templates?: PersistedTemplatesConfiguration; } type PersistedCustomFieldsConfiguration = Array<{ @@ -36,6 +42,26 @@ type PersistedCustomFieldsConfiguration = Array<{ defaultValue?: string | boolean | null; }>; +type PersistedTemplatesConfiguration = Array<{ + key: string; + name: string; + description?: string; + tags?: string[]; + caseFields?: CaseFieldsAttributes | null; +}>; + +export interface CaseFieldsAttributes { + title?: string; + assignees?: UserProfile[]; + connector?: CaseConnector; + description?: string; + severity?: CaseSeverity; + tags?: string[]; + category?: string | null; + customFields?: CaseCustomFields; + settings?: { syncAlerts: boolean }; +} + export type ConfigurationTransformedAttributes = ConfigurationAttributes; export type ConfigurationSavedObjectTransformed = SavedObject<ConfigurationTransformedAttributes>; diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 2c3f1f10ad25..48ed1722149e 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -186,6 +186,7 @@ export class CasePlugin // eslint-disable-next-line @typescript-eslint/no-non-null-assertion securityPluginSetup: this.securityPluginSetup!, securityPluginStart: plugins.security, + securityServiceStart: core.security, spacesPluginStart: plugins.spaces, featuresPluginStart: plugins.features, actionsPluginStart: plugins.actions, diff --git a/x-pack/plugins/cases/server/services/configure/index.test.ts b/x-pack/plugins/cases/server/services/configure/index.test.ts index d1a79cc1a8d6..627263de5084 100644 --- a/x-pack/plugins/cases/server/services/configure/index.test.ts +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -5,8 +5,12 @@ * 2.0. */ -import type { CaseConnector, ConfigurationAttributes } from '../../../common/types/domain'; -import { CustomFieldTypes, ConnectorTypes } from '../../../common/types/domain'; +import type { + CaseConnector, + CaseCustomFields, + ConfigurationAttributes, +} from '../../../common/types/domain'; +import { CustomFieldTypes, ConnectorTypes, CaseSeverity } from '../../../common/types/domain'; import { CASE_CONFIGURE_SAVED_OBJECT, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import type { @@ -59,6 +63,40 @@ const basicConfigFields = { defaultValue: 'foobar', }, ], + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_4', + name: 'Fourth test template', + description: 'This is a fourth test template', + caseFields: { + title: 'Case with sample template 4', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ] as CaseCustomFields, + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + ], }; const createConfigUpdateParams = (connector?: CaseConnector): Partial<ConfigurationAttributes> => ({ @@ -204,6 +242,46 @@ describe('CaseConfigureService', () => { }, ], "owner": "securitySolution", + "templates": Array [ + Object { + "caseFields": null, + "description": "This is a first test template", + "key": "test_template_1", + "name": "First test template", + }, + Object { + "caseFields": Object { + "assignees": Array [ + Object { + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + }, + ], + "category": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "My Connector", + "type": ".none", + }, + "customFields": Array [ + Object { + "key": "first_custom_field_key", + "type": "text", + "value": "this is a text field value", + }, + ], + "description": "case desc", + "severity": "low", + "tags": Array [ + "sample-4", + ], + "title": "Case with sample template 4", + }, + "description": "This is a fourth test template", + "key": "test_template_4", + "name": "Fourth test template", + }, + ], "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { "email": "testemail@elastic.co", @@ -490,6 +568,46 @@ describe('CaseConfigureService', () => { }, ], "owner": "securitySolution", + "templates": Array [ + Object { + "caseFields": null, + "description": "This is a first test template", + "key": "test_template_1", + "name": "First test template", + }, + Object { + "caseFields": Object { + "assignees": Array [ + Object { + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + }, + ], + "category": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "My Connector", + "type": ".none", + }, + "customFields": Array [ + Object { + "key": "first_custom_field_key", + "type": "text", + "value": "this is a text field value", + }, + ], + "description": "case desc", + "severity": "low", + "tags": Array [ + "sample-4", + ], + "title": "Case with sample template 4", + }, + "description": "This is a fourth test template", + "key": "test_template_4", + "name": "Fourth test template", + }, + ], "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { "email": "testemail@elastic.co", diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 6c367d9a9684..f50ac271bc4f 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -228,12 +228,17 @@ function transformToExternalModel( ? [] : (configuration.attributes.customFields as ConfigurationTransformedAttributes['customFields']); + const templates = !configuration.attributes.templates + ? [] + : (configuration.attributes.templates as ConfigurationTransformedAttributes['templates']); + return { ...configuration, attributes: { ...castedAttributes, connector, customFields, + templates, }, }; } diff --git a/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts b/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts index e17eb2f22f7b..22c93f8919a2 100644 --- a/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts +++ b/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts @@ -100,7 +100,7 @@ export class EmailNotificationService implements NotificationService { ); const uids = new Set(assignees.map((assignee) => assignee.uid)); - const userProfiles = await this.security.userProfiles.bulkGet({ uids }); + const userProfiles = await this.security.userProfiles.bulkGet({ uids }); // todo: access userProfiles from core security service start contract const users = userProfiles.map((profile) => profile.user); const to = users diff --git a/x-pack/plugins/cases/server/services/user_profiles/index.ts b/x-pack/plugins/cases/server/services/user_profiles/index.ts index 6a7be7deac4e..7bc57a96105f 100644 --- a/x-pack/plugins/cases/server/services/user_profiles/index.ts +++ b/x-pack/plugins/cases/server/services/user_profiles/index.ts @@ -27,7 +27,7 @@ const MIN_PROFILES_SIZE = 0; interface UserProfileOptions { securityPluginSetup: SecurityPluginSetup; - securityPluginStart: SecurityPluginStart; + securityPluginStart: SecurityPluginStart; // TODO: Use core's UserProfileService spaces?: SpacesPluginStart; licensingPluginStart: LicensingPluginStart; } @@ -58,6 +58,7 @@ export class UserProfileService { size?: number; owners: string[]; }) { + // TODO: Use core's UserProfileService return securityPluginStart.userProfiles.suggest({ name: searchTerm, size, diff --git a/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts b/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts index 1a9ef57ba83c..6e5106c2df5c 100644 --- a/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts +++ b/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts @@ -5,8 +5,7 @@ * 2.0. */ -import type { CoreSetup, Logger } from '@kbn/core/server'; -import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import type { AuthenticatedUser, CoreSetup, Logger } from '@kbn/core/server'; import type { CloudDefendRequestHandlerContext, CloudDefendPluginStart, @@ -33,8 +32,8 @@ export function setupRoutes({ core.http.registerRouteHandlerContext<CloudDefendRequestHandlerContext, typeof PLUGIN_ID>( PLUGIN_ID, - async (context, request) => { - const [, { security, fleet }] = await core.getStartServices(); + async (context, _request) => { + const [_, { fleet }] = await core.getStartServices(); const coreContext = await context.core; await fleet.fleetSetupCompleted(); @@ -44,7 +43,7 @@ export function setupRoutes({ get user() { // We want to call getCurrentUser only when needed and only once if (!user) { - user = security.authc.getCurrentUser(request); + user = coreContext.security.authc.getCurrentUser(); } return user; }, diff --git a/x-pack/plugins/cloud_defend/server/types.ts b/x-pack/plugins/cloud_defend/server/types.ts index e6a5d9454ba8..6b341c552f53 100644 --- a/x-pack/plugins/cloud_defend/server/types.ts +++ b/x-pack/plugins/cloud_defend/server/types.ts @@ -5,6 +5,7 @@ * 2.0. */ import type { CloudSetup } from '@kbn/cloud-plugin/server'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import type { IRouter, @@ -48,7 +49,7 @@ export interface CloudDefendPluginStartDeps { } export interface CloudDefendApiRequestHandlerContext { - user: ReturnType<SecurityPluginStart['authc']['getCurrentUser']>; + user: AuthenticatedUser | null; logger: Logger; esClient: IScopedClusterClient; soClient: SavedObjectsClientContract; diff --git a/x-pack/plugins/cloud_defend/tsconfig.json b/x-pack/plugins/cloud_defend/tsconfig.json index f63d751ec2a4..2e71cfde9128 100755 --- a/x-pack/plugins/cloud_defend/tsconfig.json +++ b/x-pack/plugins/cloud_defend/tsconfig.json @@ -37,7 +37,8 @@ "@kbn/core-http-router-server-mocks", "@kbn/core-elasticsearch-server", "@kbn/code-editor", - "@kbn/code-editor-mock" + "@kbn/code-editor-mock", + "@kbn/core-security-common" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc index 62f97a69e22f..293d5f0baf3d 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc @@ -18,7 +18,6 @@ "requiredBundles": [ ], "optionalPlugins": [ - "security", "cloudExperiments" ] } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts index f44c7cd5112e..f2142d431c63 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts @@ -6,43 +6,35 @@ */ import { coreMock } from '@kbn/core/public/mocks'; -import { securityMock } from '@kbn/security-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import type { CloudChatConfigType } from '../server/config'; import { CloudChatPlugin } from './plugin'; +import { type MockedLogger } from '@kbn/logging-mocks'; describe('Cloud Chat Plugin', () => { describe('#setup', () => { describe('setupChat', () => { - let consoleMock: jest.SpyInstance<void, [message?: any, ...optionalParams: any[]]>; let newTrialEndDate: Date; + let logger: MockedLogger; beforeEach(() => { - consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {}); newTrialEndDate = new Date(); newTrialEndDate.setDate(new Date().getDate() + 14); }); - afterEach(() => { - consoleMock.mockRestore(); - }); - const setupPlugin = async ({ config = {}, - securityEnabled = true, - currentUserProps = {}, isCloudEnabled = true, failHttp = false, trialEndDate = newTrialEndDate, }: { config?: Partial<CloudChatConfigType>; - securityEnabled?: boolean; - currentUserProps?: Record<string, any>; isCloudEnabled?: boolean; failHttp?: boolean; trialEndDate?: Date; }) => { const initContext = coreMock.createPluginInitializerContext(config); + logger = initContext.logger as MockedLogger; const plugin = new CloudChatPlugin(initContext); @@ -50,25 +42,22 @@ describe('Cloud Chat Plugin', () => { const coreStart = coreMock.createStart(); if (failHttp) { - coreSetup.http.get.mockImplementation(() => { + coreSetup.http.get.mockImplementation(async () => { throw new Error('HTTP request failed'); }); } coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]); - const securitySetup = securityMock.createSetup(); - securitySetup.authc.getCurrentUser.mockResolvedValue( - securityMock.createMockAuthenticatedUser(currentUserProps) - ); - const cloud = cloudMock.createSetup(); plugin.setup(coreSetup, { cloud: { ...cloud, isCloudEnabled, trialEndDate }, - ...(securityEnabled ? { security: securitySetup } : {}), }); + // Wait for the async processes to complete + await new Promise((resolve) => process.nextTick(resolve)); + return { initContext, plugin, coreSetup }; }; @@ -77,11 +66,6 @@ describe('Cloud Chat Plugin', () => { expect(coreSetup.http.get).not.toHaveBeenCalled(); }); - it('chatConfig is not retrieved if security is not enabled', async () => { - const { coreSetup } = await setupPlugin({ securityEnabled: false }); - expect(coreSetup.http.get).not.toHaveBeenCalled(); - }); - it('chatConfig is not retrieved if chat is enabled but url is not provided', async () => { // @ts-expect-error 2741 const { coreSetup } = await setupPlugin({ config: { chat: { enabled: true } } }); @@ -94,7 +78,7 @@ describe('Cloud Chat Plugin', () => { failHttp: true, }); expect(coreSetup.http.get).toHaveBeenCalled(); - expect(consoleMock).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining(`Error setting up Chat`)); }); it('chatConfig is not retrieved if chat is enabled and url is provided but trial has expired', async () => { diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx index d7dcb4763b67..c6d527808549 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx +++ b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx @@ -8,11 +8,11 @@ import React, { type FC, type PropsWithChildren } from 'react'; import ReactDOM from 'react-dom'; import useObservable from 'react-use/lib/useObservable'; +import { ReplaySubject, first } from 'rxjs'; +import type { Logger } from '@kbn/logging'; import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; import type { HttpSetup } from '@kbn/core-http-browser'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/public'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; -import { ReplaySubject, first } from 'rxjs'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { ChatVariant, GetChatUserDataResponseBody } from '../common/types'; import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../common/constants'; @@ -22,7 +22,6 @@ import { ChatExperimentSwitcher } from './components/chat_experiment_switcher'; interface CloudChatSetupDeps { cloud: CloudSetup; - security?: SecurityPluginSetup; } interface CloudChatStartDeps { @@ -40,6 +39,7 @@ interface CloudChatConfig { export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, CloudChatStartDeps> { private readonly config: CloudChatConfig; + private readonly logger: Logger; private chatConfig$ = new ReplaySubject<ChatConfig>(1); private kbnVersion: string; private kbnBuildNum: number; @@ -48,12 +48,12 @@ export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, C this.kbnVersion = initializerContext.env.packageInfo.version; this.kbnBuildNum = initializerContext.env.packageInfo.buildNum; this.config = initializerContext.config.get(); + this.logger = initializerContext.logger.get(); } - public setup(core: CoreSetup, { cloud, security }: CloudChatSetupDeps) { - this.setupChat({ http: core.http, cloud, security }).catch((e) => - // eslint-disable-next-line no-console - console.debug(`Error setting up Chat: ${e.toString()}`) + public setup(core: CoreSetup, { cloud }: CloudChatSetupDeps) { + this.setupChat({ http: core.http, cloud }).catch((e) => + this.logger.debug(`Error setting up Chat: ${e.toString()}`) ); } @@ -92,12 +92,11 @@ export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, C public stop() {} - private async setupChat({ cloud, http, security }: SetupChatDeps) { + private async setupChat({ cloud, http }: SetupChatDeps) { const { isCloudEnabled, trialEndDate } = cloud; const { chatURL, trialBuffer } = this.config; if ( - !security || !isCloudEnabled || !chatURL || !trialEndDate || @@ -131,8 +130,7 @@ export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, C }, }); } catch (e) { - // eslint-disable-next-line no-console - console.debug( + this.logger.debug( `[cloud.chat] Could not retrieve chat config: ${e.response.status} ${e.message}`, e ); diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts index 633a8009522a..a708dd81cf53 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts @@ -7,7 +7,6 @@ import { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import { registerChatRoute } from './routes'; @@ -16,7 +15,6 @@ import type { ChatVariant } from '../common/types'; interface CloudChatSetupDeps { cloud: CloudSetup; - security?: SecurityPluginSetup; } interface CloudChatStartDeps { @@ -32,7 +30,7 @@ export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, C this.isDev = initializerContext.env.mode.dev; } - public setup(core: CoreSetup<CloudChatStartDeps>, { cloud, security }: CloudChatSetupDeps) { + public setup(core: CoreSetup<CloudChatStartDeps>, { cloud }: CloudChatSetupDeps) { const { chatIdentitySecret, trialBuffer } = this.config; const { isCloudEnabled, trialEndDate } = cloud; @@ -42,7 +40,6 @@ export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, C chatIdentitySecret, trialEndDate, trialBuffer, - security, isDev: this.isDev, getChatVariant: () => core.getStartServices().then(([_, { cloudExperiments }]) => { diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts index 7204d248fa76..94a55b2274a9 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts @@ -11,37 +11,69 @@ jest.mock('jsonwebtoken', () => ({ }, })); -import { httpServiceMock, httpServerMock } from '@kbn/core/server/mocks'; -import { securityMock } from '@kbn/security-plugin/server/mocks'; +import { + httpServiceMock, + httpServerMock, + coreMock, + securityServiceMock, +} from '@kbn/core/server/mocks'; import { kibanaResponseFactory } from '@kbn/core/server'; -import { registerChatRoute } from './chat'; +import { type MetaWithSaml, registerChatRoute } from './chat'; import { ChatVariant } from '../../common/types'; describe('chat route', () => { const getChatVariant = async (): Promise<ChatVariant> => 'header'; const getChatDisabledThroughExperiments = async (): Promise<boolean> => false; + let security: ReturnType<typeof securityServiceMock.createRequestHandlerContext>; + let requestHandlerContextMock: ReturnType<typeof coreMock.createCustomRequestHandlerContext>; + + beforeEach(() => { + const core = coreMock.createRequestHandlerContext(); + security = core.security; + requestHandlerContextMock = coreMock.createCustomRequestHandlerContext({ core }); + }); + + test('error if no user', async () => { + security.authc.getCurrentUser.mockReturnValueOnce(null); - test('do not add the route if security is not enabled', async () => { const router = httpServiceMock.createRouter(); registerChatRoute({ router, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60, + trialEndDate: new Date(), getChatVariant, getChatDisabledThroughExperiments, }); - expect(router.get.mock.calls).toEqual([]); + + const [_config, handler] = router.get.mock.calls[0]; + + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` + KibanaResponse { + "options": Object {}, + "payload": "Not Found", + "status": 404, + } + `); }); - test('error if no user', async () => { - const security = securityMock.createSetup(); - security.authc.getCurrentUser.mockReturnValueOnce(null); + test('error if no user is missing any details', async () => { + security.authc.getCurrentUser.mockReturnValueOnce( + securityServiceMock.createMockAuthenticatedUser({ + username: undefined, + }) + ); const router = httpServiceMock.createRouter(); registerChatRoute({ router, - security, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60, @@ -52,34 +84,39 @@ describe('chat route', () => { const [_config, handler] = router.get.mock.calls[0]; - await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves - .toMatchInlineSnapshot(` - KibanaResponse { - "options": Object { - "body": "User has no email or username", - }, - "payload": "User has no email or username", - "status": 400, - } - `); + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` + KibanaResponse { + "options": Object { + "body": "User has no email or username", + }, + "payload": "User has no email or username", + "status": 400, + } + `); }); test('error if no trial end date specified', async () => { - const security = securityMock.createSetup(); const username = 'user.name'; const email = 'user@elastic.co'; - security.authc.getCurrentUser.mockReturnValueOnce({ - username, - metadata: { - saml_email: [email], - }, - }); + security.authc.getCurrentUser.mockReturnValueOnce( + securityServiceMock.createMockAuthenticatedUser({ + username, + metadata: { + saml_email: [email], + } as MetaWithSaml, + }) + ); const router = httpServiceMock.createRouter(); registerChatRoute({ router, - security, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 2, @@ -89,8 +126,13 @@ describe('chat route', () => { const [_config, handler] = router.get.mock.calls[0]; - await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves - .toMatchInlineSnapshot(` + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` KibanaResponse { "options": Object { "body": "Chat can only be started if a trial end date is specified", @@ -102,23 +144,23 @@ describe('chat route', () => { }); test('error if not in trial window', async () => { - const security = securityMock.createSetup(); const username = 'user.name'; const email = 'user@elastic.co'; - security.authc.getCurrentUser.mockReturnValueOnce({ - username, - metadata: { - saml_email: [email], - }, - }); + security.authc.getCurrentUser.mockReturnValueOnce( + securityServiceMock.createMockAuthenticatedUser({ + username, + metadata: { + saml_email: [email], + } as MetaWithSaml, + }) + ); const router = httpServiceMock.createRouter(); const trialEndDate = new Date(); trialEndDate.setDate(trialEndDate.getDate() - 30); registerChatRoute({ router, - security, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 2, @@ -129,8 +171,13 @@ describe('chat route', () => { const [_config, handler] = router.get.mock.calls[0]; - await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves - .toMatchInlineSnapshot(` + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` KibanaResponse { "options": Object { "body": "Chat can only be started during trial and trial chat buffer", @@ -142,21 +189,21 @@ describe('chat route', () => { }); test('error if disabled in experiments', async () => { - const security = securityMock.createSetup(); const username = 'user.name'; const email = 'user@elastic.co'; - security.authc.getCurrentUser.mockReturnValueOnce({ - username, - metadata: { - saml_email: [email], - }, - }); + security.authc.getCurrentUser.mockReturnValueOnce( + securityServiceMock.createMockAuthenticatedUser({ + username, + metadata: { + saml_email: [email], + } as MetaWithSaml, + }) + ); const router = httpServiceMock.createRouter(); registerChatRoute({ router, - security, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60, @@ -165,8 +212,13 @@ describe('chat route', () => { getChatDisabledThroughExperiments: async () => true, }); const [_config, handler] = router.get.mock.calls[0]; - await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves - .toMatchInlineSnapshot(` + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` KibanaResponse { "options": Object { "body": "Chat is disabled through experiments", @@ -178,21 +230,21 @@ describe('chat route', () => { }); test('returns user information taken from saml metadata and a token', async () => { - const security = securityMock.createSetup(); const username = 'user.name'; const email = 'user@elastic.co'; - security.authc.getCurrentUser.mockReturnValueOnce({ - username, - metadata: { - saml_email: [email], - }, - }); + security.authc.getCurrentUser.mockReturnValueOnce( + securityServiceMock.createMockAuthenticatedUser({ + username, + metadata: { + saml_email: [email], + } as MetaWithSaml, + }) + ); const router = httpServiceMock.createRouter(); registerChatRoute({ router, - security, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60, @@ -201,8 +253,13 @@ describe('chat route', () => { getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; - await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves - .toMatchInlineSnapshot(` + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` KibanaResponse { "options": Object { "body": Object { @@ -224,16 +281,18 @@ describe('chat route', () => { }); test('returns placeholder user information and a token in dev mode', async () => { - const security = securityMock.createSetup(); const username = 'first.last'; const email = 'test+first.last@elasticsearch.com'; - security.authc.getCurrentUser.mockReturnValueOnce({}); + security.authc.getCurrentUser.mockReturnValueOnce( + securityServiceMock.createMockAuthenticatedUser({ + username: undefined, + }) + ); const router = httpServiceMock.createRouter(); registerChatRoute({ router, - security, isDev: true, chatIdentitySecret: 'secret', trialBuffer: 60, @@ -242,8 +301,13 @@ describe('chat route', () => { getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; - await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves - .toMatchInlineSnapshot(` + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` KibanaResponse { "options": Object { "body": Object { @@ -265,21 +329,21 @@ describe('chat route', () => { }); test('returns chat variant', async () => { - const security = securityMock.createSetup(); const username = 'user.name'; const email = 'user@elastic.co'; - security.authc.getCurrentUser.mockReturnValueOnce({ - username, - metadata: { - saml_email: [email], - }, - }); + security.authc.getCurrentUser.mockReturnValueOnce( + securityServiceMock.createMockAuthenticatedUser({ + username, + metadata: { + saml_email: [email], + } as MetaWithSaml, + }) + ); const router = httpServiceMock.createRouter(); registerChatRoute({ router, - security, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60, @@ -288,8 +352,13 @@ describe('chat route', () => { getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; - await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves - .toMatchInlineSnapshot(` + await expect( + handler( + requestHandlerContextMock, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toMatchInlineSnapshot(` KibanaResponse { "options": Object { "body": Object { diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts index d14500c18372..735a5db9298c 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts @@ -5,14 +5,13 @@ * 2.0. */ -import { IRouter } from '@kbn/core/server'; -import type { SecurityPluginSetup, AuthenticatedUser } from '@kbn/security-plugin/server'; +import type { AuthenticatedUser, IRouter } from '@kbn/core/server'; import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../../common/constants'; import type { GetChatUserDataResponseBody, ChatVariant } from '../../common/types'; import { generateSignedJwt } from '../util/generate_jwt'; import { isTodayInDateWindow } from '../../common/util'; -type MetaWithSaml = AuthenticatedUser['metadata'] & { +export type MetaWithSaml = AuthenticatedUser['metadata'] & { saml_name: [string]; saml_email: [string]; saml_roles: [string]; @@ -24,7 +23,6 @@ export const registerChatRoute = ({ chatIdentitySecret, trialEndDate, trialBuffer, - security, isDev, getChatVariant, getChatDisabledThroughExperiments, @@ -33,7 +31,6 @@ export const registerChatRoute = ({ chatIdentitySecret: string; trialEndDate?: Date; trialBuffer: number; - security?: SecurityPluginSetup; isDev: boolean; getChatVariant: () => Promise<ChatVariant>; /** @@ -42,20 +39,22 @@ export const registerChatRoute = ({ */ getChatDisabledThroughExperiments: () => Promise<boolean>; }) => { - if (!security) { - return; - } - router.get( { path: GET_CHAT_USER_DATA_ROUTE_PATH, validate: {}, }, - async (_context, request, response) => { - const user = security.authc.getCurrentUser(request); - const { metadata, username } = user || {}; - let userId = username; - let [userEmail] = (metadata as MetaWithSaml)?.saml_email || []; + async (context, request, response) => { + const { security } = await context.core; + const user = security.authc.getCurrentUser(); + + if (!user) { + // Hide the API from unauthenticated users + return response.notFound(); + } + + let userId = user.username; + let [userEmail] = (user.metadata as MetaWithSaml)?.saml_email || []; // In local development, these values are not populated. This is a workaround // to allow for local testing. diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json index 0e062b06b566..ffa21f10a6b4 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json +++ b/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json @@ -13,7 +13,6 @@ "kbn_references": [ "@kbn/core", "@kbn/cloud-plugin", - "@kbn/security-plugin", "@kbn/storybook", "@kbn/core-http-browser", "@kbn/i18n", @@ -21,6 +20,8 @@ "@kbn/ui-theme", "@kbn/cloud-experiments-plugin", "@kbn/react-kibana-context-render", + "@kbn/logging", + "@kbn/logging-mocks", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 57f1e465ea73..27fc64d44966 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -199,6 +199,6 @@ export const TEMPLATE_URL_ACCOUNT_TYPE_ENV_VAR = 'ACCOUNT_TYPE'; export const ORGANIZATION_ACCOUNT = 'organization-account'; export const SINGLE_ACCOUNT = 'single-account'; -export const CLOUD_SECURITY_PLUGIN_VERSION = '1.8.1'; +export const CLOUD_SECURITY_PLUGIN_VERSION = '1.9.0'; // Cloud Credentials Template url was implemented in 1.10.0-preview01. See PR - https://github.com/elastic/integrations/pull/9828 export const CLOUD_CREDENTIALS_PACKAGE_VERSION = '1.10.0-preview01'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts index 03517383ecc3..ae8ddb48488c 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts @@ -137,7 +137,7 @@ export const useCloudPostureDataTable = ({ return { setUrlQuery, sort: urlQuery.sort, - filters: urlQuery.filters, + filters: urlQuery.filters || [], query: baseEsQuery.query ? baseEsQuery.query : { diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx index eb7974c8da9b..d228edf84555 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx @@ -114,13 +114,13 @@ describe('<CspPolicyTemplateForm />', () => { const WrappedComponent = ({ newPolicy, edit = false, - agentPolicy, + agentPolicies, packageInfo = {} as PackageInfo, agentlessPolicy, }: { edit?: boolean; newPolicy: NewPackagePolicy; - agentPolicy?: AgentPolicy; + agentPolicies?: AgentPolicy[]; packageInfo?: PackageInfo; onChange?: jest.Mock<void, [NewPackagePolicy]>; agentlessPolicy?: AgentPolicy; @@ -136,7 +136,7 @@ describe('<CspPolicyTemplateForm />', () => { onChange={onChange} packageInfo={packageInfo} isEditPage={true} - agentPolicy={agentPolicy} + agentPolicies={agentPolicies} agentlessPolicy={agentlessPolicy} /> )} @@ -146,7 +146,7 @@ describe('<CspPolicyTemplateForm />', () => { onChange={onChange} packageInfo={packageInfo} isEditPage={false} - agentPolicy={agentPolicy} + agentPolicies={agentPolicies} agentlessPolicy={agentlessPolicy} /> )} diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx index ae1a1cb755af..cd49d80ae728 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx @@ -533,7 +533,7 @@ const IntegrationSettings = ({ onChange, fields }: IntegrationInfoFieldsProps) = export const CspPolicyTemplateForm = memo<PackagePolicyReplaceDefineStepExtensionComponentProps>( ({ - agentPolicy, + agentPolicies, newPolicy, onChange, validationResults, @@ -551,7 +551,7 @@ export const CspPolicyTemplateForm = memo<PackagePolicyReplaceDefineStepExtensio const input = getSelectedOption(newPolicy.inputs, integration); const { isAgentlessAvailable, setupTechnology, updateSetupTechnology } = useSetupTechnology({ input, - agentPolicy, + agentPolicies, agentlessPolicy, handleSetupTechnologyChange, isEditPage, diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.test.ts b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.test.ts index 7494a808dcdf..f08b1ad59b9b 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.test.ts @@ -74,10 +74,10 @@ describe('useSetupTechnology', () => { it('sets to AGENT_BASED when agentPolicyId differs from agentlessPolicyId', () => { const input = { type: CLOUDBEAT_AWS } as NewPackagePolicyInput; - const agentPolicy = { id: 'agentPolicyId' } as AgentPolicy; + const agentPolicies = [{ id: 'agentPolicyId' } as AgentPolicy]; const agentlessPolicy = { id: 'agentlessPolicyId' } as AgentPolicy; const { result } = renderHook(() => - useSetupTechnology({ input, agentPolicy, agentlessPolicy, isEditPage }) + useSetupTechnology({ input, agentPolicies, agentlessPolicy, isEditPage }) ); expect(result.current.setupTechnology).toBe(SetupTechnology.AGENT_BASED); }); @@ -115,11 +115,11 @@ describe('useSetupTechnology', () => { it('initializes with AGENTLESS technology if the agent policy id is "agentless"', () => { const input = { type: CLOUDBEAT_AWS } as NewPackagePolicyInput; - const agentPolicy = { id: 'agentless' } as AgentPolicy; + const agentPolicies = [{ id: 'agentless' } as AgentPolicy]; const { result } = renderHook(() => useSetupTechnology({ input, - agentPolicy, + agentPolicies, isEditPage, }) ); diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.ts b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.ts index eb18a90b7293..20c104fc071c 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/setup_technology_selector/use_setup_technology.ts @@ -12,13 +12,13 @@ import { CLOUDBEAT_AWS, CLOUDBEAT_GCP, CLOUDBEAT_AZURE } from '../../../../commo export const useSetupTechnology = ({ input, - agentPolicy, + agentPolicies, agentlessPolicy, handleSetupTechnologyChange, isEditPage, }: { input: NewPackagePolicyInput; - agentPolicy?: AgentPolicy; + agentPolicies?: AgentPolicy[]; agentlessPolicy?: AgentPolicy; handleSetupTechnologyChange?: (value: SetupTechnology) => void; isEditPage: boolean; @@ -28,10 +28,10 @@ export const useSetupTechnology = ({ const isCspmAzure = input.type === CLOUDBEAT_AZURE; const isAgentlessSupportedForCloudProvider = isCspmAws || isCspmGcp || isCspmAzure; const isAgentlessAvailable = Boolean(isAgentlessSupportedForCloudProvider && agentlessPolicy); - const agentPolicyId = agentPolicy?.id; + const agentPolicyIds = (agentPolicies || []).map((policy: AgentPolicy) => policy.id); const agentlessPolicyId = agentlessPolicy?.id; const [setupTechnology, setSetupTechnology] = useState<SetupTechnology>(() => { - if (isEditPage && agentPolicyId === SetupTechnology.AGENTLESS) { + if (isEditPage && agentPolicyIds.includes(SetupTechnology.AGENTLESS)) { return SetupTechnology.AGENTLESS; } @@ -50,7 +50,11 @@ export const useSetupTechnology = ({ return; } - if (agentPolicyId && agentPolicyId !== agentlessPolicyId) { + const hasAgentPolicies = agentPolicyIds.length > 0; + const agentlessPolicyIsAbsent = + !agentlessPolicyId || !agentPolicyIds.includes(agentlessPolicyId); + + if (hasAgentPolicies && agentlessPolicyIsAbsent) { /* handle case when agent policy is coming from outside, e.g. from the get param or when coming to integration from a specific agent policy @@ -65,7 +69,7 @@ export const useSetupTechnology = ({ } else { setSetupTechnology(SetupTechnology.AGENT_BASED); } - }, [agentPolicyId, agentlessPolicyId, isAgentlessAvailable, isDirty, isEditPage]); + }, [agentPolicyIds, agentlessPolicyId, isAgentlessAvailable, isDirty, isEditPage]); useEffect(() => { if (isEditPage) { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.handlers.mock.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.handlers.mock.ts index 38e4edf46f77..c5fb197583dd 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.handlers.mock.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.handlers.mock.ts @@ -11,6 +11,15 @@ import { isArray } from 'lodash'; import { http, HttpResponse } from 'msw'; import { v4 as uuidV4 } from 'uuid'; +export const generateMultipleCspFindings = ( + option: { count: number; failedCount?: number } = { count: 1, failedCount: 0 } +) => { + const failedCount = option.failedCount || 0; + return Array.from({ length: option?.count }, (_, i) => { + return generateCspFinding(i.toString(), i < failedCount ? 'failed' : 'passed'); + }); +}; + export const generateCspFinding = ( id: string, evaluation: 'failed' | 'passed' = 'passed' @@ -211,25 +220,36 @@ export const bsearchFindingsHandler = (findings: CspFinding[]) => filter[0]?.bool?.should?.[0]?.term?.['rule.section']?.value !== undefined; if (hasRuleSectionQuerySearchTerm) { - const filteredFindingJson = findings.filter((finding) => { + const filteredFindings = findings.filter((finding) => { const termValue = (filter[0].bool?.should as estypes.QueryDslQueryContainer[])?.[0]?.term?.[ 'rule.section' ]?.value; return finding.rule.section === termValue; }); - return HttpResponse.json(getFindingsBsearchResponse(filteredFindingJson)); + return HttpResponse.json(getFindingsBsearchResponse(filteredFindings)); } const hasRuleSectionFilter = isArray(filter) && filter?.[0]?.match_phrase?.['rule.section'] !== undefined; if (hasRuleSectionFilter) { - const filteredFindingJson = findings.filter((finding) => { + const filteredFindings = findings.filter((finding) => { return finding.rule.section === filter?.[0]?.match_phrase?.['rule.section']; }); - return HttpResponse.json(getFindingsBsearchResponse(filteredFindingJson)); + return HttpResponse.json(getFindingsBsearchResponse(filteredFindings)); + } + + const hasResultEvaluationFilter = + isArray(filter) && filter?.[0]?.match_phrase?.['result.evaluation'] !== undefined; + + if (hasResultEvaluationFilter) { + const filteredFindings = findings.filter((finding) => { + return finding.result.evaluation === filter?.[0]?.match_phrase?.['result.evaluation']; + }); + + return HttpResponse.json(getFindingsBsearchResponse(filteredFindings)); } return HttpResponse.json(getFindingsBsearchResponse(findings)); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx index 324eddd1fd8f..9ff1b40d49c7 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx @@ -22,6 +22,7 @@ import * as statusHandlers from '../../../server/routes/status/status.handlers.m import { bsearchFindingsHandler, generateCspFinding, + generateMultipleCspFindings, rulesGetStatesHandler, } from './configurations.handlers.mock'; @@ -247,4 +248,109 @@ describe('<Findings />', () => { expect(screen.getByText(finding2.resource.name)).toBeInTheDocument(); }); }); + + describe('DistributionBar', () => { + it('renders the distribution bar', async () => { + server.use(statusHandlers.indexedHandler); + server.use( + bsearchFindingsHandler( + generateMultipleCspFindings({ + count: 10, + failedCount: 3, + }) + ) + ); + + renderFindingsPage(); + + // Loading while checking the status API + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText(/10 findings/i)).toBeInTheDocument()); + + screen.getByRole('button', { + name: /passed findings: 7/i, + }); + screen.getByRole('button', { + name: /failed findings: 3/i, + }); + + // Assert that the distribution bar has the correct percentages rendered + expect(screen.getByTestId('distribution_bar_passed')).toHaveStyle('flex: 7'); + expect(screen.getByTestId('distribution_bar_failed')).toHaveStyle('flex: 3'); + }); + + it('filters by passed findings when clicking on the passed findings button', async () => { + server.use(statusHandlers.indexedHandler); + server.use( + bsearchFindingsHandler( + generateMultipleCspFindings({ + count: 2, + failedCount: 1, + }) + ) + ); + + renderFindingsPage(); + + // Loading while checking the status API + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText(/2 findings/i)).toBeInTheDocument()); + + const passedFindingsButton = screen.getByRole('button', { + name: /passed findings: 1/i, + }); + userEvent.click(passedFindingsButton); + + await waitFor(() => expect(screen.getByText(/1 findings/i)).toBeInTheDocument()); + + screen.getByRole('button', { + name: /passed findings: 1/i, + }); + screen.getByRole('button', { + name: /failed findings: 0/i, + }); + + // Assert that the distribution bar has the correct percentages rendered + expect(screen.getByTestId('distribution_bar_passed')).toHaveStyle('flex: 1'); + expect(screen.getByTestId('distribution_bar_failed')).toHaveStyle('flex: 0'); + }, 10000); + it('filters by failed findings when clicking on the failed findings button', async () => { + server.use(statusHandlers.indexedHandler); + server.use( + bsearchFindingsHandler( + generateMultipleCspFindings({ + count: 2, + failedCount: 1, + }) + ) + ); + + renderFindingsPage(); + + // Loading while checking the status API + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText(/2 findings/i)).toBeInTheDocument()); + + const failedFindingsButton = screen.getByRole('button', { + name: /failed findings: 1/i, + }); + userEvent.click(failedFindingsButton); + + await waitFor(() => expect(screen.getByText(/1 findings/i)).toBeInTheDocument()); + + screen.getByRole('button', { + name: /passed findings: 0/i, + }); + screen.getByRole('button', { + name: /failed findings: 1/i, + }); + + // Assert that the distribution bar has the correct percentages rendered + expect(screen.getByTestId('distribution_bar_passed')).toHaveStyle('flex: 0'); + expect(screen.getByTestId('distribution_bar_failed')).toHaveStyle('flex: 1'); + }, 10000); + }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx index 35f6fd008d4b..56ca9687551d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx @@ -11,7 +11,6 @@ import { EuiBadge, EuiSpacer, EuiFlexGroup, - EuiFlexItem, useEuiTheme, EuiTextColor, } from '@elastic/eui'; @@ -28,6 +27,14 @@ interface Props { distributionOnClick: (evaluation: Evaluation) => void; } +const I18N_PASSED_FINDINGS = i18n.translate('xpack.csp.findings.distributionBar.totalPassedLabel', { + defaultMessage: 'Passed Findings', +}); + +const I18N_FAILED_FINDINGS = i18n.translate('xpack.csp.findings.distributionBar.totalFailedLabel', { + defaultMessage: 'Failed Findings', +}); + export const CurrentPageOfTotal = ({ pageEnd, pageStart, @@ -60,42 +67,21 @@ export const FindingsDistributionBar = (props: Props) => ( <DistributionBar {...props} /> </div> ); - -const Counters = (props: Props) => ( - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <PassedFailedCounters {...props} /> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> -); - -const PassedFailedCounters = ({ passed, failed }: Pick<Props, 'passed' | 'failed'>) => { +const Counters = ({ passed, failed }: Pick<Props, 'passed' | 'failed'>) => { const { euiTheme } = useEuiTheme(); + return ( - <div + <EuiFlexGroup + justifyContent="flexEnd" css={css` - display: grid; - grid-template-columns: auto auto; - grid-column-gap: ${euiTheme.size.m}; + gap: ${euiTheme.size.m}; `} > - <Counter - label={i18n.translate('xpack.csp.findings.distributionBar.totalPassedLabel', { - defaultMessage: 'Passed Findings', - })} - color={statusColors.passed} - value={passed} - /> - <Counter - label={i18n.translate('xpack.csp.findings.distributionBar.totalFailedLabel', { - defaultMessage: 'Failed Findings', - })} - color={statusColors.failed} - value={failed} - /> - </div> + <EuiHealth color={statusColors.passed}>{I18N_PASSED_FINDINGS}</EuiHealth> + <EuiBadge>{getAbbreviatedNumber(passed)}</EuiBadge> + <EuiHealth color={statusColors.failed}>{I18N_FAILED_FINDINGS}</EuiHealth> + <EuiBadge>{getAbbreviatedNumber(failed)}</EuiBadge> + </EuiFlexGroup> ); }; @@ -121,6 +107,7 @@ const DistributionBar: React.FC<Omit<Props, 'pageEnd' | 'pageStart'>> = ({ distributionOnClick(RULE_PASSED); }} data-test-subj="distribution_bar_passed" + aria-label={`${I18N_PASSED_FINDINGS}: ${passed}`} /> <DistributionBarPart value={failed} @@ -129,6 +116,7 @@ const DistributionBar: React.FC<Omit<Props, 'pageEnd' | 'pageStart'>> = ({ distributionOnClick(RULE_FAILED); }} data-test-subj="distribution_bar_failed" + aria-label={`${I18N_FAILED_FINDINGS}: ${failed}`} /> </EuiFlexGroup> ); @@ -144,25 +132,18 @@ const DistributionBarPart = ({ color: string; distributionOnClick: () => void; ['data-test-subj']: string; + ['aria-label']: string; }) => ( <button data-test-subj={rest['data-test-subj']} + aria-label={rest['aria-label']} onClick={distributionOnClick} - css={css` - flex: ${value}; - background: ${color}; - height: 100%; - `} + css={{ + background: color, + height: '100%', + }} + style={{ + flex: value, + }} /> ); - -const Counter = ({ label, value, color }: { label: string; value: number; color: string }) => ( - <EuiFlexGroup gutterSize="s" alignItems="center"> - <EuiFlexItem grow={1}> - <EuiHealth color={color}>{label}</EuiHealth> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiBadge>{getAbbreviatedNumber(value)}</EuiBadge> - </EuiFlexItem> - </EuiFlexGroup> -); diff --git a/x-pack/plugins/data_quality/public/application.tsx b/x-pack/plugins/data_quality/public/application.tsx index 32ddc64eb00e..f022ab6d0efc 100644 --- a/x-pack/plugins/data_quality/public/application.tsx +++ b/x-pack/plugins/data_quality/public/application.tsx @@ -11,6 +11,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { Route, Router, Routes } from '@kbn/shared-ux-router'; +import { PerformanceContextProvider } from '@kbn/ebt-tools'; import { KbnUrlStateStorageFromRouterProvider } from './utils/kbn_url_state_context'; import { useKibanaContextForPluginProvider } from './utils/use_kibana'; import { AppPluginStartDependencies, DataQualityPluginStart } from './types'; @@ -52,9 +53,11 @@ const App = ({ core, plugins, pluginStart, params }: AppProps) => { <KibanaContextProviderForPlugin> <KbnUrlStateStorageFromRouterProvider> <Router history={params.history}> - <Routes> - <Route path="/" exact={true} render={() => <DatasetQualityRoute />} /> - </Routes> + <PerformanceContextProvider> + <Routes> + <Route path="/" exact={true} render={() => <DatasetQualityRoute />} /> + </Routes> + </PerformanceContextProvider> </Router> </KbnUrlStateStorageFromRouterProvider> </KibanaContextProviderForPlugin> diff --git a/x-pack/plugins/data_quality/tsconfig.json b/x-pack/plugins/data_quality/tsconfig.json index 59f25745ae3e..7a904e9f95cd 100644 --- a/x-pack/plugins/data_quality/tsconfig.json +++ b/x-pack/plugins/data_quality/tsconfig.json @@ -27,6 +27,7 @@ "@kbn/share-plugin", "@kbn/utility-types", "@kbn/deeplinks-management", + "@kbn/ebt-tools", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/data_visualizer/common/types/field_stats.ts b/x-pack/plugins/data_visualizer/common/types/field_stats.ts index 97a2739f34ae..b2e5c7a906c7 100644 --- a/x-pack/plugins/data_visualizer/common/types/field_stats.ts +++ b/x-pack/plugins/data_visualizer/common/types/field_stats.ts @@ -211,8 +211,8 @@ export function isValidFieldStats(arg: unknown): arg is FieldStats { export interface FieldStatsCommonRequestParams { index: string; timeFieldName?: string; - earliestMs?: number | undefined; - latestMs?: number | undefined; + earliestMs?: number | string | undefined; + latestMs?: number | string | undefined; runtimeFieldMap?: estypes.MappingRuntimeFields; intervalMs?: number; query: estypes.QueryDslQueryContainer; @@ -227,8 +227,8 @@ export type SupportedAggs = Set<string>; export interface OverallStatsSearchStrategyParams { sessionId?: string; - earliest?: number; - latest?: number; + earliest?: number | string; + latest?: number | string; aggInterval: TimeBucketsInterval; intervalMs?: number; searchQuery: Query['query']; diff --git a/x-pack/plugins/data_visualizer/common/utils/build_query_filters.ts b/x-pack/plugins/data_visualizer/common/utils/build_query_filters.ts new file mode 100644 index 000000000000..7fb2e3534648 --- /dev/null +++ b/x-pack/plugins/data_visualizer/common/utils/build_query_filters.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Query } from '@kbn/es-query'; +import { buildBaseFilterCriteria } from '@kbn/ml-query-utils'; + +export const buildFilterCriteria = ( + timeFieldName?: string, + earliestMs?: number | string, + latestMs?: number | string, + query?: Query['query'] +): estypes.QueryDslQueryContainer[] => { + return buildBaseFilterCriteria( + timeFieldName, + earliestMs, + latestMs, + query, + 'epoch_millis||strict_date_optional_time' + ); +}; diff --git a/x-pack/plugins/data_visualizer/kibana.jsonc b/x-pack/plugins/data_visualizer/kibana.jsonc index e80ccf5d3bb9..1ad88eaea4cb 100644 --- a/x-pack/plugins/data_visualizer/kibana.jsonc +++ b/x-pack/plugins/data_visualizer/kibana.jsonc @@ -29,6 +29,7 @@ "cloud" ], "requiredBundles": [ + "dataViews", "kibanaReact", "kibanaUtils", "maps", @@ -37,6 +38,7 @@ "uiActions", "lens", "textBasedLanguages", + "visualizations" ] } } diff --git a/x-pack/plugins/data_visualizer/public/application/common/hooks/use_document_count_stats.ts b/x-pack/plugins/data_visualizer/public/application/common/hooks/use_document_count_stats.ts index 86b436b0a69f..f345c85f57a4 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/hooks/use_document_count_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/hooks/use_document_count_stats.ts @@ -16,8 +16,8 @@ import { createRandomSamplerWrapper } from '@kbn/ml-random-sampler-utils'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import { each, get } from 'lodash'; import { lastValueFrom } from 'rxjs'; -import { buildBaseFilterCriteria } from '@kbn/ml-query-utils'; import useObservable from 'react-use/lib/useObservable'; +import { buildFilterCriteria } from '../../../../common/utils/build_query_filters'; import { useDataVisualizerKibana } from '../../kibana_context'; import { displayError } from '../util/display_error'; @@ -70,7 +70,7 @@ export const getDocumentCountStatsRequest = ( } = params; const size = 0; - const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery); + const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery); const rawAggs: Record<string, estypes.AggregationsAggregationContainer> = { eventRate: { diff --git a/x-pack/plugins/data_visualizer/public/application/common/types/data_visualizer_plugin.ts b/x-pack/plugins/data_visualizer/public/application/common/types/data_visualizer_plugin.ts index cb2e90f7d4ba..ee7e89f0779e 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/types/data_visualizer_plugin.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/types/data_visualizer_plugin.ts @@ -20,6 +20,7 @@ import type { LensPublicStart } from '@kbn/lens-plugin/public'; import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; export interface DataVisualizerSetupDependencies { home?: HomePublicPluginSetup; @@ -42,4 +43,5 @@ export interface DataVisualizerStartDependencies { uiActions?: UiActionsStart; cloud?: CloudStart; savedSearch: SavedSearchPublicPluginStart; + usageCollection?: UsageCollectionStart; } diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index c6e66b4e291a..e1aae26be3a9 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -73,7 +73,7 @@ import { RANDOM_SAMPLER_OPTION, type RandomSamplerOption, } from '../../constants/random_sampler'; -import type { FieldStatisticsTableEmbeddableState } from '../../embeddables/grid_embeddable/types'; +import type { FieldStatisticTableEmbeddableProps } from '../../embeddables/grid_embeddable/types'; const defaultSearchQuery = { match_all: {}, @@ -217,7 +217,7 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi }); }; - const input: Required<FieldStatisticsTableEmbeddableState, 'dataView'> = useMemo(() => { + const input: Required<FieldStatisticTableEmbeddableProps, 'dataView'> = useMemo(() => { return { dataView: currentDataView, savedSearch: currentSavedSearch, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/constants.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/constants.ts new file mode 100644 index 000000000000..790b0edde4a5 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/constants.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. + */ + +export const FIELD_STATS_EMBEDDABLE_TYPE = 'field_stats_table'; +export const FIELD_STATS_DATA_VIEW_REF_NAME = 'fieldStatsTableDataViewId'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_esql_editor.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_esql_editor.tsx new file mode 100644 index 000000000000..bdaee8c1a5ae --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_esql_editor.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useRef, useState, useCallback } from 'react'; +import { TextBasedLangEditor } from '@kbn/text-based-languages/public'; +import { EuiFlexItem } from '@elastic/eui'; +import type { AggregateQuery } from '@kbn/es-query'; + +const expandCodeEditor = (status: boolean) => {}; + +interface FieldStatsESQLEditorProps { + canEditTextBasedQuery?: boolean; + query: AggregateQuery; + setQuery: (query: AggregateQuery) => void; + onQuerySubmit: (query: AggregateQuery, abortController: AbortController) => Promise<void>; +} +export const FieldStatsESQLEditor = ({ + canEditTextBasedQuery = true, + query, + setQuery, + onQuerySubmit, +}: FieldStatsESQLEditorProps) => { + const prevQuery = useRef<AggregateQuery>(query); + const [isVisualizationLoading, setIsVisualizationLoading] = useState(false); + + const onTextLangQuerySubmit = useCallback( + async (q, abortController) => { + if (q && onQuerySubmit) { + setIsVisualizationLoading(true); + await onQuerySubmit(q, abortController); + setIsVisualizationLoading(false); + } + }, + [onQuerySubmit] + ); + + if (!canEditTextBasedQuery) return null; + + return ( + <EuiFlexItem grow={false} data-test-subj="InlineEditingESQLEditor"> + <TextBasedLangEditor + query={query} + onTextLangQueryChange={(q) => { + setQuery(q); + prevQuery.current = q; + }} + expandCodeEditor={expandCodeEditor} + isCodeEditorExpanded + hideMinimizeButton + editorIsInline + hideRunQueryText + onTextLangQuerySubmit={onTextLangQuerySubmit} + isDisabled={false} + allowQueryCancellation + isLoading={isVisualizationLoading} + /> + </EuiFlexItem> + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_factory.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_factory.tsx new file mode 100644 index 000000000000..8e800d5d6176 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_factory.tsx @@ -0,0 +1,392 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { Reference } from '@kbn/content-management-utils'; +import type { StartServicesAccessor } from '@kbn/core-lifecycle-browser'; +import { + APPLY_FILTER_TRIGGER, + generateFilters, + type DataPublicPluginStart, +} from '@kbn/data-plugin/public'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; +import type { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { + apiHasExecutionContext, + fetch$, + initializeTimeRange, + initializeTitles, + useBatchedPublishingSubjects, + useFetchContext, +} from '@kbn/presentation-publishing'; +import { cloneDeep } from 'lodash'; +import React, { useEffect } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { + BehaviorSubject, + map, + skipWhile, + Subscription, + skip, + switchMap, + distinctUntilChanged, +} from 'rxjs'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { dynamic } from '@kbn/shared-ux-utility'; +import { isDefined } from '@kbn/ml-is-defined'; +import { EuiFlexItem } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import type { Filter } from '@kbn/es-query'; +import { FilterStateStore } from '@kbn/es-query'; +import { getESQLAdHocDataview, getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; +import { ACTION_GLOBAL_APPLY_FILTER } from '@kbn/unified-search-plugin/public'; +import type { DataVisualizerTableState } from '../../../../../common/types'; +import type { DataVisualizerPluginStart } from '../../../../plugin'; +import type { FieldStatisticsTableEmbeddableState } from '../grid_embeddable/types'; +import { FieldStatsInitializerViewType } from '../grid_embeddable/types'; +import { FIELD_STATS_EMBEDDABLE_TYPE, FIELD_STATS_DATA_VIEW_REF_NAME } from './constants'; +import { initializeFieldStatsControls } from './initialize_field_stats_controls'; +import type { DataVisualizerStartDependencies } from '../../../common/types/data_visualizer_plugin'; +import type { FieldStatisticsTableEmbeddableApi } from './types'; +import { isESQLQuery } from '../../search_strategy/requests/esql_utils'; + +export interface EmbeddableFieldStatsChartStartServices { + data: DataPublicPluginStart; +} +export type EmbeddableFieldStatsChartType = typeof FIELD_STATS_EMBEDDABLE_TYPE; + +const FieldStatisticsWrapper = dynamic(() => import('../grid_embeddable/field_stats_wrapper')); + +const ERROR_MSG = { + APPLY_FILTER_ERR: i18n.translate('xpack.dataVisualizer.fieldStats.errors.errorApplyingFilter', { + defaultMessage: 'Error applying filter', + }), + UPDATE_CONFIG_ERROR: i18n.translate( + 'xpack.dataVisualizer.fieldStats.errors.errorUpdatingConfig', + { + defaultMessage: 'Error updating settings for field statistics.', + } + ), +}; + +export const getDependencies = async ( + getStartServices: StartServicesAccessor< + DataVisualizerStartDependencies, + DataVisualizerPluginStart + > +) => { + const [ + { http, uiSettings, notifications, ...startServices }, + { lens, data, usageCollection, fieldFormats }, + ] = await getStartServices(); + + return { + http, + uiSettings, + data, + notifications, + lens, + usageCollection, + fieldFormats, + ...startServices, + }; +}; + +export const getFieldStatsChartEmbeddableFactory = ( + getStartServices: StartServicesAccessor< + DataVisualizerStartDependencies, + DataVisualizerPluginStart + > +) => { + const factory: ReactEmbeddableFactory< + FieldStatisticsTableEmbeddableState, + FieldStatisticsTableEmbeddableState, + FieldStatisticsTableEmbeddableApi + > = { + type: FIELD_STATS_EMBEDDABLE_TYPE, + deserializeState: (state) => { + const serializedState = cloneDeep(state.rawState); + // inject the reference + const dataViewIdRef = state.references?.find( + (ref) => ref.name === FIELD_STATS_DATA_VIEW_REF_NAME + ); + // if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this) + if (dataViewIdRef && serializedState && !serializedState.dataViewId) { + serializedState.dataViewId = dataViewIdRef?.id; + } + return serializedState; + }, + buildEmbeddable: async (state, buildApi, uuid, parentApi) => { + const [coreStart, pluginStart] = await getStartServices(); + + const { http, uiSettings, notifications, ...startServices } = coreStart; + const { lens, data, usageCollection, fieldFormats } = pluginStart; + + const deps = { + http, + uiSettings, + data, + notifications, + lens, + usageCollection, + fieldFormats, + ...startServices, + }; + const { + api: timeRangeApi, + comparators: timeRangeComparators, + serialize: serializeTimeRange, + } = initializeTimeRange(state); + + const { titlesApi, titleComparators, serializeTitles } = initializeTitles(state); + + const { + fieldStatsControlsApi, + dataLoadingApi, + fieldStatsControlsComparators, + serializeFieldStatsChartState, + onFieldStatsTableDestroy, + resetData$, + } = initializeFieldStatsControls(state); + const { onError, dataLoading, blockingError } = dataLoadingApi; + + const defaultDataViewId = await deps.data.dataViews.getDefaultId(); + const validDataViewId: string = + isDefined(state.dataViewId) && state.dataViewId !== '' + ? state.dataViewId + : defaultDataViewId ?? ''; + let initialDataView: DataView[] | undefined; + try { + const dataView = isESQLQuery(state.query) + ? await getESQLAdHocDataview( + getIndexPatternFromESQLQuery(state.query.esql), + deps.data.dataViews + ) + : await deps.data.dataViews.get(validDataViewId); + initialDataView = [dataView]; + } catch (error) { + // Only need to publish blocking error if viewtype is data view, and no data view found + if (state.viewType === FieldStatsInitializerViewType.DATA_VIEW) { + onError(error); + } + } + + const dataViews$ = new BehaviorSubject<DataView[] | undefined>(initialDataView); + + const subscriptions = new Subscription(); + if (fieldStatsControlsApi.dataViewId$) { + subscriptions.add( + fieldStatsControlsApi.dataViewId$ + .pipe( + skip(1), + skipWhile((dataViewId) => !dataViewId && !defaultDataViewId), + switchMap(async (dataViewId) => { + try { + return await deps.data.dataViews.get(dataViewId ?? defaultDataViewId); + } catch (error) { + return undefined; + } + }) + ) + .subscribe((nextSelectedDataView) => { + if (nextSelectedDataView) { + dataViews$.next([nextSelectedDataView]); + } + }) + ); + } + + const { toasts } = deps.notifications; + + const api = buildApi( + { + ...timeRangeApi, + ...titlesApi, + ...fieldStatsControlsApi, + // PublishesDataLoading + dataLoading, + // PublishesBlockingError + blockingError, + getTypeDisplayName: () => + i18n.translate('xpack.dataVisualizer.fieldStats.typeDisplayName', { + defaultMessage: 'field statistics', + }), + isEditingEnabled: () => true, + onEdit: async () => { + try { + const { resolveEmbeddableFieldStatsUserInput } = await import( + './resolve_field_stats_embeddable_input' + ); + const chartState = serializeFieldStatsChartState(); + const nextUpdate = await resolveEmbeddableFieldStatsUserInput( + coreStart, + pluginStart, + parentApi, + uuid, + false, + chartState, + fieldStatsControlsApi + ); + fieldStatsControlsApi.updateUserInput(nextUpdate); + } catch (e) { + toasts.addError(e, { title: ERROR_MSG.UPDATE_CONFIG_ERROR }); + } + }, + dataViews: dataViews$, + serializeState: () => { + const dataViewId = fieldStatsControlsApi.dataViewId$?.getValue(); + const references: Reference[] = dataViewId + ? [ + { + type: DATA_VIEW_SAVED_OBJECT_TYPE, + name: FIELD_STATS_DATA_VIEW_REF_NAME, + id: dataViewId, + }, + ] + : []; + return { + rawState: { + ...serializeTitles(), + ...serializeTimeRange(), + ...serializeFieldStatsChartState(), + }, + references, + }; + }, + }, + { + ...timeRangeComparators, + ...titleComparators, + ...fieldStatsControlsComparators, + } + ); + + const reload$ = fetch$(api).pipe( + skipWhile((fetchContext) => !fetchContext.isReload), + map(() => Date.now()) + ); + const reset$ = resetData$.pipe(skip(1), distinctUntilChanged()); + + const onTableUpdate = (changes: Partial<DataVisualizerTableState>) => { + if (isDefined(changes?.showDistributions)) { + fieldStatsControlsApi.showDistributions$.next(changes.showDistributions); + } + }; + + const addFilters = (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => { + if (!pluginStart.uiActions) { + toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR); + return; + } + const trigger = pluginStart.uiActions.getTrigger(APPLY_FILTER_TRIGGER); + if (!trigger) { + toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR); + return; + } + const actionContext = { + embeddable: api, + trigger, + } as ActionExecutionContext; + + const executeContext = { + ...actionContext, + filters, + }; + try { + const action = pluginStart.uiActions.getAction(actionId); + action.execute(executeContext); + } catch (error) { + toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR); + } + }; + + const statsTableCss = css({ + width: '100%', + height: '100%', + overflowY: 'auto', + }); + + return { + api, + Component: () => { + if (!apiHasExecutionContext(parentApi)) { + onError(new Error('Parent API does not have execution context')); + } + + const { filters: globalFilters, query: globalQuery, timeRange } = useFetchContext(api); + const [dataViews, esqlQuery, viewType, showPreviewByDefault] = + useBatchedPublishingSubjects( + api.dataViews, + api.query$, + api.viewType$, + api.showDistributions$ + ); + const lastReloadRequestTime = useObservable(reload$, Date.now()); + + const isEsqlMode = viewType === FieldStatsInitializerViewType.ESQL; + + const dataView = + Array.isArray(dataViews) && dataViews.length > 0 ? dataViews[0] : undefined; + const onAddFilter = ( + field: string | DataViewField, + value: string, + operator: '+' | '-' + ) => { + if (!dataView || !pluginStart.data) { + toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR); + return; + } + + let filters = generateFilters( + pluginStart.data.query.filterManager, + field, + value, + operator, + dataView + ); + filters = filters.map((filter) => ({ + ...filter, + $state: { store: FilterStateStore.APP_STATE }, + })); + addFilters(filters); + }; + + // On destroy + useEffect(() => { + return () => { + subscriptions?.unsubscribe(); + onFieldStatsTableDestroy(); + }; + }, []); + + return ( + <EuiFlexItem css={statsTableCss} data-test-subj="dashboardFieldStatsEmbeddedContent"> + <FieldStatisticsWrapper + shouldGetSubfields={false} + dataView={dataView} + esqlQuery={esqlQuery} + query={globalQuery} + filters={globalFilters} + lastReloadRequestTime={lastReloadRequestTime} + isEsqlMode={isEsqlMode} + onTableUpdate={onTableUpdate} + showPreviewByDefault={showPreviewByDefault} + onAddFilter={onAddFilter} + resetData$={reset$} + timeRange={timeRange} + onRenderComplete={dataLoadingApi.onRenderComplete} + /> + </EuiFlexItem> + ); + }, + }; + }, + }; + + return factory; +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_initializer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_initializer.tsx new file mode 100644 index 000000000000..049406bd5577 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_initializer.tsx @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, + EuiButtonEmpty, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiTitle, + EuiSpacer, + EuiIconTip, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { FC } from 'react'; +import { useEffect } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; +import { ENABLE_ESQL, getESQLAdHocDataview, getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; +import type { AggregateQuery } from '@kbn/es-query'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { useDataVisualizerKibana } from '../../../kibana_context'; +import { FieldStatsESQLEditor } from './field_stats_esql_editor'; +import type { + FieldStatisticsTableEmbeddableState, + FieldStatsInitialState, +} from '../grid_embeddable/types'; +import { FieldStatsInitializerViewType } from '../grid_embeddable/types'; +import { isESQLQuery } from '../../search_strategy/requests/esql_utils'; +import { DataSourceTypeSelector } from './field_stats_initializer_view_type'; + +export interface FieldStatsInitializerProps { + initialInput?: Partial<FieldStatisticsTableEmbeddableState>; + onCreate: (props: FieldStatsInitialState) => Promise<void>; + onCancel: () => void; + onPreview: (update: Partial<FieldStatsInitialState>) => Promise<void>; + isNewPanel: boolean; +} + +const defaultESQLQuery = { esql: '' }; +const defaultTitle = i18n.translate('xpack.dataVisualizer.fieldStatistics.displayName', { + defaultMessage: 'Field statistics', +}); + +const isScrollable = false; +export const FieldStatisticsInitializer: FC<FieldStatsInitializerProps> = ({ + initialInput, + onCreate, + onCancel, + onPreview, + isNewPanel, +}) => { + const { + data: { dataViews }, + unifiedSearch: { + ui: { IndexPatternSelect }, + }, + uiSettings, + } = useDataVisualizerKibana().services; + + const [dataViewId, setDataViewId] = useState(initialInput?.dataViewId ?? ''); + const [viewType, setViewType] = useState( + initialInput?.viewType ?? FieldStatsInitializerViewType.DATA_VIEW + ); + const [esqlQuery, setQuery] = useState<AggregateQuery>(initialInput?.query ?? defaultESQLQuery); + const isEsqlEnabled = useMemo(() => uiSettings.get(ENABLE_ESQL), [uiSettings]); + + useEffect(() => { + if (initialInput?.viewType === undefined) { + // By default, if ES|QL is enabled, then use ES|QL + setViewType( + isEsqlEnabled ? FieldStatsInitializerViewType.ESQL : FieldStatsInitializerViewType.DATA_VIEW + ); + } + }, [isEsqlEnabled, initialInput?.viewType]); + + const isEsqlMode = viewType === FieldStatsInitializerViewType.ESQL; + const updatedProps = useMemo(() => { + return { + viewType, + title: initialInput?.title ?? defaultTitle, + dataViewId, + query: isEsqlMode ? esqlQuery : undefined, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataViewId, viewType, esqlQuery.esql, isEsqlMode]); + const onESQLQuerySubmit = useCallback( + async (query: AggregateQuery, abortController: AbortController) => { + const adhocDataView = await getESQLAdHocDataview( + getIndexPatternFromESQLQuery(query.esql), + dataViews + ); + if (adhocDataView && adhocDataView.id) { + setDataViewId(adhocDataView.id); + } + + await onPreview({ + viewType, + dataViewId: adhocDataView?.id, + query, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isEsqlMode] + ); + const isEsqlFormValid = isEsqlMode ? isEsqlEnabled && isESQLQuery(esqlQuery) : true; + const isDataViewFormValid = + viewType === FieldStatsInitializerViewType.DATA_VIEW ? dataViewId !== '' : true; + + return ( + <> + <EuiFlyoutHeader + hasBorder={true} + css={css` + pointer-events: auto; + background-color: ${euiThemeVars.euiColorEmptyShade}; + `} + data-test-subj="editFlyoutHeader" + > + <EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}> + <EuiFlexItem grow={false}> + <EuiTitle size="xs" data-test-subj="inlineEditingFlyoutLabel"> + <h2> + {isNewPanel + ? i18n.translate( + 'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.createLable', + { + defaultMessage: 'Create field statistics', + } + ) + : i18n.translate( + 'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.editLabel', + { + defaultMessage: 'Edit field statistics', + } + )}{' '} + <EuiIconTip + type="iInCircle" + content={i18n.translate( + 'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.samplingTooltip', + { + defaultMessage: + 'Field statistics uses the random sampling aggregation to increase performance, but some accuracy might be lost.', + } + )} + /> + </h2> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutHeader> + <EuiFlyoutBody + className="lnsEditFlyoutBody" + css={css` + // styles needed to display extra drop targets that are outside of the config panel main area + overflow-y: auto; + padding-left: ${euiThemeVars.euiFormMaxWidth}; + margin-left: -${euiThemeVars.euiFormMaxWidth}; + pointer-events: none; + .euiFlyoutBody__overflow { + -webkit-mask-image: none; + padding-left: inherit; + margin-left: inherit; + ${!isScrollable && + ` + overflow-y: hidden; + `} + > * { + pointer-events: auto; + } + } + .euiFlyoutBody__overflowContent { + padding: 0; + block-size: 100%; + } + border-bottom: 2px solid ${euiThemeVars.euiBorderColor}; + `} + > + <EuiFlexGroup + css={css` + block-size: 100%; + `} + direction="column" + gutterSize="none" + > + {isNewPanel ? ( + <EuiCallOut + size="s" + iconType="iInCircle" + title={ + <FormattedMessage + id="xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.description" + defaultMessage="The visualization displays summarized information and statistics to show how each field in your data is populated." + /> + } + /> + ) : null} + {initialInput?.viewType === FieldStatsInitializerViewType.ESQL && !isEsqlEnabled ? ( + <> + <DataSourceTypeSelector value={viewType} onChange={setViewType} /> + </> + ) : null} + {viewType === FieldStatsInitializerViewType.ESQL && !isEsqlEnabled ? ( + <> + <EuiSpacer size="m" /> + <EuiCodeBlock>{esqlQuery.esql}</EuiCodeBlock> + </> + ) : null} + + {viewType === FieldStatsInitializerViewType.DATA_VIEW ? ( + <EuiFormRow + fullWidth + label={i18n.translate( + 'xpack.dataVisualizer.fieldStatisticsDashboardPanel.dataViewLabel', + { + defaultMessage: 'Data view', + } + )} + > + <IndexPatternSelect + autoFocus={!dataViewId} + fullWidth + compressed + indexPatternId={dataViewId} + placeholder={i18n.translate( + 'xpack.dataVisualizer.fieldStatisticsDashboardPanel.dataViewSelectorPlaceholder', + { + defaultMessage: 'Select data view', + } + )} + onChange={(newId) => { + setDataViewId(newId ?? ''); + }} + /> + </EuiFormRow> + ) : null} + {isEsqlMode && isEsqlEnabled ? ( + <FieldStatsESQLEditor + query={esqlQuery} + setQuery={setQuery} + onQuerySubmit={onESQLQuerySubmit} + /> + ) : null} + </EuiFlexGroup> + </EuiFlyoutBody> + + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + onClick={onCancel} + data-test-subj="fieldStatsInitializerCancelButton" + flush="left" + aria-label={i18n.translate( + 'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.cancelButtonAriaLabel', + { + defaultMessage: 'Cancel applied changes', + } + )} + > + <FormattedMessage + id="xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.cancelButtonLabel" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + onClick={onCreate.bind(null, updatedProps)} + fill + aria-label={i18n.translate( + 'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.applyFlyoutAriaLabel', + { + defaultMessage: 'Apply changes', + } + )} + disabled={!isEsqlFormValid || !isDataViewFormValid} + iconType="check" + data-test-subj="applyFlyoutButton" + > + <FormattedMessage + id="xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.applyAndCloseLabel" + defaultMessage="Apply and close" + /> + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + </> + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_initializer_view_type.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_initializer_view_type.tsx new file mode 100644 index 000000000000..d061fa259ece --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_initializer_view_type.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { FC } from 'react'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonGroup, EuiFormRow, type EuiButtonGroupOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FieldStatsInitializerViewType } from '../grid_embeddable/types'; + +const viewTypeOptions: EuiButtonGroupOptionProps[] = [ + { + id: FieldStatsInitializerViewType.DATA_VIEW, + label: ( + <FormattedMessage + id="xpack.dataVisualizer.fieldStatsDashboardPanel.dataSourceSelector.dataViewLabel" + defaultMessage="Data view" + /> + ), + iconType: 'visLine', + }, + { + id: FieldStatsInitializerViewType.ESQL, + label: ( + <FormattedMessage + id="xpack.dataVisualizer.fieldStatsDashboardPanel.dataSourceSelector.esqlLabel" + defaultMessage="ES|QL" + /> + ), + iconType: 'visTable', + }, +]; + +const dataSourceLabel = i18n.translate( + 'xpack.dataVisualizer.fieldStatsDashboardPanel.dataSourceLabel', + { + defaultMessage: 'Data source', + } +); + +const dataSourceAriaLabel = i18n.translate( + 'xpack.dataVisualizer.fieldStatsDashboardPanel.viewTypeLabel', + { + defaultMessage: 'Pick type of data source to use', + } +); + +export interface ViewTypeSelectorProps { + value: FieldStatsInitializerViewType; + onChange: (update: FieldStatsInitializerViewType) => void; +} + +export const DataSourceTypeSelector: FC<ViewTypeSelectorProps> = ({ value, onChange }) => { + return ( + <EuiFormRow fullWidth label={dataSourceLabel}> + <EuiButtonGroup + isFullWidth + aria-label={dataSourceAriaLabel} + options={viewTypeOptions} + idSelected={value} + onChange={onChange as (id: string) => void} + legend={dataSourceAriaLabel} + /> + </EuiFormRow> + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/index.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/index.ts new file mode 100644 index 000000000000..d976e89af2c2 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; +import type { DataVisualizerCoreSetup } from '../../../../plugin'; +import { FIELD_STATS_EMBEDDABLE_TYPE } from './constants'; + +export const registerEmbeddables = (embeddable: EmbeddableSetup, core: DataVisualizerCoreSetup) => { + embeddable.registerReactEmbeddableFactory(FIELD_STATS_EMBEDDABLE_TYPE, async () => { + const { getFieldStatsChartEmbeddableFactory } = await import('./field_stats_factory'); + return getFieldStatsChartEmbeddableFactory(core.getStartServices); + }); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/initialize_field_stats_controls.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/initialize_field_stats_controls.ts new file mode 100644 index 000000000000..c6709825f669 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/initialize_field_stats_controls.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { AggregateQuery } from '@kbn/es-query'; +import type { StateComparators } from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; +import fastIsEqual from 'fast-deep-equal'; +import { FieldStatsInitializerViewType } from '../grid_embeddable/types'; +import type { FieldStatsInitialState } from '../grid_embeddable/types'; +import type { FieldStatsControlsApi } from './types'; + +export const initializeFieldStatsControls = (rawState: FieldStatsInitialState) => { + const viewType$ = new BehaviorSubject<FieldStatsInitializerViewType | undefined>( + rawState.viewType ?? FieldStatsInitializerViewType.ESQL + ); + const dataViewId$ = new BehaviorSubject<string | undefined>(rawState.dataViewId); + const query$ = new BehaviorSubject<AggregateQuery | undefined>(rawState.query); + const showDistributions$ = new BehaviorSubject<boolean | undefined>(rawState.showDistributions); + const resetData$ = new BehaviorSubject<number>(Date.now()); + + const dataLoading$ = new BehaviorSubject<boolean | undefined>(true); + const blockingError = new BehaviorSubject<Error | undefined>(undefined); + + const updateUserInput = (update: FieldStatsInitialState, shouldResetData = false) => { + if (shouldResetData) { + resetData$.next(Date.now()); + } + viewType$.next(update.viewType); + dataViewId$.next(update.dataViewId); + query$.next(update.query); + }; + + const serializeFieldStatsChartState = (): FieldStatsInitialState => { + return { + viewType: viewType$.getValue(), + dataViewId: dataViewId$.getValue(), + query: query$.getValue(), + showDistributions: showDistributions$.getValue(), + }; + }; + + const fieldStatsControlsComparators: StateComparators<FieldStatsInitialState> = { + viewType: [viewType$, (arg) => viewType$.next(arg)], + dataViewId: [dataViewId$, (arg) => dataViewId$.next(arg)], + query: [query$, (arg) => query$.next(arg), fastIsEqual], + showDistributions: [showDistributions$, (arg) => showDistributions$.next(arg)], + }; + + const onRenderComplete = () => dataLoading$.next(false); + const onLoading = (v: boolean) => dataLoading$.next(v); + const onError = (error?: Error) => blockingError.next(error); + + return { + fieldStatsControlsApi: { + viewType$, + dataViewId$, + query$, + updateUserInput, + showDistributions$, + } as unknown as FieldStatsControlsApi, + dataLoadingApi: { + dataLoading: dataLoading$, + onRenderComplete, + onLoading, + onError, + blockingError, + }, + // Reset data is internal state management, so no need to expose this in api + resetData$, + serializeFieldStatsChartState, + fieldStatsControlsComparators, + onFieldStatsTableDestroy: () => { + viewType$.complete(); + dataViewId$.complete(); + query$.complete(); + resetData$.complete(); + }, + }; +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/resolve_field_stats_embeddable_input.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/resolve_field_stats_embeddable_input.tsx new file mode 100644 index 000000000000..893e77c12e80 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/resolve_field_stats_embeddable_input.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 type { CoreStart } from '@kbn/core/public'; +import { tracksOverlays } from '@kbn/presentation-containers'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import React from 'react'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; +import { isDefined } from '@kbn/ml-is-defined'; +import { FieldStatisticsInitializer } from './field_stats_initializer'; +import type { DataVisualizerStartDependencies } from '../../../common/types/data_visualizer_plugin'; +import type { + FieldStatisticsTableEmbeddableState, + FieldStatsInitialState, +} from '../grid_embeddable/types'; +import type { FieldStatsControlsApi } from './types'; +import { getOrCreateDataViewByIndexPattern } from '../../search_strategy/requests/get_data_view_by_index_pattern'; + +export async function resolveEmbeddableFieldStatsUserInput( + coreStart: CoreStart, + pluginStart: DataVisualizerStartDependencies, + parentApi: unknown, + focusedPanelId: string, + isNewPanel: boolean, + initialState?: FieldStatisticsTableEmbeddableState, + fieldStatsControlsApi?: FieldStatsControlsApi +): Promise<FieldStatisticsTableEmbeddableState> { + const { overlays } = coreStart; + + const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined; + const services = { + ...coreStart, + ...pluginStart, + }; + + let hasChanged = false; + return new Promise(async (resolve, reject) => { + try { + const cancelChanges = () => { + // Reset to initialState in case user has changed the preview state + if (hasChanged && fieldStatsControlsApi && initialState) { + fieldStatsControlsApi.updateUserInput(initialState); + } + + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + + const update = async (nextUpdate: FieldStatsInitialState) => { + const esqlQuery = nextUpdate?.query?.esql; + if (isDefined(esqlQuery)) { + const indexPatternFromQuery = getIndexPatternFromESQLQuery(esqlQuery); + const dv = await getOrCreateDataViewByIndexPattern( + pluginStart.data.dataViews, + indexPatternFromQuery, + undefined + ); + if (dv?.id && nextUpdate.dataViewId !== dv.id) { + nextUpdate.dataViewId = dv.id; + } + } + + resolve(nextUpdate); + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + + const flyoutSession = overlays.openFlyout( + toMountPoint( + <KibanaContextProvider services={services}> + <FieldStatisticsInitializer + initialInput={initialState} + onPreview={async (nextUpdate) => { + if (fieldStatsControlsApi) { + fieldStatsControlsApi.updateUserInput(nextUpdate); + hasChanged = true; + } + }} + onCreate={update} + onCancel={cancelChanges} + isNewPanel={isNewPanel} + /> + </KibanaContextProvider>, + coreStart + ), + { + ownFocus: true, + size: 's', + paddingSize: 'm', + hideCloseButton: true, + type: 'push', + 'data-test-subj': 'fieldStatisticsInitializerFlyout', + onClose: cancelChanges, + } + ); + + if (tracksOverlays(parentApi)) { + parentApi.openOverlay(flyoutSession, { focusedPanelId }); + } + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/types.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/types.ts new file mode 100644 index 000000000000..c56a2b280f1e --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import type { AggregateQuery } from '@kbn/es-query'; +import type { + HasEditCapabilities, + PublishesBlockingError, + PublishesDataLoading, + PublishesDataViews, + PublishesTimeRange, +} from '@kbn/presentation-publishing'; +import type { BehaviorSubject } from 'rxjs'; +import type { + FieldStatisticsTableEmbeddableState, + FieldStatsInitializerViewType, + FieldStatsInitialState, +} from '../grid_embeddable/types'; + +export interface FieldStatsControlsApi { + viewType$: BehaviorSubject<FieldStatsInitializerViewType>; + dataViewId$: BehaviorSubject<string>; + query$: BehaviorSubject<AggregateQuery>; + showDistributions$: BehaviorSubject<boolean>; + updateUserInput: (update: Partial<FieldStatsInitialState>) => void; +} + +export type FieldStatisticsTableEmbeddableApi = + DefaultEmbeddableApi<FieldStatisticsTableEmbeddableState> & + HasEditCapabilities & + PublishesDataViews & + PublishesTimeRange & + PublishesDataLoading & + PublishesBlockingError & + FieldStatsControlsApi; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_esql_field_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_esql_field_stats_table.tsx index b4bcd3c0da5a..3cf282e3da1a 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_esql_field_stats_table.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_esql_field_stats_table.tsx @@ -23,7 +23,7 @@ const restorableDefaults = getDefaultESQLDataVisualizerListState(); const EmbeddableESQLFieldStatsTableWrapper = React.memo( (props: ESQLDataVisualizerGridEmbeddableState) => { - const { onTableUpdate } = props; + const { onTableUpdate, onRenderComplete } = props; const [dataVisualizerListState, setDataVisualizerListState] = useState<Required<ESQLDataVisualizerIndexBasedAppState>>(restorableDefaults); @@ -44,15 +44,30 @@ const EmbeddableESQLFieldStatsTableWrapper = React.memo( overallStatsProgress, setLastRefresh, getItemIdToExpandedRowMap, + resetData, } = useESQLDataVisualizerData(props, dataVisualizerListState); useEffect(() => { setLastRefresh(Date.now()); }, [props?.lastReloadRequestTime, setLastRefresh]); + useEffect(() => { + const subscription = props.resetData$?.subscribe(() => { + resetData(); + }); + return () => subscription?.unsubscribe(); + }, [props.resetData$, resetData]); + + useEffect(() => { + if (progress === 100 && onRenderComplete) { + onRenderComplete(); + } + }, [progress, onRenderComplete]); + if (progress === 100 && configs.length === 0) { return <EmbeddableNoResultsEmptyPrompt />; } + return ( <DataVisualizerTable<FieldVisConfig> items={configs} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx index 5c40b9e9c94f..916e21a8b892 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx @@ -23,7 +23,7 @@ const restorableDefaults = getDefaultDataVisualizerListState(); const EmbeddableFieldStatsTableWrapper = ( props: Required<FieldStatisticTableEmbeddableProps, 'dataView'> ) => { - const { onTableUpdate, onAddFilter } = props; + const { onTableUpdate, onAddFilter, onRenderComplete } = props; const [dataVisualizerListState, setDataVisualizerListState] = useState<Required<DataVisualizerIndexBasedAppState>>(restorableDefaults); @@ -73,6 +73,12 @@ const EmbeddableFieldStatsTableWrapper = ( [props.dataView, searchQueryLanguage, searchString, props.totalDocuments, onAddFilter] ); + useEffect(() => { + if (progress === 100 && onRenderComplete) { + onRenderComplete(); + } + }, [progress, onRenderComplete]); + if (progress === 100 && configs.length === 0) { return <EmbeddableNoResultsEmptyPrompt />; } diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/field_stats_embeddable_wrapper.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/field_stats_wrapper.tsx similarity index 71% rename from x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/field_stats_embeddable_wrapper.tsx rename to x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/field_stats_wrapper.tsx index a7e57e1e550b..9899c7f9251e 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/field_stats_embeddable_wrapper.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/field_stats_wrapper.tsx @@ -30,21 +30,62 @@ const EmbeddableFieldStatsTableWrapper = dynamic(() => import('./embeddable_fiel function isESQLFieldStatisticTableEmbeddableState( input: FieldStatisticTableEmbeddableProps ): input is ESQLDataVisualizerGridEmbeddableState { - return isPopulatedObject(input, ['esql']) && input.esql === true; + return isPopulatedObject(input, ['isEsqlMode']) && input.isEsqlMode === true; } function isFieldStatisticTableEmbeddableState( input: FieldStatisticTableEmbeddableProps ): input is Required<FieldStatisticTableEmbeddableProps, 'dataView'> { - return isPopulatedObject(input, ['dataView']) && Boolean(input.esql) === false; + return isPopulatedObject(input, ['dataView']) && Boolean(input.isEsqlMode) === false; } const FieldStatisticsWrapperContent = (props: FieldStatisticTableEmbeddableProps) => { if (isESQLFieldStatisticTableEmbeddableState(props)) { - return <EmbeddableESQLFieldStatsTableWrapper {...props} />; + return ( + <EmbeddableESQLFieldStatsTableWrapper + dataView={props.dataView} + esqlQuery={props.esqlQuery} + isEsqlMode={props.isEsqlMode ?? props.esql} + filters={props.filters} + lastReloadRequestTime={props.lastReloadRequestTime} + onAddFilter={props.onAddFilter} + onTableUpdate={props.onTableUpdate} + query={props.query} + samplingOption={props.samplingOption} + savedSearch={props.savedSearch} + sessionId={props.sessionId} + shouldGetSubfields={props.shouldGetSubfields} + showPreviewByDefault={props.showPreviewByDefault} + totalDocuments={props.totalDocuments} + timeRange={props.timeRange} + visibleFieldNames={props.visibleFieldNames} + resetData$={props.resetData$} + onRenderComplete={props.onRenderComplete} + /> + ); } if (isFieldStatisticTableEmbeddableState(props)) { - return <EmbeddableFieldStatsTableWrapper {...props} />; + return ( + <EmbeddableFieldStatsTableWrapper + dataView={props.dataView} + isEsqlMode={false} + filters={props.filters} + lastReloadRequestTime={props.lastReloadRequestTime} + onAddFilter={props.onAddFilter} + onTableUpdate={props.onTableUpdate} + query={props.query} + samplingOption={props.samplingOption} + savedSearch={props.savedSearch} + sessionId={props.sessionId} + shouldGetSubfields={props.shouldGetSubfields} + showPreviewByDefault={props.showPreviewByDefault} + totalDocuments={props.totalDocuments} + timeRange={props.timeRange} + visibleFieldNames={props.visibleFieldNames} + resetData$={props.resetData$} + onRenderComplete={props.onRenderComplete} + /> + ); } else { return ( <EuiEmptyPrompt @@ -133,7 +174,8 @@ const FieldStatisticsWrapper = (props: FieldStatisticTableEmbeddableProps) => { <DatePickerContextProvider {...datePickerDeps}> <FieldStatisticsWrapperContent dataView={props.dataView} - esql={props.esql} + isEsqlMode={props.isEsqlMode ?? props.esql} + esqlQuery={props.esqlQuery} filters={props.filters} lastReloadRequestTime={props.lastReloadRequestTime} onAddFilter={props.onAddFilter} @@ -146,6 +188,9 @@ const FieldStatisticsWrapper = (props: FieldStatisticTableEmbeddableProps) => { showPreviewByDefault={props.showPreviewByDefault} totalDocuments={props.totalDocuments} visibleFieldNames={props.visibleFieldNames} + resetData$={props.resetData$} + timeRange={props.timeRange} + onRenderComplete={props.onRenderComplete} /> </DatePickerContextProvider> </KibanaContextProvider> diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/types.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/types.ts index a703c012a057..06c40515f6ca 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/types.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/types.ts @@ -5,13 +5,12 @@ * 2.0. */ -import type { AggregateQuery, Filter } from '@kbn/es-query'; +import type { AggregateQuery, Filter, TimeRange } from '@kbn/es-query'; import type { Query } from '@kbn/es-query'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; -import type { BehaviorSubject } from 'rxjs'; -import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; -import type { SerializedTitles } from '@kbn/presentation-publishing'; +import type { BehaviorSubject, Observable } from 'rxjs'; +import type { SerializedTimeRange, SerializedTitles } from '@kbn/presentation-publishing'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataVisualizerTableState } from '../../../../../common/types'; import type { SamplingOption } from '../../../../../common/types/field_stats'; @@ -82,6 +81,8 @@ export interface FieldStatisticTableEmbeddableProps { * If esql:true, switch table to ES|QL mode */ esql?: boolean; + isEsqlMode?: boolean; + esqlQuery?: AggregateQuery; /** * If esql:true, the index pattern is used to validate time field */ @@ -98,6 +99,9 @@ export interface FieldStatisticTableEmbeddableProps { */ overridableServices?: { data: DataPublicPluginStart }; renderFieldName?: (fieldName: string, item: DataVisualizerTableItem) => JSX.Element; + resetData$?: Observable<number>; + timeRange?: TimeRange; + onRenderComplete?: () => void; } export type ESQLDataVisualizerGridEmbeddableState = Omit< @@ -105,12 +109,21 @@ export type ESQLDataVisualizerGridEmbeddableState = Omit< 'query' > & { query?: ESQLQuery }; -export type FieldStatisticsTableEmbeddableState = FieldStatisticTableEmbeddableProps & - SerializedTitles; -interface FieldStatisticsTableEmbeddableComponentApi { - showDistributions$?: BehaviorSubject<boolean>; +export enum FieldStatsInitializerViewType { + DATA_VIEW = 'dataview', + ESQL = 'esql', } +export interface FieldStatsInitialState { + dataViewId?: string; + viewType?: FieldStatsInitializerViewType; + query?: AggregateQuery; + showDistributions?: boolean; +} +export type FieldStatisticsTableEmbeddableState = FieldStatsInitialState & + SerializedTitles & + SerializedTimeRange & {}; + export type OnAddFilter = (field: DataViewField | string, value: string, type: '+' | '-') => void; export interface FieldStatisticsTableEmbeddableParentApi { executionContext?: { value: string }; @@ -119,10 +132,6 @@ export interface FieldStatisticsTableEmbeddableParentApi { onAddFilter?: OnAddFilter; } -export type FieldStatisticsTableEmbeddableApi = - DefaultEmbeddableApi<FieldStatisticsTableEmbeddableState> & - FieldStatisticsTableEmbeddableComponentApi; - export type DataVisualizerGridEmbeddableApi = Partial<FieldStatisticsTableEmbeddableState>; export type ESQLDefaultLimitSizeOption = '5000' | '10000' | '100000'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx index 8513df90d682..5252e0082146 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx @@ -20,6 +20,7 @@ import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; import type { AggregateQuery, Query } from '@kbn/es-query'; import { useTimeBuckets } from '@kbn/ml-time-buckets'; import { buildEsQuery } from '@kbn/es-query'; +import usePrevious from 'react-use/lib/usePrevious'; import type { FieldVisConfig } from '../../../../../common/types/field_vis_config'; import type { SupportedFieldType } from '../../../../../common/types/job_field_type'; import type { ItemIdToExpandedRowMap } from '../../../common/components/stats_table'; @@ -82,7 +83,7 @@ export const useESQLDataVisualizerData = ( ) => { const [lastRefresh, setLastRefresh] = useState(0); const { services } = useDataVisualizerKibana(); - const { uiSettings, fieldFormats, executionContext } = services; + const { uiSettings, executionContext, data } = services; const parentExecutionContext = useObservable(executionContext?.context$); @@ -107,17 +108,20 @@ export const useESQLDataVisualizerData = ( autoRefreshSelector: true, }); + const [delayedESQLQuery, setDelayedESQLQuery] = useState<ESQLQuery | undefined>(input?.esqlQuery); + const previousQuery = usePrevious(delayedESQLQuery); + const { currentDataView, parentQuery, parentFilters, query, visibleFieldNames, indexPattern } = useMemo(() => { let q = FALLBACK_ESQL_QUERY; - if (input?.query && isESQLQuery(input?.query)) q = input.query; + if (delayedESQLQuery && isESQLQuery(delayedESQLQuery)) q = delayedESQLQuery; if (input?.savedSearch && isESQLQuery(input.savedSearch.searchSource.getField('query'))) { q = input.savedSearch.searchSource.getField('query') as ESQLQuery; } return { currentDataView: input.dataView, - query: q ?? FALLBACK_ESQL_QUERY, + query: q, // It's possible that in a dashboard setting, we will have additional filters and queries parentQuery: input?.query, parentFilters: input?.filters, @@ -131,6 +135,7 @@ export const useESQLDataVisualizerData = ( input?.filters, input?.visibleFieldNames, input?.indexPattern, + delayedESQLQuery, ]); const restorableDefaults = useMemo( @@ -181,6 +186,7 @@ export const useESQLDataVisualizerData = ( (Array.isArray(parentQuery) ? parentQuery : [parentQuery]) as AnyQuery | AnyQuery[], parentFilters ?? [] ); + const timeRange = input.timeRange ? input.timeRange : timefilter.getTime(); if (currentDataView?.timeFieldName) { if (Array.isArray(filter?.bool?.filter)) { @@ -188,8 +194,8 @@ export const useESQLDataVisualizerData = ( range: { [currentDataView.timeFieldName]: { format: 'strict_date_optional_time', - gte: timefilter.getTime().from, - lte: timefilter.getTime().to, + gte: timeRange.from, + lte: timeRange.to, }, }, }); @@ -202,8 +208,8 @@ export const useESQLDataVisualizerData = ( range: { [currentDataView.timeFieldName]: { format: 'strict_date_optional_time', - gte: timefilter.getTime().from, - lte: timefilter.getTime().to, + gte: timeRange.from, + lte: timeRange.to, }, }, }, @@ -239,6 +245,8 @@ export const useESQLDataVisualizerData = ( indexPattern, lastRefresh, limitSize, + input.timeRange?.from, + input.timeRange?.to, ] ); @@ -420,7 +428,7 @@ export const useESQLDataVisualizerData = ( ...field, ...fieldData, loading: fieldData?.existsInDocs ?? true, - fieldFormat: fieldFormats.deserialize({ id: field.secondaryType }), + fieldFormat: data.fieldFormats.deserialize({ id: field.secondaryType }), aggregatable: true, deletable: false, type: getFieldType(field) as SupportedFieldType, @@ -493,7 +501,7 @@ export const useESQLDataVisualizerData = ( secondaryType: getFieldType(field) as SupportedFieldType, loading: fieldData?.existsInDocs ?? true, deletable: false, - fieldFormat: fieldFormats.deserialize({ id: field.secondaryType }), + fieldFormat: data.fieldFormats.deserialize({ id: field.secondaryType }), }; // Map the field type from the Kibana index pattern to the field type @@ -598,12 +606,14 @@ export const useESQLDataVisualizerData = ( totalDocuments={totalCount} typeAccessor="secondaryType" timeFieldName={timeFieldName} + onAddFilter={input.onAddFilter} /> ); } return map; }, {} as ItemIdToExpandedRowMap); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [currentDataView, totalCount, query.esql, timeFieldName] ); @@ -633,6 +643,14 @@ export const useESQLDataVisualizerData = ( [cancelFieldStatsRequest, cancelOverallStatsRequest] ); + useEffect(() => { + if (previousQuery?.esql !== input?.esqlQuery?.esql) { + resetData(); + setDelayedESQLQuery(input?.esqlQuery); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [input?.esqlQuery?.esql, resetData]); + return { totalCount, progress: combinedProgress, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts index 6cc039fd08b4..7b26fa4fdc87 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts @@ -49,7 +49,7 @@ import { getDefaultPageState, } from '../constants/index_data_visualizer_viewer'; import { getFieldsWithSubFields } from '../utils/get_fields_with_subfields_utils'; -import type { FieldStatisticsTableEmbeddableState } from '../embeddables/grid_embeddable/types'; +import type { FieldStatisticTableEmbeddableProps } from '../embeddables/grid_embeddable/types'; const defaults = getDefaultPageState(); @@ -64,7 +64,7 @@ const DEFAULT_SAMPLING_OPTION: SamplingOption = { }; export const useDataVisualizerGridData = ( // Data view is required for non-ES|QL queries like kuery or lucene - input: Required<FieldStatisticsTableEmbeddableState, 'dataView'>, + input: Required<FieldStatisticTableEmbeddableProps, 'dataView'>, dataVisualizerListState: Required<DataVisualizerIndexBasedAppState>, savedRandomSamplerPreference?: RandomSamplerOption, onUpdate?: (params: Dictionary<unknown>) => void @@ -207,17 +207,19 @@ export const useDataVisualizerGridData = ( const tf = timefilter; - if (!buckets || !tf || !currentDataView) return; + if (!buckets || !tf || !currentDataView || lastRefresh === 0) return; const activeBounds = tf.getActiveBounds(); - - let earliest: number | undefined; - let latest: number | undefined; + let earliest: number | string | undefined; + let latest: number | string | undefined; if (activeBounds !== undefined && currentDataView.timeFieldName !== undefined) { earliest = activeBounds.min?.valueOf(); latest = activeBounds.max?.valueOf(); } - + if (input.timeRange) { + earliest = input.timeRange.from; + latest = input.timeRange.to; + } const bounds = tf.getActiveBounds(); const barTarget = uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ?? DEFAULT_BAR_TARGET; buckets.setInterval('auto'); @@ -264,23 +266,19 @@ export const useDataVisualizerGridData = ( nonAggregatableFields, browserSessionSeed, samplingOption: { ...samplingOption, seed: browserSessionSeed.toString() }, - componentExecutionContext, + embeddableExecutionContext: componentExecutionContext, }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [ - _timeBuckets, - timefilter, currentDataView.id, + lastRefresh, // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify(searchQuery), - // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify(samplingOption), + JSON.stringify({ searchQuery, samplingOption, fieldsToFetch }), searchSessionId, - lastRefresh, - fieldsToFetch, browserSessionSeed, - componentExecutionContext, + input.timeRange?.from, + input.timeRange?.to, ] ); @@ -335,7 +333,6 @@ export const useDataVisualizerGridData = ( () => overallStatsProgress.loaded * 0.2 + strategyResponse.progress.loaded * 0.8, [overallStatsProgress.loaded, strategyResponse.progress.loaded] ); - useEffect(() => { const timeUpdateSubscription = merge( timefilter.getTimeUpdate$(), diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts index c23764797044..563de0ef235a 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_field_stats.ts @@ -13,7 +13,8 @@ import { last, cloneDeep } from 'lodash'; import { mergeMap, switchMap } from 'rxjs'; import { Comparators } from '@elastic/eui'; import type { ISearchOptions } from '@kbn/search-types'; -import { buildBaseFilterCriteria, getSafeAggregationName } from '@kbn/ml-query-utils'; +import { getSafeAggregationName } from '@kbn/ml-query-utils'; +import { buildFilterCriteria } from '../../../../common/utils/build_query_filters'; import type { DataStatsFetchProgress, FieldStatsSearchStrategyReturnBase, @@ -146,7 +147,7 @@ export function useFieldStatsSearchStrategy( return; } - const filterCriteria = buildBaseFilterCriteria( + const filterCriteria = buildFilterCriteria( searchStrategyParams.timeFieldName, searchStrategyParams.earliest, searchStrategyParams.latest, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts index e70e3b810cdb..6e3f4ae33fa3 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_overall_stats.ts @@ -16,7 +16,6 @@ import type { } from '@kbn/search-types'; import { extractErrorProperties } from '@kbn/ml-error-utils'; import { getProcessedFields } from '@kbn/ml-data-grid'; -import { buildBaseFilterCriteria } from '@kbn/ml-query-utils'; import { isDefined } from '@kbn/ml-is-defined'; import type { FieldSpec } from '@kbn/data-views-plugin/common'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -52,6 +51,7 @@ import { fetchDataWithTimeout, rateLimitingForkJoin, } from '../search_strategy/requests/fetch_utils'; +import { buildFilterCriteria } from '../../../../common/utils/build_query_filters'; const getPopulatedFieldsInIndex = ( populatedFieldsInIndexWithoutRuntimeFields: Set<string> | undefined | null, @@ -119,12 +119,7 @@ export function useOverallStats<TParams extends OverallStatsSearchStrategyParams return; } - const filterCriteria = buildBaseFilterCriteria( - timeFieldName, - earliest, - latest, - searchQuery - ); + const filterCriteria = buildFilterCriteria(timeFieldName, earliest, latest, searchQuery); // Getting non-empty fields for the index pattern // because then we can absolutely exclude these from subsequent requests diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_document_stats.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_document_stats.ts index 12bf409065e7..72ae034c8b5a 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_document_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_document_stats.ts @@ -12,12 +12,12 @@ import type { ISearchOptions } from '@kbn/search-types'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import seedrandom from 'seedrandom'; import { isDefined } from '@kbn/ml-is-defined'; -import { buildBaseFilterCriteria } from '@kbn/ml-query-utils'; import { RANDOM_SAMPLER_PROBABILITIES } from '../../constants/random_sampler'; import type { DocumentCountStats, OverallStatsSearchStrategyParams, } from '../../../../../common/types/field_stats'; +import { buildFilterCriteria } from '../../../../../common/utils/build_query_filters'; const MINIMUM_RANDOM_SAMPLER_DOC_COUNT = 100000; const DEFAULT_INITIAL_RANDOM_SAMPLER_PROBABILITY = 0.000001; @@ -45,7 +45,7 @@ export const getDocumentCountStats = async ( // Probability = 1 represents no sampling const result = { randomlySampled: false, took: 0, totalCount: 0, probability: 1 }; - const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery); + const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery); const query = { bool: { @@ -229,12 +229,11 @@ export const processDocumentCountStats = ( buckets[time] = dataForTime.doc_count; totalCount += dataForTime.doc_count; }); - return { interval: params.intervalMs, buckets, - timeRangeEarliest: params.earliest, - timeRangeLatest: params.latest, + timeRangeEarliest: typeof params.earliest === 'number' ? params.earliest : undefined, + timeRangeLatest: typeof params.latest === 'number' ? params.latest : undefined, totalCount, }; }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_field_examples.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_field_examples.ts index 9e14bd472c84..4ae2841b56de 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_field_examples.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/get_field_examples.ts @@ -16,7 +16,6 @@ import type { import type { ISearchStart } from '@kbn/data-plugin/public'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { buildBaseFilterCriteria } from '@kbn/ml-query-utils'; import { extractErrorProperties } from '@kbn/ml-error-utils'; import { getUniqGeoOrStrExamples } from '../../../common/util/example_utils'; import type { @@ -27,6 +26,7 @@ import type { } from '../../../../../common/types/field_stats'; import { isIKibanaSearchResponse } from '../../../../../common/types/field_stats'; import { MAX_EXAMPLES_DEFAULT } from './constants'; +import { buildFilterCriteria } from '../../../../../common/utils/build_query_filters'; export const getFieldExamplesRequest = (params: FieldStatsCommonRequestParams, field: Field) => { const { index, timeFieldName, earliestMs, latestMs, query, runtimeFieldMap, maxExamples } = @@ -35,7 +35,7 @@ export const getFieldExamplesRequest = (params: FieldStatsCommonRequestParams, f // Request at least 100 docs so that we have a chance of obtaining // 'maxExamples' of the field. const size = Math.max(100, maxExamples ?? MAX_EXAMPLES_DEFAULT); - const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query); + const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query); // Use an exists filter to return examples of the field. if (Array.isArray(filterCriteria)) { diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts index 46894014f355..06030db87224 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/search_strategy/requests/overall_stats.ts @@ -11,7 +11,7 @@ import type { Query } from '@kbn/es-query'; import type { IKibanaSearchResponse } from '@kbn/search-types'; import type { AggCardinality } from '@kbn/ml-agg-utils'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; -import { buildBaseFilterCriteria, getSafeAggregationName } from '@kbn/ml-query-utils'; +import { getSafeAggregationName } from '@kbn/ml-query-utils'; import { buildAggregationWithSamplingOption } from './build_random_sampler_agg'; import { getDatafeedAggregations } from '../../../../../common/utils/datafeed_utils'; import type { AggregatableField, NonAggregatableField } from '../../types/overall_stats'; @@ -20,6 +20,7 @@ import type { OverallStatsSearchStrategyParams, SamplingOption, } from '../../../../../common/types/field_stats'; +import { buildFilterCriteria } from '../../../../../common/utils/build_query_filters'; export const checkAggregatableFieldsExistRequest = ( dataViewTitle: string, @@ -27,14 +28,14 @@ export const checkAggregatableFieldsExistRequest = ( aggregatableFields: OverallStatsSearchStrategyParams['aggregatableFields'], samplingOption: SamplingOption, timeFieldName: string | undefined, - earliestMs?: number, - latestMs?: number, + earliestMs?: number | string, + latestMs?: number | string, datafeedConfig?: estypes.MlDatafeed, runtimeMappings?: estypes.MappingRuntimeFields ): estypes.SearchRequest => { const index = dataViewTitle; const size = 0; - const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query); + const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query); const datafeedAggregations = getDatafeedAggregations(datafeedConfig); // Value count aggregation faster way of checking if field exists than using @@ -217,13 +218,13 @@ export const checkNonAggregatableFieldExistsRequest = ( query: Query['query'], field: string, timeFieldName: string | undefined, - earliestMs: number | undefined, - latestMs: number | undefined, + earliestMs: number | string | undefined, + latestMs: number | string | undefined, runtimeMappings?: estypes.MappingRuntimeFields ): estypes.SearchRequest => { const index = dataViewTitle; const size = 0; - const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query); + const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query); if (Array.isArray(filterCriteria)) { filterCriteria.push({ exists: { field } }); @@ -256,12 +257,12 @@ export const getSampleOfDocumentsForNonAggregatableFields = ( dataViewTitle: string, query: Query['query'], timeFieldName: string | undefined, - earliestMs: number | undefined, - latestMs: number | undefined, + earliestMs: number | string | undefined, + latestMs: number | string | undefined, runtimeMappings?: estypes.MappingRuntimeFields ): estypes.SearchRequest => { const index = dataViewTitle; - const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query); + const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query); return { index, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/ui_actions/create_field_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/ui_actions/create_field_stats_table.tsx new file mode 100644 index 000000000000..441fbd4d6c5e --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/ui_actions/create_field_stats_table.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { PresentationContainer } from '@kbn/presentation-containers'; +import { tracksOverlays } from '@kbn/presentation-containers'; +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import type { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public'; +import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import React from 'react'; +import { isDefined } from '@kbn/ml-is-defined'; +import { COMMON_VISUALIZATION_GROUPING } from '@kbn/visualizations-plugin/public'; +import { FIELD_STATS_EMBEDDABLE_TYPE } from '../embeddables/field_stats/constants'; +import type { DataVisualizerStartDependencies } from '../../common/types/data_visualizer_plugin'; +import type { + FieldStatisticsTableEmbeddableApi, + FieldStatsControlsApi, +} from '../embeddables/field_stats/types'; +import { FieldStatsInitializerViewType } from '../embeddables/grid_embeddable/types'; +import type { FieldStatsInitialState } from '../embeddables/grid_embeddable/types'; +import { getOrCreateDataViewByIndexPattern } from '../search_strategy/requests/get_data_view_by_index_pattern'; +import { FieldStatisticsInitializer } from '../embeddables/field_stats/field_stats_initializer'; + +const parentApiIsCompatible = async ( + parentApi: unknown +): Promise<PresentationContainer | undefined> => { + const { apiIsPresentationContainer } = await import('@kbn/presentation-containers'); + // we cannot have an async type check, so return the casted parentApi rather than a boolean + return apiIsPresentationContainer(parentApi) ? (parentApi as PresentationContainer) : undefined; +}; + +interface FieldStatsActionContext extends EmbeddableApiContext { + embeddable: FieldStatisticsTableEmbeddableApi; +} + +async function updatePanelFromFlyoutEdits({ + api, + isNewPanel, + deletePanel, + coreStart, + pluginStart, + initialState, +}: { + api: FieldStatisticsTableEmbeddableApi; + isNewPanel: boolean; + deletePanel?: () => void; + coreStart: CoreStart; + pluginStart: DataVisualizerStartDependencies; + initialState: FieldStatsInitialState; + fieldStatsControlsApi?: FieldStatsControlsApi; +}) { + const parentApi = api.parentApi; + const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined; + const services = { + ...coreStart, + ...pluginStart, + }; + let hasChanged = false; + const cancelChanges = () => { + // Reset to initialState in case user has changed the preview state + if (hasChanged && api && initialState) { + api.updateUserInput(initialState); + } + + if (isNewPanel && deletePanel) { + deletePanel(); + } + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + + const update = async (nextUpdate: FieldStatsInitialState) => { + const esqlQuery = nextUpdate?.query?.esql; + if (isDefined(esqlQuery)) { + const indexPatternFromQuery = getIndexPatternFromESQLQuery(esqlQuery); + const dv = await getOrCreateDataViewByIndexPattern( + pluginStart.data.dataViews, + indexPatternFromQuery, + undefined + ); + if (dv?.id && nextUpdate.dataViewId !== dv.id) { + nextUpdate.dataViewId = dv.id; + } + } + if (api) { + api.updateUserInput(nextUpdate); + } + + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + const flyoutSession = services.overlays.openFlyout( + toMountPoint( + <KibanaContextProvider services={services}> + <FieldStatisticsInitializer + initialInput={initialState} + onPreview={async (nextUpdate) => { + if (api.updateUserInput) { + api.updateUserInput(nextUpdate); + hasChanged = true; + } + }} + onCreate={update} + onCancel={cancelChanges} + isNewPanel={isNewPanel} + /> + </KibanaContextProvider>, + coreStart + ), + { + ownFocus: true, + size: 's', + paddingSize: 'm', + hideCloseButton: true, + type: 'push', + 'data-test-subj': 'fieldStatisticsInitializerFlyout', + onClose: cancelChanges, + } + ); + overlayTracker?.openOverlay(flyoutSession, { focusedPanelId: api.uuid }); +} + +export function createAddFieldStatsTableAction( + coreStart: CoreStart, + pluginStart: DataVisualizerStartDependencies +): UiActionsActionDefinition<FieldStatsActionContext> { + return { + id: 'create-field-stats-table', + grouping: COMMON_VISUALIZATION_GROUPING, + order: 10, + getIconType: () => 'inspect', + getDisplayName: () => + i18n.translate('xpack.dataVisualizer.fieldStatistics.displayName', { + defaultMessage: 'Field statistics', + }), + async isCompatible(context: EmbeddableApiContext) { + return Boolean(await parentApiIsCompatible(context.embeddable)); + }, + async execute(context) { + const presentationContainerParent = await parentApiIsCompatible(context.embeddable); + if (!presentationContainerParent) throw new IncompatibleActionError(); + + try { + const defaultIndexPattern = await pluginStart.data.dataViews.getDefault(); + const defaultInitialState: FieldStatsInitialState = { + viewType: FieldStatsInitializerViewType.ESQL, + query: { + // Initial default query + esql: `from ${defaultIndexPattern?.getIndexPattern()} | limit 10`, + }, + }; + const embeddable = await presentationContainerParent.addNewPanel< + object, + FieldStatisticsTableEmbeddableApi + >({ + panelType: FIELD_STATS_EMBEDDABLE_TYPE, + initialState: defaultInitialState, + }); + // open the flyout if embeddable has been created successfully + if (embeddable) { + const deletePanel = () => { + presentationContainerParent.removePanel(embeddable.uuid); + }; + + updatePanelFromFlyoutEdits({ + api: embeddable, + isNewPanel: true, + deletePanel, + coreStart, + pluginStart, + initialState: defaultInitialState, + }); + } + } catch (e) { + return Promise.reject(e); + } + }, + }; +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/ui_actions/index.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/ui_actions/index.ts new file mode 100644 index 000000000000..faa8f34bdfbd --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/ui_actions/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import type { UiActionsSetup } from '@kbn/ui-actions-plugin/public'; +import type { DataVisualizerStartDependencies } from '../../common/types/data_visualizer_plugin'; + +export function registerDataVisualizerUiActions( + uiActions: UiActionsSetup, + coreStart: CoreStart, + pluginStart: DataVisualizerStartDependencies +) { + import('./create_field_stats_table').then(({ createAddFieldStatsTableAction }) => { + const addFieldStatsAction = createAddFieldStatsTableAction(coreStart, pluginStart); + uiActions.addTriggerAction('ADD_PANEL_TRIGGER', addFieldStatsAction); + }); +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/get_fields_with_subfields_utils.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/get_fields_with_subfields_utils.ts index 0c159878e8b2..4c509fb21275 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/get_fields_with_subfields_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/get_fields_with_subfields_utils.ts @@ -6,7 +6,7 @@ */ import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { isDefined } from '@kbn/ml-is-defined'; -import type { FieldStatisticsTableEmbeddableState } from '../embeddables/grid_embeddable/types'; +import type { FieldStatisticTableEmbeddableProps } from '../embeddables/grid_embeddable/types'; /** * Helper logic to add multi-fields to the table for embeddables outside of Index data visualizer @@ -19,7 +19,7 @@ export const getFieldsWithSubFields = ({ currentDataView, shouldGetSubfields = false, }: { - input: FieldStatisticsTableEmbeddableState; + input: FieldStatisticTableEmbeddableProps; currentDataView: DataView; shouldGetSubfields: boolean; }) => { diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 15e1cb88eb1b..deaf4bdc9967 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -20,6 +20,8 @@ import type { DataVisualizerSetupDependencies, DataVisualizerStartDependencies, } from './application/common/types/data_visualizer_plugin'; +import { registerEmbeddables } from './application/index_data_visualizer/embeddables/field_stats'; +import { registerDataVisualizerUiActions } from './application/index_data_visualizer/ui_actions'; export type DataVisualizerPluginSetup = ReturnType<DataVisualizerPlugin['setup']>; export type DataVisualizerPluginStart = ReturnType<DataVisualizerPlugin['start']>; @@ -50,7 +52,17 @@ export class DataVisualizerPlugin } } - public setup(core: DataVisualizerCoreSetup, plugins: DataVisualizerSetupDependencies) { + public async setup(core: DataVisualizerCoreSetup, plugins: DataVisualizerSetupDependencies) { + if (plugins.embeddable) { + registerEmbeddables(plugins.embeddable, core); + } + + const [coreStart, pluginStart] = await core.getStartServices(); + + if (plugins.uiActions) { + registerDataVisualizerUiActions(plugins.uiActions, coreStart, pluginStart); + } + if (plugins.home) { registerHomeAddData(plugins.home, this.resultsLinks); registerHomeFeatureCatalogue(plugins.home); @@ -75,7 +87,7 @@ export class DataVisualizerPlugin FieldStatisticsTable: dynamic( async () => import( - './application/index_data_visualizer/embeddables/grid_embeddable/field_stats_embeddable_wrapper' + './application/index_data_visualizer/embeddables/grid_embeddable/field_stats_wrapper' ) ), }; diff --git a/x-pack/plugins/data_visualizer/tsconfig.json b/x-pack/plugins/data_visualizer/tsconfig.json index 5a166df52eed..961678309435 100644 --- a/x-pack/plugins/data_visualizer/tsconfig.json +++ b/x-pack/plugins/data_visualizer/tsconfig.json @@ -82,7 +82,12 @@ "@kbn/presentation-publishing", "@kbn/shared-ux-utility", "@kbn/search-types", - "@kbn/unified-field-list" + "@kbn/unified-field-list", + "@kbn/content-management-utils", + "@kbn/core-lifecycle-browser", + "@kbn/presentation-containers", + "@kbn/react-kibana-mount", + "@kbn/visualizations-plugin" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/prompts_schema.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/prompts_schema.mock.ts index 404ff5954c00..adbe299c3399 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/prompts_schema.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/prompts_schema.mock.ts @@ -42,8 +42,10 @@ export const getPromptsSearchEsMock = () => { id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', content: 'test content', name: 'test', - prompt_type: 'quickPrompt', - is_shared: false, + prompt_type: 'quick', + consumer: 'securitySolutionUI', + categories: [], + color: 'red', created_by: 'elastic', users: [ { @@ -62,15 +64,19 @@ export const getCreatePromptSchemaMock = (): PromptCreateProps => ({ name: 'test', content: 'test content', isNewConversationDefault: false, - isShared: true, + consumer: 'securitySolutionUI', + categories: [], + color: 'red', isDefault: false, - promptType: 'quickPrompt', + promptType: 'quick', }); export const getUpdatePromptSchemaMock = (promptId = 'prompt-1'): PromptUpdateProps => ({ content: 'test content', isNewConversationDefault: false, - isShared: true, + consumer: 'securitySolutionUI', + categories: [], + color: 'red', isDefault: false, id: promptId, }); @@ -79,7 +85,7 @@ export const getPromptMock = (params: PromptCreateProps | PromptUpdateProps): Pr id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', content: 'test content', name: 'test', - promptType: 'quickPrompt', + promptType: 'quick', isDefault: false, ...params, createdAt: '2019-12-13T16:40:33.400Z', @@ -97,19 +103,23 @@ export const getQueryPromptParams = (isUpdate?: boolean): PromptCreateProps | Pr ? { content: 'test 2', name: 'test', - promptType: 'quickPrompt', + promptType: 'quick', isDefault: false, isNewConversationDefault: true, - isShared: true, + consumer: 'securitySolutionUI', + categories: [], + color: 'red', id: '1', } : { content: 'test 2', name: 'test', - promptType: 'quickPrompt', + promptType: 'quick', isDefault: false, isNewConversationDefault: true, - isShared: true, + consumer: 'securitySolutionUI', + categories: [], + color: 'red', }; }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts index 72c51d8f917a..7c4f9708862a 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.ts @@ -43,7 +43,7 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient { logger: this.options.logger, conversationIndex: this.indexTemplateAndPattern.alias, id, - user: authenticatedUser, + user: authenticatedUser ?? this.options.currentUser, }); }; @@ -84,18 +84,19 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient { */ public createConversation = async ({ conversation, - authenticatedUser, }: { conversation: ConversationCreateProps; - authenticatedUser: AuthenticatedUser; }): Promise<ConversationResponse | null> => { + if (!this.options.currentUser) { + throw new Error('AIAssistantConversationsDataClient currentUser is not defined.'); + } const esClient = await this.options.elasticsearchClientPromise; return createConversation({ esClient, logger: this.options.logger, conversationIndex: this.indexTemplateAndPattern.alias, spaceId: this.spaceId, - user: authenticatedUser, + user: this.options.currentUser, conversation, }); }; @@ -128,7 +129,7 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient { conversationIndex: this.indexTemplateAndPattern.alias, conversationUpdateProps, isPatch, - user: authenticatedUser, + user: authenticatedUser ?? this.options.currentUser ?? undefined, }); }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/field_maps_configuration.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/field_maps_configuration.ts index 50df573d0187..5a916793332b 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/field_maps_configuration.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/field_maps_configuration.ts @@ -23,11 +23,21 @@ export const assistantPromptsFieldMap: FieldMap = { array: false, required: false, }, - is_shared: { - type: 'boolean', + consumer: { + type: 'text', + array: false, + required: false, + }, + color: { + type: 'keyword', array: false, required: false, }, + categories: { + type: 'keyword', + array: true, + required: false, + }, is_new_conversation_default: { type: 'boolean', array: false, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts index 83d7713c23f1..a4534972c847 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts @@ -9,6 +9,7 @@ import { estypes } from '@elastic/elasticsearch'; import { PromptCreateProps, PromptResponse, + PromptType, PromptUpdateProps, } from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; import { AuthenticatedUser } from '@kbn/core-security-common'; @@ -31,8 +32,10 @@ export const transformESToPrompts = (response: EsPromptsSchema[]): PromptRespons namespace: promptSchema.namespace, id: promptSchema.id, name: promptSchema.name, - promptType: promptSchema.prompt_type, - isShared: promptSchema.is_shared, + promptType: promptSchema.prompt_type as unknown as PromptType, + color: promptSchema.color, + categories: promptSchema.categories, + consumer: promptSchema.consumer, createdBy: promptSchema.created_by, updatedBy: promptSchema.updated_by, }; @@ -65,8 +68,10 @@ export const transformESSearchToPrompts = ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion id: hit._id!, name: promptSchema.name, - promptType: promptSchema.prompt_type, - isShared: promptSchema.is_shared, + promptType: promptSchema.prompt_type as unknown as PromptType, + color: promptSchema.color, + categories: promptSchema.categories, + consumer: promptSchema.consumer, createdBy: promptSchema.created_by, updatedBy: promptSchema.updated_by, }; @@ -78,14 +83,15 @@ export const transformESSearchToPrompts = ( export const transformToUpdateScheme = ( user: AuthenticatedUser, updatedAt: string, - { content, isNewConversationDefault, isShared, id }: PromptUpdateProps + { content, isNewConversationDefault, categories, color, id }: PromptUpdateProps ): UpdatePromptSchema => { return { id, updated_at: updatedAt, content: content ?? '', is_new_conversation_default: isNewConversationDefault, - is_shared: isShared, + categories, + color, users: [ { id: user.profile_uid, @@ -98,13 +104,25 @@ export const transformToUpdateScheme = ( export const transformToCreateScheme = ( user: AuthenticatedUser, updatedAt: string, - { content, isDefault, isNewConversationDefault, isShared, name, promptType }: PromptCreateProps + { + content, + isDefault, + isNewConversationDefault, + categories, + color, + consumer, + name, + promptType, + }: PromptCreateProps ): CreatePromptSchema => { return { + '@timestamp': updatedAt, updated_at: updatedAt, content: content ?? '', is_new_conversation_default: isNewConversationDefault, - is_shared: isShared, + color, + consumer, + categories, name, is_default: isDefault, prompt_type: promptType, @@ -132,8 +150,11 @@ export const getUpdateScript = ({ if (params.assignEmpty == true || params.containsKey('is_new_conversation_default')) { ctx._source.is_new_conversation_default = params.is_new_conversation_default; } - if (params.assignEmpty == true || params.containsKey('is_shared')) { - ctx._source.is_shared = params.is_shared; + if (params.assignEmpty == true || params.containsKey('color')) { + ctx._source.color = params.color; + } + if (params.assignEmpty == true || params.containsKey('categories')) { + ctx._source.categories = params.categories; } ctx._source.updated_at = params.updated_at; `, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/types.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/types.ts index 91f52fb3a082..0d936cc852ac 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/types.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/types.ts @@ -12,7 +12,9 @@ export interface EsPromptsSchema { created_by: string; content: string; is_default?: boolean; - is_shared?: boolean; + consumer?: string; + color?: string; + categories?: string[]; is_new_conversation_default?: boolean; name: string; prompt_type: string; @@ -28,7 +30,8 @@ export interface EsPromptsSchema { export interface UpdatePromptSchema { id: string; '@timestamp'?: string; - is_shared?: boolean; + color?: string; + categories?: string[]; is_new_conversation_default?: boolean; content?: string; updated_at?: string; @@ -42,7 +45,9 @@ export interface UpdatePromptSchema { export interface CreatePromptSchema { '@timestamp'?: string; - is_shared?: boolean; + consumer?: string; + color?: string; + categories?: string[]; is_new_conversation_default?: boolean; is_default?: boolean; name: string; diff --git a/x-pack/plugins/elastic_assistant/server/lib/executor.test.ts b/x-pack/plugins/elastic_assistant/server/lib/executor.test.ts index bacdd6cac1b4..a01ac3d126e5 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/executor.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/executor.test.ts @@ -13,17 +13,10 @@ import { executeAction, Props } from './executor'; import { PassThrough } from 'stream'; -import { KibanaRequest } from '@kbn/core-http-server'; -import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; -import { ExecuteConnectorRequestBody } from '@kbn/elastic-assistant-common'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; import { loggerMock } from '@kbn/logging-mocks'; import * as ParseStream from './parse_stream'; -const request = { - body: { - subAction: 'invokeAI', - message: 'hello', - }, -} as KibanaRequest<unknown, unknown, ExecuteConnectorRequestBody>; + const onLlmResponse = jest.fn(async () => {}); // We need it to be a promise, or it'll crash because of missing `.catch` const connectorId = 'testConnectorId'; const mockLogger = loggerMock.create(); @@ -33,8 +26,8 @@ const testProps: Omit<Props, 'actions'> = { subActionParams: { messages: [{ content: 'hello', role: 'user' }] }, }, actionTypeId: '.bedrock', - request, connectorId, + actionsClient: actionsClientMock.create(), onLlmResponse, logger: mockLogger, }; @@ -46,17 +39,13 @@ describe('executeAction', () => { jest.clearAllMocks(); }); it('should execute an action and return a StaticResponse when the response from the actions framework is a string', async () => { - const actions = { - getActionsClientWithRequest: jest.fn().mockResolvedValue({ - execute: jest.fn().mockResolvedValue({ - data: { - message: 'Test message', - }, - }), - }), - } as unknown as Props['actions']; - - const result = await executeAction({ ...testProps, actions }); + testProps.actionsClient.execute = jest.fn().mockResolvedValue({ + data: { + message: 'Test message', + }, + }); + + const result = await executeAction({ ...testProps }); expect(result).toEqual({ connector_id: connectorId, @@ -68,15 +57,15 @@ describe('executeAction', () => { it('should execute an action and return a Readable object when the response from the actions framework is a stream', async () => { const readableStream = new PassThrough(); - const actions = { - getActionsClientWithRequest: jest.fn().mockResolvedValue({ - execute: jest.fn().mockResolvedValue({ - data: readableStream, - }), - }), - } as unknown as Props['actions']; + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockImplementationOnce( + jest.fn().mockResolvedValue({ + status: 'ok', + data: readableStream, + }) + ); - const result = await executeAction({ ...testProps, actions }); + const result = await executeAction({ ...testProps, actionsClient }); expect(JSON.stringify(result)).toStrictEqual( JSON.stringify(readableStream.pipe(new PassThrough())) @@ -90,83 +79,69 @@ describe('executeAction', () => { }); }); - it('should throw an error if the actions plugin fails to retrieve the actions client', async () => { - const actions = { - getActionsClientWithRequest: jest - .fn() - .mockRejectedValue(new Error('Failed to retrieve actions client')), - } as unknown as Props['actions']; - - await expect(executeAction({ ...testProps, actions })).rejects.toThrowError( - 'Failed to retrieve actions client' - ); - }); - it('should throw an error if the actions client fails to execute the action', async () => { - const actions = { - getActionsClientWithRequest: jest.fn().mockResolvedValue({ - execute: jest.fn().mockRejectedValue(new Error('Failed to execute action')), - }), - } as unknown as Props['actions']; + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockRejectedValue(new Error('Failed to execute action')); + testProps.actionsClient = actionsClient; - await expect(executeAction({ ...testProps, actions })).rejects.toThrowError( + await expect(executeAction({ ...testProps, actionsClient })).rejects.toThrowError( 'Failed to execute action' ); }); it('should throw an error when the response from the actions framework is null or undefined', async () => { - const actions = { - getActionsClientWithRequest: jest.fn().mockResolvedValue({ - execute: jest.fn().mockResolvedValue({ - data: null, - }), - }), - } as unknown as Props['actions']; + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockImplementationOnce( + jest.fn().mockResolvedValue({ + data: null, + }) + ); + testProps.actionsClient = actionsClient; try { - await executeAction({ ...testProps, actions }); + await executeAction({ ...testProps, actionsClient }); } catch (e) { expect(e.message).toBe('Action result status is error: result is not streamable'); } }); it('should throw an error if action result status is "error"', async () => { - const actions = { - getActionsClientWithRequest: jest.fn().mockResolvedValue({ - execute: jest.fn().mockResolvedValue({ - status: 'error', - message: 'Error message', - serviceMessage: 'Service error message', - }), - }), - } as unknown as ActionsPluginStart; + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockImplementationOnce( + jest.fn().mockResolvedValue({ + status: 'error', + message: 'Error message', + serviceMessage: 'Service error message', + }) + ); + testProps.actionsClient = actionsClient; await expect( executeAction({ ...testProps, - actions, + actionsClient, connectorId: '12345', }) ).rejects.toThrowError('Action result status is error: Error message - Service error message'); }); it('should throw an error if content of response data is not a string or streamable', async () => { - const actions = { - getActionsClientWithRequest: jest.fn().mockResolvedValue({ - execute: jest.fn().mockResolvedValue({ - status: 'ok', - data: { - message: 12345, - }, - }), - }), - } as unknown as ActionsPluginStart; + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockImplementationOnce( + jest.fn().mockResolvedValue({ + status: 'ok', + data: { + message: 12345, + }, + }) + ); + testProps.actionsClient = actionsClient; await expect( executeAction({ ...testProps, - actions, + actionsClient, connectorId: '12345', }) ).rejects.toThrowError('Action result status is error: result is not streamable'); diff --git a/x-pack/plugins/elastic_assistant/server/lib/executor.ts b/x-pack/plugins/elastic_assistant/server/lib/executor.ts index e7797805854e..bd25a77808db 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/executor.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/executor.ts @@ -6,20 +6,18 @@ */ import { get } from 'lodash/fp'; -import { KibanaRequest } from '@kbn/core-http-server'; -import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { ActionsClient } from '@kbn/actions-plugin/server'; import { PassThrough, Readable } from 'stream'; -import { ExecuteConnectorRequestBody } from '@kbn/elastic-assistant-common'; import { Logger } from '@kbn/core/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { handleStreamStorage } from './parse_stream'; export interface Props { onLlmResponse?: (content: string) => Promise<void>; abortSignal?: AbortSignal; - actions: ActionsPluginStart; + actionsClient: PublicMethodsOf<ActionsClient>; connectorId: string; params: InvokeAIActionsParams; - request: KibanaRequest<unknown, unknown, ExecuteConnectorRequestBody>; actionTypeId: string; logger: Logger; } @@ -43,15 +41,13 @@ interface InvokeAIActionsParams { export const executeAction = async ({ onLlmResponse, - actions, + actionsClient, params, connectorId, actionTypeId, - request, logger, abortSignal, }: Props): Promise<StaticResponse | Readable> => { - const actionsClient = await actions.getActionsClientWithRequest(request); const actionResult = await actionsClient.execute({ actionId: connectorId, params: { diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts index e1e8cdc50eee..6f05edbed007 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { coreMock } from '@kbn/core/server/mocks'; import { KibanaRequest } from '@kbn/core/server'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; + import { loggerMock } from '@kbn/logging-mocks'; import { initializeAgentExecutorWithOptions } from 'langchain/agents'; @@ -84,7 +85,7 @@ const mockRequest: KibanaRequest<unknown, unknown, any, any> = { body: {} } as K any // eslint-disable-line @typescript-eslint/no-explicit-any >; -const mockActions: ActionsPluginStart = {} as ActionsPluginStart; +const actionsClient = actionsClientMock.create(); const mockLogger = loggerMock.create(); const mockTelemetry = coreMock.createSetup().analytics; const esClientMock = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; @@ -95,7 +96,7 @@ const esStoreMock = new ElasticsearchStore( mockTelemetry ); const defaultProps: AgentExecutorParams<true> = { - actions: mockActions, + actionsClient, isEnabledKnowledgeBase: true, connectorId: mockConnectorId, esClient: esClientMock, @@ -151,11 +152,10 @@ describe('callAgentExecutor', () => { await callAgentExecutor(defaultProps); expect(ActionsClientChatOpenAI).toHaveBeenCalledWith({ - actions: mockActions, + actionsClient, connectorId: mockConnectorId, logger: mockLogger, maxRetries: 0, - request: mockRequest, streaming: false, temperature: 0.2, llmType: 'openai', @@ -189,11 +189,10 @@ describe('callAgentExecutor', () => { await callAgentExecutor({ ...defaultProps, isStream: true }); expect(ActionsClientChatOpenAI).toHaveBeenCalledWith({ - actions: mockActions, + actionsClient, connectorId: mockConnectorId, logger: mockLogger, maxRetries: 0, - request: mockRequest, streaming: true, temperature: 0.2, llmType: 'openai', @@ -213,11 +212,10 @@ describe('callAgentExecutor', () => { await callAgentExecutor(bedrockProps); expect(ActionsClientSimpleChatModel).toHaveBeenCalledWith({ - actions: mockActions, + actionsClient, connectorId: mockConnectorId, logger: mockLogger, maxRetries: 0, - request: mockRequest, streaming: false, temperature: 0, llmType: 'bedrock', @@ -253,11 +251,10 @@ describe('callAgentExecutor', () => { await callAgentExecutor({ ...bedrockProps, isStream: true }); expect(ActionsClientSimpleChatModel).toHaveBeenCalledWith({ - actions: mockActions, + actionsClient, connectorId: mockConnectorId, logger: mockLogger, maxRetries: 0, - request: mockRequest, streaming: true, temperature: 0, llmType: 'bedrock', diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts index bcf39320f21c..b6a624b368d8 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts @@ -18,6 +18,8 @@ import { ActionsClientSimpleChatModel, } from '@kbn/langchain/server'; import { MessagesPlaceholder } from '@langchain/core/prompts'; +import { EsAnonymizationFieldsSchema } from '../../../ai_assistant_data_clients/anonymization_fields/types'; +import { transformESSearchToAnonymizationFields } from '../../../ai_assistant_data_clients/anonymization_fields/helpers'; import { AgentExecutor } from '../executors/types'; import { APMTracer } from '../tracers/apm_tracer'; import { AssistantToolParams } from '../../../types'; @@ -31,9 +33,8 @@ export const DEFAULT_AGENT_EXECUTOR_ID = 'Elastic AI Assistant Agent Executor'; */ export const callAgentExecutor: AgentExecutor<true | false> = async ({ abortSignal, - actions, + actionsClient, alertsIndexPattern, - anonymizationFields, isEnabledKnowledgeBase, assistantTools = [], connectorId, @@ -49,14 +50,14 @@ export const callAgentExecutor: AgentExecutor<true | false> = async ({ request, size, traceOptions, + dataClients, }) => { const isOpenAI = llmType === 'openai'; const llmClass = isOpenAI ? ActionsClientChatOpenAI : ActionsClientSimpleChatModel; const llm = new llmClass({ - actions, + actionsClient, connectorId, - request, llmType, logger, // possible client model override, @@ -72,6 +73,16 @@ export const callAgentExecutor: AgentExecutor<true | false> = async ({ maxRetries: 0, }); + const anonymizationFieldsRes = + await dataClients?.anonymizationFieldsDataClient?.findDocuments<EsAnonymizationFieldsSchema>({ + perPage: 1000, + page: 1, + }); + + const anonymizationFields = anonymizationFieldsRes + ? transformESSearchToAnonymizationFields(anonymizationFieldsRes.data) + : undefined; + const pastMessages = langChainMessages.slice(0, -1); // all but the last message const latestMessage = langChainMessages.slice(-1); // the last message diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/openai_functions_executor.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/openai_functions_executor.ts index 62b1d7ac7814..6aa1aa3ce789 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/openai_functions_executor.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/openai_functions_executor.ts @@ -25,7 +25,7 @@ export const OPEN_AI_FUNCTIONS_AGENT_EXECUTOR_ID = * NOTE: This is not to be used in production as-is, and must be used with an OpenAI ConnectorId */ export const callOpenAIFunctionsExecutor: AgentExecutor<false> = async ({ - actions, + actionsClient, connectorId, esClient, esStore, @@ -36,9 +36,8 @@ export const callOpenAIFunctionsExecutor: AgentExecutor<false> = async ({ traceOptions, }) => { const llm = new ActionsClientLlm({ - actions, + actionsClient, connectorId, - request, llmType, logger, model: request.body.model, diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/types.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/types.ts index bd07099e312b..7af0b459f4bc 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/types.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/executors/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { ActionsClient } from '@kbn/actions-plugin/server'; import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { BaseMessage } from '@langchain/core/messages'; import { Logger } from '@kbn/logging'; @@ -13,7 +13,7 @@ import { KibanaRequest, KibanaResponseFactory, ResponseHeaders } from '@kbn/core import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; import { ExecuteConnectorRequestBody, Message, Replacements } from '@kbn/elastic-assistant-common'; import { StreamResponseWithHeaders } from '@kbn/ml-response-stream/server'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { ResponseBody } from '../types'; import type { AssistantTool } from '../../../types'; import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store'; @@ -36,8 +36,7 @@ export interface AssistantDataClients { export interface AgentExecutorParams<T extends boolean> { abortSignal?: AbortSignal; alertsIndexPattern?: string; - actions: ActionsPluginStart; - anonymizationFields?: AnonymizationFieldResponse[]; + actionsClient: PublicMethodsOf<ActionsClient>; isEnabledKnowledgeBase: boolean; assistantTools?: AssistantTool[]; connectorId: string; @@ -56,6 +55,7 @@ export interface AgentExecutorParams<T extends boolean> { response?: KibanaResponseFactory; size?: number; traceOptions?: TraceOptions; + responseLanguage?: string; } export interface StaticReturnType { 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 779bf20a6172..b16f7d9693e5 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 @@ -14,11 +14,25 @@ import type { Logger } from '@kbn/logging'; import { BaseMessage } from '@langchain/core/messages'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { ConversationResponse, Replacements } from '@kbn/elastic-assistant-common'; import { AgentState, NodeParamsBase } from './types'; import { AssistantDataClients } from '../../executors/types'; -import { shouldContinue } from './nodes/should_continue'; +import { + shouldContinue, + shouldContinueGenerateTitle, + shouldContinueGetConversation, +} from './nodes/should_continue'; import { AGENT_NODE, runAgent } from './nodes/run_agent'; import { executeTools, TOOLS_NODE } from './nodes/execute_tools'; +import { GENERATE_CHAT_TITLE_NODE, generateChatTitle } from './nodes/generate_chat_title'; +import { + GET_PERSISTED_CONVERSATION_NODE, + getPersistedConversation, +} from './nodes/get_persisted_conversation'; +import { + PERSIST_CONVERSATION_CHANGES_NODE, + persistConversationChanges, +} from './nodes/persist_conversation_changes'; export const DEFAULT_ASSISTANT_GRAPH_ID = 'Default Security Assistant Graph'; @@ -28,8 +42,9 @@ interface GetDefaultAssistantGraphParams { conversationId?: string; llm: BaseChatModel; logger: Logger; - messages: BaseMessage[]; tools: StructuredTool[]; + responseLanguage: string; + replacements: Replacements; } export type DefaultAssistantGraph = ReturnType<typeof getDefaultAssistantGraph>; @@ -43,8 +58,9 @@ export const getDefaultAssistantGraph = ({ dataClients, llm, logger, - messages, + responseLanguage, tools, + replacements, }: GetDefaultAssistantGraphParams) => { try { // Default graph state @@ -66,7 +82,16 @@ export const getDefaultAssistantGraph = ({ }, messages: { value: (x: BaseMessage[], y: BaseMessage[]) => x.concat(y), - default: () => messages, + default: () => [], + }, + chatTitle: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + conversation: { + value: (x: ConversationResponse | undefined, y?: ConversationResponse | undefined) => + y ?? x, + default: () => undefined, }, }; @@ -94,19 +119,68 @@ export const getDefaultAssistantGraph = ({ state, tools, }); + const generateChatTitleNode = (state: AgentState) => + generateChatTitle({ + ...nodeParams, + state, + responseLanguage, + }); + + const getPersistedConversationNode = (state: AgentState) => + getPersistedConversation({ + ...nodeParams, + state, + conversationsDataClient: dataClients?.conversationsDataClient, + conversationId, + }); + + const persistConversationChangesNode = (state: AgentState) => + persistConversationChanges({ + ...nodeParams, + state, + conversationsDataClient: dataClients?.conversationsDataClient, + conversationId, + replacements, + }); const shouldContinueEdge = (state: AgentState) => shouldContinue({ ...nodeParams, state }); + const shouldContinueGenerateTitleEdge = (state: AgentState) => + shouldContinueGenerateTitle({ ...nodeParams, state }); + const shouldContinueGetConversationEdge = (state: AgentState) => + shouldContinueGetConversation({ ...nodeParams, state, conversationId }); // Put together a new graph using the nodes and default state from above - const graph = new StateGraph<AgentState, Partial<AgentState>, '__start__' | 'agent' | 'tools'>({ + const graph = new StateGraph< + AgentState, + Partial<AgentState>, + | '__start__' + | 'agent' + | 'tools' + | 'generateChatTitle' + | 'getPersistedConversation' + | 'persistConversationChanges' + >({ channels: graphState, }); // Define the nodes to cycle between + graph.addNode(GET_PERSISTED_CONVERSATION_NODE, getPersistedConversationNode); + graph.addNode(GENERATE_CHAT_TITLE_NODE, generateChatTitleNode); + graph.addNode(PERSIST_CONVERSATION_CHANGES_NODE, persistConversationChangesNode); graph.addNode(AGENT_NODE, runAgentNode); graph.addNode(TOOLS_NODE, executeToolsNode); + + // Add edges, alternating between agent and action until finished + graph.addConditionalEdges(START, shouldContinueGetConversationEdge, { + continue: GET_PERSISTED_CONVERSATION_NODE, + end: AGENT_NODE, + }); + graph.addConditionalEdges(GET_PERSISTED_CONVERSATION_NODE, shouldContinueGenerateTitleEdge, { + continue: GENERATE_CHAT_TITLE_NODE, + end: PERSIST_CONVERSATION_CHANGES_NODE, + }); + graph.addEdge(GENERATE_CHAT_TITLE_NODE, PERSIST_CONVERSATION_CHANGES_NODE); + graph.addEdge(PERSIST_CONVERSATION_CHANGES_NODE, AGENT_NODE); // Add conditional edge for basic routing graph.addConditionalEdges(AGENT_NODE, shouldContinueEdge, { continue: TOOLS_NODE, end: END }); - // Add edges, alternating between agent and action until finished - graph.addEdge(START, AGENT_NODE); graph.addEdge(TOOLS_NODE, AGENT_NODE); // Compile the graph return graph.compile(); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/helpers.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/helpers.ts index 383b3e9f5cee..482c89c10e96 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/helpers.ts @@ -11,6 +11,7 @@ import { streamFactory, StreamResponseWithHeaders } from '@kbn/ml-response-strea import { transformError } from '@kbn/securitysolution-es-utils'; import type { KibanaRequest } from '@kbn/core-http-server'; import type { ExecuteConnectorRequestBody, TraceData } from '@kbn/elastic-assistant-common'; +import { AGENT_NODE_TAG } from './nodes/run_agent'; import { DEFAULT_ASSISTANT_GRAPH_ID, DefaultAssistantGraph } from './graph'; import type { OnLlmResponse, TraceOptions } from '../../executors/types'; import type { APMTracer } from '../../tracers/apm_tracer'; @@ -91,27 +92,35 @@ export const streamGraph = async ({ if (done) return; const event = value; - if (event.event === 'on_llm_stream') { - const chunk = event.data?.chunk; - // TODO: For Bedrock streaming support, override `handleLLMNewToken` in callbacks, - // TODO: or maybe we can update ActionsClientSimpleChatModel to handle this `on_llm_stream` event - if (event.name === 'ActionsClientChatOpenAI') { - const msg = chunk.message; - - if (msg.tool_call_chunks && msg.tool_call_chunks.length > 0) { - /* empty */ - } else if (!didEnd) { - if (msg.response_metadata.finish_reason === 'stop') { - handleStreamEnd(finalMessage); - } else { - push({ payload: msg.content, type: 'content' }); - finalMessage += msg.content; + // only process events that are part of the agent run + if ((event.tags || []).includes(AGENT_NODE_TAG)) { + if (event.event === 'on_llm_stream') { + const chunk = event.data?.chunk; + // TODO: For Bedrock streaming support, override `handleLLMNewToken` in callbacks, + // TODO: or maybe we can update ActionsClientSimpleChatModel to handle this `on_llm_stream` event + if (event.name === 'ActionsClientChatOpenAI') { + const msg = chunk.message; + + if (msg.tool_call_chunks && msg.tool_call_chunks.length > 0) { + /* empty */ + } else if (!didEnd) { + if (msg.response_metadata.finish_reason === 'stop') { + handleStreamEnd(finalMessage); + } else { + push({ payload: msg.content, type: 'content' }); + finalMessage += msg.content; + } } } + } else if (event.event === 'on_llm_end') { + const generations = event.data.output?.generations[0]; + if (generations && generations[0]?.generationInfo.finish_reason === 'stop') { + handleStreamEnd(finalMessage); + } } } - await processEvent(); + void processEvent(); } catch (err) { // if I throw an error here, it crashes the server. Not sure how to get around that. // If I put await on this function the error works properly, but when there is not an error @@ -129,7 +138,7 @@ export const streamGraph = async ({ }; // Start processing events, do not await! Return `responseWithHeaders` immediately - await processEvent(); + void processEvent(); return responseWithHeaders; }; 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 1e40f6b2fe12..517ac1047946 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 @@ -13,21 +13,22 @@ import { ActionsClientSimpleChatModel, } from '@kbn/langchain/server'; import { createOpenAIFunctionsAgent, createStructuredChatAgent } from 'langchain/agents'; +import { EsAnonymizationFieldsSchema } from '../../../../ai_assistant_data_clients/anonymization_fields/types'; import { AssistantToolParams } from '../../../../types'; import { AgentExecutor } from '../../executors/types'; import { openAIFunctionAgentPrompt, structuredChatAgentPrompt } from './prompts'; import { APMTracer } from '../../tracers/apm_tracer'; import { getDefaultAssistantGraph } from './graph'; import { invokeGraph, streamGraph } from './helpers'; +import { transformESSearchToAnonymizationFields } from '../../../../ai_assistant_data_clients/anonymization_fields/helpers'; /** * Drop in replacement for the existing `callAgentExecutor` that uses LangGraph */ export const callAssistantGraph: AgentExecutor<true | false> = async ({ abortSignal, - actions, + actionsClient, alertsIndexPattern, - anonymizationFields, isEnabledKnowledgeBase, assistantTools = [], connectorId, @@ -45,15 +46,15 @@ export const callAssistantGraph: AgentExecutor<true | false> = async ({ request, size, traceOptions, + responseLanguage = 'English', }) => { const logger = parentLogger.get('defaultAssistantGraph'); const isOpenAI = llmType === 'openai'; const llmClass = isOpenAI ? ActionsClientChatOpenAI : ActionsClientSimpleChatModel; const llm = new llmClass({ - actions, + actionsClient, connectorId, - request, llmType, logger, // possible client model override, @@ -68,15 +69,23 @@ export const callAssistantGraph: AgentExecutor<true | false> = async ({ // failure could be due to bad connector, we should deliver that result to the client asap maxRetries: 0, }); - const model = llm; - const messages = langChainMessages.slice(0, -1); // all but the last message + const anonymizationFieldsRes = + await dataClients?.anonymizationFieldsDataClient?.findDocuments<EsAnonymizationFieldsSchema>({ + perPage: 1000, + page: 1, + }); + + const anonymizationFields = anonymizationFieldsRes + ? transformESSearchToAnonymizationFields(anonymizationFieldsRes.data) + : undefined; + const latestMessage = langChainMessages.slice(-1); // the last message const modelExists = await esStore.isModelInstalled(); // Create a chain that uses the ELSER backed ElasticsearchStore, override k=10 for esql query generation for now - const chain = RetrievalQAChain.fromLLM(model, esStore.asRetriever(10)); + const chain = RetrievalQAChain.fromLLM(llm, esStore.asRetriever(10)); // Fetch any applicable tools that the source plugin may have registered const assistantToolParams: AssistantToolParams = { @@ -86,7 +95,7 @@ export const callAssistantGraph: AgentExecutor<true | false> = async ({ esClient, isEnabledKnowledgeBase, kbDataClient: dataClients?.kbDataClient, - llm: model, + llm, logger, modelExists, onNewReplacements, @@ -121,10 +130,11 @@ export const callAssistantGraph: AgentExecutor<true | false> = async ({ dataClients, llm, logger, - messages, tools, + responseLanguage, + replacements, }); - const inputs = { input: latestMessage[0].content as string }; + const inputs = { input: latestMessage[0]?.content as string }; if (isStream) { return streamGraph({ apmTracer, assistantGraph, inputs, logger, onLlmResponse, request }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/generate_chat_title.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/generate_chat_title.ts index bcba25eab0b0..d1d60e9bed9b 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/generate_chat_title.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/generate_chat_title.ts @@ -8,46 +8,45 @@ import { StringOutputParser } from '@langchain/core/output_parsers'; import { ChatPromptTemplate } from '@langchain/core/prompts'; import { AgentState, NodeParamsBase } from '../types'; -import { AIAssistantConversationsDataClient } from '../../../../../ai_assistant_data_clients/conversations'; -export const GENERATE_CHAT_TITLE_PROMPT = ChatPromptTemplate.fromMessages([ - [ - 'system', - `You are a helpful assistant for Elastic Security. Assume the following user message is the start of a conversation between you and a user; give this conversation a title based on the content below. DO NOT UNDER ANY CIRCUMSTANCES wrap this title in single or double quotes. This title is shown in a list of conversations to the user, so title it for the user, not for you. As an example, for the given MESSAGE, this is the TITLE: +export const GENERATE_CHAT_TITLE_PROMPT = (responseLanguage: string) => + ChatPromptTemplate.fromMessages([ + [ + 'system', + `You are a helpful assistant for Elastic Security. Assume the following user message is the start of a conversation between you and a user; give this conversation a title based on the content below. DO NOT UNDER ANY CIRCUMSTANCES wrap this title in single or double quotes. This title is shown in a list of conversations to the user, so title it for the user, not for you. Please create the title in ${responseLanguage}. As an example, for the given MESSAGE, this is the TITLE: MESSAGE: I am having trouble with the Elastic Security app. TITLE: Troubleshooting Elastic Security app issues `, - ], - ['human', '{input}'], -]); + ], + ['human', '{input}'], + ]); export interface GenerateChatTitleParams extends NodeParamsBase { - conversationsDataClient?: AIAssistantConversationsDataClient; - conversationId?: string; + responseLanguage: string; state: AgentState; } export const GENERATE_CHAT_TITLE_NODE = 'generateChatTitle'; export const generateChatTitle = async ({ - conversationsDataClient, + responseLanguage, logger, model, state, }: GenerateChatTitleParams) => { logger.debug(`Node state:\n ${JSON.stringify(state, null, 2)}`); + if (state.messages.length !== 0) { logger.debug('No need to generate chat title, messages already exist'); - return; + return { chatTitle: '' }; } const outputParser = new StringOutputParser(); - const graph = GENERATE_CHAT_TITLE_PROMPT.pipe(model).pipe(outputParser); + const graph = GENERATE_CHAT_TITLE_PROMPT(responseLanguage).pipe(model).pipe(outputParser); const chatTitle = await graph.invoke({ input: JSON.stringify(state.input, null, 2), }); - logger.debug(`chatTitle: ${chatTitle}`); return { diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/get_persisted_conversation.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/get_persisted_conversation.ts new file mode 100644 index 000000000000..6dbf284e462c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/get_persisted_conversation.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AgentState, NodeParamsBase } from '../types'; +import { AIAssistantConversationsDataClient } from '../../../../../ai_assistant_data_clients/conversations'; +import { getLangChainMessages } from '../../../helpers'; + +export interface GetPersistedConversationParams extends NodeParamsBase { + conversationsDataClient?: AIAssistantConversationsDataClient; + conversationId?: string; + state: AgentState; +} + +export const GET_PERSISTED_CONVERSATION_NODE = 'getPersistedConversation'; + +export const getPersistedConversation = async ({ + conversationsDataClient, + conversationId, + logger, + state, +}: GetPersistedConversationParams) => { + logger.debug(`Node state:\n ${JSON.stringify(state, null, 2)}`); + if (!conversationId) { + logger.debug('Cannot get conversation, because conversationId is undefined'); + return { + conversation: undefined, + messages: [], + chatTitle: '', + input: state.input, + }; + } + + const conversation = await conversationsDataClient?.getConversation({ id: conversationId }); + if (!conversation) { + logger.debug('Requested conversation, because conversation is undefined'); + return { + conversation: undefined, + messages: [], + chatTitle: '', + input: state.input, + }; + } + + logger.debug(`conversationId: ${conversationId}`); + + const messages = getLangChainMessages(conversation.messages ?? []); + return { + conversation, + messages, + chatTitle: conversation.title, + input: !state.input ? conversation.messages?.slice(-1)[0].content : state.input, + }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/persist_conversation_changes.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/persist_conversation_changes.ts new file mode 100644 index 000000000000..a86897e67adb --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/persist_conversation_changes.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + Replacements, + replaceAnonymizedValuesWithOriginalValues, +} from '@kbn/elastic-assistant-common'; +import { AgentState, NodeParamsBase } from '../types'; +import { AIAssistantConversationsDataClient } from '../../../../../ai_assistant_data_clients/conversations'; +import { getLangChainMessages } from '../../../helpers'; + +export interface PersistConversationChangesParams extends NodeParamsBase { + conversationsDataClient?: AIAssistantConversationsDataClient; + conversationId?: string; + state: AgentState; + replacements?: Replacements; +} + +export const PERSIST_CONVERSATION_CHANGES_NODE = 'persistConversationChanges'; + +export const persistConversationChanges = async ({ + conversationsDataClient, + conversationId, + logger, + state, + replacements = {}, +}: PersistConversationChangesParams) => { + logger.debug(`Node state:\n ${JSON.stringify(state, null, 2)}`); + + if (!state.conversation || !conversationId) { + logger.debug('No need to generate chat title, conversationId is undefined'); + return { + conversation: undefined, + messages: [], + }; + } + + let conversation; + if (state.conversation?.title !== state.chatTitle) { + conversation = await conversationsDataClient?.updateConversation({ + conversationUpdateProps: { + id: conversationId, + title: state.chatTitle, + }, + }); + } + + const updatedConversation = await conversationsDataClient?.appendConversationMessages({ + existingConversation: conversation ? conversation : state.conversation, + messages: [ + { + content: replaceAnonymizedValuesWithOriginalValues({ + messageContent: state.input, + replacements, + }), + role: 'user', + timestamp: new Date().toISOString(), + }, + ], + }); + if (!updatedConversation) { + logger.debug('Not updated conversation'); + return { conversation: undefined, messages: [] }; + } + + logger.debug(`conversationId: ${conversationId}`); + const langChainMessages = getLangChainMessages(updatedConversation.messages ?? []); + const messages = langChainMessages.slice(0, -1); // all but the last message + + return { + conversation: updatedConversation, + messages, + }; +}; 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 b0353bb5d8ec..0a6ee3b79087 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 @@ -19,6 +19,8 @@ export interface RunAgentParams extends NodeParamsBase { export const AGENT_NODE = 'agent'; +export const AGENT_NODE_TAG = 'agent_run'; + const NO_HISTORY = '[No existing knowledge history]'; /** * Node to run the agent @@ -44,11 +46,11 @@ export const runAgent = async ({ query: '', }); - const agentOutcome = await agentRunnable.invoke( + const agentOutcome = await agentRunnable.withConfig({ tags: [AGENT_NODE_TAG] }).invoke( { ...state, chat_history: state.messages, // TODO: Message de-dupe with ...state spread - knowledge_history: knowledgeHistory?.length ? knowledgeHistory : NO_HISTORY, + knowledge_history: JSON.stringify(knowledgeHistory?.length ? knowledgeHistory : NO_HISTORY), }, config ); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/should_continue.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/should_continue.ts index 281963df363a..046c4a86d4c7 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/should_continue.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/should_continue.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { NEW_CHAT } from '../../../../../routes/helpers'; import { AgentState, NodeParamsBase } from '../types'; export interface ShouldContinueParams extends NodeParamsBase { @@ -26,3 +27,31 @@ export const shouldContinue = ({ logger, state }: ShouldContinueParams) => { return 'continue'; }; + +export const shouldContinueGenerateTitle = ({ logger, state }: ShouldContinueParams) => { + logger.debug(`Node state:\n${JSON.stringify(state, null, 2)}`); + + if (state.conversation?.title !== NEW_CHAT) { + return 'end'; + } + + return 'continue'; +}; + +export interface ShouldContinueGetConversation extends NodeParamsBase { + state: AgentState; + conversationId?: string; +} +export const shouldContinueGetConversation = ({ + logger, + state, + conversationId, +}: ShouldContinueGetConversation) => { + logger.debug(`Node state:\n${JSON.stringify(state, null, 2)}`); + + if (!conversationId) { + return 'end'; + } + + return 'continue'; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/types.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/types.ts index 1d19646fb6eb..4ee4f1ba1b14 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/types.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/types.ts @@ -9,6 +9,7 @@ import { BaseMessage } from '@langchain/core/messages'; import { AgentAction, AgentFinish, AgentStep } from '@langchain/core/agents'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { Logger } from '@kbn/logging'; +import { ConversationResponse } from '@kbn/elastic-assistant-common'; export interface AgentStateBase { agentOutcome?: AgentAction | AgentFinish; @@ -18,6 +19,8 @@ export interface AgentStateBase { export interface AgentState extends AgentStateBase { input: string; messages: BaseMessage[]; + chatTitle: string; + conversation: ConversationResponse | undefined; } export interface NodeParamsBase { 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 bd8f7983921c..15877e672771 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 @@ -7,6 +7,8 @@ import { AuthenticatedUser } from '@kbn/core-security-common'; import moment from 'moment'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; + import { REQUIRED_FOR_ATTACK_DISCOVERY, addGenerationInterval, @@ -21,7 +23,6 @@ import { import { ActionsClientLlm } from '@kbn/langchain/server'; import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { KibanaRequest } from '@kbn/core-http-server'; @@ -90,7 +91,6 @@ const mockApiConfig = { const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; -const mockActions: ActionsPluginStart = {} as ActionsPluginStart; // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockRequest: KibanaRequest<unknown, unknown, any, any> = {} as unknown as KibanaRequest< unknown, @@ -117,14 +117,14 @@ describe('helpers', () => { describe('getAssistantToolParams', () => { const alertsIndexPattern = '.alerts-security.alerts-default'; const esClient = elasticsearchClientMock.createElasticsearchClient(); + const actionsClient = actionsClientMock.create(); const langChainTimeout = 1000; const latestReplacements = {}; const llm = new ActionsClientLlm({ - actions: mockActions, + actionsClient, connectorId: 'test-connecter-id', llmType: 'bedrock', logger: mockLogger, - request: mockRequest, temperature: 0, timeout: 580000, }); @@ -132,7 +132,7 @@ describe('helpers', () => { const size = 20; const mockParams = { - actions: {} as unknown as ActionsPluginStart, + actionsClient, alertsIndexPattern: 'alerts-*', anonymizationFields: [{ id: '1', field: 'field1', allowed: true, anonymized: true }], apiConfig: mockApiConfig, @@ -173,7 +173,7 @@ describe('helpers', () => { ]; const result = getAssistantToolParams({ - actions: mockParams.actions, + actionsClient, alertsIndexPattern, apiConfig: mockApiConfig, anonymizationFields, @@ -208,7 +208,7 @@ describe('helpers', () => { const anonymizationFields = undefined; const result = getAssistantToolParams({ - actions: mockParams.actions, + actionsClient, alertsIndexPattern, apiConfig: mockApiConfig, anonymizationFields, 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 c3665d1583a3..5f5eb8d0d865 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 @@ -24,9 +24,10 @@ import { ActionsClientLlm } from '@kbn/langchain/server'; import { Moment } from 'moment'; import { transformError } from '@kbn/securitysolution-es-utils'; -import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; import moment from 'moment/moment'; import { uniq } from 'lodash/fp'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { getLangSmithTracer } from '../evaluate/utils'; import { getLlmType } from '../utils'; import type { GetRegisteredTools } from '../../services/app_context'; @@ -53,7 +54,7 @@ export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [ ]; export const getAssistantToolParams = ({ - actions, + actionsClient, alertsIndexPattern, anonymizationFields, apiConfig, @@ -68,7 +69,7 @@ export const getAssistantToolParams = ({ request, size, }: { - actions: ActionsPluginStart; + actionsClient: PublicMethodsOf<ActionsClient>; alertsIndexPattern: string; anonymizationFields?: AnonymizationFieldResponse[]; apiConfig: ApiConfig; @@ -99,11 +100,10 @@ export const getAssistantToolParams = ({ }; const llm = new ActionsClientLlm({ - actions, + actionsClient, connectorId: apiConfig.connectorId, llmType: getLlmType(apiConfig.actionTypeId), logger, - request, temperature: 0, // zero temperature for attack discovery, because we want structured JSON output timeout: connectorTimeout, traceOptions, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts index 9ecfb5c2af33..cbd3e6063fbd 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts @@ -10,6 +10,7 @@ import { postAttackDiscoveryRoute } from './post_attack_discovery'; import { serverMock } from '../../__mocks__/server'; import { requestContextMock } from '../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; @@ -68,6 +69,7 @@ describe('postAttackDiscoveryRoute', () => { jest.clearAllMocks(); context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser); context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(mockDataClient); + context.elasticAssistant.actions = actionsMock.createStart(); postAttackDiscoveryRoute(server.router); findAttackDiscoveryByConnectorId.mockResolvedValue(mockCurrentAd); (getAssistantTool as jest.Mock).mockReturnValue({ getTool: jest.fn() }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts index 8ff2cd72ee36..b9c680dde3d1 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts @@ -70,6 +70,7 @@ export const postAttackDiscoveryRoute = ( try { // get the actions plugin start contract from the request context: const actions = (await context.elasticAssistant).actions; + const actionsClient = await actions.getActionsClientWithRequest(request); const dataClient = await assistantContext.getAttackDiscoveryDataClient(); const authenticatedUser = assistantContext.getCurrentUser(); if (authenticatedUser == null) { @@ -120,7 +121,7 @@ export const postAttackDiscoveryRoute = ( } const assistantToolParams = getAssistantToolParams({ - actions, + actionsClient, alertsIndexPattern, anonymizationFields, apiConfig, diff --git a/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts new file mode 100644 index 000000000000..a487e56019bd --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts @@ -0,0 +1,456 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient, IRouter, KibanaRequest, Logger } from '@kbn/core/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import { BaseMessage } from '@langchain/core/messages'; +import { NEVER } from 'rxjs'; +import { mockActionResponse } from '../../__mocks__/action_result_data'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { coreMock } from '@kbn/core/server/mocks'; +import { + INVOKE_ASSISTANT_ERROR_EVENT, + INVOKE_ASSISTANT_SUCCESS_EVENT, +} from '../../lib/telemetry/event_based_telemetry'; +import { PassThrough } from 'stream'; +import { getConversationResponseMock } from '../../ai_assistant_data_clients/conversations/update_conversation.test'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; +import { getFindAnonymizationFieldsResultWithSingleHit } from '../../__mocks__/response'; +import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; +import { chatCompleteRoute } from './chat_complete_route'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; + +const license = licensingMock.createLicenseMock(); + +const actionsClient = actionsClientMock.create(); +jest.mock('../../lib/build_response', () => ({ + buildResponse: jest.fn().mockImplementation((x) => x), +})); +const mockStream = jest.fn().mockImplementation(() => new PassThrough()); +jest.mock('../../lib/langchain/execute_custom_llm_chain', () => ({ + callAgentExecutor: jest.fn().mockImplementation( + async ({ + connectorId, + isStream, + onLlmResponse, + }: { + onLlmResponse: ( + content: string, + replacements: Record<string, string>, + isError: boolean + ) => Promise<void>; + actionsClient: PublicMethodsOf<ActionsClient>; + connectorId: string; + esClient: ElasticsearchClient; + langChainMessages: BaseMessage[]; + logger: Logger; + isStream: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: KibanaRequest<unknown, unknown, any, any>; + }) => { + if (!isStream && connectorId === 'mock-connector-id') { + return { + connector_id: 'mock-connector-id', + data: mockActionResponse, + status: 'ok', + }; + } else if (isStream && connectorId === 'mock-connector-id') { + return mockStream; + } else { + onLlmResponse('simulated error', {}, true).catch(() => {}); + throw new Error('simulated error'); + } + } + ), +})); +const existingConversation = getConversationResponseMock(); +const reportEvent = jest.fn(); +const appendConversationMessages = jest.fn(); +const mockContext = { + resolve: jest.fn().mockResolvedValue({ + elasticAssistant: { + actions: { + getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClient), + }, + getRegisteredTools: jest.fn(() => []), + getRegisteredFeatures: jest.fn(() => defaultAssistantFeatures), + logger: loggingSystemMock.createLogger(), + telemetry: { ...coreMock.createSetup().analytics, reportEvent }, + getCurrentUser: () => ({ + username: 'user', + email: 'email', + fullName: 'full name', + roles: ['user-role'], + enabled: true, + authentication_realm: { name: 'native1', type: 'native' }, + lookup_realm: { name: 'native1', type: 'native' }, + authentication_provider: { type: 'basic', name: 'basic1' }, + authentication_type: 'realm', + elastic_cloud_user: false, + metadata: { _reserved: false }, + }), + getAIAssistantConversationsDataClient: jest.fn().mockResolvedValue({ + getConversation: jest.fn().mockResolvedValue(existingConversation), + updateConversation: jest.fn().mockResolvedValue(existingConversation), + createConversation: jest.fn().mockResolvedValue(existingConversation), + appendConversationMessages: + appendConversationMessages.mockResolvedValue(existingConversation), + }), + getAIAssistantAnonymizationFieldsDataClient: jest.fn().mockResolvedValue({ + findDocuments: jest.fn().mockResolvedValue(getFindAnonymizationFieldsResultWithSingleHit()), + }), + }, + core: { + elasticsearch: { + client: elasticsearchServiceMock.createScopedClusterClient(), + }, + savedObjects: coreMock.createRequestHandlerContext().savedObjects, + }, + licensing: { + ...licensingMock.createRequestHandlerContext({ license }), + license, + }, + }), +}; + +const mockRequest = { + body: { + conversationId: 'mock-conversation-id', + connectorId: 'mock-connector-id', + persist: true, + isEnabledKnowledgeBase: true, + isEnabledRAGAlerts: false, + model: 'gpt-4', + messages: [ + { + role: 'user', + content: + "Evaluate the event from the context and format your output neatly in markdown syntax for my Elastic Security case.\nAdd your description, recommended actions and bulleted triage steps. Use the MITRE ATT&CK data provided to add more context and recommendations from MITRE, and hyperlink to the relevant pages on MITRE's website. Be sure to include the user and host risk score data from the context. Your response should include steps that point to Elastic Security specific features, including endpoint response actions, the Elastic Agent OSQuery manager integration (with example osquery queries), timelines and entity analytics and link to all the relevant Elastic Security documentation.", + data: { + 'event.category': 'process', + 'process.pid': 69516, + 'host.os.version': 14.5, + 'host.os.name': 'macOS', + 'host.name': 'Yuliias-MBP', + 'process.name': 'biomesyncd', + 'user.name': 'yuliianaumenko', + 'process.working_directory': '/', + 'event.module': 'system', + 'process.executable': '/usr/libexec/biomesyncd', + 'process.args': '/usr/libexec/biomesyncd', + }, + }, + ], + }, + events: { + aborted$: NEVER, + }, +}; + +const mockResponse = { + ok: jest.fn().mockImplementation((x) => x), + error: jest.fn().mockImplementation((x) => x), +}; + +describe('chatCompleteRoute', () => { + const mockGetElser = jest.fn().mockResolvedValue('.elser_model_2'); + + beforeEach(() => { + jest.clearAllMocks(); + license.hasAtLeast.mockReturnValue(true); + actionsClient.execute.mockImplementation( + jest.fn().mockResolvedValue(() => ({ + data: 'mockChatCompletion', + status: 'ok', + })) + ); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + isPreconfigured: false, + isSystemAction: false, + isDeprecated: false, + name: 'my name', + actionTypeId: '.gen-ai', + isMissingSecrets: false, + config: { + a: true, + b: true, + c: true, + }, + }, + ]); + }); + + it('returns the expected response when using the existingConversation', async () => { + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + const result = await handler( + mockContext, + { + ...mockRequest, + body: { + ...mockRequest.body, + conversationId: existingConversation.id, + }, + }, + mockResponse + ); + + expect(result).toEqual({ + connector_id: 'mock-connector-id', + data: mockActionResponse, + status: 'ok', + }); + }), + }; + }), + }, + }; + + chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); + + it('returns the expected error when executeCustomLlmChain fails', async () => { + const requestWithBadConnectorId = { + ...mockRequest, + body: { + ...mockRequest.body, + connectorId: 'bad-connector-id', + }, + }; + + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + const result = await handler(mockContext, requestWithBadConnectorId, mockResponse); + + expect(result).toEqual({ + body: 'simulated error', + statusCode: 500, + }); + }), + }; + }), + }, + }; + + await chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); + + it('reports success events to telemetry - kb on, RAG alerts off', async () => { + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + await handler(mockContext, mockRequest, mockResponse); + + expect(reportEvent).toHaveBeenCalledWith(INVOKE_ASSISTANT_SUCCESS_EVENT.eventType, { + isEnabledKnowledgeBase: true, + isEnabledRAGAlerts: false, + actionTypeId: '.gen-ai', + model: 'gpt-4', + assistantStreamingEnabled: false, + }); + }), + }; + }), + }, + }; + + await chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); + + it('reports success events to telemetry - kb on, RAG alerts on', async () => { + const ragRequest = { + ...mockRequest, + body: { + ...mockRequest.body, + isEnabledRAGAlerts: true, + anonymizationFields: [ + { id: '@timestamp', field: '@timestamp', allowed: true, anonymized: false }, + { id: 'host.name', field: 'host.name', allowed: true, anonymized: true }, + ], + }, + }; + + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + await handler(mockContext, ragRequest, mockResponse); + + expect(reportEvent).toHaveBeenCalledWith(INVOKE_ASSISTANT_SUCCESS_EVENT.eventType, { + isEnabledKnowledgeBase: true, + isEnabledRAGAlerts: true, + actionTypeId: '.gen-ai', + model: 'gpt-4', + assistantStreamingEnabled: false, + }); + }), + }; + }), + }, + }; + + await chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); + + it('reports error events to telemetry - kb on, RAG alerts off', async () => { + const requestWithBadConnectorId = { + ...mockRequest, + body: { + ...mockRequest.body, + connectorId: 'bad-connector-id', + }, + }; + + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + await handler(mockContext, requestWithBadConnectorId, mockResponse); + + expect(reportEvent).toHaveBeenCalledWith(INVOKE_ASSISTANT_ERROR_EVENT.eventType, { + errorMessage: 'simulated error', + isEnabledKnowledgeBase: true, + isEnabledRAGAlerts: true, + actionTypeId: '.gen-ai', + model: 'gpt-4', + assistantStreamingEnabled: false, + }); + }), + }; + }), + }, + }; + + await chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); + + it('Adds error to conversation history', async () => { + const badRequest = { + ...mockRequest, + body: { + ...mockRequest.body, + conversationId: '99999', + connectorId: 'bad-connector-id', + }, + }; + + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + await handler(mockContext, badRequest, mockResponse); + expect(appendConversationMessages.mock.calls[1][0].messages[0]).toEqual( + expect.objectContaining({ + content: 'simulated error', + isError: true, + role: 'assistant', + }) + ); + }), + }; + }), + }, + }; + + await chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); + + it('returns the expected response when isStream=true and actionTypeId=.gen-ai', async () => { + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + const result = await handler( + mockContext, + { + ...mockRequest, + body: { + ...mockRequest.body, + isStream: true, + }, + }, + mockResponse + ); + + expect(result).toEqual(mockStream); + }), + }; + }), + }, + }; + + await chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); + + it('returns the expected response when isStream=true and actionTypeId=.bedrock', async () => { + const mockRouter = { + versioned: { + post: jest.fn().mockImplementation(() => { + return { + addVersion: jest.fn().mockImplementation(async (_, handler) => { + const result = await handler( + mockContext, + { + ...mockRequest, + body: { + ...mockRequest.body, + isStream: true, + }, + }, + mockResponse + ); + + expect(result).toEqual(mockStream); + }), + }; + }), + }, + }; + await chatCompleteRoute( + mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>, + mockGetElser + ); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts new file mode 100644 index 000000000000..10da330a36c7 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformError } from '@kbn/securitysolution-es-utils'; +import { Logger } from '@kbn/core/server'; +import { + ELASTIC_AI_ASSISTANT_CHAT_COMPLETE_URL, + ChatCompleteProps, + API_VERSIONS, + Message, + Replacements, + transformRawData, + getAnonymizedValue, + ConversationResponse, +} from '@kbn/elastic-assistant-common'; +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; +import { getRequestAbortedSignal } from '@kbn/data-plugin/server'; +import { INVOKE_ASSISTANT_ERROR_EVENT } from '../../lib/telemetry/event_based_telemetry'; +import { ElasticAssistantPluginRouter, GetElser } from '../../types'; +import { buildResponse } from '../../lib/build_response'; +import { + DEFAULT_PLUGIN_NAME, + appendAssistantMessageToConversation, + createOrUpdateConversationWithUserInput, + getPluginNameFromRequest, + langChainExecute, + performChecks, +} from '../helpers'; +import { transformESSearchToAnonymizationFields } from '../../ai_assistant_data_clients/anonymization_fields/helpers'; +import { EsAnonymizationFieldsSchema } from '../../ai_assistant_data_clients/anonymization_fields/types'; + +export const SYSTEM_PROMPT_CONTEXT_NON_I18N = (context: string) => { + return `CONTEXT:\n"""\n${context}\n"""`; +}; + +export const chatCompleteRoute = ( + router: ElasticAssistantPluginRouter, + getElser: GetElser +): void => { + router.versioned + .post({ + access: 'public', + path: ELASTIC_AI_ASSISTANT_CHAT_COMPLETE_URL, + + options: { + tags: ['access:elasticAssistant'], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + body: buildRouteValidationWithZod(ChatCompleteProps), + }, + }, + }, + async (context, request, response) => { + const abortSignal = getRequestAbortedSignal(request.events.aborted$); + const assistantResponse = buildResponse(response); + let telemetry; + let actionTypeId; + try { + const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); + const logger: Logger = ctx.elasticAssistant.logger; + telemetry = ctx.elasticAssistant.telemetry; + + // Perform license and authenticated user checks + const checkResponse = performChecks({ + authenticatedUser: true, + context: ctx, + license: true, + request, + response, + }); + if (checkResponse) { + return checkResponse; + } + + const conversationsDataClient = + await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); + + const anonymizationFieldsDataClient = + await ctx.elasticAssistant.getAIAssistantAnonymizationFieldsDataClient(); + + let messages; + const conversationId = request.body.conversationId; + const connectorId = request.body.connectorId; + + let latestReplacements: Replacements = {}; + const onNewReplacements = (newReplacements: Replacements) => { + latestReplacements = { ...latestReplacements, ...newReplacements }; + }; + + // get the actions plugin start contract from the request context: + const actions = ctx.elasticAssistant.actions; + const actionsClient = await actions.getActionsClientWithRequest(request); + const connectors = await actionsClient.getBulk({ ids: [connectorId] }); + actionTypeId = connectors.length > 0 ? connectors[0].actionTypeId : '.gen-ai'; + + // replacements + const anonymizationFieldsRes = + await anonymizationFieldsDataClient?.findDocuments<EsAnonymizationFieldsSchema>({ + perPage: 1000, + page: 1, + }); + + let anonymizationFields = anonymizationFieldsRes + ? transformESSearchToAnonymizationFields(anonymizationFieldsRes.data) + : undefined; + + // anonymize messages before sending to LLM + messages = request.body.messages.map((m) => { + let content = m.content ?? ''; + if (m.data) { + // includes/anonymize fields from the messages data + if (m.fields_to_anonymize && m.fields_to_anonymize.length > 0) { + anonymizationFields = anonymizationFields?.map((a) => { + if (m.fields_to_anonymize?.includes(a.field)) { + return { + ...a, + allowed: true, + anonymized: true, + }; + } + return a; + }); + } + const anonymizedData = transformRawData({ + anonymizationFields, + currentReplacements: latestReplacements, + getAnonymizedValue, + onNewReplacements, + rawData: Object.keys(m.data).reduce( + (obj, key) => ({ ...obj, [key]: [m.data ? m.data[key] : ''] }), + {} + ), + }); + const wr = `${SYSTEM_PROMPT_CONTEXT_NON_I18N(anonymizedData)}\n`; + content = `${wr}\n${m.content}`; + } + const transformedMessage = { + role: m.role, + content, + }; + return transformedMessage; + }); + + let updatedConversation: ConversationResponse | undefined | null; + // Fetch any tools registered by the request's originating plugin + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + const enableKnowledgeBaseByDefault = + ctx.elasticAssistant.getRegisteredFeatures(pluginName).assistantKnowledgeBaseByDefault; + // TODO: remove non-graph persistance when KB will be enabled by default + if ( + (!enableKnowledgeBaseByDefault || (enableKnowledgeBaseByDefault && !conversationId)) && + request.body.persist && + conversationsDataClient + ) { + updatedConversation = await createOrUpdateConversationWithUserInput({ + actionsClient, + actionTypeId, + connectorId, + conversationId, + conversationsDataClient, + promptId: request.body.promptId, + logger, + replacements: latestReplacements, + newMessages: messages, + model: request.body.model, + }); + if (updatedConversation == null) { + return assistantResponse.error({ + body: `conversation id: "${conversationId}" not updated`, + statusCode: 400, + }); + } + // messages are anonymized by conversationsDataClient + messages = updatedConversation?.messages?.map((c) => ({ + role: c.role, + content: c.content, + })); + } + + const onLlmResponse = async ( + content: string, + traceData: Message['traceData'] = {}, + isError = false + ): Promise<void> => { + if (updatedConversation?.id && conversationsDataClient) { + await appendAssistantMessageToConversation({ + conversationId: updatedConversation?.id, + conversationsDataClient, + messageContent: content, + replacements: latestReplacements, + isError, + traceData, + }); + } + }; + + return await langChainExecute({ + abortSignal, + isEnabledKnowledgeBase: true, + isStream: request.body.isStream ?? false, + actionsClient, + actionTypeId, + connectorId, + conversationId, + context: ctx, + getElser, + logger, + messages: messages ?? [], + onLlmResponse, + onNewReplacements, + replacements: latestReplacements, + request, + response, + telemetry, + responseLanguage: request.body.responseLanguage, + }); + } catch (err) { + const error = transformError(err as Error); + telemetry?.reportEvent(INVOKE_ASSISTANT_ERROR_EVENT.eventType, { + actionTypeId: actionTypeId ?? '', + isEnabledKnowledgeBase: true, + isEnabledRAGAlerts: true, + model: request.body.model, + errorMessage: error.message, + // TODO rm actionTypeId check when llmClass for bedrock streaming is implemented + // tracked here: https://github.com/elastic/security-team/issues/7363 + assistantStreamingEnabled: request.body.isStream ?? false, + }); + return assistantResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; 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 ef1950b5e90a..990417b79923 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 @@ -192,7 +192,7 @@ export const postEvaluateRoute = ( agents.push({ agentEvaluator: async (langChainMessages, exampleId) => { const evalResult = await AGENT_EXECUTOR_MAP[agentName]({ - actions, + actionsClient, isEnabledKnowledgeBase: true, assistantTools, connectorId, @@ -237,9 +237,8 @@ export const postEvaluateRoute = ( evalModel == null || evalModel === '' ? undefined : new ActionsClientLlm({ - actions, + actionsClient, connectorId: evalModel, - request: skeletonRequest, logger, model: skeletonRequest.body.model, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts index 243de14d67ed..aa060e24bc5d 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts @@ -5,15 +5,46 @@ * 2.0. */ -import { IKibanaResponse, KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server'; -import { Logger } from '@kbn/core/server'; -import { Message, TraceData } from '@kbn/elastic-assistant-common'; +import { + AnalyticsServiceSetup, + IKibanaResponse, + KibanaRequest, + KibanaResponseFactory, + Logger, +} from '@kbn/core/server'; +import { StreamResponseWithHeaders } from '@kbn/ml-response-stream/server'; + +import { + TraceData, + ConversationResponse, + ExecuteConnectorRequestBody, + Message, + Replacements, + replaceAnonymizedValuesWithOriginalValues, +} from '@kbn/elastic-assistant-common'; import { ILicense } from '@kbn/licensing-plugin/server'; -import { AwaitedProperties } from '@kbn/utility-types'; +import { i18n } from '@kbn/i18n'; +import { AwaitedProperties, PublicMethodsOf } from '@kbn/utility-types'; +import { ActionsClient } from '@kbn/actions-plugin/server'; import { AssistantFeatureKey } from '@kbn/elastic-assistant-common/impl/capabilities'; import { MINIMUM_AI_ASSISTANT_LICENSE } from '../../common/constants'; -import { ElasticAssistantRequestHandlerContext } from '../types'; -import { buildResponse } from './utils'; +import { ESQL_RESOURCE, KNOWLEDGE_BASE_INDEX_PATTERN } from './knowledge_base/constants'; +import { callAgentExecutor } from '../lib/langchain/execute_custom_llm_chain'; +import { buildResponse, getLlmType } from './utils'; +import { + AgentExecutorParams, + AssistantDataClients, + StaticReturnType, +} from '../lib/langchain/executors/types'; +import { executeAction, StaticResponse } from '../lib/executor'; +import { getLangChainMessages } from '../lib/langchain/helpers'; + +import { getLangSmithTracer } from './evaluate/utils'; +import { ElasticsearchStore } from '../lib/langchain/elasticsearch_store/elasticsearch_store'; +import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; +import { INVOKE_ASSISTANT_SUCCESS_EVENT } from '../lib/telemetry/event_based_telemetry'; +import { ElasticAssistantRequestHandlerContext, GetElser } from '../types'; +import { callAssistantGraph } from '../lib/langchain/graphs/default_assistant_graph'; interface GetPluginNameFromRequestParams { request: KibanaRequest; @@ -23,6 +54,10 @@ interface GetPluginNameFromRequestParams { export const DEFAULT_PLUGIN_NAME = 'securitySolutionUI'; +export const NEW_CHAT = i18n.translate('xpack.elasticAssistantPlugin.server.newChat', { + defaultMessage: 'New chat', +}); + /** * Attempts to extract the plugin name the request originated from using the request headers. * @@ -92,6 +127,495 @@ export const hasAIAssistantLicense = (license: ILicense): boolean => export const UPGRADE_LICENSE_MESSAGE = 'Your license does not support AI Assistant. Please upgrade your license.'; +export interface GenerateTitleForNewChatConversationParams { + message: Pick<Message, 'content' | 'role'>; + model?: string; + actionTypeId: string; + connectorId: string; + logger: Logger; + actionsClient: PublicMethodsOf<ActionsClient>; + responseLanguage?: string; +} +export const generateTitleForNewChatConversation = async ({ + message, + model, + actionTypeId, + connectorId, + logger, + actionsClient, + responseLanguage = 'English', +}: GenerateTitleForNewChatConversationParams) => { + try { + const autoTitle = (await executeAction({ + actionsClient, + connectorId, + actionTypeId, + params: { + subAction: 'invokeAI', + subActionParams: { + model, + messages: [ + { + role: 'system', + content: `You are a helpful assistant for Elastic Security. Assume the following message is the start of a conversation between you and a user; give this conversation a title based on the content below. DO NOT UNDER ANY CIRCUMSTANCES wrap this title in single or double quotes. This title is shown in a list of conversations to the user, so title it for the user, not for you. Please create the title in ${responseLanguage}.`, + }, + { + role: message.role, + content: message.content, + }, + ], + ...(actionTypeId === '.gen-ai' + ? { n: 1, stop: null, temperature: 0.2 } + : { temperature: 0, stopSequences: [] }), + }, + }, + logger, + })) as unknown as StaticResponse; // TODO: Use function overloads in executeAction to avoid this cast when sending subAction: 'invokeAI', + if (autoTitle.status === 'ok') { + // This regular expression captures a string enclosed in single or double quotes. + // It extracts the string content without the quotes. + // Example matches: + // - "Hello, World!" => Captures: Hello, World! + // - 'Another Example' => Captures: Another Example + // - JustTextWithoutQuotes => Captures: JustTextWithoutQuotes + const match = autoTitle.data.match(/^["']?([^"']+)["']?$/); + const title = match ? match[1] : autoTitle.data; + return title; + } + } catch (e) { + /* empty */ + } +}; + +export interface AppendMessageToConversationParams { + conversationsDataClient: AIAssistantConversationsDataClient; + messages: Array<Pick<Message, 'content' | 'role'>>; + replacements: Replacements; + conversation: ConversationResponse; +} +export const appendMessageToConversation = async ({ + conversationsDataClient, + messages, + replacements, + conversation, +}: AppendMessageToConversationParams) => { + const updatedConversation = await conversationsDataClient?.appendConversationMessages({ + existingConversation: conversation, + messages: messages.map((m) => ({ + ...{ + content: replaceAnonymizedValuesWithOriginalValues({ + messageContent: m.content, + replacements, + }), + role: m.role ?? 'user', + }, + timestamp: new Date().toISOString(), + })), + }); + return updatedConversation; +}; + +export interface AppendAssistantMessageToConversationParams { + conversationsDataClient: AIAssistantConversationsDataClient; + messageContent: string; + replacements: Replacements; + conversationId: string; + isError?: boolean; + traceData?: Message['traceData']; +} +export const appendAssistantMessageToConversation = async ({ + conversationsDataClient, + messageContent, + replacements, + conversationId, + isError = false, + traceData = {}, +}: AppendAssistantMessageToConversationParams) => { + const conversation = await conversationsDataClient.getConversation({ id: conversationId }); + if (!conversation) { + return; + } + + await conversationsDataClient.appendConversationMessages({ + existingConversation: conversation, + messages: [ + getMessageFromRawResponse({ + rawContent: replaceAnonymizedValuesWithOriginalValues({ + messageContent, + replacements, + }), + traceData, + isError, + }), + ], + }); + if (Object.keys(replacements).length > 0) { + await conversationsDataClient?.updateConversation({ + conversationUpdateProps: { + id: conversation.id, + replacements, + }, + }); + } +}; + +export interface NonLangChainExecuteParams { + request: KibanaRequest<unknown, unknown, ExecuteConnectorRequestBody>; + messages: Array<Pick<Message, 'content' | 'role'>>; + abortSignal: AbortSignal; + actionTypeId: string; + connectorId: string; + logger: Logger; + actionsClient: PublicMethodsOf<ActionsClient>; + onLlmResponse?: ( + content: string, + traceData?: Message['traceData'], + isError?: boolean + ) => Promise<void>; + response: KibanaResponseFactory; + telemetry: AnalyticsServiceSetup; +} +export const nonLangChainExecute = async ({ + messages, + abortSignal, + actionTypeId, + connectorId, + logger, + actionsClient, + onLlmResponse, + response, + request, + telemetry, +}: NonLangChainExecuteParams) => { + logger.debug('Executing via actions framework directly'); + const result = await executeAction({ + abortSignal, + onLlmResponse, + actionsClient, + connectorId, + actionTypeId, + params: { + subAction: request.body.subAction, + subActionParams: { + model: request.body.model, + messages, + ...(actionTypeId === '.gen-ai' + ? { n: 1, stop: null, temperature: 0.2 } + : { temperature: 0, stopSequences: [] }), + }, + }, + logger, + }); + + telemetry.reportEvent(INVOKE_ASSISTANT_SUCCESS_EVENT.eventType, { + actionTypeId, + isEnabledKnowledgeBase: request.body.isEnabledKnowledgeBase, + isEnabledRAGAlerts: request.body.isEnabledRAGAlerts, + model: request.body.model, + assistantStreamingEnabled: request.body.subAction !== 'invokeAI', + }); + return response.ok({ + body: result, + ...(request.body.subAction === 'invokeAI' + ? { headers: { 'content-type': 'application/json' } } + : {}), + }); +}; + +export interface LangChainExecuteParams { + messages: Array<Pick<Message, 'content' | 'role'>>; + replacements: Replacements; + isEnabledKnowledgeBase: boolean; + isStream?: boolean; + onNewReplacements: (newReplacements: Replacements) => void; + abortSignal: AbortSignal; + telemetry: AnalyticsServiceSetup; + actionTypeId: string; + connectorId: string; + conversationId?: string; + context: AwaitedProperties< + Pick<ElasticAssistantRequestHandlerContext, 'elasticAssistant' | 'licensing' | 'core'> + >; + actionsClient: PublicMethodsOf<ActionsClient>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: KibanaRequest<unknown, unknown, any>; + logger: Logger; + onLlmResponse?: ( + content: string, + traceData?: Message['traceData'], + isError?: boolean + ) => Promise<void>; + getElser: GetElser; + response: KibanaResponseFactory; + responseLanguage?: string; +} +export const langChainExecute = async ({ + messages, + replacements, + onNewReplacements, + isEnabledKnowledgeBase, + abortSignal, + telemetry, + actionTypeId, + connectorId, + context, + actionsClient, + request, + logger, + conversationId, + onLlmResponse, + getElser, + response, + responseLanguage, + isStream = true, +}: LangChainExecuteParams) => { + // TODO: Add `traceId` to actions request when calling via langchain + logger.debug( + `Executing via langchain, isEnabledKnowledgeBase: ${isEnabledKnowledgeBase}, isEnabledRAGAlerts: ${request.body.isEnabledRAGAlerts}` + ); + // Fetch any tools registered by the request's originating plugin + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + const assistantContext = context.elasticAssistant; + const assistantTools = assistantContext + .getRegisteredTools(pluginName) + .filter((x) => x.id !== 'attack-discovery'); // We don't (yet) support asking the assistant for NEW attack discoveries from a conversation + + // get a scoped esClient for assistant memory + const esClient = context.core.elasticsearch.client.asCurrentUser; + + // convert the assistant messages to LangChain messages: + const langChainMessages = getLangChainMessages(messages); + + const elserId = await getElser(); + + const anonymizationFieldsDataClient = + await assistantContext.getAIAssistantAnonymizationFieldsDataClient(); + const conversationsDataClient = await assistantContext.getAIAssistantConversationsDataClient(); + + // Create an ElasticsearchStore for KB interactions + // Setup with kbDataClient if `assistantKnowledgeBaseByDefault` FF is enabled + const enableKnowledgeBaseByDefault = + assistantContext.getRegisteredFeatures(pluginName).assistantKnowledgeBaseByDefault; + const kbDataClient = enableKnowledgeBaseByDefault + ? (await assistantContext.getAIAssistantKnowledgeBaseDataClient(false)) ?? undefined + : undefined; + const kbIndex = + enableKnowledgeBaseByDefault && kbDataClient != null + ? kbDataClient.indexTemplateAndPattern.alias + : KNOWLEDGE_BASE_INDEX_PATTERN; + const esStore = new ElasticsearchStore( + esClient, + kbIndex, + logger, + telemetry, + elserId, + ESQL_RESOURCE, + kbDataClient + ); + + const dataClients: AssistantDataClients = { + anonymizationFieldsDataClient: anonymizationFieldsDataClient ?? undefined, + conversationsDataClient: conversationsDataClient ?? undefined, + kbDataClient, + }; + + // Shared executor params + const executorParams: AgentExecutorParams<boolean> = { + abortSignal, + dataClients, + alertsIndexPattern: request.body.alertsIndexPattern, + actionsClient, + isEnabledKnowledgeBase, + assistantTools, + conversationId, + connectorId, + esClient, + esStore, + isStream, + llmType: getLlmType(actionTypeId), + langChainMessages, + logger, + onNewReplacements, + onLlmResponse, + request, + replacements, + responseLanguage, + size: request.body.size, + traceOptions: { + projectName: request.body.langSmithProject, + tracers: getLangSmithTracer({ + apiKey: request.body.langSmithApiKey, + projectName: request.body.langSmithProject, + logger, + }), + }, + }; + + // New code path for LangGraph implementation, behind `assistantKnowledgeBaseByDefault` FF + let result: StreamResponseWithHeaders | StaticReturnType; + if (enableKnowledgeBaseByDefault && request.body.isEnabledKnowledgeBase) { + result = await callAssistantGraph(executorParams); + } else { + result = await callAgentExecutor(executorParams); + } + + telemetry.reportEvent(INVOKE_ASSISTANT_SUCCESS_EVENT.eventType, { + actionTypeId, + isEnabledKnowledgeBase, + isEnabledRAGAlerts: request.body.isEnabledRAGAlerts ?? true, + model: request.body.model, + // TODO rm actionTypeId check when llmClass for bedrock streaming is implemented + // tracked here: https://github.com/elastic/security-team/issues/7363 + assistantStreamingEnabled: isStream && actionTypeId === '.gen-ai', + }); + return response.ok<StreamResponseWithHeaders['body'] | StaticReturnType['body']>(result); +}; + +export interface CreateOrUpdateConversationWithParams { + logger: Logger; + conversationsDataClient: AIAssistantConversationsDataClient; + replacements: Replacements; + conversationId?: string; + promptId?: string; + actionTypeId: string; + connectorId: string; + actionsClient: PublicMethodsOf<ActionsClient>; + newMessages?: Array<Pick<Message, 'content' | 'role'>>; + model?: string; + responseLanguage?: string; +} +export const createOrUpdateConversationWithUserInput = async ({ + logger, + conversationsDataClient, + replacements, + conversationId, + actionTypeId, + promptId, + connectorId, + actionsClient, + newMessages, + model, + responseLanguage, +}: CreateOrUpdateConversationWithParams) => { + if (!conversationId) { + if (newMessages && newMessages.length > 0) { + const title = await generateTitleForNewChatConversation({ + message: newMessages[0], + actionsClient, + actionTypeId, + connectorId, + logger, + model, + responseLanguage, + }); + if (title) { + return conversationsDataClient.createConversation({ + conversation: { + title, + messages: newMessages.map((m) => ({ + content: m.content, + role: m.role, + timestamp: new Date().toISOString(), + })), + replacements, + apiConfig: { + connectorId, + actionTypeId, + model, + defaultSystemPromptId: promptId, + }, + }, + }); + } + } + return; + } + return updateConversationWithUserInput({ + actionsClient, + actionTypeId, + connectorId, + conversationId, + conversationsDataClient, + logger, + replacements, + newMessages, + model, + }); +}; + +export interface UpdateConversationWithParams { + logger: Logger; + conversationsDataClient: AIAssistantConversationsDataClient; + replacements: Replacements; + conversationId: string; + actionTypeId: string; + connectorId: string; + actionsClient: PublicMethodsOf<ActionsClient>; + newMessages?: Array<Pick<Message, 'content' | 'role'>>; + model?: string; +} +export const updateConversationWithUserInput = async ({ + logger, + conversationsDataClient, + replacements, + conversationId, + actionTypeId, + connectorId, + actionsClient, + newMessages, + model, +}: UpdateConversationWithParams) => { + const conversation = await conversationsDataClient?.getConversation({ + id: conversationId, + }); + if (conversation == null) { + throw new Error(`conversation id: "${conversationId}" not found`); + } + let updatedConversation = conversation; + + const messages = updatedConversation?.messages?.map((c) => ({ + role: c.role, + content: c.content, + timestamp: c.timestamp, + })); + + const lastMessage = newMessages?.[0] ?? messages?.[0]; + + if (conversation?.title === NEW_CHAT && lastMessage) { + const title = await generateTitleForNewChatConversation({ + message: lastMessage, + actionsClient, + actionTypeId, + connectorId, + logger, + model, + }); + const res = await conversationsDataClient.updateConversation({ + conversationUpdateProps: { + id: conversationId, + title, + }, + }); + if (res) { + updatedConversation = res; + } + } + + if (newMessages) { + return appendMessageToConversation({ + conversation: updatedConversation, + conversationsDataClient, + messages: newMessages, + replacements, + }); + } + return updatedConversation; +}; + interface PerformChecksParams { authenticatedUser?: boolean; capability?: AssistantFeatureKey; diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts index 5ee8d8e83c84..91c2cdf18aa9 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts @@ -24,7 +24,9 @@ import { getConversationResponseMock } from '../ai_assistant_data_clients/conver import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; import { getFindAnonymizationFieldsResultWithSingleHit } from '../__mocks__/response'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +const license = licensingMock.createLicenseMock(); const actionsClient = actionsClientMock.create(); jest.mock('../lib/build_response', () => ({ buildResponse: jest.fn().mockImplementation((x) => x), @@ -88,43 +90,49 @@ const existingConversation = getConversationResponseMock(); const reportEvent = jest.fn(); const appendConversationMessages = jest.fn(); const mockContext = { - elasticAssistant: { - actions: { - getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClient), + resolve: jest.fn().mockResolvedValue({ + elasticAssistant: { + actions: { + getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClient), + }, + getRegisteredTools: jest.fn(() => []), + getRegisteredFeatures: jest.fn(() => defaultAssistantFeatures), + logger: loggingSystemMock.createLogger(), + telemetry: { ...coreMock.createSetup().analytics, reportEvent }, + getCurrentUser: () => ({ + username: 'user', + email: 'email', + fullName: 'full name', + roles: ['user-role'], + enabled: true, + authentication_realm: { name: 'native1', type: 'native' }, + lookup_realm: { name: 'native1', type: 'native' }, + authentication_provider: { type: 'basic', name: 'basic1' }, + authentication_type: 'realm', + elastic_cloud_user: false, + metadata: { _reserved: false }, + }), + getAIAssistantConversationsDataClient: jest.fn().mockResolvedValue({ + getConversation: jest.fn().mockResolvedValue(existingConversation), + updateConversation: jest.fn().mockResolvedValue(existingConversation), + appendConversationMessages: + appendConversationMessages.mockResolvedValue(existingConversation), + }), + getAIAssistantAnonymizationFieldsDataClient: jest.fn().mockResolvedValue({ + findDocuments: jest.fn().mockResolvedValue(getFindAnonymizationFieldsResultWithSingleHit()), + }), }, - getRegisteredTools: jest.fn(() => []), - getRegisteredFeatures: jest.fn(() => defaultAssistantFeatures), - logger: loggingSystemMock.createLogger(), - telemetry: { ...coreMock.createSetup().analytics, reportEvent }, - getCurrentUser: () => ({ - username: 'user', - email: 'email', - fullName: 'full name', - roles: ['user-role'], - enabled: true, - authentication_realm: { name: 'native1', type: 'native' }, - lookup_realm: { name: 'native1', type: 'native' }, - authentication_provider: { type: 'basic', name: 'basic1' }, - authentication_type: 'realm', - elastic_cloud_user: false, - metadata: { _reserved: false }, - }), - getAIAssistantConversationsDataClient: jest.fn().mockResolvedValue({ - getConversation: jest.fn().mockResolvedValue(existingConversation), - updateConversation: jest.fn().mockResolvedValue(existingConversation), - appendConversationMessages: - appendConversationMessages.mockResolvedValue(existingConversation), - }), - getAIAssistantAnonymizationFieldsDataClient: jest.fn().mockResolvedValue({ - findDocuments: jest.fn().mockResolvedValue(getFindAnonymizationFieldsResultWithSingleHit()), - }), - }, - core: { - elasticsearch: { - client: elasticsearchServiceMock.createScopedClusterClient(), + core: { + elasticsearch: { + client: elasticsearchServiceMock.createScopedClusterClient(), + }, + savedObjects: coreMock.createRequestHandlerContext().savedObjects, }, - savedObjects: coreMock.createRequestHandlerContext().savedObjects, - }, + licensing: { + ...licensingMock.createRequestHandlerContext({ license }), + license, + }, + }), }; const mockRequest = { @@ -153,6 +161,7 @@ describe('postActionsConnectorExecuteRoute', () => { beforeEach(() => { jest.clearAllMocks(); + license.hasAtLeast.mockReturnValue(true); actionsClient.getBulk.mockResolvedValue([ { id: '1', @@ -195,6 +204,7 @@ describe('postActionsConnectorExecuteRoute', () => { data: mockActionResponse, status: 'ok', }, + headers: { 'content-type': 'application/json' }, }); }), }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts index 197479fc24dd..af095dbb4734 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts @@ -8,7 +8,6 @@ import { IRouter, Logger } from '@kbn/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import { getRequestAbortedSignal } from '@kbn/data-plugin/server'; -import { StreamResponseWithHeaders } from '@kbn/ml-response-stream/server'; import { schema } from '@kbn/config-schema'; import { @@ -16,37 +15,20 @@ import { ExecuteConnectorRequestBody, Message, Replacements, - replaceAnonymizedValuesWithOriginalValues, } from '@kbn/elastic-assistant-common'; import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; -import { i18n } from '@kbn/i18n'; -import { getLlmType } from './utils'; -import { - AgentExecutorParams, - AssistantDataClients, - StaticReturnType, -} from '../lib/langchain/executors/types'; -import { - INVOKE_ASSISTANT_ERROR_EVENT, - INVOKE_ASSISTANT_SUCCESS_EVENT, -} from '../lib/telemetry/event_based_telemetry'; -import { executeAction, StaticResponse } from '../lib/executor'; +import { INVOKE_ASSISTANT_ERROR_EVENT } from '../lib/telemetry/event_based_telemetry'; import { POST_ACTIONS_CONNECTOR_EXECUTE } from '../../common/constants'; -import { getLangChainMessages } from '../lib/langchain/helpers'; import { buildResponse } from '../lib/build_response'; import { ElasticAssistantRequestHandlerContext, GetElser } from '../types'; -import { ESQL_RESOURCE, KNOWLEDGE_BASE_INDEX_PATTERN } from './knowledge_base/constants'; -import { callAgentExecutor } from '../lib/langchain/execute_custom_llm_chain'; import { DEFAULT_PLUGIN_NAME, - getMessageFromRawResponse, + appendAssistantMessageToConversation, getPluginNameFromRequest, + langChainExecute, + nonLangChainExecute, + updateConversationWithUserInput, } from './helpers'; -import { getLangSmithTracer } from './evaluate/utils'; -import { EsAnonymizationFieldsSchema } from '../ai_assistant_data_clients/anonymization_fields/types'; -import { transformESSearchToAnonymizationFields } from '../ai_assistant_data_clients/anonymization_fields/helpers'; -import { ElasticsearchStore } from '../lib/langchain/elasticsearch_store/elasticsearch_store'; -import { callAssistantGraph } from '../lib/langchain/graphs/default_assistant_graph'; export const postActionsConnectorExecuteRoute = ( router: IRouter<ElasticAssistantRequestHandlerContext>, @@ -76,7 +58,8 @@ export const postActionsConnectorExecuteRoute = ( const abortSignal = getRequestAbortedSignal(request.events.aborted$); const resp = buildResponse(response); - const assistantContext = await context.elasticAssistant; + const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); + const assistantContext = ctx.elasticAssistant; const logger: Logger = assistantContext.logger; const telemetry = assistantContext.telemetry; let onLlmResponse; @@ -88,23 +71,16 @@ export const postActionsConnectorExecuteRoute = ( body: `Authenticated user not found`, }); } - const conversationsDataClient = - await assistantContext.getAIAssistantConversationsDataClient(); - - const anonymizationFieldsDataClient = - await assistantContext.getAIAssistantAnonymizationFieldsDataClient(); - let latestReplacements: Replacements = request.body.replacements; const onNewReplacements = (newReplacements: Replacements) => { latestReplacements = { ...latestReplacements, ...newReplacements }; }; - let prevMessages; + let messages; let newMessage: Pick<Message, 'content' | 'role'> | undefined; const conversationId = request.body.conversationId; const actionTypeId = request.body.actionTypeId; - const langSmithProject = request.body.langSmithProject; - const langSmithApiKey = request.body.langSmithApiKey; + const connectorId = decodeURIComponent(request.params.connectorId); // if message is undefined, it means the user is regenerating a message from the stored conversation if (request.body.message) { @@ -114,303 +90,100 @@ export const postActionsConnectorExecuteRoute = ( }; } - const connectorId = decodeURIComponent(request.params.connectorId); - // get the actions plugin start contract from the request context: - const actions = (await context.elasticAssistant).actions; + const actions = ctx.elasticAssistant.actions; + const actionsClient = await actions.getActionsClientWithRequest(request); - if (conversationId) { - const conversation = await conversationsDataClient?.getConversation({ - id: conversationId, - authenticatedUser, + const conversationsDataClient = + await assistantContext.getAIAssistantConversationsDataClient(); + + // Fetch any tools registered by the request's originating plugin + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + const isGraphAvailable = + assistantContext.getRegisteredFeatures(pluginName).assistantKnowledgeBaseByDefault && + request.body.isEnabledKnowledgeBase; + + // TODO: remove non-graph persistance when KB will be enabled by default + if (!isGraphAvailable && conversationId && conversationsDataClient) { + const updatedConversation = await updateConversationWithUserInput({ + actionsClient, + actionTypeId, + connectorId, + conversationId, + conversationsDataClient, + logger, + replacements: latestReplacements, + newMessages: newMessage ? [newMessage] : [], + model: request.body.model, }); - if (conversation == null) { - return response.notFound({ - body: `conversation id: "${conversationId}" not found`, + if (updatedConversation == null) { + return response.badRequest({ + body: `conversation id: "${conversationId}" not updated`, }); } - // messages are anonymized by conversationsDataClient - prevMessages = conversation?.messages?.map((c) => ({ + messages = updatedConversation?.messages?.map((c) => ({ role: c.role, content: c.content, })); + } - if (request.body.message) { - const res = await conversationsDataClient?.appendConversationMessages({ - existingConversation: conversation, - messages: [ - { - ...{ - content: replaceAnonymizedValuesWithOriginalValues({ - messageContent: request.body.message, - replacements: request.body.replacements, - }), - role: 'user', - }, - timestamp: new Date().toISOString(), - }, - ], - }); - - if (res == null) { - return response.badRequest({ - body: `conversation id: "${conversationId}" not updated`, - }); - } - } - const updatedConversation = await conversationsDataClient?.getConversation({ - id: conversationId, - authenticatedUser, - }); - - if (updatedConversation == null) { - return response.notFound({ - body: `conversation id: "${conversationId}" not found`, + onLlmResponse = async ( + content: string, + traceData: Message['traceData'] = {}, + isError = false + ): Promise<void> => { + if (conversationsDataClient && conversationId) { + await appendAssistantMessageToConversation({ + conversationId, + conversationsDataClient, + messageContent: content, + replacements: latestReplacements, + isError, + traceData, }); } + }; - const NEW_CHAT = i18n.translate('xpack.elasticAssistantPlugin.server.newChat', { - defaultMessage: 'New chat', - }); - if (conversation?.title === NEW_CHAT && prevMessages) { - try { - const autoTitle = (await executeAction({ - actions, - request, - connectorId, - actionTypeId, - params: { - subAction: 'invokeAI', - subActionParams: { - model: request.body.model, - messages: [ - { - role: 'system', - content: i18n.translate( - 'xpack.elasticAssistantPlugin.server.autoTitlePromptDescription', - { - defaultMessage: - 'You are a helpful assistant for Elastic Security. Assume the following message is the start of a conversation between you and a user; give this conversation a title based on the content below. DO NOT UNDER ANY CIRCUMSTANCES wrap this title in single or double quotes. This title is shown in a list of conversations to the user, so title it for the user, not for you.', - } - ), - }, - newMessage ?? prevMessages?.[0], - ], - ...(actionTypeId === '.gen-ai' - ? { n: 1, stop: null, temperature: 0.2 } - : { temperature: 0, stopSequences: [] }), - }, - }, - logger, - })) as unknown as StaticResponse; // TODO: Use function overloads in executeAction to avoid this cast when sending subAction: 'invokeAI', - if (autoTitle.status === 'ok') { - try { - // This regular expression captures a string enclosed in single or double quotes. - // It extracts the string content without the quotes. - // Example matches: - // - "Hello, World!" => Captures: Hello, World! - // - 'Another Example' => Captures: Another Example - // - JustTextWithoutQuotes => Captures: JustTextWithoutQuotes - const match = autoTitle.data.match(/^["']?([^"']+)["']?$/); - const title = match ? match[1] : autoTitle.data; - - await conversationsDataClient?.updateConversation({ - conversationUpdateProps: { - id: conversationId, - title, - }, - }); - } catch (e) { - logger.warn(`Failed to update conversation with generated title: ${e.message}`); - } - } - } catch (e) { - /* empty */ - } - } - - onLlmResponse = async ( - content: string, - traceData: Message['traceData'] = {}, - isError = false - ): Promise<void> => { - if (updatedConversation) { - await conversationsDataClient?.appendConversationMessages({ - existingConversation: updatedConversation, - messages: [ - getMessageFromRawResponse({ - rawContent: replaceAnonymizedValuesWithOriginalValues({ - messageContent: content, - replacements: latestReplacements, - }), - traceData, - isError, - }), - ], - }); - } - if (Object.keys(latestReplacements).length > 0) { - await conversationsDataClient?.updateConversation({ - conversationUpdateProps: { - id: conversationId, - replacements: latestReplacements, - }, - }); - } - }; - } - - // if not langchain, call execute action directly and return the response: if (!request.body.isEnabledKnowledgeBase && !request.body.isEnabledRAGAlerts) { - logger.debug('Executing via actions framework directly'); - - const result = await executeAction({ + // if not langchain, call execute action directly and return the response: + return await nonLangChainExecute({ abortSignal, - onLlmResponse, - actions, - request, - connectorId, + actionsClient, actionTypeId, - params: { - subAction: request.body.subAction, - subActionParams: { - model: request.body.model, - messages: [...(prevMessages ?? []), ...(newMessage ? [newMessage] : [])], - ...(actionTypeId === '.gen-ai' - ? { n: 1, stop: null, temperature: 0.2 } - : { temperature: 0, stopSequences: [] }), - }, - }, + connectorId, logger, - }); - - telemetry.reportEvent(INVOKE_ASSISTANT_SUCCESS_EVENT.eventType, { - actionTypeId, - isEnabledKnowledgeBase: request.body.isEnabledKnowledgeBase, - isEnabledRAGAlerts: request.body.isEnabledRAGAlerts, - model: request.body.model, - assistantStreamingEnabled: request.body.subAction !== 'invokeAI', - }); - return response.ok({ - body: result, + messages: messages ?? [], + onLlmResponse, + request, + response, + telemetry, }); } - // TODO: Add `traceId` to actions request when calling via langchain - logger.debug( - `Executing via langchain, isEnabledKnowledgeBase: ${request.body.isEnabledKnowledgeBase}, isEnabledRAGAlerts: ${request.body.isEnabledRAGAlerts}` - ); - - // Fetch any tools registered by the request's originating plugin - const pluginName = getPluginNameFromRequest({ - request, - defaultPluginName: DEFAULT_PLUGIN_NAME, - logger, - }); - const assistantTools = (await context.elasticAssistant) - .getRegisteredTools(pluginName) - .filter((x) => x.id !== 'attack-discovery'); // We don't (yet) support asking the assistant for NEW attack discoveries from a conversation - - // get a scoped esClient for assistant memory - const esClient = (await context.core).elasticsearch.client.asCurrentUser; - - // convert the assistant messages to LangChain messages: - const langChainMessages = getLangChainMessages( - ([...(prevMessages ?? []), ...(newMessage ? [newMessage] : [])] ?? - []) as unknown as Array<Pick<Message, 'content' | 'role'>> - ); - - const elserId = await getElser(); - - const anonymizationFieldsRes = - await anonymizationFieldsDataClient?.findDocuments<EsAnonymizationFieldsSchema>({ - perPage: 1000, - page: 1, - }); - - // Create an ElasticsearchStore for KB interactions - // Setup with kbDataClient if `assistantKnowledgeBaseByDefault` FF is enabled - const enableKnowledgeBaseByDefault = - assistantContext.getRegisteredFeatures(pluginName).assistantKnowledgeBaseByDefault; - const kbDataClient = enableKnowledgeBaseByDefault - ? (await assistantContext.getAIAssistantKnowledgeBaseDataClient(false)) ?? undefined - : undefined; - const kbIndex = - enableKnowledgeBaseByDefault && kbDataClient != null - ? kbDataClient.indexTemplateAndPattern.alias - : KNOWLEDGE_BASE_INDEX_PATTERN; - const esStore = new ElasticsearchStore( - esClient, - kbIndex, - logger, - telemetry, - elserId, - ESQL_RESOURCE, - kbDataClient - ); - - const dataClients: AssistantDataClients = { - anonymizationFieldsDataClient: anonymizationFieldsDataClient ?? undefined, - conversationsDataClient: conversationsDataClient ?? undefined, - kbDataClient, - }; - - // Shared executor params - const executorParams: AgentExecutorParams<boolean> = { + return await langChainExecute({ abortSignal, - alertsIndexPattern: request.body.alertsIndexPattern, - anonymizationFields: anonymizationFieldsRes - ? transformESSearchToAnonymizationFields(anonymizationFieldsRes.data) - : undefined, - actions, + isStream: request.body.subAction !== 'invokeAI', isEnabledKnowledgeBase: request.body.isEnabledKnowledgeBase ?? false, - assistantTools, + actionsClient, + actionTypeId, connectorId, conversationId, - dataClients, - esClient, - esStore, - isStream: request.body.subAction !== 'invokeAI', - llmType: getLlmType(actionTypeId), - langChainMessages, + context: ctx, + getElser, logger, - onNewReplacements, + messages: (isGraphAvailable && newMessage ? [newMessage] : messages) ?? [], onLlmResponse, + onNewReplacements, + replacements: latestReplacements, request, response, - replacements: request.body.replacements, - size: request.body.size, - traceOptions: { - projectName: langSmithProject, - tracers: getLangSmithTracer({ - apiKey: langSmithApiKey, - projectName: langSmithProject, - logger, - }), - }, - }; - - // New code path for LangGraph implementation, behind `assistantKnowledgeBaseByDefault` FF - let result: StreamResponseWithHeaders | StaticReturnType; - if (enableKnowledgeBaseByDefault) { - result = await callAssistantGraph(executorParams); - } else { - result = await callAgentExecutor(executorParams); - } - - telemetry.reportEvent(INVOKE_ASSISTANT_SUCCESS_EVENT.eventType, { - actionTypeId, - isEnabledKnowledgeBase: request.body.isEnabledKnowledgeBase, - isEnabledRAGAlerts: request.body.isEnabledRAGAlerts, - model: request.body.model, - // TODO rm actionTypeId check when llmClass for bedrock streaming is implemented - // tracked here: https://github.com/elastic/security-team/issues/7363 - assistantStreamingEnabled: - request.body.subAction !== 'invokeAI' && actionTypeId === '.gen-ai', + telemetry, }); - - return response.ok<StreamResponseWithHeaders['body'] | StaticReturnType['body']>(result); } catch (err) { logger.error(err); const error = transformError(err); diff --git a/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts b/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts index df2ec323bc35..8838db3c4494 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts @@ -59,7 +59,7 @@ export const findPromptsRoute = (router: ElasticAssistantPluginRouter, logger: L page: query.page, sortField: query.sort_field, sortOrder: query.sort_order, - filter: query.filter, + filter: query.filter ? decodeURIComponent(query.filter) : undefined, fields: query.fields, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index f4da7f9f1803..bab389d514b7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -23,12 +23,13 @@ import { getKnowledgeBaseStatusRoute } from './knowledge_base/get_knowledge_base import { postKnowledgeBaseRoute } from './knowledge_base/post_knowledge_base'; import { getEvaluateRoute } from './evaluate/get_evaluate'; import { postEvaluateRoute } from './evaluate/post_evaluate'; -import { postActionsConnectorExecuteRoute } from './post_actions_connector_execute'; import { getCapabilitiesRoute } from './capabilities/get_capabilities_route'; import { bulkPromptsRoute } from './prompts/bulk_actions_route'; import { findPromptsRoute } from './prompts/find_route'; import { bulkActionAnonymizationFieldsRoute } from './anonymization_fields/bulk_actions_route'; import { findAnonymizationFieldsRoute } from './anonymization_fields/find_route'; +import { chatCompleteRoute } from './chat/chat_complete_route'; +import { postActionsConnectorExecuteRoute } from './post_actions_connector_execute'; import { bulkActionKnowledgeBaseEntriesRoute } from './knowledge_base/entries/bulk_actions_route'; import { createKnowledgeBaseEntryRoute } from './knowledge_base/entries/create_route'; import { findKnowledgeBaseEntriesRoute } from './knowledge_base/entries/find_route'; @@ -38,6 +39,11 @@ export const registerRoutes = ( logger: Logger, getElserId: GetElser ) => { + /** PUBLIC */ + // Chat + chatCompleteRoute(router, getElserId); + + /** INTERNAL */ // Capabilities getCapabilitiesRoute(router); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts index e66c83f77510..9c02586a60b8 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts @@ -16,7 +16,7 @@ import { import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { ElasticAssistantPluginRouter } from '../../types'; import { buildResponse } from '../utils'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const createConversationRoute = (router: ElasticAssistantPluginRouter): void => { router.versioned @@ -41,27 +41,25 @@ export const createConversationRoute = (router: ElasticAssistantPluginRouter): v const assistantResponse = buildResponse(response); try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + // Perform license and authenticated user checks + const checkResponse = performChecks({ + authenticatedUser: true, + context: ctx, + license: true, + request, + response, + }); + if (checkResponse) { + return checkResponse; } const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); - } const result = await dataClient?.findDocuments({ perPage: 100, page: 1, - filter: `users:{ id: "${authenticatedUser?.profile_uid}" } AND title:${request.body.title}`, + filter: `users:{ id: "${ + ctx.elasticAssistant.getCurrentUser()?.profile_uid + }" } AND title:${request.body.title}`, fields: ['title'], }); if (result?.data != null && result.total > 0) { @@ -73,7 +71,6 @@ export const createConversationRoute = (router: ElasticAssistantPluginRouter): v const createdConversation = await dataClient?.createConversation({ conversation: request.body, - authenticatedUser, }); if (createdConversation == null) { diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts index 4a7fd5a9d67c..069c189609f2 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts @@ -19,7 +19,7 @@ import { UpdateConversationRequestParams } from '@kbn/elastic-assistant-common/i import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { ElasticAssistantPluginRouter } from '../../types'; import { buildResponse } from '../utils'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const updateConversationRoute = (router: ElasticAssistantPluginRouter) => { router.versioned @@ -45,23 +45,20 @@ export const updateConversationRoute = (router: ElasticAssistantPluginRouter) => const { id } = request.params; try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); + // Perform license and authenticated user checks + const checkResponse = performChecks({ + authenticatedUser: true, + context: ctx, + license: true, + request, + response, + }); + if (checkResponse) { + return checkResponse; } const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); - } const existingConversation = await dataClient?.getConversation({ id, authenticatedUser }); if (existingConversation == null) { @@ -72,7 +69,6 @@ export const updateConversationRoute = (router: ElasticAssistantPluginRouter) => } const conversation = await dataClient?.updateConversation({ conversationUpdateProps: request.body, - authenticatedUser, }); if (conversation == null) { return assistantResponse.error({ diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 9830f66a441e..47c4741e41af 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -214,6 +214,8 @@ export const ENTERPRISE_SEARCH_ELASTICSEARCH_URL = '/app/enterprise_search/elast export const WORKPLACE_SEARCH_URL = '/app/enterprise_search/workplace_search'; export const CREATE_NEW_INDEX_URL = '/search_indices/new_index'; +export const MANAGE_API_KEYS_URL = '/app/management/security/api_keys'; + export const ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT = 25; export const ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE = 'elastic-crawler'; diff --git a/x-pack/plugins/enterprise_search/common/types/error_codes.ts b/x-pack/plugins/enterprise_search/common/types/error_codes.ts index 1fe2d557d15c..251cbbe27d05 100644 --- a/x-pack/plugins/enterprise_search/common/types/error_codes.ts +++ b/x-pack/plugins/enterprise_search/common/types/error_codes.ts @@ -28,4 +28,5 @@ export enum ErrorCode { STATUS_TRANSITION_ERROR = 'status_transition_error', UNAUTHORIZED = 'unauthorized', UNCAUGHT_EXCEPTION = 'uncaught_exception', + GENERATE_INDEX_NAME_ERROR = 'generate_index_name_error', } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx index b26cc00379f3..2cd48283a285 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx @@ -8,10 +8,13 @@ jest.mock('./nav', () => ({ useAppSearchNav: () => [], })); +import '../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../__mocks__/kea_logic'; import React from 'react'; import { shallow } from 'enzyme'; +import { of } from 'rxjs'; import { SetAppSearchChrome } from '../../../shared/kibana_chrome'; import { EnterpriseSearchPageTemplateWrapper } from '../../../shared/layout'; @@ -19,7 +22,16 @@ import { SendAppSearchTelemetry } from '../../../shared/telemetry'; import { AppSearchPageTemplate } from './page_template'; +const mockValues = { + getChromeStyle$: () => of('classic'), + updateSideNavDefinition: jest.fn(), +}; + describe('AppSearchPageTemplate', () => { + beforeEach(() => { + setMockValues({ ...mockValues }); + }); + it('renders', () => { const wrapper = shallow( <AppSearchPageTemplate> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx index 27d4067e5975..7e4d72c232b3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx @@ -7,7 +7,11 @@ import React from 'react'; +import { useValues } from 'kea'; +import useObservable from 'react-use/lib/useObservable'; + import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { KibanaLogic } from '../../../shared/kibana'; import { SetAppSearchChrome } from '../../../shared/kibana_chrome'; import { EnterpriseSearchPageTemplateWrapper, PageTemplateProps } from '../../../shared/layout'; import { SendAppSearchTelemetry } from '../../../shared/telemetry'; @@ -17,12 +21,27 @@ import { useAppSearchNav } from './nav'; export const AppSearchPageTemplate: React.FC< Omit<PageTemplateProps, 'useEndpointHeaderActions'> > = ({ children, pageChrome, pageViewTelemetry, ...pageTemplateProps }) => { + const navItems = useAppSearchNav(); + const { getChromeStyle$, updateSideNavDefinition } = useValues(KibanaLogic); + const chromeStyle = useObservable(getChromeStyle$(), 'classic'); + + React.useEffect(() => { + if (chromeStyle === 'classic') return; + // We update the new side nav definition with the selected app items + updateSideNavDefinition({ appSearch: navItems?.[0]?.items }); + }, [chromeStyle, navItems, updateSideNavDefinition]); + React.useEffect(() => { + return () => { + updateSideNavDefinition({ appSearch: undefined }); + }; + }, [updateSideNavDefinition]); + return ( <EnterpriseSearchPageTemplateWrapper {...pageTemplateProps} solutionNav={{ name: APP_SEARCH_PLUGIN.NAME, - items: useAppSearchNav(), + items: chromeStyle === 'classic' ? navItems : undefined, }} setPageChrome={pageChrome && <SetAppSearchChrome trail={pageChrome} />} useEndpointHeaderActions={false} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/api_key/get_api_key_by_id_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/api_key/get_api_key_by_id_api_logic.ts new file mode 100644 index 000000000000..ee4402cd393c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/api_key/get_api_key_by_id_api_logic.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; +import { APIKeyResponse } from '../generate_api_key/generate_api_key_logic'; + +export const getApiKeyById = async (id: string) => { + const route = `/internal/enterprise_search/api_keys/${id}`; + + return await HttpLogic.values.http.get<APIKeyResponse>(route); +}; + +export const GetApiKeyByIdLogic = createApiLogic(['get_api_key_by_id_logic'], getApiKeyById); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_api_key_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_api_key_api_logic.ts index 7b67f21f05da..cb3c512f660d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_api_key_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_api_key_api_logic.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; export interface ApiKey { @@ -14,14 +14,12 @@ export interface ApiKey { id: string; name: string; } - -export const generateApiKey = async ({ - indexName, - isNative, -}: { +export interface GenerateConnectorApiKeyApiArgs { indexName: string; isNative: boolean; -}) => { +} + +export const generateApiKey = async ({ indexName, isNative }: GenerateConnectorApiKeyApiArgs) => { const route = `/internal/enterprise_search/indices/${indexName}/api_key`; const params = { is_native: isNative, @@ -35,3 +33,8 @@ export const GenerateConnectorApiKeyApiLogic = createApiLogic( ['generate_connector_api_key_api_logic'], generateApiKey ); + +export type GenerateConnectorApiKeyApiLogicActions = Actions< + GenerateConnectorApiKeyApiArgs, + ApiKey +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_config_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_config_api_logic.ts new file mode 100644 index 000000000000..21edf734bc23 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_config_api_logic.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface GenerateConfigApiArgs { + connectorId: string; +} + +export const generateConnectorConfig = async ({ connectorId }: GenerateConfigApiArgs) => { + const route = `/internal/enterprise_search/connectors/${connectorId}/generate_config`; + return await HttpLogic.values.http.post(route); +}; + +export const GenerateConfigApiLogic = createApiLogic( + ['generate_config_api_logic'], + generateConnectorConfig +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/generate_api_key/generate_api_key_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/generate_api_key/generate_api_key_logic.ts index d5d0f6c691dd..26fe3476f642 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/generate_api_key/generate_api_key_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/generate_api_key/generate_api_key_logic.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; -interface APIKeyResponse { +export interface APIKeyResponse { apiKey: { api_key: string; encoded: string; @@ -17,13 +17,12 @@ interface APIKeyResponse { }; } -export const generateApiKey = async ({ - indexName, - keyName, -}: { +export interface GenerateApiKeyApiArgs { indexName: string; keyName: string; -}) => { +} + +export const generateApiKey = async ({ indexName, keyName }: GenerateApiKeyApiArgs) => { const route = `/internal/enterprise_search/${indexName}/api_keys`; return await HttpLogic.values.http.post<APIKeyResponse>(route, { @@ -34,3 +33,5 @@ export const generateApiKey = async ({ }; export const GenerateApiKeyLogic = createApiLogic(['generate_api_key_logic'], generateApiKey); + +export type GenerateApiKeyApiLogicActions = Actions<GenerateApiKeyApiArgs, APIKeyResponse>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/advanced_config_override_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/advanced_config_override_callout.tsx new file mode 100644 index 000000000000..d92571057b12 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/advanced_config_override_callout.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { docLinks } from '../../../../shared/doc_links'; + +export const AdvancedConfigOverrideCallout: React.FC = () => ( + <EuiCallOut + title={i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.advancedRulesCallout', + { defaultMessage: 'Configuration warning' } + )} + iconType="iInCircle" + color="warning" + > + <FormattedMessage + id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.advancedRulesCallout.description" + defaultMessage="{advancedSyncRulesDocs} can override some configuration fields." + values={{ + advancedSyncRulesDocs: ( + <EuiLink + data-test-subj="entSearchContent-connector-configuration-advancedSyncRulesDocsLink" + data-telemetry-id="entSearchContent-connector-configuration-advancedSyncRulesDocsLink" + href={docLinks.syncRules} + target="_blank" + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.advancedSyncRulesDocs', + { defaultMessage: 'Advanced Sync Rules' } + )} + </EuiLink> + ), + }} + /> + </EuiCallOut> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/configuration_skeleton.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/configuration_skeleton.tsx new file mode 100644 index 000000000000..85011b74c85c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/configuration_skeleton.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { EuiSkeletonTitle, EuiSpacer } from '@elastic/eui'; + +export const ConfigurationSkeleton: React.FC = () => ( + <> + <EuiSkeletonTitle size="m" /> + <EuiSpacer size="m" /> + <EuiSkeletonTitle size="xxs" /> + <EuiSpacer size="xs" /> + <EuiSkeletonTitle size="xxs" /> + <EuiSpacer size="xs" /> + <EuiSkeletonTitle size="xxs" /> + <EuiSpacer size="l" /> + <EuiSkeletonTitle size="m" /> + <EuiSpacer size="m" /> + <EuiSkeletonTitle size="xxs" /> + <EuiSpacer size="xs" /> + <EuiSkeletonTitle size="xxs" /> + <EuiSpacer size="xs" /> + <EuiSkeletonTitle size="xxs" /> + <EuiSpacer size="xs" /> + </> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/connector_linked.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/connector_linked.tsx new file mode 100644 index 000000000000..1f2359664144 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/connector_linked.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 { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const ConnectorLinked: React.FC = () => { + return ( + <EuiCallOut + color="success" + title={i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.connectorLinked.callout.title', + { + defaultMessage: 'Connector connected', + } + )} + iconType="check" + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.connectorLinked.callout.description', + { + defaultMessage: 'Congratulations. Looks like your connector is deployed and connected.', + } + )} + </EuiCallOut> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/docker_instructions_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/docker_instructions_step.tsx new file mode 100644 index 000000000000..c3415b781c47 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/docker_instructions_step.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { EuiAccordion, EuiAccordionProps, EuiCode, EuiSpacer, EuiText } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeBox } from '@kbn/search-api-panels'; + +import { useCloudDetails } from '../../../../shared/cloud_details/cloud_details'; + +import { ApiKey } from '../../../api/connector/generate_connector_api_key_api_logic'; +import { + getConnectorTemplate, + getRunFromDockerSnippet, +} from '../../search_index/connector/constants'; + +export interface DockerInstructionsStepProps { + apiKeyData?: ApiKey; + connectorId: string; + hasApiKey: boolean; + isWaitingForConnector: boolean; + serviceType: string; +} +export const DockerInstructionsStep: React.FC<DockerInstructionsStepProps> = ({ + connectorId, + isWaitingForConnector, + serviceType, + apiKeyData, +}) => { + const [isOpen, setIsOpen] = React.useState<EuiAccordionProps['forceState']>('open'); + const { elasticsearchUrl } = useCloudDetails(); + + useEffect(() => { + if (!isWaitingForConnector) { + setIsOpen('closed'); + } + }, [isWaitingForConnector]); + + return ( + <> + <EuiAccordion + id="collapsibleDocker" + onToggle={() => setIsOpen(isOpen === 'closed' ? 'open' : 'closed')} + forceState={isOpen} + buttonContent={ + <EuiText size="s"> + <p> + {i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.p.downloadConfigurationLabel', + { + defaultMessage: + 'You can either download the configuration file manually or run the following command', + } + )} + </p> + </EuiText> + } + > + <EuiSpacer /> + <CodeBox + showTopBar={false} + languageType="bash" + codeSnippet={ + 'curl https://raw.githubusercontent.com/elastic/connectors/main/config.yml.example --output </absolute/path/to>/connectors' + } + /> + <EuiSpacer /> + <EuiText size="s"> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.connectorDeployment.p.changeOutputPathLabel" + defaultMessage="Change the {output} argument value to the path where you want to save the configuration file." + values={{ + output: <EuiCode>--output</EuiCode>, + }} + /> + </p> + </EuiText> + <EuiSpacer /> + <FormattedMessage + id="xpack.enterpriseSearch.connectorDeployment.p.editConfigYamlLabel" + defaultMessage="Edit the {configYaml} file and provide the next credentials" + values={{ + configYaml: <EuiCode>config.yml</EuiCode>, + }} + /> + <EuiSpacer /> + <CodeBox + showTopBar={false} + languageType="yaml" + codeSnippet={getConnectorTemplate({ + apiKeyData, + connectorData: { + id: connectorId ?? '', + service_type: serviceType ?? '', + }, + host: elasticsearchUrl, + })} + /> + <EuiSpacer /> + <EuiText size="m"> + <p> + {i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.p.runTheFollowingCommandLabel', + { + defaultMessage: + 'Run the following command in your terminal. Make sure you have Docker installed on your machine', + } + )} + </p> + </EuiText> + <EuiSpacer /> + <CodeBox + showTopBar={false} + languageType="bash" + codeSnippet={getRunFromDockerSnippet({ + version: '8.15.0', + })} + /> + </EuiAccordion> + </> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/example_config_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/example_config_callout.tsx new file mode 100644 index 000000000000..a21dba1cbb19 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/example_config_callout.tsx @@ -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 React from 'react'; + +import { EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export const ExampleConfigCallout: React.FC = () => ( + <> + <EuiCallOut + iconType="iInCircle" + color="warning" + title={i18n.translate( + 'xpack.enterpriseSearch.content.connectors.overview.connectorUnsupportedCallOut.title', + { + defaultMessage: 'Example connector', + } + )} + > + <EuiSpacer size="s" /> + <EuiText size="s"> + <FormattedMessage + id="xpack.enterpriseSearch.content.connectors.overview.connectorUnsupportedCallOut.description" + defaultMessage="This is an example connector that serves as a building block for customizations. The design and code is being provided as-is with no warranties. This is not subject to the SLA of supported features." + /> + </EuiText> + </EuiCallOut> + <EuiSpacer /> + </> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generate_config_button.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generate_config_button.tsx new file mode 100644 index 000000000000..bb34d652ee74 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generate_config_button.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface GenerateConfigButtonProps { + connectorId: string; + generateConfiguration: (params: { connectorId: string }) => void; + isGenerateLoading: boolean; +} +export const GenerateConfigButton: React.FC<GenerateConfigButtonProps> = ({ + connectorId, + generateConfiguration, + isGenerateLoading, +}) => { + return ( + <EuiFlexGroup direction="row" gutterSize="xs" responsive={false} alignItems="center"> + <EuiFlexItem grow={false}> + <EuiButton + data-test-subj="entSearchContent-connector-configuration-generateConfigButton" + data-telemetry-id="entSearchContent-connector-configuration-generateConfigButton" + fill + iconType="sparkles" + isLoading={isGenerateLoading} + onClick={() => { + generateConfiguration({ connectorId }); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.generateApiKey.button.label', + { + defaultMessage: 'Generate configuration', + } + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generated_config_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generated_config_fields.tsx new file mode 100644 index 000000000000..acf6ae42c03b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generated_config_fields.tsx @@ -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 React, { useState } from 'react'; + +import { + EuiButtonIcon, + EuiCallOut, + EuiCode, + EuiConfirmModal, + EuiCopy, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { Connector } from '@kbn/search-connectors'; + +import { MANAGE_API_KEYS_URL } from '../../../../../../common/constants'; +import { generateEncodedPath } from '../../../../shared/encode_path_params'; +import { EuiLinkTo } from '../../../../shared/react_router_helpers'; + +import { ApiKey } from '../../../api/connector/generate_connector_api_key_api_logic'; +import { CONNECTOR_DETAIL_PATH, SEARCH_INDEX_PATH } from '../../../routes'; + +export interface GeneratedConfigFieldsProps { + apiKey?: ApiKey; + connector: Connector; + generateApiKey: () => void; + isGenerateLoading: boolean; +} + +const ConfirmModal: React.FC<{ + onCancel: () => void; + onConfirm: () => void; +}> = ({ onCancel, onConfirm }) => ( + <EuiConfirmModal + title={i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.apiKey.confirmModal.title', + { + defaultMessage: 'Generate an Elasticsearch API key', + } + )} + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.apiKey.confirmModal.cancelButton.label', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.apiKey.confirmModal.confirmButton.label', + { + defaultMessage: 'Generate API key', + } + )} + defaultFocusedButton="confirm" + > + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.apiKey.confirmModal.description', + { + defaultMessage: + 'Generating a new API key will invalidate the previous key. Are you sure you want to generate a new API key? This can not be undone.', + } + )} + </EuiConfirmModal> +); + +export const GeneratedConfigFields: React.FC<GeneratedConfigFieldsProps> = ({ + apiKey, + connector, + generateApiKey, + isGenerateLoading, +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); + + const refreshButtonClick = () => { + setIsModalVisible(true); + }; + const onCancel = () => { + setIsModalVisible(false); + }; + + const onConfirm = () => { + generateApiKey(); + setIsModalVisible(false); + }; + + return ( + <> + {isModalVisible && <ConfirmModal onCancel={onCancel} onConfirm={onConfirm} />} + <> + <EuiFlexGrid columns={3} alignItems="center" gutterSize="s"> + <EuiFlexItem> + <EuiFlexGroup responsive={false} gutterSize="xs"> + <EuiFlexItem grow={false}> + <EuiIcon type="check" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiText size="s"> + <p> + {i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.connectorCreatedFlexItemLabel', + { defaultMessage: 'Connector created' } + )} + </p> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem> + <EuiLinkTo + to={generateEncodedPath(CONNECTOR_DETAIL_PATH, { + connectorId: connector.id, + })} + > + {connector.name} + </EuiLinkTo> + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup + responsive={false} + gutterSize="xs" + justifyContent="flexEnd" + alignItems="center" + > + <EuiFlexItem grow={false}> + <EuiLinkTo + to={generateEncodedPath(CONNECTOR_DETAIL_PATH, { + connectorId: connector.id, + })} + > + {connector.id} + </EuiLinkTo> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiCopy textToCopy={connector.id}> + {(copy) => ( + <EuiButtonIcon + size="xs" + data-test-subj="enterpriseSearchConnectorDeploymentButton" + iconType="copyClipboard" + onClick={copy} + /> + )} + </EuiCopy> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup responsive={false} gutterSize="xs"> + <EuiFlexItem grow={false}> + <EuiIcon type="check" /> + </EuiFlexItem> + <EuiFlexItem> + {i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.indexCreatedFlexItemLabel', + { defaultMessage: 'Index created' } + )} + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem> + {connector.index_name && ( + <EuiLinkTo + to={generateEncodedPath(SEARCH_INDEX_PATH, { + indexName: connector.index_name, + })} + > + {connector.index_name} + </EuiLinkTo> + )} + </EuiFlexItem> + <EuiFlexItem /> + <EuiFlexItem> + <EuiFlexGroup responsive={false} gutterSize="xs"> + <EuiFlexItem grow={false}> + <EuiIcon type="check" /> + </EuiFlexItem> + <EuiFlexItem> + {i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.apiKeyCreatedFlexItemLabel', + { defaultMessage: 'API key created' } + )} + {apiKey?.encoded && ` *`} + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiLink + data-test-subj="enterpriseSearchConnectorDeploymentLink" + href={generateEncodedPath(MANAGE_API_KEYS_URL, {})} + external + target="_blank" + > + {apiKey?.name} + </EuiLink> + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup + responsive={false} + gutterSize="xs" + justifyContent="flexEnd" + alignItems="center" + > + {apiKey?.encoded ? ( + <EuiFlexItem> + <EuiCopy textToCopy={apiKey?.encoded}> + {(copy) => ( + <EuiFlexGroup responsive={false} alignItems="center" gutterSize="xs"> + <EuiFlexItem> + <EuiCode>{apiKey?.encoded}</EuiCode> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj="enterpriseSearchGeneratedConfigFieldsButton" + size="xs" + iconType="refresh" + isLoading={isGenerateLoading} + onClick={refreshButtonClick} + disabled={!connector.index_name} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + size="xs" + data-test-subj="enterpriseSearchConnectorDeploymentButton" + iconType="copyClipboard" + onClick={copy} + /> + </EuiFlexItem> + </EuiFlexGroup> + )} + </EuiCopy> + </EuiFlexItem> + ) : ( + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj="enterpriseSearchGeneratedConfigFieldsButton" + size="xs" + iconType="refresh" + isLoading={isGenerateLoading} + onClick={refreshButtonClick} + disabled={!connector.index_name} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGrid> + + {apiKey?.encoded && ( + <> + <EuiSpacer size="m" /> + <EuiCallOut + color="success" + size="s" + title={i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.generatedConfigCallout', + { + defaultMessage: `You'll only see this API key once, so save it somewhere safe. We don't store your API keys, so if you lose a key you'll need to generate a replacement`, + } + )} + iconType="asterisk" + /> + </> + )} + </> + </> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_from_source_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_from_source_step.tsx new file mode 100644 index 000000000000..07df59597fa7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_from_source_step.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import dedent from 'dedent'; + +import { + EuiAccordion, + EuiAccordionProps, + EuiButton, + EuiCode, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeBox } from '@kbn/search-api-panels'; + +import { useCloudDetails } from '../../../../shared/cloud_details/cloud_details'; + +import { ApiKey } from '../../../api/connector/generate_connector_api_key_api_logic'; +import { getConnectorTemplate } from '../../search_index/connector/constants'; + +export interface RunFromSourceStepProps { + apiKeyData?: ApiKey; + connectorId?: string; + isWaitingForConnector: boolean; + serviceType: string; +} + +export const RunFromSourceStep: React.FC<RunFromSourceStepProps> = ({ + apiKeyData, + connectorId, + isWaitingForConnector, + serviceType, +}) => { + const [isOpen, setIsOpen] = React.useState<EuiAccordionProps['forceState']>('open'); + useEffect(() => { + if (!isWaitingForConnector) { + setIsOpen('closed'); + } + }, [isWaitingForConnector]); + + const { elasticsearchUrl } = useCloudDetails(); + + return ( + <> + <EuiText size="m"> + <p> + {i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.p.addTheFollowingConfigurationLabel', + { + defaultMessage: 'Clone or download the repo to your local machine', + } + )} + </p> + </EuiText> + <EuiSpacer size="s" /> + <EuiCode>git clone https://github.com/elastic/connectors</EuiCode>    + {i18n.translate('xpack.enterpriseSearch.connectorDeployment.orLabel', { + defaultMessage: 'or', + })} +     + <EuiButton + data-test-subj="enterpriseSearchConnectorDeploymentGoToSourceButton" + iconType="logoGithub" + href="https://github.com/elastic/connectors" + target="_blank" + > + <EuiFlexGroup responsive={false} gutterSize="xs"> + <EuiFlexItem> + {i18n.translate('xpack.enterpriseSearch.connectorDeployment.goToSourceButtonLabel', { + defaultMessage: 'Go to Source', + })} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiIcon type="popout" /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiButton> + <EuiSpacer size="s" /> + <EuiAccordion + id="collapsibleAccordion" + onToggle={() => setIsOpen(isOpen === 'closed' ? 'open' : 'closed')} + forceState={isOpen} + buttonContent={ + <EuiText size="s"> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.connectorDeployment.p.editConfigLabel" + defaultMessage="Edit the {configYaml} file and provide the following configuration" + values={{ + configYaml: ( + <EuiCode> + {i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.configYamlCodeBlockLabel', + { defaultMessage: 'config.yml' } + )} + </EuiCode> + ), + }} + /> + </p> + </EuiText> + } + > + <EuiSpacer size="s" /> + <CodeBox + showTopBar={false} + languageType="yaml" + codeSnippet={getConnectorTemplate({ + apiKeyData, + connectorData: { + id: connectorId ?? '', + service_type: serviceType, + }, + host: elasticsearchUrl, + })} + /> + <EuiSpacer /> + <EuiText size="s"> + <p> + {i18n.translate('xpack.enterpriseSearch.connectorDeployment.p.compileAndRunLabel', { + defaultMessage: 'Compile and run', + })} + </p> + </EuiText> + <EuiSpacer /> + <CodeBox + showTopBar={false} + languageType="bash" + codeSnippet={dedent` + make install + make run + `} + /> + </EuiAccordion> + </> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_options_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_options_buttons.tsx new file mode 100644 index 000000000000..c0dd0ff23622 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_options_buttons.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 { + EuiCheckableCard, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export interface RunOptionsButtonsProps { + selectDeploymentMethod: (method: 'docker' | 'source') => void; + selectedDeploymentMethod: 'docker' | 'source' | null; +} + +export const RunOptionsButtons: React.FC<RunOptionsButtonsProps> = ({ + selectDeploymentMethod, + selectedDeploymentMethod, +}) => { + return ( + <> + <EuiSpacer size="s" /> + <EuiText size="s"> + <FormattedMessage + id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.description" + defaultMessage="The connector service is a Python package that you host on your own infrastructure. You can deploy with Docker or, optionally, run from source." + /> + <EuiSpacer /> + <EuiFlexGroup direction="row"> + <EuiFlexItem> + <EuiCheckableCard + onChange={() => selectDeploymentMethod('docker')} + id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.runConnectorService.docker" + checked={selectedDeploymentMethod === 'docker'} + label={ + <EuiFlexGroup responsive={false} gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiIcon type="logoDocker" size="l" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiText> + {i18n.translate( + 'xpack.enterpriseSearch.connectorConfiguration.dockerTextLabel', + { defaultMessage: 'Run with Docker' } + )} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + } + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiCheckableCard + onChange={() => selectDeploymentMethod('source')} + id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.runConnectorService.source" + checked={selectedDeploymentMethod === 'source'} + label={ + <EuiFlexGroup responsive={false} gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiIcon type="logoGithub" size="l" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiText> + {i18n.translate( + 'xpack.enterpriseSearch.connectorConfiguration.sourceTextLabel', + { defaultMessage: 'Run from source' } + )} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + } + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiText> + </> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/waiting_for_connector_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/waiting_for_connector_step.tsx new file mode 100644 index 000000000000..eb22897e6507 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/waiting_for_connector_step.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 { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface WaitingForConnectorStepProps { + isLoading: boolean; + isRecheckDisabled: boolean; + recheck: () => void; + showFinishLaterButton?: boolean; +} +export const WaitingForConnectorStep: React.FC<WaitingForConnectorStepProps> = ({ + recheck, + isLoading, + isRecheckDisabled, + showFinishLaterButton = false, +}) => { + return ( + <> + <EuiSpacer /> + <EuiCallOut + color="warning" + title={i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.waitingForConnector.callout.title', + { + defaultMessage: 'Waiting for your connector', + } + )} + iconType="iInCircle" + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.waitingForConnector.callout.description', + { + defaultMessage: + 'Your connector has not connected to Search. Troubleshoot your configuration and refresh the page.', + } + )} + <EuiSpacer size="s" /> + <EuiFlexGroup direction="row" responsive={false}> + <EuiFlexItem grow={false}> + <EuiButton + color="warning" + fill + disabled={isRecheckDisabled} + data-test-subj="entSearchContent-connector-waitingForConnector-callout-recheckNow" + data-telemetry-id="entSearchContent-connector-waitingForConnector-callout-recheckNow" + iconType="refresh" + onClick={recheck} + isLoading={isLoading} + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.waitingForConnector.callout.button.label', + { + defaultMessage: 'Recheck now', + } + )} + </EuiButton> + </EuiFlexItem> + {showFinishLaterButton && ( + <EuiFlexItem grow={false}> + <EuiButton + color="warning" + data-test-subj="entSearchContent-connector-waitingForConnector-callout-finishLaterButton" + data-telemetry-id="entSearchContent-connector-waitingForConnector-callout-finishLaterButton" + iconType="save" + onClick={() => {}} + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.waitingForConnector.callout.finishLaterButton.label', + { + defaultMessage: 'Finish deployment later', + } + )} + </EuiButton> + </EuiFlexItem> + )} + </EuiFlexGroup> + </EuiCallOut> + </> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/whats_next_box.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/whats_next_box.tsx new file mode 100644 index 000000000000..2e04c094e7d7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/whats_next_box.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { useValues } from 'kea'; + +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiProgress, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { ConnectorStatus } from '@kbn/search-connectors'; + +import { APPLICATIONS_PLUGIN } from '../../../../../../common/constants'; + +import { PLAYGROUND_PATH } from '../../../../applications/routes'; +import { generateEncodedPath } from '../../../../shared/encode_path_params'; +import { KibanaLogic } from '../../../../shared/kibana'; +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { CONNECTOR_DETAIL_TAB_PATH } from '../../../routes'; +import { SyncsContextMenu } from '../../shared/header_actions/syncs_context_menu'; + +import { ConnectorDetailTabId } from '../connector_detail'; + +export interface WhatsNextBoxProps { + connectorId: string; + connectorIndex?: string; + connectorStatus: ConnectorStatus; + disabled?: boolean; + isSyncing?: boolean; + isWaitingForConnector?: boolean; +} + +export const WhatsNextBox: React.FC<WhatsNextBoxProps> = ({ + connectorId, + connectorIndex, + connectorStatus, + disabled = false, + isSyncing = false, + isWaitingForConnector = false, +}) => { + const { navigateToUrl } = useValues(KibanaLogic); + const isConfigured = !( + connectorStatus === ConnectorStatus.NEEDS_CONFIGURATION || + connectorStatus === ConnectorStatus.CREATED + ); + return ( + <EuiPanel hasBorder style={{ position: 'relative' }}> + {isSyncing && <EuiProgress size="xs" position="absolute" />} + <EuiTitle size="s"> + <h3> + {i18n.translate('xpack.enterpriseSearch.whatsNextBox.whatsNextPanelLabel', { + defaultMessage: "What's next?", + })} + </h3> + </EuiTitle> + <EuiSpacer /> + <EuiText> + <p> + {i18n.translate('xpack.enterpriseSearch.whatsNextBox.whatsNextPanelDescription', { + defaultMessage: + 'You can manually sync your data, schedule a recurring sync or see your documents.', + })} + </p> + </EuiText> + <EuiSpacer /> + <EuiFlexGroup responsive={false} gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiButton + data-test-subj="enterpriseSearchWhatsNextBoxSearchPlaygroundButton" + iconType="sparkles" + disabled={!connectorIndex || disabled} + onClick={() => { + navigateToUrl( + `${APPLICATIONS_PLUGIN.URL}${PLAYGROUND_PATH}?default-index=${connectorIndex}`, + { + shouldNotCreateHref: true, + } + ); + }} + > + <FormattedMessage + id="xpack.enterpriseSearch.whatsNextBox.searchPlaygroundButtonLabel" + defaultMessage="Search Playground" + /> + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonTo + data-test-subj="entSearchContent-connector-configuration-setScheduleAndSync" + data-telemetry-id="entSearchContent-connector-configuration-setScheduleAndSync" + isDisabled={isWaitingForConnector || !connectorIndex || !isConfigured} + to={`${generateEncodedPath(CONNECTOR_DETAIL_TAB_PATH, { + connectorId, + tabId: ConnectorDetailTabId.SCHEDULING, + })}`} + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.button.label', + { + defaultMessage: 'Set schedule and sync', + } + )} + </EuiButtonTo> + </EuiFlexItem> + + <EuiFlexItem> + <EuiFlexGroup responsive={false} gutterSize="xs"> + <EuiFlexItem grow={false}> + <SyncsContextMenu + disabled={isWaitingForConnector || !connectorIndex || !isConfigured} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_configuration.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_configuration.tsx index 70c5c46902b6..e0aa934f1795 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_configuration.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_configuration.tsx @@ -10,73 +10,54 @@ import React, { useMemo } from 'react'; import { useActions, useValues } from 'kea'; import { - EuiText, + EuiBadge, EuiFlexGroup, EuiFlexItem, - EuiLink, + EuiIcon, EuiPanel, + EuiSkeletonLoading, EuiSpacer, - EuiSteps, - EuiCodeBlock, - EuiCallOut, - EuiButton, + EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; - import { ConnectorConfigurationComponent, ConnectorStatus } from '@kbn/search-connectors'; -import { EXAMPLE_CONNECTOR_SERVICE_TYPES } from '../../../../../common/constants'; - import { Status } from '../../../../../common/types/api'; -import { BetaConnectorCallout } from '../../../shared/beta/beta_connector_callout'; -import { useCloudDetails } from '../../../shared/cloud_details/cloud_details'; import { docLinks } from '../../../shared/doc_links'; -import { generateEncodedPath } from '../../../shared/encode_path_params'; import { HttpLogic } from '../../../shared/http'; import { KibanaLogic } from '../../../shared/kibana'; import { LicensingLogic } from '../../../shared/licensing'; -import { EuiButtonTo, EuiLinkTo } from '../../../shared/react_router_helpers'; -import { GenerateConnectorApiKeyApiLogic } from '../../api/connector/generate_connector_api_key_api_logic'; -import { CONNECTOR_DETAIL_TAB_PATH } from '../../routes'; -import { isLastSeenOld } from '../../utils/connector_status_helpers'; -import { isAdvancedSyncRuleSnippetEmpty } from '../../utils/sync_rules_helpers'; -import { ApiKeyConfig } from '../search_index/connector/api_key_configuration'; - -import { getConnectorTemplate } from '../search_index/connector/constants'; +import { hasNonEmptyAdvancedSnippet, isExampleConnector } from '../../utils/connector_helpers'; import { ConnectorFilteringLogic } from '../search_index/connector/sync_rules/connector_filtering_logic'; -import { SyncsContextMenu } from '../shared/header_actions/syncs_context_menu'; + +import { IndexViewLogic } from '../search_index/index_view_logic'; import { AttachIndexBox } from './attach_index_box'; -import { ConnectorDetailTabId } from './connector_detail'; +import { AdvancedConfigOverrideCallout } from './components/advanced_config_override_callout'; +import { ConfigurationSkeleton } from './components/configuration_skeleton'; +import { ExampleConfigCallout } from './components/example_config_callout'; +import { WhatsNextBox } from './components/whats_next_box'; import { ConnectorViewLogic } from './connector_view_logic'; +import { ConnectorDeployment } from './deployment'; import { NativeConnectorConfiguration } from './native_connector_configuration'; export const ConnectorConfiguration: React.FC = () => { - const { data: apiKeyData } = useValues(GenerateConnectorApiKeyApiLogic); - const { - index, - isLoading, - connector, - updateConnectorConfigurationStatus, - hasAdvancedFilteringFeature, - } = useValues(ConnectorViewLogic); - const cloudContext = useCloudDetails(); + const { connector, updateConnectorConfigurationStatus } = useValues(ConnectorViewLogic); + const { connectorTypes: connectors } = useValues(KibanaLogic); + const { isSyncing, isWaitingForSync } = useValues(IndexViewLogic); const { hasPlatinumLicense } = useValues(LicensingLogic); - const { errorConnectingMessage, http } = useValues(HttpLogic); + const { http } = useValues(HttpLogic); const { advancedSnippet } = useValues(ConnectorFilteringLogic); - const isAdvancedSnippetEmpty = isAdvancedSyncRuleSnippetEmpty(advancedSnippet); - const { connectorTypes } = useValues(KibanaLogic); - const BETA_CONNECTORS = useMemo( - () => connectorTypes.filter(({ isBeta }) => isBeta), - [connectorTypes] + const NATIVE_CONNECTORS = useMemo( + () => connectors.filter(({ isNative }) => isNative), + [connectors] ); - const { fetchConnector, updateConnectorConfiguration } = useActions(ConnectorViewLogic); + const { updateConnectorConfiguration } = useActions(ConnectorViewLogic); if (!connector) { return <></>; @@ -86,435 +67,112 @@ export const ConnectorConfiguration: React.FC = () => { return <NativeConnectorConfiguration />; } - const hasApiKey = !!(connector.api_key_id ?? apiKeyData); - const docsUrl = connectorTypes.find( - ({ serviceType }) => serviceType === connector.service_type - )?.docsUrl; + const isWaitingForConnector = !connector.status || connector.status === ConnectorStatus.CREATED; - const isBeta = Boolean( - BETA_CONNECTORS.find(({ serviceType }) => serviceType === connector.service_type) - ); + const nativeConnector = NATIVE_CONNECTORS.find( + (connectorDefinition) => connectorDefinition.serviceType === connector.service_type + ) || { + docsUrl: '', + externalAuthDocsUrl: '', + externalDocsUrl: '', + iconPath: 'custom.svg', + isBeta: true, + isNative: false, + keywords: [], + name: connector.name, + serviceType: connector.service_type ?? '', + }; + + const iconPath = nativeConnector.iconPath; return ( <> - <EuiSpacer /> { // TODO remove this callout when example status is removed - connector && - connector.service_type && - EXAMPLE_CONNECTOR_SERVICE_TYPES.includes(connector.service_type) && ( - <> - <EuiCallOut - iconType="iInCircle" - color="warning" - title={i18n.translate( - 'xpack.enterpriseSearch.content.connectors.overview.connectorUnsupportedCallOut.title', - { - defaultMessage: 'Example connector', - } - )} - > - <EuiSpacer size="s" /> - <EuiText size="s"> - <FormattedMessage - id="xpack.enterpriseSearch.content.connectors.overview.connectorUnsupportedCallOut.description" - defaultMessage="This is an example connector that serves as a building block for customizations. The design and code is being provided as-is with no warranties. This is not subject to the SLA of supported features." - /> - </EuiText> - </EuiCallOut> - <EuiSpacer /> - </> - ) + isExampleConnector(connector) && <ExampleConfigCallout /> } <EuiFlexGroup> - <EuiFlexItem grow={2}> - <EuiPanel hasShadow={false} hasBorder> - { - <> - <EuiSpacer /> - <AttachIndexBox connector={connector} /> - </> - } - {connector.index_name && ( - <> - <EuiSpacer /> - <EuiSteps - steps={[ - { - children: ( - <ApiKeyConfig - indexName={connector.index_name} - hasApiKey={!!connector.api_key_id} - isNative={false} - /> - ), - status: hasApiKey ? 'complete' : 'incomplete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.generateApiKey.title', - { - defaultMessage: 'Generate an API key', - } - ), - titleSize: 'xs', - }, - { - children: ( - <> - <EuiSpacer /> - <EuiText size="s"> - <FormattedMessage - id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.description.thirdParagraph" - defaultMessage="In this step, you will need the API key and connector ID values for your config.yml file. Here's an {exampleLink}." - values={{ - exampleLink: ( - <EuiLink - data-test-subj="entSearchContent-connector-configuration-exampleConfigFileLink" - data-telemetry-id="entSearchContent-connector-configuration-exampleConfigFileLink" - href="https://github.com/elastic/connectors-python/blob/main/config.yml.example" - target="_blank" - external - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.configurationFileLink', - { defaultMessage: 'example config file' } - )} - </EuiLink> - ), - }} - /> - </EuiText> - <EuiSpacer /> - <EuiCodeBlock fontSize="m" paddingSize="m" color="dark" isCopyable> - {getConnectorTemplate({ - apiKeyData, - connectorData: { - id: connector.id, - service_type: connector.service_type, - }, - host: cloudContext.elasticsearchUrl, - })} - </EuiCodeBlock> - <EuiSpacer /> - <EuiText size="s"> - <FormattedMessage - id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.description.fourthParagraph" - defaultMessage="Because this connector is self-managed, you need to deploy the connector service on your own infrastructure. You can build from source or use Docker. Refer to the {link} for your deployment options." - values={{ - link: ( - <EuiLink - data-test-subj="entSearchContent-connector-configuration-deploymentModeLink" - data-telemetry-id="entSearchContent-connector-configuration-deploymentModeLink" - href={docLinks.connectorsClientDeploy} - target="_blank" - external - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.deploymentModeLink', - { defaultMessage: 'documentation' } - )} - </EuiLink> - ), - }} - /> - </EuiText> - </> - ), - status: - !connector.status || connector.status === ConnectorStatus.CREATED - ? 'incomplete' - : 'complete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.deployConnector.title', - { - defaultMessage: 'Set up and deploy connector', - } - ), - titleSize: 'xs', - }, - { - children: ( - <ConnectorConfigurationComponent - connector={connector} - hasPlatinumLicense={hasPlatinumLicense} - isLoading={updateConnectorConfigurationStatus === Status.LOADING} - saveConfig={(configuration) => - updateConnectorConfiguration({ - configuration, - connectorId: connector.id, - }) - } - subscriptionLink={docLinks.licenseManagement} - stackManagementLink={http.basePath.prepend( - '/app/management/stack/license_management' - )} - > - {!connector.status || connector.status === ConnectorStatus.CREATED ? ( - <EuiCallOut - title={i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorTitle', - { - defaultMessage: 'Waiting for your connector', - } - )} - iconType="iInCircle" - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorText', - { - defaultMessage: - 'Your connector has not connected to Search. Troubleshoot your configuration and refresh the page.', - } - )} - <EuiSpacer size="s" /> - <EuiButton - disabled={!index} - data-test-subj="entSearchContent-connector-configuration-recheckNow" - data-telemetry-id="entSearchContent-connector-configuration-recheckNow" - iconType="refresh" - onClick={() => fetchConnector({ connectorId: connector.id })} - isLoading={isLoading} - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnector.button.label', - { - defaultMessage: 'Recheck now', - } - )} - </EuiButton> - </EuiCallOut> - ) : ( - !isLastSeenOld(connector) && ( - <EuiCallOut - iconType="check" - color="success" - title={i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.connectorConnected', - { - defaultMessage: - 'Your connector {name} has connected to Search successfully.', - values: { name: connector.name }, - } - )} - /> - ) - )} - <EuiSpacer size="s" /> - {connector.status && - hasAdvancedFilteringFeature && - !isAdvancedSnippetEmpty && ( - <EuiCallOut - title={i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.advancedRulesCallout', - { defaultMessage: 'Configuration warning' } - )} - iconType="iInCircle" - color="warning" - > - <FormattedMessage - id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.advancedRulesCallout.description" - defaultMessage="{advancedSyncRulesDocs} can override some configuration fields." - values={{ - advancedSyncRulesDocs: ( - <EuiLink - data-test-subj="entSearchContent-connector-configuration-advancedSyncRulesDocsLink" - data-telemetry-id="entSearchContent-connector-configuration-advancedSyncRulesDocsLink" - href={docLinks.syncRules} - target="_blank" - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.advancedSyncRulesDocs', - { defaultMessage: 'Advanced Sync Rules' } - )} - </EuiLink> - ), - }} - /> - </EuiCallOut> - )} - </ConnectorConfigurationComponent> - ), - status: - connector.status === ConnectorStatus.CONNECTED ? 'complete' : 'incomplete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.enhance.title', - { - defaultMessage: 'Configure your connector', - } - ), - titleSize: 'xs', - }, - { - children: ( - <EuiFlexGroup direction="column"> - <EuiFlexItem> - <EuiText size="s"> - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.scheduleSync.description', - { - defaultMessage: - 'Finalize your connector by triggering a one-time sync, or setting a recurring sync to keep your data source in sync over time', - } - )} - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <EuiFlexGroup responsive={false}> - <EuiFlexItem grow={false}> - <EuiButtonTo - data-test-subj="entSearchContent-connector-configuration-setScheduleAndSync" - data-telemetry-id="entSearchContent-connector-configuration-setScheduleAndSync" - isDisabled={ - (connector?.is_native && !!errorConnectingMessage) || - [ - ConnectorStatus.NEEDS_CONFIGURATION, - ConnectorStatus.CREATED, - ].includes(connector?.status) || - !connector?.index_name - } - to={`${generateEncodedPath(CONNECTOR_DETAIL_TAB_PATH, { - connectorId: connector.id, - tabId: ConnectorDetailTabId.SCHEDULING, - })}`} - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.button.label', - { - defaultMessage: 'Set schedule and sync', - } - )} - </EuiButtonTo> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <SyncsContextMenu /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - ), - status: connector.scheduling.full.enabled ? 'complete' : 'incomplete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.title', - { - defaultMessage: 'Sync your data', - } - ), - titleSize: 'xs', - }, - ]} - /> - </> + <EuiFlexItem> + <EuiFlexGroup gutterSize="m" direction="row" alignItems="center"> + {iconPath && ( + <EuiFlexItem grow={false}> + <EuiIcon size="xl" type={iconPath} /> + </EuiFlexItem> )} - </EuiPanel> - </EuiFlexItem> - <EuiFlexItem grow={1}> - <EuiFlexGroup direction="column"> <EuiFlexItem grow={false}> - <EuiPanel hasBorder hasShadow={false}> - <EuiFlexGroup direction="column"> - <EuiFlexItem> - <EuiText> - <h4> - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.title', - { - defaultMessage: 'Support and documentation', - } - )} - </h4> - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <EuiText size="s"> - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.description', - { - defaultMessage: - 'You need to deploy this connector on your own infrastructure.', - } - )} - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <EuiLink - data-test-subj="entSearchContent-connector-configuration-connectorDocumentationLink" - data-telemetry-id="entSearchContent-connector-configuration-connectorDocumentationLink" - href={docLinks.connectors} - target="_blank" - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.viewDocumentation.label', - { - defaultMessage: 'View documentation', - } - )} - </EuiLink> - </EuiFlexItem> - <EuiFlexItem> - <EuiLinkTo to={'/app/management/security/api_keys'} shouldNotCreateHref> - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.manageKeys.label', - { - defaultMessage: 'Manage API keys', - } - )} - </EuiLinkTo> - </EuiFlexItem> - <EuiFlexItem> - <EuiLink - data-test-subj="entSearchContent-connector-configuration-readmeLink" - data-telemetry-id="entSearchContent-connector-configuration-readmeLink" - href="https://github.com/elastic/connectors-python/blob/main/README.md" - target="_blank" - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.readme.label', - { - defaultMessage: 'Connector readme', - } + <EuiTitle size="s"> + <h2>{nativeConnector?.name ?? connector.name}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiBadge color="hollow"> + {connector.is_native + ? i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.badgeType.nativeConnector', + { defaultMessage: 'Native connector' } + ) + : i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.badgeType.connectorClient', + { defaultMessage: 'Connector client' } + )} + </EuiBadge> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="l" /> + <AttachIndexBox connector={connector} /> + <EuiSpacer /> + {connector.index_name && ( + <> + <ConnectorDeployment /> + <EuiSpacer /> + <EuiPanel hasShadow={false} hasBorder> + <EuiTitle size="s"> + <h3> + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.configuration.title', + { defaultMessage: 'Configuration' } + )} + </h3> + </EuiTitle> + <EuiSpacer /> + <EuiSkeletonLoading + isLoading={isWaitingForConnector} + loadingContent={<ConfigurationSkeleton />} + loadedContent={ + <ConnectorConfigurationComponent + connector={connector} + hasPlatinumLicense={hasPlatinumLicense} + isLoading={updateConnectorConfigurationStatus === Status.LOADING} + saveConfig={(configuration) => + updateConnectorConfiguration({ + configuration, + connectorId: connector.id, + }) + } + subscriptionLink={docLinks.licenseManagement} + stackManagementLink={http.basePath.prepend( + '/app/management/stack/license_management' )} - </EuiLink> - </EuiFlexItem> - {docsUrl && ( - <EuiFlexItem> - <EuiLink - data-test-subj="entSearchContent-connector-configuration-deployWithDockerLink" - data-telemetry-id="entSearchContent-connector-configuration-deployWithDockerLink" - href={docsUrl} - target="_blank" - > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.dockerDeploy.label', - { - defaultMessage: 'Deploy with Docker', - } - )} - </EuiLink> - </EuiFlexItem> - )} - <EuiFlexItem> - <EuiLink - data-test-subj="entSearchContent-connector-configuration-deployWithoutDockerLink" - data-telemetry-id="entSearchContent-connector-configuration-deployWithoutDockerLink" - href="https://github.com/elastic/connectors-python/blob/main/docs/CONFIG.md#run-the-connector-service-for-a-custom-connector" - target="_blank" > - {i18n.translate( - 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.deploy.label', - { - defaultMessage: 'Deploy without Docker', - } + <EuiSpacer size="s" /> + {hasNonEmptyAdvancedSnippet(connector, advancedSnippet) && ( + <AdvancedConfigOverrideCallout /> )} - </EuiLink> - </EuiFlexItem> - </EuiFlexGroup> + </ConnectorConfigurationComponent> + } + /> </EuiPanel> - </EuiFlexItem> - {isBeta ? ( - <EuiFlexItem> - <BetaConnectorCallout /> - </EuiFlexItem> - ) : null} - </EuiFlexGroup> + <EuiSpacer /> + <WhatsNextBox + connectorId={connector.id} + disabled={isWaitingForConnector || !connector.last_synced} + isWaitingForConnector={isWaitingForConnector} + connectorIndex={connector.index_name} + connectorStatus={connector.status} + isSyncing={Boolean(isSyncing || isWaitingForSync)} + /> + </> + )} </EuiFlexItem> </EuiFlexGroup> </> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_view_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_view_logic.ts index 8c8596991552..88594a9f0ea9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_view_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/connector_view_logic.ts @@ -7,12 +7,7 @@ import { kea, MakeLogicType } from 'kea'; -import { - Connector, - FeatureName, - IngestPipelineParams, - IngestionMethod, -} from '@kbn/search-connectors'; +import { Connector, IngestionMethod, IngestPipelineParams } from '@kbn/search-connectors'; import { Status } from '../../../../../common/types/api'; @@ -22,6 +17,10 @@ import { CachedFetchConnectorByIdApiLogicValues, } from '../../api/connector/cached_fetch_connector_by_id_api_logic'; +import { + GenerateConnectorApiKeyApiLogicActions, + GenerateConnectorApiKeyApiLogic, +} from '../../api/connector/generate_connector_api_key_api_logic'; import { ConnectorConfigurationApiLogic, PostConnectorConfigurationActions, @@ -30,15 +29,18 @@ import { FetchIndexActions, FetchIndexApiLogic } from '../../api/index/fetch_ind import { ElasticsearchViewIndex } from '../../types'; import { + hasAdvancedFilteringFeature, + hasBasicFilteringFeature, hasDocumentLevelSecurityFeature, hasIncrementalSyncFeature, } from '../../utils/connector_helpers'; import { getConnectorLastSeenError, isLastSeenOld } from '../../utils/connector_status_helpers'; import { - ConnectorNameAndDescriptionLogic, ConnectorNameAndDescriptionActions, + ConnectorNameAndDescriptionLogic, } from './connector_name_and_description_logic'; +import { DeploymentLogic, DeploymentLogicActions } from './deployment_logic'; export interface ConnectorViewActions { fetchConnector: CachedFetchConnectorByIdApiLogicActions['makeRequest']; @@ -49,6 +51,8 @@ export interface ConnectorViewActions { fetchIndexApiError: FetchIndexActions['apiError']; fetchIndexApiReset: FetchIndexActions['apiReset']; fetchIndexApiSuccess: FetchIndexActions['apiSuccess']; + generateApiKeySuccess: GenerateConnectorApiKeyApiLogicActions['apiSuccess']; + generateConfigurationSuccess: DeploymentLogicActions['generateConfigurationSuccess']; nameAndDescriptionApiError: ConnectorNameAndDescriptionActions['apiError']; nameAndDescriptionApiSuccess: ConnectorNameAndDescriptionActions['apiSuccess']; startConnectorPoll: CachedFetchConnectorByIdApiLogicActions['startPolling']; @@ -78,8 +82,6 @@ export interface ConnectorViewValues { isCanceling: boolean; isHiddenIndex: boolean; isLoading: boolean; - isSyncing: boolean; - isWaitingForSync: boolean; lastUpdated: string | null; pipelineData: IngestPipelineParams | undefined; recheckIndexLoading: boolean; @@ -114,6 +116,10 @@ export const ConnectorViewLogic = kea<MakeLogicType<ConnectorViewValues, Connect ], ConnectorNameAndDescriptionLogic, ['apiSuccess as nameAndDescriptionApiSuccess', 'apiError as nameAndDescriptionApiError'], + DeploymentLogic, + ['generateConfigurationSuccess'], + GenerateConnectorApiKeyApiLogic, + ['apiSuccess as generateApiKeySuccess'], ], values: [ CachedFetchConnectorByIdApiLogic, @@ -131,6 +137,21 @@ export const ConnectorViewLogic = kea<MakeLogicType<ConnectorViewValues, Connect }, }), listeners: ({ actions, values }) => ({ + fetchConnectorApiSuccess: ({ connector }) => { + if (!values.index && connector?.index_name) { + actions.fetchIndex({ indexName: connector.index_name }); + } + }, + generateApiKeySuccess: () => { + if (values.connectorId) { + actions.fetchConnector({ connectorId: values.connectorId }); + } + }, + generateConfigurationSuccess: () => { + if (values.connectorId) { + actions.fetchConnector({ connectorId: values.connectorId }); + } + }, nameAndDescriptionApiError: () => { if (values.connectorId) { actions.fetchConnector({ connectorId: values.connectorId }); @@ -146,11 +167,6 @@ export const ConnectorViewLogic = kea<MakeLogicType<ConnectorViewValues, Connect actions.fetchConnector({ connectorId: values.connectorId }); } }, - fetchConnectorApiSuccess: ({ connector }) => { - if (!values.index && connector?.index_name) { - actions.fetchIndex({ indexName: connector.index_name }); - } - }, }), path: ['enterprise_search', 'content', 'connector_view_logic'], selectors: ({ selectors }) => ({ @@ -176,19 +192,11 @@ export const ConnectorViewLogic = kea<MakeLogicType<ConnectorViewValues, Connect ], hasAdvancedFilteringFeature: [ () => [selectors.connector], - (connector?: Connector) => - connector?.features - ? connector.features[FeatureName.SYNC_RULES]?.advanced?.enabled ?? - connector.features[FeatureName.FILTERING_ADVANCED_CONFIG] - : false, + (connector?: Connector) => hasAdvancedFilteringFeature(connector), ], hasBasicFilteringFeature: [ () => [selectors.connector], - (connector?: Connector) => - connector?.features - ? connector.features[FeatureName.SYNC_RULES]?.basic?.enabled ?? - connector.features[FeatureName.FILTERING_RULES] - : false, + (connector?: Connector) => hasBasicFilteringFeature(connector), ], hasDocumentLevelSecurityFeature: [ () => [selectors.connector], diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx new file mode 100644 index 000000000000..fb6427901984 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx @@ -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 React, { useEffect, useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { + EuiCode, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiSteps, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import { ConnectorStatus } from '@kbn/search-connectors'; + +import { Status } from '../../../../../common/types/api'; + +import { GetApiKeyByIdLogic } from '../../api/api_key/get_api_key_by_id_api_logic'; + +import { GenerateConnectorApiKeyApiLogic } from '../../api/connector/generate_connector_api_key_api_logic'; + +import { ConnectorLinked } from './components/connector_linked'; +import { DockerInstructionsStep } from './components/docker_instructions_step'; +import { GenerateConfigButton } from './components/generate_config_button'; +import { GeneratedConfigFields } from './components/generated_config_fields'; +import { RunFromSourceStep } from './components/run_from_source_step'; +import { RunOptionsButtons } from './components/run_options_buttons'; +import { WaitingForConnectorStep } from './components/waiting_for_connector_step'; +import { ConnectorViewLogic } from './connector_view_logic'; +import { DeploymentLogic } from './deployment_logic'; + +export const ConnectorDeployment: React.FC = () => { + const [selectedDeploymentMethod, setSelectedDeploymentMethod] = useState<'docker' | 'source'>( + 'docker' + ); + const { generatedData, isGenerateLoading } = useValues(DeploymentLogic); + const { index, isLoading, connector, connectorId } = useValues(ConnectorViewLogic); + const { fetchConnector } = useActions(ConnectorViewLogic); + const { generateConfiguration } = useActions(DeploymentLogic); + const { makeRequest: getApiKeyById } = useActions(GetApiKeyByIdLogic); + const { data: apiKeyMetaData } = useValues(GetApiKeyByIdLogic); + const { makeRequest: generateConnectorApiKey } = useActions(GenerateConnectorApiKeyApiLogic); + const { status, data: apiKeyData } = useValues(GenerateConnectorApiKeyApiLogic); + + const [connectorUiOptions, setConnectorUiOptions] = useLocalStorage< + Record<string, { deploymentMethod: 'docker' | 'source' }> + >('search:connector-ui-options', {}); + + useEffect(() => { + if (connectorUiOptions && connectorId && connectorUiOptions[connectorId]) { + setSelectedDeploymentMethod(connectorUiOptions[connectorId].deploymentMethod); + } else { + selectDeploymentMethod('docker'); + } + }, [connectorUiOptions, connectorId]); + + useEffect(() => { + if (connectorId && connector && connector.api_key_id) { + getApiKeyById(connector.api_key_id); + } + }, [connector, connectorId]); + + if (!connector || connector.is_native) { + return <></>; + } + + const selectDeploymentMethod = (deploymentMethod: 'docker' | 'source') => { + setSelectedDeploymentMethod(deploymentMethod); + setConnectorUiOptions({ + ...connectorUiOptions, + [connector.id]: { deploymentMethod }, + }); + }; + + const hasApiKey = !!(connector.api_key_id ?? generatedData?.apiKey); + + const isWaitingForConnector = !connector.status || connector.status === ConnectorStatus.CREATED; + const apiKey = generatedData?.apiKey || apiKeyData || apiKeyMetaData; + + return ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiPanel hasShadow={false} hasBorder> + <> + <EuiTitle size="s"> + <h3> + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.DeploymentTitle', + { + defaultMessage: 'Deployment', + } + )} + </h3> + </EuiTitle> + <EuiSpacer /> + <EuiSteps + steps={[ + { + children: ( + <RunOptionsButtons + selectDeploymentMethod={selectDeploymentMethod} + selectedDeploymentMethod={selectedDeploymentMethod} + /> + ), + status: selectedDeploymentMethod === null ? 'incomplete' : 'complete', + title: i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.runConnectorService.title', + { + defaultMessage: 'Run connector service', + } + ), + titleSize: 'xs', + }, + { + children: ( + <> + <EuiSpacer size="s" /> + <EuiText size="s"> + {selectedDeploymentMethod === 'source' ? ( + <FormattedMessage + id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.configureIndexAndApiKey.description.source" + defaultMessage="When you generate a configuration, Elastic will create an index, an API key and a Connector ID. You'll need to add this information to the {configYaml} file for your connector. Alternatively use an existing index and API key. " + values={{ + configYaml: ( + <EuiCode> + {i18n.translate( + 'xpack.enterpriseSearch.connectorConfiguration.configymlCodeBlockLabel', + { defaultMessage: 'config.yml' } + )} + </EuiCode> + ), + }} + /> + ) : ( + <FormattedMessage + id="xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.configureIndexAndApiKey.description.docker" + defaultMessage="When you generate a configuration, Elastic will create an index, an API key and a Connector ID. Alternatively use an existing index and API key." + /> + )} + </EuiText> + + <EuiSpacer /> + {hasApiKey && connector.index_name ? ( + <GeneratedConfigFields + apiKey={apiKey} + connector={connector} + generateApiKey={() => { + if (connector.index_name) { + generateConnectorApiKey({ + indexName: connector.index_name, + isNative: connector.is_native, + }); + } + }} + isGenerateLoading={status === Status.LOADING} + /> + ) : ( + <GenerateConfigButton + connectorId={connector.id} + generateConfiguration={generateConfiguration} + isGenerateLoading={isGenerateLoading} + /> + )} + </> + ), + status: hasApiKey ? 'complete' : 'incomplete', + title: i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.generateApiKey.title', + { + defaultMessage: 'Configure index and API key', + } + ), + titleSize: 'xs', + }, + { + children: ( + <> + <EuiSpacer size="s" /> + {selectedDeploymentMethod === 'source' ? ( + <RunFromSourceStep + connectorId={connectorId ?? ''} + serviceType={connector.service_type ?? ''} + apiKeyData={apiKey} + isWaitingForConnector={isWaitingForConnector} + /> + ) : ( + <DockerInstructionsStep + connectorId={connectorId ?? ''} + hasApiKey={hasApiKey} + serviceType={connector.service_type ?? ''} + isWaitingForConnector={isWaitingForConnector} + apiKeyData={apiKey} + /> + )} + </> + ), + status: + !connector.status || connector.status === ConnectorStatus.CREATED + ? 'incomplete' + : 'complete', + title: i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.runConnector.title', + { + defaultMessage: 'Run connector service', + } + ), + titleSize: 'xs', + }, + { + children: isWaitingForConnector ? ( + <WaitingForConnectorStep + isLoading={isLoading} + isRecheckDisabled={!index} + recheck={() => fetchConnector({ connectorId: connector.id })} + /> + ) : ( + <ConnectorLinked /> + ), + status: isWaitingForConnector ? 'loading' : 'complete', + title: i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.waitingForConnector.title', + { + defaultMessage: 'Waiting for your connector', + } + ), + titleSize: 'xs', + }, + ]} + /> + </> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment_logic.ts new file mode 100644 index 000000000000..09c2c8db48e0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment_logic.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { Connector } from '@kbn/search-connectors'; + +import { HttpError, Status } from '../../../../../common/types/api'; +import { Actions } from '../../../shared/api_logic/create_api_logic'; +import { + GenerateConfigApiArgs, + GenerateConfigApiLogic, +} from '../../api/connector/generate_connector_config_api_logic'; +import { APIKeyResponse } from '../../api/generate_api_key/generate_api_key_logic'; + +type GenerateConfigApiActions = Actions<GenerateConfigApiArgs, {}>; + +export interface DeploymentLogicValues { + generateConfigurationError: HttpError; + generateConfigurationStatus: Status; + generatedData: { + apiKey: APIKeyResponse['apiKey']; + connectorId: Connector['id']; + indexName: string; + }; + isGenerateLoading: boolean; +} +export interface DeploymentLogicActions { + generateConfiguration: GenerateConfigApiActions['makeRequest']; + generateConfigurationSuccess: GenerateConfigApiActions['apiSuccess']; +} + +export const DeploymentLogic = kea<MakeLogicType<DeploymentLogicValues, DeploymentLogicActions>>({ + connect: { + actions: [ + GenerateConfigApiLogic, + ['makeRequest as generateConfiguration', 'apiSuccess as generateConfigurationSuccess'], + ], + values: [ + GenerateConfigApiLogic, + [ + 'status as generateConfigurationStatus', + 'data as generatedData', + 'error as generateConfigurationError', + ], + ], + }, + selectors: { + isGenerateLoading: [ + (selectors) => [selectors.generateConfigurationStatus], + (status) => status === Status.LOADING, + ], + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/native_connector_configuration.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/native_connector_configuration.tsx index ada3b65114ef..fac70afd156d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/native_connector_configuration.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/native_connector_configuration.tsx @@ -10,42 +10,31 @@ import React, { useMemo } from 'react'; import { useValues } from 'kea'; import { + EuiBadge, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiIcon, - EuiLink, EuiPanel, EuiSpacer, - EuiSteps, - EuiText, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { FeatureName } from '@kbn/search-connectors'; - import { BetaConnectorCallout } from '../../../shared/beta/beta_connector_callout'; -import { docLinks } from '../../../shared/doc_links'; -import { generateEncodedPath } from '../../../shared/encode_path_params'; import { HttpLogic } from '../../../shared/http'; import { KibanaLogic } from '../../../shared/kibana'; -import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { GenerateConnectorApiKeyApiLogic } from '../../api/connector/generate_connector_api_key_api_logic'; -import { CONNECTOR_DETAIL_TAB_PATH } from '../../routes'; -import { hasConfiguredConfiguration } from '../../utils/has_configured_configuration'; import { ApiKeyConfig } from '../search_index/connector/api_key_configuration'; import { ConvertConnector } from '../search_index/connector/native_connector_configuration/convert_connector'; import { NativeConnectorConfigurationConfig } from '../search_index/connector/native_connector_configuration/native_connector_configuration_config'; import { ResearchConfiguration } from '../search_index/connector/native_connector_configuration/research_configuration'; -import { SyncsContextMenu } from '../shared/header_actions/syncs_context_menu'; import { AttachIndexBox } from './attach_index_box'; -import { ConnectorDetailTabId } from './connector_detail'; +import { WhatsNextBox } from './components/whats_next_box'; import { ConnectorViewLogic } from './connector_view_logic'; export const NativeConnectorConfiguration: React.FC = () => { @@ -78,17 +67,7 @@ export const NativeConnectorConfiguration: React.FC = () => { serviceType: connector.service_type ?? '', }; - const hasDescription = !!connector.description; - const hasConfigured = hasConfiguredConfiguration(connector.configuration); - const hasConfiguredAdvanced = - connector.last_synced || - connector.scheduling.full.enabled || - connector.scheduling.incremental.enabled; - const hasResearched = hasDescription || hasConfigured || hasConfiguredAdvanced; const iconPath = nativeConnector.iconPath; - const hasDocumentLevelSecurity = - connector.features?.[FeatureName.DOCUMENT_LEVEL_SECURITY]?.enabled || false; - const hasApiKey = !!(connector.api_key_id ?? apiKeyData); // TODO service_type === "" is considered unknown/custom connector multipleplaces replace all of them with a better solution @@ -98,249 +77,127 @@ export const NativeConnectorConfiguration: React.FC = () => { return ( <> - <EuiSpacer /> + {isBeta ? ( + <> + <EuiFlexItem grow={false}> + <EuiPanel hasBorder hasShadow={false}> + <BetaConnectorCallout /> + </EuiPanel> + </EuiFlexItem> + <EuiSpacer /> + </> + ) : null} <EuiFlexGroup> - <EuiFlexItem grow={2}> - <EuiPanel hasShadow={false} hasBorder> - <EuiFlexGroup gutterSize="m" direction="row" alignItems="center"> - {iconPath && ( - <EuiFlexItem grow={false}> - <EuiIcon size="xl" type={iconPath} /> - </EuiFlexItem> - )} + <EuiFlexItem> + <EuiFlexGroup gutterSize="m" direction="row" alignItems="center"> + {iconPath && ( <EuiFlexItem grow={false}> - <EuiTitle size="s"> - <h2>{nativeConnector?.name ?? connector.name}</h2> - </EuiTitle> + <EuiIcon size="xl" type={iconPath} /> </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer /> - {config.host && config.canDeployEntSearch && errorConnectingMessage && ( - <> - <EuiCallOut - color="warning" - size="m" - title={i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.title', - { - defaultMessage: 'No running Enterprise Search instance detected', - } - )} - iconType="warning" - > - <p> - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.text', - { - defaultMessage: - 'Native connectors require a running Enterprise Search instance to sync content from source.', - } - )} - </p> - </EuiCallOut> - - <EuiSpacer /> - </> )} - { - <> - <EuiSpacer /> - <AttachIndexBox connector={connector} /> - </> - } - {connector.index_name && ( - <> - <EuiSpacer /> - <EuiSteps - steps={[ - { - children: <ResearchConfiguration nativeConnector={nativeConnector} />, - status: hasResearched ? 'complete' : 'incomplete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.researchConfigurationTitle', - { - defaultMessage: 'Research configuration requirements', - } - ), - titleSize: 'xs', - }, - { - children: ( - <NativeConnectorConfigurationConfig - connector={connector} - nativeConnector={nativeConnector} - status={connector.status} - /> - ), - status: hasConfigured ? 'complete' : 'incomplete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.configurationTitle', - { - defaultMessage: 'Configuration', - } - ), - titleSize: 'xs', - }, - { - children: ( - <ApiKeyConfig - indexName={connector.index_name || ''} - hasApiKey={hasApiKey} - isNative - /> - ), - status: hasApiKey ? 'complete' : 'incomplete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.manageApiKeyTitle', - { - defaultMessage: 'Manage API key', - } - ), - titleSize: 'xs', - }, - { - children: ( - <EuiFlexGroup direction="column"> - <EuiFlexItem> - <EuiText size="s"> - <FormattedMessage - id="xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.description" - defaultMessage="Finalize your connector by triggering a one time sync, or setting a recurring sync schedule." - /> - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <EuiFlexGroup responsive={false}> - <EuiFlexItem grow={false}> - <EuiButtonTo - to={`${generateEncodedPath(CONNECTOR_DETAIL_TAB_PATH, { - connectorId: connector.id, - tabId: ConnectorDetailTabId.SCHEDULING, - })}`} - > - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.schedulingButtonLabel', - { - defaultMessage: 'Set schedule and sync', - } - )} - </EuiButtonTo> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <SyncsContextMenu /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - ), - status: hasConfiguredAdvanced ? 'complete' : 'incomplete', - title: i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.advancedConfigurationTitle', - { - defaultMessage: 'Sync your data', - } - ), - titleSize: 'xs', - }, - ]} - /> - </> - )} - </EuiPanel> - </EuiFlexItem> - <EuiFlexItem grow={1}> - <EuiFlexGroup direction="column"> <EuiFlexItem grow={false}> - <EuiPanel hasBorder hasShadow={false}> - <EuiFlexGroup direction="row" alignItems="center" gutterSize="s"> - <EuiFlexItem grow={false}> - <EuiIcon type="clock" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiTitle size="xs"> - <h3> - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.title', - { - defaultMessage: 'Configurable sync schedule', - } - )} - </h3> - </EuiTitle> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="s" /> - <EuiText size="s"> + <EuiTitle size="s"> + <h2>{nativeConnector?.name ?? connector.name}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiBadge color="hollow"> + {connector.is_native + ? i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.badgeType.nativeConnector', + { defaultMessage: 'Native connector' } + ) + : i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.badgeType.connectorClient', + { defaultMessage: 'Connector client' } + )} + </EuiBadge> + </EuiFlexItem> + </EuiFlexGroup> + {config.host && config.canDeployEntSearch && errorConnectingMessage && ( + <> + <EuiCallOut + color="warning" + size="m" + title={i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.title', + { + defaultMessage: 'No running Enterprise Search instance detected', + } + )} + iconType="warning" + > + <p> {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.description', + 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.text', { defaultMessage: - 'Remember to set a sync schedule in the Scheduling tab to continually refresh your searchable data.', + 'Native connectors require a running Enterprise Search instance to sync content from source.', } )} - </EuiText> + </p> + </EuiCallOut> + + <EuiSpacer /> + </> + )} + { + <> + <EuiSpacer /> + <AttachIndexBox connector={connector} /> + </> + } + {connector.index_name && ( + <> + <EuiSpacer /> + <EuiPanel hasBorder> + <EuiTitle size="s"> + <h3> + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.nativeConfigurationConnector.configuration.title', + { defaultMessage: 'Configuration' } + )} + </h3> + </EuiTitle> + <EuiSpacer /> + <ResearchConfiguration nativeConnector={nativeConnector} /> + <EuiSpacer size="m" /> + <NativeConnectorConfigurationConfig + connector={connector} + nativeConnector={nativeConnector} + status={connector.status} + /> + <EuiSpacer /> </EuiPanel> - </EuiFlexItem> - {hasDocumentLevelSecurity && ( - <EuiFlexItem grow={false}> - <EuiPanel hasBorder hasShadow={false}> - <EuiFlexGroup direction="row" alignItems="center" gutterSize="s"> - <EuiFlexItem grow={false}> - <EuiIcon type="globe" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiTitle size="xs"> - <h3> - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.title', - { - defaultMessage: 'Document level security', - } - )} - </h3> - </EuiTitle> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="s" /> - <EuiText size="s"> + <EuiSpacer /> + <EuiPanel hasBorder> + <EuiTitle size="s"> + <h4> {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.description', - { - defaultMessage: - 'Restrict and personalize the read access users have to the index documents at query time.', - } + 'xpack.enterpriseSearch.content.connector_detail.nativeConfigurationConnector.apiKey.title', + { defaultMessage: 'API Key' } )} - <EuiSpacer size="s" /> - <EuiLink - data-test-subj="entSearchContent-connectorDetail-documentLevelSecurityLink" - data-telemetry-id="entSearchContent-connectorDetail-documentLevelSecurityLink" - href={docLinks.documentLevelSecurity} - target="_blank" - > - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.securityLinkLabel', - { - defaultMessage: 'Document level security', - } - )} - </EuiLink> - </EuiText> - </EuiPanel> - </EuiFlexItem> - )} - <EuiFlexItem grow={false}> - <EuiPanel hasBorder hasShadow={false}> + </h4> + </EuiTitle> + <EuiSpacer size="m" /> + <ApiKeyConfig + indexName={connector.index_name || ''} + hasApiKey={hasApiKey} + isNative + /> + </EuiPanel> + <EuiSpacer /> + <EuiPanel hasBorder> <ConvertConnector /> </EuiPanel> - </EuiFlexItem> - {isBeta ? ( - <EuiFlexItem grow={false}> - <EuiPanel hasBorder hasShadow={false}> - <BetaConnectorCallout /> - </EuiPanel> - </EuiFlexItem> - ) : null} - </EuiFlexGroup> + <EuiSpacer /> + <WhatsNextBox + connectorId={connector.id} + connectorStatus={connector.status} + connectorIndex={connector.index_name} + /> + </> + )} </EuiFlexItem> </EuiFlexGroup> </> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/delete_connector_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/delete_connector_modal.tsx index 512b0bd38469..a047b8ab8219 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/delete_connector_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/delete_connector_modal.tsx @@ -9,6 +9,9 @@ import React, { useState, useEffect } from 'react'; import { useActions, useValues } from 'kea'; +import { omit } from 'lodash'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + import { EuiCheckbox, EuiConfirmModal, @@ -30,7 +33,11 @@ export interface DeleteConnectorModalProps { isCrawler: boolean; } export const DeleteConnectorModal: React.FC<DeleteConnectorModalProps> = ({ isCrawler }) => { + const [connectorUiOptions, setConnectorUiOptions] = useLocalStorage< + Record<string, { deploymentMethod: 'docker' | 'source' | null }> + >('search:connector-ui-options', {}); const { closeDeleteModal, deleteConnector, deleteIndex } = useActions(ConnectorsLogic); + const { deleteModalConnectorId: connectorId, deleteModalConnectorName, @@ -75,6 +82,7 @@ export const DeleteConnectorModal: React.FC<DeleteConnectorModalProps> = ({ isCr connectorId, shouldDeleteIndex, }); + setConnectorUiOptions(omit(connectorUiOptions, connectorId)); } }} cancelButtonText={ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/api_key_configuration.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/api_key_configuration.tsx index 868bf4fe5072..29655218034d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/api_key_configuration.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/api_key_configuration.tsx @@ -20,6 +20,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { Status } from '../../../../../../common/types/api'; import { GenerateConnectorApiKeyApiLogic } from '../../../api/connector/generate_connector_api_key_api_logic'; @@ -150,9 +151,10 @@ export const ApiKeyConfig: React.FC<{ <></> )} <EuiFlexItem> - <EuiFlexGroup justifyContent="spaceBetween" alignItems="center"> + <EuiFlexGroup alignItems="center"> <EuiFlexItem grow={false}> <EuiButton + data-test-subj="enterpriseSearchApiKeyConfigGenerateApiKeyButton" onClick={clickGenerateApiKey} isLoading={status === Status.LOADING} isDisabled={indexName.length === 0} @@ -166,6 +168,21 @@ export const ApiKeyConfig: React.FC<{ )} </EuiButton> </EuiFlexItem> + {status === Status.SUCCESS && ( + <EuiFlexItem grow={false}> + <EuiCallOut + color="success" + size="s" + iconType="check" + title={ + <FormattedMessage + id="xpack.enterpriseSearch.apiKeyConfig.newApiKeyCreatedCalloutLabel" + defaultMessage="New API key created succesfully" + /> + } + /> + </EuiFlexItem> + )} </EuiFlexGroup> </EuiFlexItem> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts index 08a47b9a1b09..3962bbb888d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts @@ -34,3 +34,18 @@ export const getConnectorTemplate = ({ host: "${host || 'http://localhost:9200'}" api_key: "${apiKeyData?.encoded || ''}" `; + +export const getRunFromDockerSnippet = ({ version }: { version: string }) => dedent` +docker run \\ + + -v "</absolute/path/to>/connectors-config:/config" \ # NOTE: change absolute path to match where config.yml is located on your machine + --tty \\ + + --rm \\ + + docker.elastic.co/enterprise-search/elastic-connectors:${version} \\ + + /app/bin/elastic-ingest \\ + + -c /config/config.yml # Path to your configuration file in the container +`; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/convert_connector.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/convert_connector.tsx index 454b056a87f4..5b1478086aad 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/convert_connector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/convert_connector.tsx @@ -12,7 +12,6 @@ import { useActions, useValues } from 'kea'; import { EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiTitle, EuiSpacer, EuiText, @@ -37,11 +36,8 @@ export const ConvertConnector: React.FC = () => { <> {isModalVisible && <ConvertConnectorModal />} <EuiFlexGroup direction="row" alignItems="center" gutterSize="s"> - <EuiFlexItem grow={false}> - <EuiIcon type="wrench" /> - </EuiFlexItem> <EuiFlexItem> - <EuiTitle size="xs"> + <EuiTitle size="s"> <h3> {i18n.translate( 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.title', @@ -53,7 +49,7 @@ export const ConvertConnector: React.FC = () => { </EuiTitle> </EuiFlexItem> </EuiFlexGroup> - <EuiSpacer size="s" /> + <EuiSpacer size="l" /> <EuiText size="s"> <FormattedMessage id="xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.description" @@ -69,7 +65,7 @@ export const ConvertConnector: React.FC = () => { ), }} /> - <EuiSpacer size="s" /> + <EuiSpacer size="l" /> <EuiButton onClick={() => showModal()}> {i18n.translate( 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.buttonTitle', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx index d4e3ae19ae43..d2681a5d3df9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useActions, useValues } from 'kea'; -import { EuiSpacer, EuiLink, EuiText, EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elastic/eui'; +import { EuiSpacer, EuiLink, EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -62,32 +62,7 @@ export const NativeConnectorConfigurationConfig: React.FC< subscriptionLink={docLinks.licenseManagement} stackManagementLink={http.basePath.prepend('/app/management/stack/license_management')} > - <EuiText size="s"> - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.encryptionWarningMessage', - { - defaultMessage: - 'Encryption for data source credentials is unavailable in this version. Your data source credentials will be stored, unencrypted, in Elasticsearch.', - } - )} - </EuiText> - <EuiSpacer /> <EuiFlexGroup direction="row"> - <EuiFlexItem grow={false}> - <EuiLink - data-test-subj="entSearchContent-connector-nativeConnector-learnMoreAboutSecurityLink" - data-telemetry-id="entSearchContent-connector-nativeConnector-learnMoreAboutSecurityLink" - href={docLinks.elasticsearchSecureCluster} - target="_blank" - > - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.securityDocumentationLinkLabel', - { - defaultMessage: 'Learn more about Elasticsearch security', - } - )} - </EuiLink> - </EuiFlexItem> {nativeConnector.externalAuthDocsUrl && ( <EuiFlexItem grow={false}> <EuiLink diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx index 993c7b9e1ac0..0625c60a354f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx @@ -7,9 +7,10 @@ import React from 'react'; -import { EuiText, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { EuiText, EuiFlexGroup, EuiFlexItem, EuiLink, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { ConnectorDefinition } from '@kbn/search-connectors-plugin/common/types'; @@ -22,42 +23,63 @@ export const ResearchConfiguration: React.FC<ResearchConfigurationProps> = ({ const { docsUrl, externalDocsUrl, name } = nativeConnector; return ( - <> - <EuiText size="s"> - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.description', - { - defaultMessage: - 'This connector supports several authentication methods. Ask your administrator for the correct connection credentials.', - } - )} - </EuiText> - <EuiSpacer /> - <EuiFlexGroup direction="row" alignItems="flexStart"> - <EuiFlexItem grow={false}> - <EuiLink target="_blank" href={docsUrl}> - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.connectorDocumentationLinkLabel', - { - defaultMessage: 'Documentation', - } - )} - </EuiLink> + <EuiCallOut + title={ + <FormattedMessage + id="xpack.enterpriseSearch.researchConfiguration.euiText.checkRequirementsLabel" + defaultMessage="Check Requirements" + /> + } + iconType="iInCircle" + > + <EuiFlexGroup direction="column" alignItems="flexStart" gutterSize="s"> + <EuiFlexItem> + <EuiText size="s"> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.researchConfiguration.p.referToTheDocumentationLabel" + defaultMessage="Refer to the documentation for this connector to learn about prerequisites for connecting to {serviceType} and configuration requirements." + values={{ + serviceType: name, + }} + /> + </p> + </EuiText> </EuiFlexItem> - {externalDocsUrl && ( + <EuiFlexGroup direction="row" alignItems="center"> <EuiFlexItem grow={false}> - <EuiLink target="_blank" href={externalDocsUrl}> + <EuiLink + data-test-subj="enterpriseSearchResearchConfigurationDocumentationLink" + target="_blank" + href={docsUrl} + > {i18n.translate( - 'xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.serviceDocumentationLinkLabel', + 'xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.connectorDocumentationLinkLabel', { - defaultMessage: '{name} documentation', - values: { name }, + defaultMessage: 'Documentation', } )} </EuiLink> </EuiFlexItem> - )} + {externalDocsUrl && ( + <EuiFlexItem grow={false}> + <EuiLink + data-test-subj="enterpriseSearchResearchConfigurationNameDocumentationLink" + target="_blank" + href={externalDocsUrl} + > + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.serviceDocumentationLinkLabel', + { + defaultMessage: '{name} documentation', + values: { name }, + } + )} + </EuiLink> + </EuiFlexItem> + )} + </EuiFlexGroup> </EuiFlexGroup> - </> + </EuiCallOut> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/header_actions/syncs_context_menu.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/header_actions/syncs_context_menu.tsx index c525c2f67207..b4c8f39c253d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/header_actions/syncs_context_menu.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/header_actions/syncs_context_menu.tsx @@ -34,7 +34,11 @@ import { IndexViewLogic } from '../../search_index/index_view_logic'; import { SyncsLogic } from './syncs_logic'; -export const SyncsContextMenu: React.FC = () => { +export interface SyncsContextMenuProps { + disabled?: boolean; +} + +export const SyncsContextMenu: React.FC<SyncsContextMenuProps> = ({ disabled = false }) => { const { config, productFeatures } = useValues(KibanaLogic); const { ingestionStatus, isCanceling, isSyncing, isWaitingForSync } = useValues(IndexViewLogic); const { connector, hasDocumentLevelSecurityFeature, hasIncrementalSyncFeature } = @@ -171,6 +175,7 @@ export const SyncsContextMenu: React.FC = () => { <EuiPopover button={ <EuiButton + disabled={disabled} data-test-subj="enterpriseSearchSyncsContextMenuButton" data-telemetry-id="entSearchContent-connector-header-sync-openSyncMenu" iconType="arrowDown" diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_helpers.ts index 2d91be4c269b..03b3fba0ed33 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_helpers.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_helpers.ts @@ -7,6 +7,10 @@ import { Connector, FeatureName } from '@kbn/search-connectors'; +import { EXAMPLE_CONNECTOR_SERVICE_TYPES } from '../../../../common/constants'; + +import { isAdvancedSyncRuleSnippetEmpty } from './sync_rules_helpers'; + export const hasIncrementalSyncFeature = (connector: Connector | undefined): boolean => { return connector?.features?.[FeatureName.INCREMENTAL_SYNC]?.enabled || false; }; @@ -14,3 +18,38 @@ export const hasIncrementalSyncFeature = (connector: Connector | undefined): boo export const hasDocumentLevelSecurityFeature = (connector: Connector | undefined): boolean => { return connector?.features?.[FeatureName.DOCUMENT_LEVEL_SECURITY]?.enabled || false; }; + +// TODO remove this when example status is removed +export const isExampleConnector = (connector: Connector | undefined): boolean => + Boolean( + connector && + connector.service_type && + EXAMPLE_CONNECTOR_SERVICE_TYPES.includes(connector.service_type) + ); + +export const hasAdvancedFilteringFeature = (connector: Connector | undefined): boolean => + Boolean( + connector?.features + ? connector.features[FeatureName.SYNC_RULES]?.advanced?.enabled ?? + connector.features[FeatureName.FILTERING_ADVANCED_CONFIG] + : false + ); + +export const hasBasicFilteringFeature = (connector: Connector | undefined): boolean => + Boolean( + connector?.features + ? connector.features[FeatureName.SYNC_RULES]?.basic?.enabled ?? + connector.features[FeatureName.FILTERING_RULES] + : false + ); + +export const hasNonEmptyAdvancedSnippet = ( + connector: Connector | undefined, + advancedSnippet: string +): boolean => + Boolean( + connector && + connector.status && + hasAdvancedFilteringFeature(connector) && + !isAdvancedSyncRuleSnippetEmpty(advancedSnippet) + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx index 32e029f9f5f2..bc1f6f13a0bb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx @@ -83,7 +83,6 @@ export const EnterpriseSearchPageTemplateWrapper: React.FC<PageTemplateProps> = }, []); return ( <KibanaPageTemplate - restrictWidth={false} {...pageTemplateProps} className={classNames('enterpriseSearchPageTemplate', className)} mainProps={{ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.test.tsx index 57611e1bacdc..723cd3ed6458 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.test.tsx @@ -8,10 +8,13 @@ jest.mock('./nav', () => ({ useWorkplaceSearchNav: () => [], })); +import '../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../__mocks__/kea_logic'; import React from 'react'; import { shallow } from 'enzyme'; +import { of } from 'rxjs'; import { SetWorkplaceSearchChrome } from '../../../shared/kibana_chrome'; import { EnterpriseSearchPageTemplateWrapper } from '../../../shared/layout'; @@ -19,7 +22,16 @@ import { SendWorkplaceSearchTelemetry } from '../../../shared/telemetry'; import { WorkplaceSearchPageTemplate } from './page_template'; +const mockValues = { + getChromeStyle$: () => of('classic'), + updateSideNavDefinition: jest.fn(), +}; + describe('WorkplaceSearchPageTemplate', () => { + beforeEach(() => { + setMockValues({ ...mockValues }); + }); + it('renders', () => { const wrapper = shallow( <WorkplaceSearchPageTemplate> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx index b2b2f73e5199..ed352cd42aa8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx @@ -7,7 +7,11 @@ import React from 'react'; +import { useValues } from 'kea'; +import useObservable from 'react-use/lib/useObservable'; + import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { KibanaLogic } from '../../../shared/kibana'; import { SetWorkplaceSearchChrome } from '../../../shared/kibana_chrome'; import { EnterpriseSearchPageTemplateWrapper, PageTemplateProps } from '../../../shared/layout'; import { SendWorkplaceSearchTelemetry } from '../../../shared/telemetry'; @@ -20,13 +24,28 @@ export const WorkplaceSearchPageTemplate: React.FC<PageTemplateProps> = ({ pageViewTelemetry, ...pageTemplateProps }) => { + const navItems = useWorkplaceSearchNav(); + const { getChromeStyle$, updateSideNavDefinition } = useValues(KibanaLogic); + const chromeStyle = useObservable(getChromeStyle$(), 'classic'); + + React.useEffect(() => { + if (chromeStyle === 'classic') return; + // We update the new side nav definition with the selected app items + updateSideNavDefinition({ workplaceSearch: navItems?.[0]?.items }); + }, [chromeStyle, navItems, updateSideNavDefinition]); + React.useEffect(() => { + return () => { + updateSideNavDefinition({ workplaceSearch: undefined }); + }; + }, [updateSideNavDefinition]); + return ( <EnterpriseSearchPageTemplateWrapper restrictWidth {...pageTemplateProps} solutionNav={{ + items: chromeStyle === 'classic' ? navItems : undefined, name: WORKPLACE_SEARCH_PLUGIN.NAME, - items: useWorkplaceSearchNav(), }} setPageChrome={pageChrome && <SetWorkplaceSearchChrome trail={pageChrome} />} useEndpointHeaderActions={false} diff --git a/x-pack/plugins/enterprise_search/public/navigation_tree.ts b/x-pack/plugins/enterprise_search/public/navigation_tree.ts index 051cfaa6779a..8bb6bf70e603 100644 --- a/x-pack/plugins/enterprise_search/public/navigation_tree.ts +++ b/x-pack/plugins/enterprise_search/public/navigation_tree.ts @@ -21,9 +21,11 @@ import { SEARCH_APPLICATIONS_PATH } from './applications/applications/routes'; import { SEARCH_INDICES_PATH } from './applications/enterprise_search_content/routes'; export interface DynamicSideNavItems { + appSearch?: Array<EuiSideNavItemType<unknown>>; collections?: Array<EuiSideNavItemType<unknown>>; indices?: Array<EuiSideNavItemType<unknown>>; searchApps?: Array<EuiSideNavItemType<unknown>>; + workplaceSearch?: Array<EuiSideNavItemType<unknown>>; } const title = i18n.translate( @@ -79,7 +81,7 @@ export const getNavigationTreeDefinition = ({ id: 'es', navigationTree$: dynamicItems$.pipe( debounceTime(10), - map(({ indices, searchApps, collections }) => { + map(({ appSearch, indices, searchApps, collections, workplaceSearch }) => { const navTree: NavigationTreeDefinition = { body: [ { @@ -218,11 +220,7 @@ export const getNavigationTreeDefinition = ({ { children: [ { - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith( - prepend('/app/enterprise_search/app_search') - ); - }, + getIsActive: () => false, link: 'appSearch:engines', title: i18n.translate( 'xpack.enterpriseSearch.searchNav.entsearch.appSearch', @@ -230,9 +228,24 @@ export const getNavigationTreeDefinition = ({ defaultMessage: 'App Search', } ), + ...(appSearch + ? { + children: appSearch.map(euiItemTypeToNodeDefinition), + isCollapsible: false, + renderAs: 'accordion', + } + : {}), }, { + getIsActive: () => false, link: 'workplaceSearch', + ...(workplaceSearch + ? { + children: workplaceSearch.map(euiItemTypeToNodeDefinition), + isCollapsible: false, + renderAs: 'accordion', + } + : {}), }, ], id: 'entsearch', diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/generate_config.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/generate_config.ts new file mode 100644 index 000000000000..d9f0eefd0fb5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/generate_config.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 { IScopedClusterClient } from '@kbn/core/server'; + +import { Connector, CONNECTORS_INDEX } from '@kbn/search-connectors'; + +import { createIndex } from '../indices/create_index'; +import { indexOrAliasExists } from '../indices/exists_index'; +import { generateApiKey } from '../indices/generate_api_key'; +import { generatedIndexName } from '../indices/generate_index_name'; + +export const generateConfig = async (client: IScopedClusterClient, connector: Connector) => { + let associatedIndex: string; + + if (connector.index_name) { + associatedIndex = connector.index_name; + } else { + associatedIndex = await generatedIndexName( + client, + connector.name || connector.service_type || 'my-connector' // pass a default name to generate a readable index name rather than gibberish + ); + } + + if (!indexOrAliasExists(client, associatedIndex)) { + await createIndex(client, associatedIndex, connector.language, true); + } + + await client.asCurrentUser.transport.request({ + body: { + index_name: associatedIndex, + }, + method: 'PUT', + path: `/_connector/${connector.id}/_index_name`, + }); + + await client.asCurrentUser.indices.refresh({ index: CONNECTORS_INDEX }); + const apiKeyResponse = await generateApiKey(client, associatedIndex, connector.is_native); + + return { + apiKeyResponse, + associatedIndex, + }; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.test.ts b/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.test.ts index 828ad73e0373..73dfce85f011 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.test.ts @@ -69,7 +69,7 @@ describe('generateApiKey lib function for connector clients', () => { name: 'index_name-connector', role_descriptors: { ['index-name-connector-role']: { - cluster: ['monitor'], + cluster: ['monitor', 'manage_connector'], index: [ { names: ['index_name', '.search-acl-filter-index_name', `${CONNECTORS_INDEX}*`], @@ -108,7 +108,7 @@ describe('generateApiKey lib function for connector clients', () => { name: 'search-test-connector', role_descriptors: { ['search-test-connector-role']: { - cluster: ['monitor'], + cluster: ['monitor', 'manage_connector'], index: [ { names: ['search-test', '.search-acl-filter-search-test', `${CONNECTORS_INDEX}*`], @@ -159,7 +159,7 @@ describe('generateApiKey lib function for connector clients', () => { name: 'index_name-connector', role_descriptors: { ['index-name-connector-role']: { - cluster: ['monitor'], + cluster: ['monitor', 'manage_connector'], index: [ { names: ['index_name', '.search-acl-filter-index_name', `${CONNECTORS_INDEX}*`], @@ -229,7 +229,7 @@ describe('generateApiKey lib function for native connectors', () => { name: 'index_name-connector', role_descriptors: { ['index-name-connector-role']: { - cluster: ['monitor'], + cluster: ['monitor', 'manage_connector'], index: [ { names: ['index_name', '.search-acl-filter-index_name', `${CONNECTORS_INDEX}*`], @@ -270,7 +270,7 @@ describe('generateApiKey lib function for native connectors', () => { name: 'search-test-connector', role_descriptors: { ['search-test-connector-role']: { - cluster: ['monitor'], + cluster: ['monitor', 'manage_connector'], index: [ { names: ['search-test', '.search-acl-filter-search-test', `${CONNECTORS_INDEX}*`], @@ -323,7 +323,7 @@ describe('generateApiKey lib function for native connectors', () => { name: 'index_name-connector', role_descriptors: { ['index-name-connector-role']: { - cluster: ['monitor'], + cluster: ['monitor', 'manage_connector'], index: [ { names: ['index_name', '.search-acl-filter-index_name', `${CONNECTORS_INDEX}*`], diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.ts b/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.ts index 4c75cee5e4de..9c1175dfa75d 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/generate_api_key.ts @@ -28,7 +28,7 @@ export const generateApiKey = async ( name: `${indexName}-connector`, role_descriptors: { [`${toAlphanumeric(indexName)}-connector-role`]: { - cluster: ['monitor'], + cluster: ['monitor', 'manage_connector'], index: [ { names: [indexName, aclIndexName, `${CONNECTORS_INDEX}*`], diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/generate_index_name.ts b/x-pack/plugins/enterprise_search/server/lib/indices/generate_index_name.ts new file mode 100644 index 000000000000..5a4f1cd8208f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/indices/generate_index_name.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidv4 } from 'uuid'; + +import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; + +import { ErrorCode } from '../../../common/types/error_codes'; + +import { toAlphanumeric } from '../../../common/utils/to_alphanumeric'; + +import { indexOrAliasExists } from './exists_index'; + +export const generatedIndexName = async (client: IScopedClusterClient, indexNamePrefix: string) => { + const prefix = toAlphanumeric(indexNamePrefix); + if (!prefix || prefix.length === 0) { + throw new Error('Index name prefix is required'); + } + for (let i = 0; i < 20; i++) { + const indexName = `${prefix}-${uuidv4().split('-')[0]}`; + const result = await indexOrAliasExists(client, indexName); + if (!result) { + return indexName; + } + } + throw new Error(ErrorCode.GENERATE_INDEX_NAME_ERROR); +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts index 7549f7ed002c..8b94de5e6955 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/api_keys.ts @@ -76,6 +76,40 @@ export function registerApiKeysRoutes( } ); + router.get( + { + path: '/internal/enterprise_search/api_keys/{apiKeyId}', + validate: { + params: schema.object({ + apiKeyId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const core = await context.core; + const { client } = core.elasticsearch; + const { apiKeyId } = request.params; + const user = core.security.authc.getCurrentUser(); + + if (user) { + try { + const apiKey = await client.asCurrentUser.security.getApiKey({ id: apiKeyId }); + return response.ok({ body: apiKey.api_keys[0] }); + } catch { + // Ideally we check the error response here for unauthorized user + // Unfortunately the error response is not structured enough for us to filter those + // Always returning an empty array should also be fine, and deals with transient errors + + return response.ok({ body: { api_keys: [] } }); + } + } + return response.customError({ + body: 'Could not retrieve current user, security plugin is not ready', + statusCode: 502, + }); + } + ); + router.post( { path: '/internal/enterprise_search/api_keys', diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts index 66cea5b83073..e3ba2bd9d53c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts @@ -37,6 +37,7 @@ import { import { ErrorCode } from '../../../common/types/error_codes'; import { addConnector } from '../../lib/connectors/add_connector'; +import { generateConfig } from '../../lib/connectors/generate_config'; import { startSync } from '../../lib/connectors/start_sync'; import { deleteAccessControlIndex } from '../../lib/indices/delete_access_control_index'; import { fetchIndexCounts } from '../../lib/indices/fetch_index_counts'; @@ -770,4 +771,68 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { }); }) ); + + router.post( + { + path: '/internal/enterprise_search/connectors/{connectorId}/generate_config', + validate: { + params: schema.object({ + connectorId: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + const { connectorId } = request.params; + + let associatedIndex; + let apiKeyResponse; + try { + const connector = await fetchConnectorById(client.asCurrentUser, connectorId); + + if (!connector) { + return createError({ + errorCode: ErrorCode.RESOURCE_NOT_FOUND, + message: i18n.translate( + 'xpack.enterpriseSearch.server.routes.connectors.resource_not_found_error', + { + defaultMessage: 'Connector with id {connectorId} is not found.', + values: { connectorId }, + } + ), + response, + statusCode: 404, + }); + } + + const configResponse = await generateConfig(client, connector); + associatedIndex = configResponse.associatedIndex; + apiKeyResponse = configResponse.apiKeyResponse; + } catch (error) { + if (error.message === ErrorCode.GENERATE_INDEX_NAME_ERROR) { + createError({ + errorCode: ErrorCode.GENERATE_INDEX_NAME_ERROR, + message: i18n.translate( + 'xpack.enterpriseSearch.server.routes.connectors.generateConfiguration.indexAlreadyExistsError', + { + defaultMessage: 'Cannot find a unique index name to generate configuration', + } + ), + response, + statusCode: 409, + }); + throw error; + } + } + + return response.ok({ + body: { + apiKey: apiKeyResponse, + connectorId, + indexName: associatedIndex, + }, + headers: { 'content-type': 'application/json' }, + }); + }) + ); } diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index e10485affa87..adfbcb299238 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -25,37 +25,23 @@ export const FLEET_CLOUD_SECURITY_POSTURE_KSPM_POLICY_TEMPLATE = 'kspm'; export const FLEET_CLOUD_SECURITY_POSTURE_CSPM_POLICY_TEMPLATE = 'cspm'; export const FLEET_CLOUD_SECURITY_POSTURE_CNVM_POLICY_TEMPLATE = 'vuln_mgmt'; export const FLEET_CLOUD_DEFEND_PACKAGE = 'cloud_defend'; -export const FLEET_PF_HOST_AGENT_PACKAGE = 'pf-host-agent'; -export const FLEET_PF_ELASTIC_SYMBOLIZER_PACKAGE = 'pf-elastic-symbolizer'; -export const FLEET_PF_ELASTIC_COLLECTOR_PACKAGE = 'pf-elastic-collector'; export const FLEET_CLOUD_BEAT_PACKAGE = 'cloudbeat'; -export const FLEET_CLOUD_BEAT_CIS_K8S_PACKAGE = `${FLEET_CLOUD_BEAT_PACKAGE}/cis_k8s`; -export const FLEET_CLOUD_BEAT_CIS_EKS_PACKAGE = `${FLEET_CLOUD_BEAT_PACKAGE}/cis_eks`; -export const FLEET_CLOUD_BEAT_CIS_AWS_PACKAGE = `${FLEET_CLOUD_BEAT_PACKAGE}/cis_aws`; -export const FLEET_CLOUD_BEAT_CIS_GCP_PACKAGE = `${FLEET_CLOUD_BEAT_PACKAGE}/cis_gcp`; -export const FLEET_CLOUD_BEAT_CIS_AZURE_PACKAGE = `${FLEET_CLOUD_BEAT_PACKAGE}/cis_azure`; -export const FLEET_CLOUD_BEAT_VULN_MGMT_AWS_PACKAGE = `${FLEET_CLOUD_BEAT_PACKAGE}/vuln_mgmt_aws`; export const GLOBAL_DATA_TAG_EXCLUDED_INPUTS = new Set<string>([ FLEET_APM_PACKAGE, - FLEET_PF_HOST_AGENT_PACKAGE, - FLEET_PF_ELASTIC_SYMBOLIZER_PACKAGE, - FLEET_PF_ELASTIC_COLLECTOR_PACKAGE, - /* The package names and input types are not the same. For example package - * name for fleet server is "fleet_server" whereas the input type is "fleet-server". - * This is the same case for cloud defend. That's why we are replacing the - * underscores with dashes for the two of them. Global data tag functionality - * relies on input types. - */ - FLEET_SERVER_PACKAGE.replace(/_/g, '-'), - FLEET_CLOUD_DEFEND_PACKAGE.replace(/_/g, '-'), + `pf-host-agent`, + `pf-elastic-symbolizer`, + `pf-elastic-collector`, + `fleet-server`, + FLEET_CLOUD_DEFEND_PACKAGE, + `${FLEET_CLOUD_DEFEND_PACKAGE}/control`, FLEET_CLOUD_BEAT_PACKAGE, - FLEET_CLOUD_BEAT_CIS_K8S_PACKAGE, - FLEET_CLOUD_BEAT_CIS_EKS_PACKAGE, - FLEET_CLOUD_BEAT_CIS_AWS_PACKAGE, - FLEET_CLOUD_BEAT_CIS_GCP_PACKAGE, - FLEET_CLOUD_BEAT_CIS_AZURE_PACKAGE, - FLEET_CLOUD_BEAT_VULN_MGMT_AWS_PACKAGE, + `${FLEET_CLOUD_BEAT_PACKAGE}/cis_k8s`, + `${FLEET_CLOUD_BEAT_PACKAGE}/cis_eks`, + `${FLEET_CLOUD_BEAT_PACKAGE}/cis_aws`, + `${FLEET_CLOUD_BEAT_PACKAGE}/cis_gcp`, + `${FLEET_CLOUD_BEAT_PACKAGE}/cis_azure`, + `${FLEET_CLOUD_BEAT_PACKAGE}/vuln_mgmt_aws`, ]); export const PACKAGE_TEMPLATE_SUFFIX = '@package'; diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 2f32d66f4ec7..0ff598fc0dd4 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -212,8 +212,9 @@ export const DOWNLOAD_SOURCE_API_ROUTES = { DELETE_PATTERN: `${API_ROOT}/agent_download_sources/{sourceId}`, }; -// Fleet debug routes +export const CREATE_STANDALONE_AGENT_API_KEY_ROUTE = `${INTERNAL_ROOT}/create_standalone_agent_api_key`; +// Fleet debug routes export const FLEET_DEBUG_ROUTES = { INDEX_PATTERN: `${INTERNAL_ROOT}/debug/index`, SAVED_OBJECTS_PATTERN: `${INTERNAL_ROOT}/debug/saved_objects`, diff --git a/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts b/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts index 18d995c96f2b..4d464427a998 100644 --- a/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts +++ b/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts @@ -28,15 +28,20 @@ const POLICY_KEYS_ORDER = [ 'signed', ]; -export const fullAgentPolicyToYaml = (policy: FullAgentPolicy, toYaml: typeof safeDump): string => { +export const fullAgentPolicyToYaml = ( + policy: FullAgentPolicy, + toYaml: typeof safeDump, + apiKey?: string +): string => { const yaml = toYaml(policy, { skipInvalid: true, sortKeys: _sortYamlKeys, }); + const formattedYml = apiKey ? replaceApiKey(yaml, apiKey) : yaml; - if (!policy?.secret_references?.length) return yaml; + if (!policy?.secret_references?.length) return formattedYml; - return _formatSecrets(policy.secret_references, yaml); + return _formatSecrets(policy.secret_references, formattedYml); }; export function _sortYamlKeys(keyA: string, keyB: string) { @@ -67,3 +72,8 @@ function _formatSecrets( return formattedText; } + +function replaceApiKey(ymlText: string, apiKey: string) { + const regex = new RegExp(/\'\${API_KEY}\'/, 'g'); + return ymlText.replace(regex, `'${apiKey}'`); +} diff --git a/x-pack/plugins/fleet/common/types/rest_spec/index.ts b/x-pack/plugins/fleet/common/types/rest_spec/index.ts index 34613da13f9d..7aeaad859803 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/index.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/index.ts @@ -20,3 +20,4 @@ export * from './package_policy'; export * from './settings'; export * from './health_check'; export * from './fleet_server_hosts'; +export * from './standalone_agent_api_key'; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/standalone_agent_api_key.ts b/x-pack/plugins/fleet/common/types/rest_spec/standalone_agent_api_key.ts new file mode 100644 index 000000000000..3bb4e3f05ed6 --- /dev/null +++ b/x-pack/plugins/fleet/common/types/rest_spec/standalone_agent_api_key.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SecurityCreateApiKeyResponse } from '@elastic/elasticsearch/lib/api/types'; + +export interface PostStandaloneAgentAPIKeyRequest { + body: { + name: string; + }; +} + +export interface PostStandaloneAgentAPIKeyResponse { + action: string; + item: SecurityCreateApiKeyResponse; +} diff --git a/x-pack/plugins/fleet/dev_docs/local_setup/enrolling_agents.md b/x-pack/plugins/fleet/dev_docs/local_setup/enrolling_agents.md index 6c657cf7e586..88aa370ca3aa 100644 --- a/x-pack/plugins/fleet/dev_docs/local_setup/enrolling_agents.md +++ b/x-pack/plugins/fleet/dev_docs/local_setup/enrolling_agents.md @@ -12,6 +12,9 @@ Add the following to your `kibana.dev.yml`. Note that the only differences betwe # Set the Kibana server address to Fleet Server default host. server.host: 0.0.0.0 +# Use default version resolution to let APIs work without version header +server.versioned.versionResolution: oldest + # Install Fleet Server package. xpack.fleet.packages: - name: fleet_server diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.test.tsx index a79c298a6e9e..dffe682bcc45 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.test.tsx @@ -35,7 +35,7 @@ describe('GlobalDataTagsTable', () => { ]; let renderer: TestRenderer; - const renderComponent = (tags: GlobalDataTag[]) => { + const renderComponent = (tags: GlobalDataTag[], options?: { isDisabled?: boolean }) => { mockUpdateAgentPolicy = jest.fn(); renderer = createFleetTestRendererMock(); @@ -53,6 +53,7 @@ describe('GlobalDataTagsTable', () => { <GlobalDataTagsTable updateAgentPolicy={updateAgentPolicy} globalDataTags={agentPolicy.global_data_tags} + isDisabled={options?.isDisabled} /> ); }; @@ -287,4 +288,19 @@ describe('GlobalDataTagsTable', () => { ], }); }); + + it('should not allow to add tag when disabled and no tags exists', () => { + renderComponent([], { isDisabled: true }); + + const test = renderResult.getByTestId('globalDataTagAddFieldBtn'); + expect(test).toBeDisabled(); + }); + + it('should not allow to add/edit/remove tag when disabled and tags already exists', () => { + renderComponent(globalDataTags, { isDisabled: true }); + + expect(renderResult.getByTestId('globalDataTagAddAnotherFieldBtn')).toBeDisabled(); + expect(renderResult.getByTestId('globalDataTagDeleteField1Btn')).toBeDisabled(); + expect(renderResult.getByTestId('globalDataTagEditField1Btn')).toBeDisabled(); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.tsx index 9f51b5c18145..1a30d9ad14fe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/global_data_tags_table.tsx @@ -20,6 +20,7 @@ import { EuiFieldText, EuiButtonIcon, EuiCode, + EuiSpacer, type EuiBasicTableColumn, } from '@elastic/eui'; @@ -34,6 +35,7 @@ import type { interface Props { updateAgentPolicy: (u: Partial<NewAgentPolicy | AgentPolicy>) => void; globalDataTags: GlobalDataTag[]; + isDisabled?: boolean; } function parseValue(value: string | number): string | number { @@ -50,6 +52,7 @@ function parseValue(value: string | number): string | number { export const GlobalDataTagsTable: React.FunctionComponent<Props> = ({ updateAgentPolicy, globalDataTags, + isDisabled, }) => { const { overlays } = useStartServices(); const [editTags, setEditTags] = useState<{ [k: number]: GlobalDataTag }>({}); @@ -358,6 +361,8 @@ export const GlobalDataTagsTable: React.FunctionComponent<Props> = ({ aria-label="Edit" iconType="pencil" color="text" + data-test-subj={`globalDataTagEditField${index}Btn`} + isDisabled={isDisabled} onClick={() => handleStartEdit(index)} /> ); @@ -387,6 +392,8 @@ export const GlobalDataTagsTable: React.FunctionComponent<Props> = ({ aria-label="Delete" iconType="trash" color="text" + data-test-subj={`globalDataTagDeleteField${index}Btn`} + isDisabled={isDisabled} onClick={() => deleteTag(index)} /> ); @@ -408,6 +415,7 @@ export const GlobalDataTagsTable: React.FunctionComponent<Props> = ({ newTagErrors, deleteTag, handleStartEdit, + isDisabled, ] ); @@ -416,21 +424,24 @@ export const GlobalDataTagsTable: React.FunctionComponent<Props> = ({ return ( <> {globalDataTags.length === 0 && !isAdding ? ( - <EuiPanel hasShadow={false}> + <EuiPanel color="subdued" paddingSize="l" className="eui-textCenter"> <EuiText> - <h4> + <h5> <FormattedMessage id="xpack.fleet.globalDataTagsTable.noFieldsMessage" defaultMessage="This policy has no custom fields" /> - </h4> + </h5> </EuiText> + <EuiSpacer size="xs" /> <EuiFlexGroup justifyContent="center"> <EuiFlexItem grow={false}> <EuiButton iconType="plusInCircle" onClick={handleAddField} style={{ marginTop: '16px' }} + disabled={isDisabled} + data-test-subj="globalDataTagAddFieldBtn" > <FormattedMessage id="xpack.fleet.globalDataTagsTable.addFieldBtn" @@ -449,7 +460,8 @@ export const GlobalDataTagsTable: React.FunctionComponent<Props> = ({ iconType="plusInCircle" onClick={handleAddField} style={{ marginTop: '16px' }} - isDisabled={isAdding} + isDisabled={isDisabled || isAdding} + data-test-subj="globalDataTagAddAnotherFieldBtn" > <FormattedMessage id="xpack.fleet.globalDataTagsTable.addAnotherFieldBtn" diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.test.tsx index 26c0742598ac..666eaabc1d18 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.test.tsx @@ -66,7 +66,7 @@ describe('CustomFields', () => { renderComponent(mockAgentPolicy); - const unsupportedInputsWarning = renderResult.getByText('Unsupported Inputs'); + const unsupportedInputsWarning = renderResult.getByText('Unsupported inputs'); expect(unsupportedInputsWarning).toBeInTheDocument(); const strongElements = renderResult.container.querySelector('strong'); @@ -87,7 +87,7 @@ describe('CustomFields', () => { ], }); renderComponent(mockAgentPolicy); - expect(renderResult.queryByText('Unsupported Inputs')).not.toBeInTheDocument(); + expect(renderResult.queryByText('Unsupported inputs')).not.toBeInTheDocument(); }); it('should render global data tags table with initial tags', () => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.tsx index a10c6c3d2fb8..b62d5438b9c1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/custom_fields/index.tsx @@ -4,11 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import React from 'react'; +import styled from 'styled-components'; import { EuiDescribedFormGroup, EuiSpacer, EuiCallOut } from '@elastic/eui'; - import { FormattedMessage } from '@kbn/i18n-react'; -import React from 'react'; import type { NewAgentPolicy, @@ -24,11 +23,20 @@ import { GlobalDataTagsTable } from './global_data_tags_table'; interface Props { agentPolicy: Partial<AgentPolicy | NewAgentPolicy>; updateAgentPolicy: (u: Partial<NewAgentPolicy | AgentPolicy>) => void; + isDisabled?: boolean; } +// Fix to align description to top during empty state w/ unsupported callout +const DescribedFormGroup = styled(EuiDescribedFormGroup)` + .euiFlexGroup { + align-items: flex-start; + } +`; + export const CustomFields: React.FunctionComponent<Props> = ({ agentPolicy, updateAgentPolicy, + isDisabled, }) => { const isAgentPolicy = (policy: Partial<AgentPolicy | NewAgentPolicy>): policy is AgentPolicy => { return (policy as AgentPolicy).package_policies !== undefined; @@ -56,7 +64,7 @@ export const CustomFields: React.FunctionComponent<Props> = ({ const unsupportedInputs = findUnsupportedInputs(agentPolicy, GLOBAL_DATA_TAG_EXCLUDED_INPUTS); return ( - <EuiDescribedFormGroup + <DescribedFormGroup fullWidth title={ <h3> @@ -79,7 +87,7 @@ export const CustomFields: React.FunctionComponent<Props> = ({ title={ <FormattedMessage id="xpack.fleet.agentPolicyForm.globalDataTagUnsupportedInputTitle" - defaultMessage="Unsupported Inputs" + defaultMessage="Unsupported inputs" /> } color="warning" @@ -103,9 +111,10 @@ export const CustomFields: React.FunctionComponent<Props> = ({ } > <GlobalDataTagsTable + isDisabled={isDisabled} updateAgentPolicy={updateAgentPolicy} globalDataTags={agentPolicy.global_data_tags ? agentPolicy.global_data_tags : []} /> - </EuiDescribedFormGroup> + </DescribedFormGroup> ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 470288f2ebac..ef5dc9b8e3c4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -308,7 +308,11 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = /> </EuiFormRow> </EuiDescribedFormGroup> - <CustomFields updateAgentPolicy={updateAgentPolicy} agentPolicy={agentPolicy} /> + <CustomFields + updateAgentPolicy={updateAgentPolicy} + agentPolicy={agentPolicy} + isDisabled={disabled || agentPolicy.is_managed === true} + /> <EuiDescribedFormGroup fullWidth title={ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx index 1177d29970b9..58b764ed68ad 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx @@ -13,7 +13,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useHistory } from 'react-router-dom'; import { SO_SEARCH_LIMIT } from '../../../../../constants'; -import { ExperimentalFeaturesService } from '../../../services'; +import { useMultipleAgentPolicies } from '../../../hooks'; import { useStartServices, @@ -53,7 +53,7 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ const { getPath } = useLink(); const history = useHistory(); const deleteAgentPolicyMutation = useDeleteAgentPolicyMutation(); - const { enableReusableIntegrationPolicies } = ExperimentalFeaturesService.get(); + const { canUseMultipleAgentPolicies } = useMultipleAgentPolicies(); const deleteAgentPolicyPrompt: DeleteAgentPolicy = ( agentPolicyToDelete, @@ -132,11 +132,11 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ const packagePoliciesWithMultiplePolicies = useMemo(() => { // Find if there are package policies that have multiple agent policies - if (packagePolicies && enableReusableIntegrationPolicies) { + if (packagePolicies && canUseMultipleAgentPolicies) { return packagePolicies.some((policy) => policy?.policy_ids.length > 1); } return false; - }, [enableReusableIntegrationPolicies, packagePolicies]); + }, [canUseMultipleAgentPolicies, packagePolicies]); const renderModal = () => { if (!isModalOpen) { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx index 4b10b2e2fc9a..63d49ab4dffe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx @@ -9,10 +9,11 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { uniq } from 'lodash'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; -import type { PackageInfo } from '../../../../../../../../../common'; +import type { AgentPolicy, PackageInfo } from '../../../../../../../../../common'; export interface Props { isLoading: boolean; @@ -20,6 +21,7 @@ export interface Props { selectedPolicyIds: string[]; setSelectedPolicyIds: (policyIds: string[]) => void; packageInfo?: PackageInfo; + selectedAgentPolicies: AgentPolicy[]; } export const AgentPolicyMultiSelect: React.FunctionComponent<Props> = ({ @@ -27,11 +29,25 @@ export const AgentPolicyMultiSelect: React.FunctionComponent<Props> = ({ agentPolicyMultiOptions, selectedPolicyIds, setSelectedPolicyIds, + selectedAgentPolicies, }) => { const selectedOptions = useMemo(() => { return agentPolicyMultiOptions.filter((option) => selectedPolicyIds.includes(option.key!)); }, [agentPolicyMultiOptions, selectedPolicyIds]); + // managed policies cannot be removed + const updateSelectedPolicyIds = useCallback( + (ids: string[]) => { + setSelectedPolicyIds( + uniq([ + ...selectedAgentPolicies.filter((policy) => policy.is_managed).map((policy) => policy.id), + ...ids, + ]) + ); + }, + [selectedAgentPolicies, setSelectedPolicyIds] + ); + return ( <EuiComboBox aria-label="Select Multiple Agent Policies" @@ -44,9 +60,9 @@ export const AgentPolicyMultiSelect: React.FunctionComponent<Props> = ({ )} options={agentPolicyMultiOptions} selectedOptions={selectedOptions} - onChange={(newOptions) => { - setSelectedPolicyIds(newOptions.map((option: any) => option.key)); - }} + onChange={(newOptions) => + updateSelectedPolicyIds(newOptions.map((option: any) => option.key)) + } isClearable={true} isLoading={isLoading} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx new file mode 100644 index 000000000000..c39466d77954 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import type { EuiComboBoxOptionOption, EuiSuperSelectOption } from '@elastic/eui'; +import { EuiIcon, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { AgentPolicy, Output, PackageInfo } from '../../../../../../../../../common'; +import { + FLEET_APM_PACKAGE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + SO_SEARCH_LIMIT, +} from '../../../../../../../../../common'; +import { outputType } from '../../../../../../../../../common/constants'; +import { isPackageLimited } from '../../../../../../../../../common/services'; +import { useGetAgentPolicies, useGetOutputs, useGetPackagePolicies } from '../../../../../../hooks'; + +export function useAgentPoliciesOptions(packageInfo?: PackageInfo) { + // Fetch agent policies info + const { + data: agentPoliciesData, + error: agentPoliciesError, + isLoading: isAgentPoliciesLoading, + } = useGetAgentPolicies({ + page: 1, + perPage: SO_SEARCH_LIMIT, + sortField: 'name', + sortOrder: 'asc', + noAgentCount: true, // agentPolicy.agents will always be 0 + full: false, // package_policies will always be empty + }); + const agentPolicies = useMemo( + () => agentPoliciesData?.items.filter((policy) => !policy.is_managed) || [], + [agentPoliciesData?.items] + ); + + const { data: outputsData, isLoading: isOutputLoading } = useGetOutputs(); + + // get all package policies with apm integration or the current integration + const { data: packagePoliciesForThisPackage, isLoading: isLoadingPackagePolicies } = + useGetPackagePolicies({ + page: 1, + perPage: SO_SEARCH_LIMIT, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: ${packageInfo?.name}`, + }); + + const packagePoliciesForThisPackageByAgentPolicyId = useMemo( + () => + packagePoliciesForThisPackage?.items.reduce( + (acc: { [key: string]: boolean }, packagePolicy) => { + packagePolicy.policy_ids.forEach((policyId) => { + acc[policyId] = true; + }); + return acc; + }, + {} + ), + [packagePoliciesForThisPackage?.items] + ); + + const { getDataOutputForPolicy } = useMemo(() => { + const defaultOutput = (outputsData?.items ?? []).find((output) => output.is_default); + const outputsById = (outputsData?.items ?? []).reduce( + (acc: { [key: string]: Output }, output) => { + acc[output.id] = output; + return acc; + }, + {} + ); + + return { + getDataOutputForPolicy: (policy: Pick<AgentPolicy, 'data_output_id'>) => { + return policy.data_output_id ? outputsById[policy.data_output_id] : defaultOutput; + }, + }; + }, [outputsData]); + + const agentPolicyOptions: Array<EuiSuperSelectOption<string>> = useMemo( + () => + packageInfo + ? agentPolicies.map((policy) => { + const isLimitedPackageAlreadyInPolicy = + isPackageLimited(packageInfo!) && + packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; + + const isAPMPackageAndDataOutputIsLogstash = + packageInfo?.name === FLEET_APM_PACKAGE && + getDataOutputForPolicy(policy)?.type === outputType.Logstash; + + return { + inputDisplay: ( + <> + <EuiText size="s">{policy.name}</EuiText> + {isAPMPackageAndDataOutputIsLogstash && ( + <> + <EuiSpacer size="xs" /> + <EuiText size="s"> + <FormattedMessage + id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyDisabledAPMLogstashOuputText" + defaultMessage="Logstash output for integrations is not supported with APM" + /> + </EuiText> + </> + )} + </> + ), + value: policy.id, + disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, + 'data-test-subj': 'agentPolicyItem', + }; + }) + : [], + [ + packageInfo, + agentPolicies, + packagePoliciesForThisPackageByAgentPolicyId, + getDataOutputForPolicy, + ] + ); + + const agentPolicyMultiOptions: Array<EuiComboBoxOptionOption<string>> = useMemo( + () => + packageInfo && !isOutputLoading && !isAgentPoliciesLoading && !isLoadingPackagePolicies + ? agentPolicies.map((policy) => { + const isLimitedPackageAlreadyInPolicy = + isPackageLimited(packageInfo!) && + packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; + + const isAPMPackageAndDataOutputIsLogstash = + packageInfo?.name === FLEET_APM_PACKAGE && + getDataOutputForPolicy(policy)?.type === outputType.Logstash; + + return { + append: isAPMPackageAndDataOutputIsLogstash ? ( + <EuiToolTip + content={ + <FormattedMessage + id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyDisabledAPMLogstashOuputText" + defaultMessage="Logstash output for integrations is not supported with APM" + /> + } + > + <EuiIcon size="s" type="warningFilled" /> + </EuiToolTip> + ) : null, + key: policy.id, + label: policy.name, + disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, + 'data-test-subj': 'agentPolicyMultiItem', + }; + }) + : [], + [ + packageInfo, + agentPolicies, + packagePoliciesForThisPackageByAgentPolicyId, + getDataOutputForPolicy, + isOutputLoading, + isAgentPoliciesLoading, + isLoadingPackagePolicies, + ] + ); + + return { + agentPoliciesError, + isLoading: isOutputLoading || isAgentPoliciesLoading || isLoadingPackagePolicies, + agentPolicyOptions, + agentPolicies, + agentPolicyMultiOptions, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx index 653986a7128d..114d973f3254 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx @@ -420,6 +420,7 @@ function SecretInputField({ iconType="refresh" iconSide="left" size="xs" + data-test-subj={`button-replace-${fieldTestSelector}`} > <FormattedMessage id="xpack.fleet.editPackagePolicy.stepConfigure.fieldSecretValueSetEditButton" diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx index 33ff461f7efd..30688c7a99b1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx @@ -76,7 +76,7 @@ describe('step select agent policy', () => { agentPolicies={[]} updateAgentPolicies={updateAgentPoliciesMock} setHasAgentPolicyError={mockSetHasAgentPolicyError} - selectedAgentPolicyIds={selectedAgentPolicyIds} + initialSelectedAgentPolicyIds={selectedAgentPolicyIds} /> )); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx index f28593d84ef9..6238a2cc62a0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { EuiComboBoxOptionOption, EuiSuperSelectOption } from '@elastic/eui'; -import { EuiIcon, EuiToolTip } from '@elastic/eui'; import { EuiSuperSelect } from '@elastic/eui'; import { EuiFlexGroup, @@ -19,29 +17,18 @@ import { EuiDescribedFormGroup, EuiTitle, EuiText, - EuiSpacer, } from '@elastic/eui'; import { Error } from '../../../../../components'; -import type { AgentPolicy, Output, PackageInfo } from '../../../../../types'; + +import type { AgentPolicy, PackageInfo } from '../../../../../types'; import { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from '../../../../../services'; -import { - useGetAgentPolicies, - useGetOutputs, - useFleetStatus, - useGetPackagePolicies, - sendBulkGetAgentPolicies, -} from '../../../../../hooks'; -import { - FLEET_APM_PACKAGE, - SO_SEARCH_LIMIT, - outputType, - PACKAGE_POLICY_SAVED_OBJECT_TYPE, -} from '../../../../../../../../common/constants'; +import { useFleetStatus, sendBulkGetAgentPolicies } from '../../../../../hooks'; import { useMultipleAgentPolicies } from '../../../../../hooks'; import { AgentPolicyMultiSelect } from './components/agent_policy_multi_select'; +import { useAgentPoliciesOptions } from './components/agent_policy_options'; const AgentPolicyFormRow = styled(EuiFormRow)` .euiFormRow__label { @@ -49,161 +36,6 @@ const AgentPolicyFormRow = styled(EuiFormRow)` } `; -function useAgentPoliciesOptions(packageInfo?: PackageInfo) { - // Fetch agent policies info - const { - data: agentPoliciesData, - error: agentPoliciesError, - isLoading: isAgentPoliciesLoading, - } = useGetAgentPolicies({ - page: 1, - perPage: SO_SEARCH_LIMIT, - sortField: 'name', - sortOrder: 'asc', - noAgentCount: true, // agentPolicy.agents will always be 0 - full: false, // package_policies will always be empty - }); - const agentPolicies = useMemo( - () => agentPoliciesData?.items.filter((policy) => !policy.is_managed) || [], - [agentPoliciesData?.items] - ); - - const { data: outputsData, isLoading: isOutputLoading } = useGetOutputs(); - - // get all package policies with apm integration or the current integration - const { data: packagePoliciesForThisPackage, isLoading: isLoadingPackagePolicies } = - useGetPackagePolicies({ - page: 1, - perPage: SO_SEARCH_LIMIT, - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: ${packageInfo?.name}`, - }); - - const packagePoliciesForThisPackageByAgentPolicyId = useMemo( - () => - packagePoliciesForThisPackage?.items.reduce( - (acc: { [key: string]: boolean }, packagePolicy) => { - packagePolicy.policy_ids.forEach((policyId) => { - acc[policyId] = true; - }); - return acc; - }, - {} - ), - [packagePoliciesForThisPackage?.items] - ); - - const { getDataOutputForPolicy } = useMemo(() => { - const defaultOutput = (outputsData?.items ?? []).find((output) => output.is_default); - const outputsById = (outputsData?.items ?? []).reduce( - (acc: { [key: string]: Output }, output) => { - acc[output.id] = output; - return acc; - }, - {} - ); - - return { - getDataOutputForPolicy: (policy: Pick<AgentPolicy, 'data_output_id'>) => { - return policy.data_output_id ? outputsById[policy.data_output_id] : defaultOutput; - }, - }; - }, [outputsData]); - - const agentPolicyOptions: Array<EuiSuperSelectOption<string>> = useMemo( - () => - packageInfo - ? agentPolicies.map((policy) => { - const isLimitedPackageAlreadyInPolicy = - isPackageLimited(packageInfo) && - packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; - - const isAPMPackageAndDataOutputIsLogstash = - packageInfo?.name === FLEET_APM_PACKAGE && - getDataOutputForPolicy(policy)?.type === outputType.Logstash; - - return { - inputDisplay: ( - <> - <EuiText size="s">{policy.name}</EuiText> - {isAPMPackageAndDataOutputIsLogstash && ( - <> - <EuiSpacer size="xs" /> - <EuiText size="s"> - <FormattedMessage - id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyDisabledAPMLogstashOuputText" - defaultMessage="Logstash output for integrations is not supported with APM" - /> - </EuiText> - </> - )} - </> - ), - value: policy.id, - disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, - 'data-test-subj': 'agentPolicyItem', - }; - }) - : [], - [ - packageInfo, - agentPolicies, - packagePoliciesForThisPackageByAgentPolicyId, - getDataOutputForPolicy, - ] - ); - - const agentPolicyMultiOptions: Array<EuiComboBoxOptionOption<string>> = useMemo( - () => - packageInfo && !isOutputLoading && !isAgentPoliciesLoading && !isLoadingPackagePolicies - ? agentPolicies.map((policy) => { - const isLimitedPackageAlreadyInPolicy = - isPackageLimited(packageInfo) && - packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; - - const isAPMPackageAndDataOutputIsLogstash = - packageInfo?.name === FLEET_APM_PACKAGE && - getDataOutputForPolicy(policy)?.type === outputType.Logstash; - - return { - append: isAPMPackageAndDataOutputIsLogstash ? ( - <EuiToolTip - content={ - <FormattedMessage - id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyDisabledAPMLogstashOuputText" - defaultMessage="Logstash output for integrations is not supported with APM" - /> - } - > - <EuiIcon size="s" type="warningFilled" /> - </EuiToolTip> - ) : null, - key: policy.id, - label: policy.name, - disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, - 'data-test-subj': 'agentPolicyMultiItem', - }; - }) - : [], - [ - packageInfo, - agentPolicies, - packagePoliciesForThisPackageByAgentPolicyId, - getDataOutputForPolicy, - isOutputLoading, - isAgentPoliciesLoading, - isLoadingPackagePolicies, - ] - ); - - return { - agentPoliciesError, - isLoading: isOutputLoading || isAgentPoliciesLoading || isLoadingPackagePolicies, - agentPolicyOptions, - agentPolicies, - agentPolicyMultiOptions, - }; -} - function doesAgentPolicyHaveLimitedPackage(policy: AgentPolicy, pkgInfo: PackageInfo) { return policy ? isPackageLimited(pkgInfo) && doesAgentPolicyAlreadyIncludePackage(policy, pkgInfo.name) @@ -215,13 +47,13 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ agentPolicies: AgentPolicy[]; updateAgentPolicies: (agentPolicies: AgentPolicy[]) => void; setHasAgentPolicyError: (hasError: boolean) => void; - selectedAgentPolicyIds: string[]; + initialSelectedAgentPolicyIds: string[]; }> = ({ packageInfo, agentPolicies, updateAgentPolicies: updateSelectedAgentPolicies, setHasAgentPolicyError, - selectedAgentPolicyIds, + initialSelectedAgentPolicyIds, }) => { const { isReady: isFleetReady } = useFleetStatus(); @@ -239,7 +71,6 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ const [selectedPolicyIds, setSelectedPolicyIds] = useState<string[]>([]); const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true); - const [isLoadingSelectedAgentPolicies, setIsLoadingSelectedAgentPolicies] = useState<boolean>(false); const [selectedAgentPolicies, setSelectedAgentPolicies] = useState<AgentPolicy[]>(agentPolicies); @@ -292,17 +123,17 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ setIsFirstLoad(false); if (canUseMultipleAgentPolicies) { const enabledOptions = agentPolicyMultiOptions.filter((option) => !option.disabled); - if (enabledOptions.length === 1) { + if (enabledOptions.length === 1 && initialSelectedAgentPolicyIds.length === 0) { setSelectedPolicyIds([enabledOptions[0].key!]); - } else if (selectedAgentPolicyIds.length > 0) { - setSelectedPolicyIds(selectedAgentPolicyIds); + } else if (initialSelectedAgentPolicyIds.length > 0) { + setSelectedPolicyIds(initialSelectedAgentPolicyIds); } } else { const enabledOptions = agentPolicyOptions.filter((option) => !option.disabled); if (enabledOptions.length === 1) { setSelectedPolicyIds([enabledOptions[0].value]); - } else if (selectedAgentPolicyIds.length > 0) { - setSelectedPolicyIds(selectedAgentPolicyIds); + } else if (initialSelectedAgentPolicyIds.length > 0) { + setSelectedPolicyIds(initialSelectedAgentPolicyIds); } } } @@ -310,7 +141,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ agentPolicyOptions, agentPolicyMultiOptions, canUseMultipleAgentPolicies, - selectedAgentPolicyIds, + initialSelectedAgentPolicyIds, selectedPolicyIds, existingAgentPolicies, isFirstLoad, @@ -346,7 +177,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ const someNewAgentPoliciesHaveLimitedPackage = !packageInfo || selectedAgentPolicies - .filter((policy) => !selectedAgentPolicyIds.find((id) => policy.id === id)) + .filter((policy) => !initialSelectedAgentPolicyIds.find((id) => policy.id === id)) .some((selectedAgentPolicy) => doesAgentPolicyHaveLimitedPackage(selectedAgentPolicy, packageInfo) ); @@ -426,6 +257,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ selectedPolicyIds={selectedPolicyIds} setSelectedPolicyIds={setSelectedPolicyIds} agentPolicyMultiOptions={agentPolicyMultiOptions} + selectedAgentPolicies={agentPolicies} /> ) : ( <EuiSuperSelect diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.tsx index 73671e97e95d..4416b7340ef3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.tsx @@ -112,7 +112,7 @@ export const StepSelectHosts: React.FunctionComponent<Props> = ({ agentPolicies={agentPolicies} updateAgentPolicies={updateAgentPolicies} setHasAgentPolicyError={setHasAgentPolicyError} - selectedAgentPolicyIds={selectedAgentPolicyIds} + initialSelectedAgentPolicyIds={selectedAgentPolicyIds} /> ), }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx index e2f92331759a..1b0e791fbfd8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx @@ -5,79 +5,37 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSteps, EuiSpacer } from '@elastic/eui'; -import { safeDump } from 'js-yaml'; -import type { FullAgentPolicy } from '../../../../../../../../../../common/types/models/agent_policy'; -import { API_VERSIONS } from '../../../../../../../../../../common/constants'; import { getRootIntegrations } from '../../../../../../../../../../common/services'; import { AgentStandaloneBottomBar, StandaloneModeWarningCallout, NotObscuredByBottomBar, } from '../..'; -import { - fullAgentPolicyToYaml, - agentPolicyRouteService, -} from '../../../../../../../../../services'; import { Error as FleetError } from '../../../../../../../components'; -import { - useKibanaVersion, - useStartServices, - sendGetOneAgentPolicyFull, -} from '../../../../../../../../../hooks'; +import { useKibanaVersion } from '../../../../../../../../../hooks'; import { InstallStandaloneAgentStep, ConfigureStandaloneAgentStep, } from '../../../../../../../../../components/agent_enrollment_flyout/steps'; import { StandaloneInstructions } from '../../../../../../../../../components/enrollment_instructions'; +import { useFetchFullPolicy } from '../../../../../../../../../components/agent_enrollment_flyout/hooks'; + import type { InstallAgentPageProps } from './types'; export const InstallElasticAgentStandalonePageStep: React.FC<InstallAgentPageProps> = (props) => { const { setIsManaged, agentPolicy, cancelUrl, onNext, cancelClickHandler } = props; - const core = useStartServices(); + const kibanaVersion = useKibanaVersion(); - const [yaml, setYaml] = useState<any | undefined>(''); const [commandCopied, setCommandCopied] = useState(false); const [policyCopied, setPolicyCopied] = useState(false); - const [fullAgentPolicy, setFullAgentPolicy] = useState<FullAgentPolicy | undefined>(); - useEffect(() => { - async function fetchFullPolicy() { - try { - if (!agentPolicy?.id) { - return; - } - const query = { standalone: true, kubernetes: false }; - const res = await sendGetOneAgentPolicyFull(agentPolicy?.id, query); - if (res.error) { - throw res.error; - } - - if (!res.data) { - throw new Error('No data while fetching full agent policy'); - } - setFullAgentPolicy(res.data.item); - } catch (error) { - core.notifications.toasts.addError(error, { - title: 'Error', - }); - } - } - fetchFullPolicy(); - }, [core.http.basePath, agentPolicy?.id, core.notifications.toasts]); - - useEffect(() => { - if (!fullAgentPolicy) { - return; - } - - setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump)); - }, [fullAgentPolicy]); + const { yaml, onCreateApiKey, apiKey, downloadYaml } = useFetchFullPolicy(agentPolicy); if (!agentPolicy) { return ( @@ -95,16 +53,13 @@ export const InstallElasticAgentStandalonePageStep: React.FC<InstallAgentPagePro const installManagedCommands = StandaloneInstructions(kibanaVersion); - const downloadLink = core.http.basePath.prepend( - `${agentPolicyRouteService.getInfoFullDownloadPath( - agentPolicy?.id - )}?standalone=true&apiVersion=${API_VERSIONS.public.v1}` - ); const steps = [ ConfigureStandaloneAgentStep({ selectedPolicyId: agentPolicy?.id, yaml, - downloadLink, + downloadYaml, + apiKey, + onCreateApiKey, isComplete: policyCopied, onCopy: () => setPolicyCopied(true), }), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index 32ea92e93275..71b9278a6d54 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -358,7 +358,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ ) : ( <ExtensionWrapper> <replaceDefineStepView.Component - agentPolicy={agentPolicies[0]} + agentPolicies={agentPolicies} packageInfo={packageInfo} newPolicy={packagePolicy} onChange={handleExtensionViewOnChange} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/root_callout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/root_callout.tsx index 3a4c01528f4d..46f940e5c655 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/root_callout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/root_callout.tsx @@ -5,15 +5,19 @@ * 2.0. */ -import { EuiCallOut } from '@elastic/eui'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; +import { useStartServices } from '../../../../hooks'; + interface Props { dataStreams: Array<{ name: string; title: string }>; } export const RootPrivilegesCallout: React.FC<Props> = ({ dataStreams }) => { + const { docLinks } = useStartServices(); + return ( <EuiCallOut size="m" @@ -35,7 +39,17 @@ export const RootPrivilegesCallout: React.FC<Props> = ({ dataStreams }) => { <> <FormattedMessage id="xpack.fleet.addIntegration.confirmModal.unprivilegedAgentsDataStreamsMessage" - defaultMessage="This integration has the following data streams that require Elastic Agents to have root privileges. To ensure that all data required by the integration can be collected, enroll agents using an account with root privileges." + defaultMessage="This integration has the following data streams that require Elastic Agents to have root privileges. To ensure that all data required by the integration can be collected, enroll agents using an account with root privileges. For more information, see the {guideLink}" + values={{ + guideLink: ( + <EuiLink href={docLinks.links.fleet.unprivilegedMode} target="_blank" external> + <FormattedMessage + id="xpack.fleet.addIntegration.confirmModal.unprivilegedAgentsDataStreamsMessage.guideLink" + defaultMessage="Fleet and Elastic Agent Guide" + /> + </EuiLink> + ), + }} /> <ul> {dataStreams.map((item) => ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx index a121c797757d..e9bbde252083 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { EuiInMemoryTableProps } from '@elastic/eui'; @@ -36,6 +36,7 @@ import { useIsPackagePolicyUpgradable, usePermissionCheck, useStartServices, + useMultipleAgentPolicies, } from '../../../../../hooks'; import { pkgKeyFromPackageInfo } from '../../../../../services'; @@ -63,9 +64,11 @@ export const PackagePoliciesTable: React.FunctionComponent<Props> = ({ const { application } = useStartServices(); const authz = useAuthz(); const canWriteIntegrationPolicies = authz.integrations.writeIntegrationPolicies; + const canReadAgentPolicies = authz.fleet.readAgentPolicies; const canReadIntegrationPolicies = authz.integrations.readIntegrationPolicies; const { isPackagePolicyUpgradable } = useIsPackagePolicyUpgradable(); const { getHref } = useLink(); + const { canUseMultipleAgentPolicies } = useMultipleAgentPolicies(); const permissionCheck = usePermissionCheck(); const missingSecurityConfiguration = @@ -99,6 +102,10 @@ export const PackagePoliciesTable: React.FunctionComponent<Props> = ({ return [mappedPackagePolicies, namespaceFilterOptions]; }, [originalPackagePolicies, isPackagePolicyUpgradable]); + const getSharedPoliciesNumber = useCallback((packagePolicy: PackagePolicy) => { + return packagePolicy.policy_ids.length || 0; + }, []); + const columns = useMemo( (): EuiInMemoryTableProps<InMemoryPackagePolicy>['columns'] => [ { @@ -109,29 +116,62 @@ export const PackagePoliciesTable: React.FunctionComponent<Props> = ({ defaultMessage: 'Name', }), render: (value: string, packagePolicy: InMemoryPackagePolicy) => ( - <EuiLink - title={value} - {...(canReadIntegrationPolicies - ? { - href: getHref('edit_integration', { - policyId: agentPolicy.id, - packagePolicyId: packagePolicy.id, - }), - } - : { disabled: true })} - > - <span className="eui-textTruncate" title={value}> - {value} - </span> - {packagePolicy.description ? ( - <span> -   - <EuiToolTip content={packagePolicy.description}> - <EuiIcon type="help" /> - </EuiToolTip> - </span> - ) : null} - </EuiLink> + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem data-test-subj="PackagePoliciesTableName" grow={false}> + <EuiLink + title={value} + {...(canReadIntegrationPolicies + ? { + href: getHref('edit_integration', { + policyId: agentPolicy.id, + packagePolicyId: packagePolicy.id, + }), + } + : { disabled: true })} + > + <span className="eui-textTruncate" title={value}> + {value} + </span> + {packagePolicy.description ? ( + <span> +   + <EuiToolTip content={packagePolicy.description}> + <EuiIcon type="help" /> + </EuiToolTip> + </span> + ) : null} + </EuiLink> + </EuiFlexItem> + {canUseMultipleAgentPolicies && + canReadAgentPolicies && + canReadIntegrationPolicies && + getSharedPoliciesNumber(packagePolicy) > 1 && ( + <EuiFlexItem grow={false}> + <EuiToolTip + content={ + <FormattedMessage + id="xpack.fleet.agentPolicyList.agentsColumn.sharedTooltip" + defaultMessage="This integration is shared by {numberShared} agent policies" + values={{ numberShared: getSharedPoliciesNumber(packagePolicy) }} + /> + } + > + <EuiText + data-test-subj="PackagePoliciesTableSharedLabel" + color="subdued" + size="xs" + className="eui-textNoWrap" + > + <FormattedMessage + id="xpack.fleet.agentPolicyList.agentsColumn.sharedText" + defaultMessage="Shared" + />{' '} + <EuiIcon type="iInCircle" /> + </EuiText> + </EuiToolTip> + </EuiFlexItem> + )} + </EuiFlexGroup> ), }, { @@ -265,7 +305,15 @@ export const PackagePoliciesTable: React.FunctionComponent<Props> = ({ ], }, ], - [agentPolicy, getHref, canWriteIntegrationPolicies, canReadIntegrationPolicies] + [ + canReadIntegrationPolicies, + getHref, + agentPolicy, + canUseMultipleAgentPolicies, + canReadAgentPolicies, + canWriteIntegrationPolicies, + getSharedPoliciesNumber, + ] ); return ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx index 7d50d3e494db..8e52ced483d7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx @@ -23,6 +23,7 @@ import { sendBulkGetAgentPolicies, useGetAgentPolicies, useMultipleAgentPolicies, + useGetPackagePolicies, } from '../../../hooks'; import { useGetOnePackagePolicy } from '../../../../integrations/hooks'; @@ -134,6 +135,18 @@ jest.mock('../../../hooks', () => { sendCreateAgentPolicy: jest.fn(), sendBulkGetAgentPolicies: jest.fn(), sendBulkInstallPackages: jest.fn(), + useGetPackagePolicies: jest.fn(), + useGetOutputs: jest.fn().mockReturnValue({ + data: { + items: [ + { + id: 'logstash-1', + type: 'logstash', + }, + ], + }, + isLoading: false, + }), }; }); @@ -223,8 +236,11 @@ describe('edit package policy page', () => { item: mockPackagePolicy, }, }); - (sendGetOneAgentPolicy as MockFn).mockResolvedValue({ - data: { item: { id: 'agent-policy-1', name: 'Agent policy 1', namespace: 'default' } }, + (useGetPackagePolicies as MockFn).mockReturnValue({ + data: { + items: [mockPackagePolicy], + }, + isLoading: false, }); (sendUpgradePackagePolicyDryRun as MockFn).mockResolvedValue({ data: [ @@ -496,6 +512,7 @@ describe('edit package policy page', () => { (sendGetAgentStatus as jest.MockedFunction<any>).mockResolvedValue({ data: { results: { total: 0 } }, }); + jest.clearAllMocks(); }); it('should create agent policy with sys monitoring when new hosts is selected', async () => { @@ -539,5 +556,60 @@ describe('edit package policy page', () => { }) ); }); + + it('should not remove managed policy when policies are modified', async () => { + (sendBulkGetAgentPolicies as MockFn).mockImplementation((ids: string[]) => { + const items = []; + if (ids.includes('agent-policy-1')) { + items.push({ id: 'agent-policy-1', name: 'Agent policy 1', is_managed: true }); + } + if (ids.includes('fleet-server-policy')) { + items.push({ id: 'fleet-server-policy', name: 'Fleet Server Policy' }); + } + return Promise.resolve({ + data: { + items, + }, + }); + }); + (useGetAgentPolicies as MockFn).mockReturnValue({ + data: { + items: [ + { id: 'agent-policy-1', name: 'Agent policy 1', is_managed: true }, + { id: 'fleet-server-policy', name: 'Fleet Server Policy' }, + ], + }, + isLoading: false, + }); + + await act(async () => { + render(); + }); + expect(renderResult.getByTestId('agentPolicyMultiSelect')).toBeInTheDocument(); + + await act(async () => { + renderResult.getByTestId('comboBoxToggleListButton').click(); + }); + + expect(renderResult.queryByText('Agent policy 1')).toBeNull(); + + await act(async () => { + fireEvent.click(renderResult.getByText('Fleet Server Policy')); + }); + + await act(async () => { + fireEvent.click(renderResult.getByText(/Save integration/).closest('button')!); + }); + await act(async () => { + fireEvent.click(renderResult.getAllByText(/Save and deploy changes/)[1].closest('button')!); + }); + + expect(sendUpdatePackagePolicy).toHaveBeenCalledWith( + 'nginx-1', + expect.objectContaining({ + policy_ids: ['agent-policy-1', 'fleet-server-policy'], + }) + ); + }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 53e7f2688ef7..acaf623afa33 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -128,6 +128,7 @@ export const EditPackagePolicyForm = memo<{ } = usePackagePolicyWithRelatedData(packagePolicyId, { forceUpgrade, }); + const hasAgentlessAgentPolicy = packagePolicy.policy_ids.includes(AGENTLESS_POLICY_ID); const canWriteIntegrationPolicies = useAuthz().integrations.writeIntegrationPolicies; useSetIsReadOnly(!canWriteIntegrationPolicies); @@ -183,24 +184,23 @@ export const EditPackagePolicyForm = memo<{ // if `from === 'edit'` then it links back to Policy Details // if `from === 'package-edit'`, or `upgrade-from-integrations-policy-list` then it links back to the Integration Policy List const cancelUrl = useMemo((): string => { - if (packageInfo && policyId) { - return from === 'package-edit' - ? getHref('integration_details_policies', { - pkgkey: pkgKeyFromPackageInfo(packageInfo!), - }) - : getHref('policy_details', { policyId }); - } - return '/'; + return from === 'package-edit' && packageInfo + ? getHref('integration_details_policies', { + pkgkey: pkgKeyFromPackageInfo(packageInfo!), + }) + : policyId + ? getHref('policy_details', { policyId }) + : '/'; }, [from, getHref, packageInfo, policyId]); const successRedirectPath = useMemo(() => { - if (packageInfo && policyId) { - return from === 'package-edit' || from === 'upgrade-from-integrations-policy-list' - ? getHref('integration_details_policies', { - pkgkey: pkgKeyFromPackageInfo(packageInfo!), - }) - : getHref('policy_details', { policyId }); - } - return '/'; + return (from === 'package-edit' || from === 'upgrade-from-integrations-policy-list') && + packageInfo + ? getHref('integration_details_policies', { + pkgkey: pkgKeyFromPackageInfo(packageInfo!), + }) + : policyId + ? getHref('policy_details', { policyId }) + : '/'; }, [from, getHref, packageInfo, policyId]); useHistoryBlock(isEdited); @@ -241,7 +241,7 @@ export const EditPackagePolicyForm = memo<{ } if ( (agentCount !== 0 || agentPoliciesToAdd.length > 0 || agentPoliciesToRemove.length > 0) && - !packagePolicy.policy_ids.includes(AGENTLESS_POLICY_ID) && + !hasAgentlessAgentPolicy && formState !== 'CONFIRM' ) { setFormState('CONFIRM'); @@ -432,7 +432,7 @@ export const EditPackagePolicyForm = memo<{ const replaceConfigurePackage = replaceDefineStepView && originalPackagePolicy && packageInfo && ( <ExtensionWrapper> <replaceDefineStepView.Component - agentPolicy={agentPolicies[0]} + agentPolicies={agentPolicies} packageInfo={packageInfo} policy={originalPackagePolicy} newPolicy={packagePolicy} @@ -521,7 +521,7 @@ export const EditPackagePolicyForm = memo<{ <EuiSpacer size="xxl" /> </> )} - {canUseMultipleAgentPolicies ? ( + {canUseMultipleAgentPolicies && !hasAgentlessAgentPolicy ? ( <StepsWithLessPadding steps={steps} /> ) : ( replaceConfigurePackage || configurePackage diff --git a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx index 5b1105762dd5..440637512490 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx @@ -46,7 +46,7 @@ export const DefaultLayout: React.FC<Props> = memo( }, ]; - const CreateIntegrationCardButton = integrationAssistant?.CreateIntegrationCardButton; + const { CreateIntegrationCardButton } = integrationAssistant?.components ?? {}; return ( <WithHeaderLayout @@ -81,7 +81,7 @@ export const DefaultLayout: React.FC<Props> = memo( rightColumn={ CreateIntegrationCardButton ? ( <EuiFlexItem grow={false}> - <CreateIntegrationCardButton href={getHref('integration_create')} /> + <CreateIntegrationCardButton /> </EuiFlexItem> ) : undefined } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx index 9f1d716f6f39..4261d32b6b4b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx @@ -23,7 +23,7 @@ import { EuiSwitch, } from '@elastic/eui'; -import { usePutSettingsMutation, useStartServices } from '../../../hooks'; +import { usePutSettingsMutation, useStartServices, useAuthz } from '../../../hooks'; export type IntegrationPreferenceType = 'recommended' | 'beats' | 'agent'; @@ -92,7 +92,7 @@ export const IntegrationPreference = ({ const [prereleaseIntegrationsChecked, setPrereleaseIntegrationsChecked] = React.useState< boolean | undefined >(undefined); - + const authz = useAuthz(); const { docLinks, notifications } = useStartServices(); const { mutateAsync: mutateSettingsAsync } = usePutSettingsMutation(); @@ -153,18 +153,24 @@ export const IntegrationPreference = ({ updateSettings(event.target.checked); }; + const canUpdateBetaSetting = authz.fleet.allSettings; + return ( <EuiPanel hasShadow={false} paddingSize="none"> - <EuiSwitchNoWrap - label="Display beta integrations" - checked={ - typeof prereleaseIntegrationsChecked !== 'undefined' - ? prereleaseIntegrationsChecked - : prereleaseIntegrationsEnabled - } - onChange={onPrereleaseSwitchChange} - /> - <EuiSpacer size="l" /> + {canUpdateBetaSetting && ( + <> + <EuiSwitchNoWrap + label="Display beta integrations" + checked={ + typeof prereleaseIntegrationsChecked !== 'undefined' + ? prereleaseIntegrationsChecked + : prereleaseIntegrationsEnabled + } + onChange={onPrereleaseSwitchChange} + /> + <EuiSpacer size="l" /> + </> + )} <EuiText size="s">{title}</EuiText> <EuiSpacer size="m" /> <EuiForm> diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/side_bar_column.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/side_bar_column.tsx new file mode 100644 index 000000000000..e0fd9bdd5a0d --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/side_bar_column.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import styled from 'styled-components'; +import { EuiFlexItem } from '@elastic/eui'; + +export const SideBarColumn = styled(EuiFlexItem)` + max-width: 160px; +`; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/create/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/create/index.tsx index 18454a865c2d..ec97d01a5719 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/create/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/create/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { useStartServices, useBreadcrumbs } from '../../../../hooks'; @@ -13,10 +13,7 @@ export const CreateIntegration = React.memo(() => { const { integrationAssistant } = useStartServices(); useBreadcrumbs('integration_create'); - const CreateIntegrationAssistant = useMemo( - () => integrationAssistant?.CreateIntegration, - [integrationAssistant] - ); + const CreateIntegrationAssistant = integrationAssistant?.components.CreateIntegration; return CreateIntegrationAssistant ? <CreateIntegrationAssistant /> : null; }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx index e9ae0c148a18..4f11f0412ee1 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx @@ -34,6 +34,7 @@ import { useFleetStatus, } from '../../../../../hooks'; import { sendGetBulkAssets } from '../../../../../hooks'; +import { SideBarColumn } from '../../../components/side_bar_column'; import { DeferredAssetsSection } from './deferred_assets_accordion'; import { AssetsAccordion } from './assets_accordion'; @@ -299,7 +300,7 @@ export const AssetsPage = ({ packageInfo, refetchPackageInfo }: AssetsPanelProps return ( <EuiFlexGroup alignItems="flexStart"> - <EuiFlexItem grow={1} /> + <SideBarColumn grow={1} /> <EuiFlexItem grow={7}> {fetchError && ( <> diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/configs/index.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/configs/index.test.tsx new file mode 100644 index 000000000000..a1de54ae5f9f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/configs/index.test.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { createIntegrationsTestRendererMock } from '../../../../../../../mock'; +import { isPackagePrerelease } from '../../../../../../../../common/services'; +import { useGetInputsTemplatesQuery } from '../../../../../hooks'; +import type { PackageInfo } from '../../../../../types'; + +import { Configs } from '.'; + +jest.mock('../../../../../hooks', () => { + return { + ...jest.requireActual('../../../../../hooks'), + useGetInputsTemplatesQuery: jest.fn(), + useConfirmForceInstall: jest.fn(), + useStartServices: jest.fn().mockReturnValue({ + notifications: { + toasts: { + addError: jest.fn(), + addSuccess: jest.fn(), + }, + }, + docLinks: { + links: { + fleet: {}, + }, + }, + }), + }; +}); +jest.mock('../../../../../../../../common/services'); + +const mockIsPackagePrerelease = isPackagePrerelease as jest.Mock; + +function renderComponent(packageInfo: PackageInfo) { + const renderer = createIntegrationsTestRendererMock(); + + return renderer.render(<Configs packageInfo={packageInfo} />); +} + +describe('Configs', () => { + beforeEach(() => { + mockIsPackagePrerelease.mockReset(); + (useGetInputsTemplatesQuery as jest.Mock).mockReset(); + }); + + const packageInfo = { + name: 'nginx', + title: 'Nginx', + version: '1.3.0', + release: 'ga', + description: 'Collect logs and metrics from Nginx HTTP servers with Elastic Agent.', + format_version: '', + owner: { github: '' }, + assets: {} as any, + policy_templates: [ + { + name: 'nginx', + title: 'Nginx logs and metrics', + description: 'Collect logs and metrics from Nginx instances', + inputs: [ + { + type: 'logfile', + title: 'Collect logs from Nginx instances', + description: 'Collecting Nginx access and error logs', + }, + ], + multiple: true, + }, + ], + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + title: 'Nginx access logs', + release: 'experimental', + ingest_pipeline: 'default', + streams: [ + { + input: 'logfile', + vars: [ + { + name: 'paths', + type: 'text', + title: 'Paths', + multi: true, + required: true, + show_user: true, + default: ['/var/log/nginx/access.log*'], + }, + ], + template_path: 'stream.yml.hbs', + title: 'Nginx access logs', + description: 'Collect Nginx access logs', + enabled: true, + }, + ], + package: 'nginx', + path: 'access', + }, + ], + latestVersion: '1.3.0', + keepPoliciesUpToDate: false, + status: 'not_installed', + } as PackageInfo; + + it('it should display configs tab and a warning if the integration is not installed', () => { + mockIsPackagePrerelease.mockReturnValue(false); + (useGetInputsTemplatesQuery as jest.Mock).mockReturnValue({ data: 'yaml configs' }); + + const result = renderComponent(packageInfo); + expect(result.queryByTestId('configsTab.notInstalled')).toBeInTheDocument(); + expect(result.queryByTestId('configsTab.info')).toBeInTheDocument(); + expect(result.queryByTestId('configsTab.codeblock')?.textContent).toContain('yaml configs'); + expect(result.queryByTestId('prereleaseCallout')).not.toBeInTheDocument(); + }); + + it('it should display prerelease callout if the package is prerelease', () => { + mockIsPackagePrerelease.mockReturnValue(true); + (useGetInputsTemplatesQuery as jest.Mock).mockReturnValue({ data: 'yaml configs' }); + + const result = renderComponent(packageInfo); + expect(result.queryByTestId('configsTab.info')).toBeInTheDocument(); + expect(result.queryByTestId('prereleaseCallout')).toBeInTheDocument(); + expect(result.queryByTestId('configsTab.codeblock')).toBeInTheDocument(); + }); + + it('it should display a warning callout if there is an error', () => { + mockIsPackagePrerelease.mockReturnValue(false); + (useGetInputsTemplatesQuery as jest.Mock).mockReturnValue({ error: 'some error' }); + + const result = renderComponent(packageInfo); + expect(result.queryByTestId('configsTab.errorCallout')).toBeInTheDocument(); + expect(result.queryByTestId('configsTab.codeblock')).not.toBeInTheDocument(); + expect(result.queryByTestId('configsTab.info')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/configs/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/configs/index.tsx index ac1855afae7b..e80665631f3e 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/configs/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/configs/index.tsx @@ -17,7 +17,6 @@ import { EuiCode, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { PackageInfo } from '../../../../../types'; @@ -26,13 +25,14 @@ import { useGetInputsTemplatesQuery, useStartServices } from '../../../../../hoo import { PrereleaseCallout } from '../overview/overview'; import { isPackagePrerelease } from '../../../../../../../../common/services'; +import { SideBarColumn } from '../../../components/side_bar_column'; interface ConfigsProps { packageInfo: PackageInfo; } export const Configs: React.FC<ConfigsProps> = ({ packageInfo }) => { - const { notifications, docLinks } = useStartServices(); + const { docLinks } = useStartServices(); const { name: pkgName, version: pkgVersion, title: pkgTitle } = packageInfo; const notInstalled = packageInfo.status !== 'installing'; @@ -46,78 +46,100 @@ export const Configs: React.FC<ConfigsProps> = ({ packageInfo }) => { { format: 'yaml', prerelease: isPrerelease } ); - if (error) { - notifications.toasts.addError(error, { - title: i18n.translate('xpack.fleet.epm.InputTemplates.loadingErro', { - defaultMessage: 'Error input templates', - }), - }); - } - return ( <EuiFlexGroup data-test-subj="epm.Configs" alignItems="flexStart"> - <EuiFlexItem grow={1} /> - <EuiFlexItem grow={7}> - {isLoading && !configs ? ( - <EuiSkeletonText lines={10} /> - ) : ( - <> - {isPrerelease && ( - <> - <EuiSpacer size="s" /> - <PrereleaseCallout - packageName={packageInfo.name} - packageTitle={packageInfo.title} - /> - </> - )} - <EuiText> - <p> - <FormattedMessage - id="xpack.fleet.epm.InputTemplates.mainText" - defaultMessage="View sample configurations for each of the {name} integration's data streams below. Copy/paste this YML into your {elasticAgentYml} file or into a file within your {inputsDir} directory. For more information, see the {userGuideLink}" - values={{ - name: pkgTitle, - elasticAgentYml: <EuiCode>elastic-agent.yml</EuiCode>, - inputsDir: <EuiCode>inputs.d</EuiCode>, - userGuideLink: ( - <EuiLink - href={docLinks.links.fleet.elasticAgentInputConfiguration} - external - target="_blank" - > - <FormattedMessage - id="xpack.fleet.epm.InputTemplates.guideLink" - defaultMessage="Fleet and Elastic Agent Guide" - /> - </EuiLink> - ), - }} - /> - </p> - </EuiText> - {notInstalled && ( - <> - <EuiSpacer size="s" /> - <EuiCallOut - title={ - <FormattedMessage - id="xpack.fleet.epm.InputTemplates.installCallout" - defaultMessage="Install the integration to use the following configs." - /> - } - color="warning" - iconType="warning" - /> - </> - )} - <EuiSpacer size="s" /> - <EuiCodeBlock language="yaml" isCopyable={true} paddingSize="s" overflowHeight={1000}> - {configs} - </EuiCodeBlock> - </> - )} - </EuiFlexItem> + <SideBarColumn grow={1} /> + {error ? ( + <EuiFlexItem grow={7}> + <EuiCallOut + data-test-subj="configsTab.errorCallout" + title={ + <FormattedMessage + id="xpack.fleet.epm.InputTemplates.errorTitle" + defaultMessage="Unsupported" + /> + } + color="warning" + iconType="alert" + > + <p> + <FormattedMessage + id="xpack.fleet.epm.InputTemplates.error" + defaultMessage="This integration doesn't support automatic generation of sample configurations." + /> + </p> + </EuiCallOut> + </EuiFlexItem> + ) : ( + <EuiFlexItem grow={7}> + {isLoading && !configs ? ( + <EuiSkeletonText lines={10} /> + ) : ( + <> + {isPrerelease && ( + <> + <EuiSpacer size="s" /> + <PrereleaseCallout + packageName={packageInfo.name} + packageTitle={packageInfo.title} + /> + </> + )} + <EuiText> + <p data-test-subj="configsTab.info"> + <FormattedMessage + id="xpack.fleet.epm.InputTemplates.mainText" + defaultMessage="View sample configurations for each of the {name} integration's data streams below. Copy/paste this YML into your {elasticAgentYml} file or into a file within your {inputsDir} directory. For more information, see the {userGuideLink}" + values={{ + name: pkgTitle, + elasticAgentYml: <EuiCode>elastic-agent.yml</EuiCode>, + inputsDir: <EuiCode>inputs.d</EuiCode>, + userGuideLink: ( + <EuiLink + href={docLinks.links.fleet.elasticAgentInputConfiguration} + external + target="_blank" + > + <FormattedMessage + id="xpack.fleet.epm.InputTemplates.guideLink" + defaultMessage="Fleet and Elastic Agent Guide" + /> + </EuiLink> + ), + }} + /> + </p> + </EuiText> + {notInstalled && ( + <> + <EuiSpacer size="s" /> + <EuiCallOut + data-test-subj="configsTab.notInstalled" + title={ + <FormattedMessage + id="xpack.fleet.epm.InputTemplates.installCallout" + defaultMessage="Install the integration to use the following configs." + /> + } + color="warning" + iconType="warning" + /> + </> + )} + <EuiSpacer size="s" /> + <EuiCodeBlock + language="yaml" + isCopyable={true} + paddingSize="s" + overflowHeight={1000} + data-test-subj="configsTab.codeblock" + > + {configs} + </EuiCodeBlock> + </> + )} + </EuiFlexItem> + )} </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/custom/custom.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/custom/custom.tsx index 824ceb0d569f..0d66fd96e75c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/custom/custom.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/custom/custom.tsx @@ -12,6 +12,7 @@ import { useLink, useUIExtension } from '../../../../../hooks'; import type { PackageInfo } from '../../../../../types'; import { pkgKeyFromPackageInfo } from '../../../../../services'; import { ExtensionWrapper } from '../../../../../components'; +import { SideBarColumn } from '../../../components/side_bar_column'; interface Props { packageInfo: PackageInfo; @@ -24,7 +25,7 @@ export const CustomViewPage: React.FC<Props> = memo(({ packageInfo }) => { return customViewExtension ? ( <EuiFlexGroup alignItems="flexStart"> - <EuiFlexItem grow={1} /> + <SideBarColumn grow={1} /> <EuiFlexItem grow={7}> <ExtensionWrapper> <customViewExtension.Component pkgkey={pkgkey} packageInfo={packageInfo} /> diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx index 40a87bde535b..a418c38cd8f3 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx @@ -29,6 +29,7 @@ import type { } from '../../../../../types'; import { useStartServices } from '../../../../../../../hooks'; import { getStreamsForInputType } from '../../../../../../../../common/services'; +import { SideBarColumn } from '../../../components/side_bar_column'; interface Props { packageInfo: PackageInfo; @@ -70,7 +71,7 @@ export const DocumentationPage: React.FunctionComponent<Props> = ({ packageInfo, return ( <EuiFlexGroup alignItems="flexStart"> - <EuiFlexItem grow={1} /> + <SideBarColumn grow={1} /> <EuiFlexItem grow={7}> <EuiText> <FormattedMessage diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx index 9d6d05a7b11e..85899dc8d86c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx @@ -34,6 +34,7 @@ import { } from '../../../../../../../hooks'; import { isPackageUnverified } from '../../../../../../../services'; import type { PackageInfo, RegistryPolicyTemplate } from '../../../../../types'; +import { SideBarColumn } from '../../../components/side_bar_column'; import { Screenshots } from './screenshots'; import { Readme } from './readme'; @@ -59,13 +60,12 @@ interface HeadingWithPosition { position: number; } -const SideBar = styled(EuiFlexItem)` +const SideBar = styled(SideBarColumn)` position: sticky; top: 70px; padding-top: 50px; padding-left: 10px; text-overflow: ellipsis; - max-width: 180px; max-height: 500px; `; const StyledSideNav = styled(EuiSideNav)` diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/requirements.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/requirements.tsx index e028f45aaf7c..fb106f38f628 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/requirements.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/requirements.tsx @@ -7,9 +7,20 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiDescriptionList, EuiToolTip } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiDescriptionList, + EuiToolTip, + EuiLink, +} from '@elastic/eui'; + +import { useStartServices } from '../../../../../hooks'; export const Requirements = memo(() => { + const { docLinks } = useStartServices(); + return ( <EuiFlexGroup direction="column" gutterSize="s"> <EuiFlexItem> @@ -48,13 +59,27 @@ export const Requirements = memo(() => { 'xpack.fleet.epm.requirements.permissionRequireRootTooltip', { defaultMessage: - 'Elastic agent needs to be run with root or administor privileges', + 'Elastic agent needs to be run with root or administrator privileges', } )} > <FormattedMessage id="xpack.fleet.epm.requirements.permissionRequireRootMessage" - defaultMessage="root privileges" + defaultMessage="{guideLink}" + values={{ + guideLink: ( + <EuiLink + href={docLinks.links.fleet.unprivilegedMode} + target="_blank" + external + > + <FormattedMessage + id="xpack.fleet.permissionRequireRootMessage.guideLink" + defaultMessage="Root privileges" + /> + </EuiLink> + ), + }} /> </EuiToolTip> </> diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index d33958c5a68e..cc91af6a873a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -31,6 +31,7 @@ import { AgentPolicyRefreshContext, useIsPackagePolicyUpgradable, useAuthz, + useMultipleAgentPolicies, } from '../../../../../hooks'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; import { @@ -39,11 +40,11 @@ import { AgentPolicySummaryLine, PackagePolicyActionsMenu, } from '../../../../../components'; +import { SideBarColumn } from '../../../components/side_bar_column'; import { PackagePolicyAgentsCell } from './components/package_policy_agents_cell'; import { usePackagePoliciesWithAgentPolicy } from './use_package_policies_with_agent_policy'; import { Persona } from './persona'; -import { useMultipleAgentPolicies } from '../../../../../hooks'; interface PackagePoliciesPanelProps { name: string; @@ -232,10 +233,14 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps defaultMessage: 'Agent policy', }), truncateText: true, - render(id, { agentPolicies }) { + render(id, { agentPolicies, packagePolicy }) { return agentPolicies.length > 0 ? ( - canShowMultiplePoliciesCell && agentPolicies.length > 1 ? ( - <MultipleAgentPoliciesSummaryLine policies={agentPolicies} /> + canShowMultiplePoliciesCell ? ( + <MultipleAgentPoliciesSummaryLine + policies={agentPolicies} + packagePolicyId={packagePolicy.id} + onAgentPoliciesChange={refreshPolicies} + /> ) : ( <AgentPolicySummaryLine policy={agentPolicies[0]} /> ) @@ -326,6 +331,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps canAddFleetServers, canAddAgents, showAddAgentHelpForPackagePolicyId, + refreshPolicies, ] ); @@ -364,7 +370,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps return ( <AgentPolicyRefreshContext.Provider value={{ refresh: refreshPolicies }}> <EuiFlexGroup alignItems="flexStart"> - <EuiFlexItem grow={1} /> + <SideBarColumn grow={1} /> <EuiFlexItem grow={7}> <EuiBasicTable items={packageAndAgentPolicies || []} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 9255048674ae..769f979e83bf 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -45,6 +45,7 @@ import { AUTO_UPGRADE_POLICIES_PACKAGES, SO_SEARCH_LIMIT, } from '../../../../../constants'; +import { SideBarColumn } from '../../../components/side_bar_column'; import { KeepPoliciesUpToDateSwitch } from '../components'; @@ -255,7 +256,7 @@ export const SettingsPage: React.FC<Props> = memo(({ packageInfo, startServices return ( <> <EuiFlexGroup alignItems="flexStart"> - <EuiFlexItem grow={1} /> + <SideBarColumn grow={1} /> <EuiFlexItem grow={7}> <EuiText> <EuiTitle> diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx index 139268fbb240..326a3c973ea0 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx @@ -4,11 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useState, useEffect, useMemo } from 'react'; +import crypto from 'crypto'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; +import { safeDump } from 'js-yaml'; + import type { PackagePolicy, AgentPolicy } from '../../types'; -import { sendGetOneAgentPolicy, useGetPackageInfoByKeyQuery, useStartServices } from '../../hooks'; +import { + sendGetOneAgentPolicy, + sendGetOneAgentPolicyFull, + useGetPackageInfoByKeyQuery, + useStartServices, +} from '../../hooks'; import { FLEET_KUBERNETES_PACKAGE, FLEET_CLOUD_SECURITY_POSTURE_PACKAGE, @@ -23,6 +32,12 @@ import { SUPPORTED_TEMPLATES_URL_FROM_AGENT_POLICY_CONFIG, } from '../cloud_security_posture/services'; +import { sendCreateStandaloneAgentAPIKey } from '../../hooks'; + +import type { FullAgentPolicy } from '../../../common'; + +import { fullAgentPolicyToYaml } from '../../services'; + import type { K8sMode, CloudSecurityIntegrationType, @@ -190,3 +205,104 @@ const getCloudSecurityPackagePolicyFromAgentPolicy = ( (input) => input.package?.name === FLEET_CLOUD_SECURITY_POSTURE_PACKAGE ); }; + +export function useGetCreateApiKey() { + const core = useStartServices(); + + const [apiKey, setApiKey] = useState<string | undefined>(undefined); + const onCreateApiKey = useCallback(async () => { + try { + const res = await sendCreateStandaloneAgentAPIKey({ + name: crypto.randomBytes(16).toString('hex'), + }); + const newApiKey = `${res.data?.item.id}:${res.data?.item.api_key}`; + setApiKey(newApiKey); + } catch (err) { + core.notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.standaloneAgentPage.errorCreatingAgentAPIKey', { + defaultMessage: 'Error creating Agent API Key', + }), + }); + } + }, [core.notifications.toasts]); + return { + apiKey, + onCreateApiKey, + }; +} + +export function useFetchFullPolicy(agentPolicy: AgentPolicy | undefined, isK8s?: K8sMode) { + const core = useStartServices(); + const [yaml, setYaml] = useState<any | undefined>(''); + const [fullAgentPolicy, setFullAgentPolicy] = useState<FullAgentPolicy | undefined>(); + const { apiKey, onCreateApiKey } = useGetCreateApiKey(); + + useEffect(() => { + async function fetchFullPolicy() { + try { + if (!agentPolicy?.id) { + return; + } + let query = { standalone: true, kubernetes: false }; + if (isK8s === 'IS_KUBERNETES') { + query = { standalone: true, kubernetes: true }; + } + const res = await sendGetOneAgentPolicyFull(agentPolicy?.id, query); + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data while fetching full agent policy'); + } + setFullAgentPolicy(res.data.item); + } catch (error) { + core.notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.standaloneAgentPage.errorFetchingFullAgentPolicy', { + defaultMessage: 'Error fetching full agent policy', + }), + }); + } + } + + if (isK8s === 'IS_NOT_KUBERNETES' || isK8s !== 'IS_LOADING') { + fetchFullPolicy(); + } + }, [core.http.basePath, agentPolicy?.id, core.notifications.toasts, apiKey, isK8s, agentPolicy]); + + useEffect(() => { + if (!fullAgentPolicy) { + return; + } + + if (isK8s === 'IS_KUBERNETES') { + if (typeof fullAgentPolicy === 'object') { + return; + } + setYaml(fullAgentPolicy); + } else { + if (typeof fullAgentPolicy === 'string') { + return; + } + setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump, apiKey)); + } + }, [apiKey, fullAgentPolicy, isK8s]); + + const downloadYaml = useMemo( + () => () => { + const link = document.createElement('a'); + link.href = `data:text/json;charset=utf-8,${yaml}`; + link.download = `elastic-agent.yaml`; + link.click(); + }, + [yaml] + ); + + return { + yaml, + onCreateApiKey, + fullAgentPolicy, + apiKey, + downloadYaml, + }; +} diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx index 615c6399b202..bc4e7755044e 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx @@ -5,28 +5,20 @@ * 2.0. */ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { EuiSteps, EuiLoadingSpinner } from '@elastic/eui'; -import { safeDump } from 'js-yaml'; import type { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; -import type { FullAgentPolicy } from '../../../../common/types/models/agent_policy'; -import { API_VERSIONS } from '../../../../common/constants'; import { getRootIntegrations } from '../../../../common/services'; -import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../../../services'; import { getGcpIntegrationDetailsFromAgentPolicy } from '../../cloud_security_posture/services'; import { StandaloneInstructions, ManualInstructions } from '../../enrollment_instructions'; -import { - useGetOneEnrollmentAPIKey, - useStartServices, - sendGetOneAgentPolicyFull, - useAgentVersion, -} from '../../../hooks'; +import { useGetOneEnrollmentAPIKey, useStartServices, useAgentVersion } from '../../../hooks'; +import { useFetchFullPolicy } from '../hooks'; import type { InstructionProps } from '../types'; import { usePollingAgentCount } from '../confirm_agent_enrollment'; @@ -62,74 +54,7 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({ isK8s, cloudSecurityIntegration, }) => { - const core = useStartServices(); - const { notifications } = core; - const [fullAgentPolicy, setFullAgentPolicy] = useState<FullAgentPolicy | undefined>(); - const [yaml, setYaml] = useState<any | undefined>(''); - - let downloadLink = ''; - - if (selectedPolicy?.id) { - downloadLink = - isK8s === 'IS_KUBERNETES' - ? core.http.basePath.prepend( - `${agentPolicyRouteService.getInfoFullDownloadPath( - selectedPolicy?.id - )}?kubernetes=true&standalone=true&apiVersion=${API_VERSIONS.public.v1}` - ) - : core.http.basePath.prepend( - `${agentPolicyRouteService.getInfoFullDownloadPath( - selectedPolicy?.id - )}?standalone=true&apiVersion=${API_VERSIONS.public.v1}` - ); - } - - useEffect(() => { - async function fetchFullPolicy() { - try { - if (!selectedPolicy?.id) { - return; - } - let query = { standalone: true, kubernetes: false }; - if (isK8s === 'IS_KUBERNETES') { - query = { standalone: true, kubernetes: true }; - } - const res = await sendGetOneAgentPolicyFull(selectedPolicy?.id, query); - if (res.error) { - throw res.error; - } - - if (!res.data) { - throw new Error('No data while fetching full agent policy'); - } - setFullAgentPolicy(res.data.item); - } catch (error) { - notifications.toasts.addError(error, { - title: 'Error', - }); - } - } - if (isK8s !== 'IS_LOADING') { - fetchFullPolicy(); - } - }, [selectedPolicy, notifications.toasts, isK8s, core.http.basePath]); - - useEffect(() => { - if (!fullAgentPolicy) { - return; - } - if (isK8s === 'IS_KUBERNETES') { - if (typeof fullAgentPolicy === 'object') { - return; - } - setYaml(fullAgentPolicy); - } else { - if (typeof fullAgentPolicy === 'string') { - return; - } - setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump)); - } - }, [fullAgentPolicy, isK8s]); + const { yaml, onCreateApiKey, apiKey, downloadYaml } = useFetchFullPolicy(selectedPolicy, isK8s); const agentVersion = useAgentVersion(); @@ -160,7 +85,9 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({ isK8s, selectedPolicyId: selectedPolicy?.id, yaml, - downloadLink, + downloadYaml, + apiKey, + onCreateApiKey, }) ); @@ -176,8 +103,6 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({ return steps; }, [ agentVersion, - isK8s, - cloudSecurityIntegration, agentPolicy, selectedPolicy, agentPolicies, @@ -186,8 +111,12 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({ setSelectedPolicyId, refreshAgentPolicies, selectionType, + isK8s, yaml, - downloadLink, + downloadYaml, + apiKey, + onCreateApiKey, + cloudSecurityIntegration, mode, setMode, ]); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx index 289c5b8ad8df..30b08bf3a808 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx @@ -16,6 +16,9 @@ import { EuiCopy, EuiCodeBlock, EuiLink, + EuiCallOut, + EuiFieldText, + EuiButtonIcon, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -27,21 +30,25 @@ import { useStartServices } from '../../../hooks'; export const ConfigureStandaloneAgentStep = ({ isK8s, - selectedPolicyId, yaml, - downloadLink, + downloadYaml, + apiKey, + onCreateApiKey, isComplete, onCopy, }: { isK8s?: K8sMode; selectedPolicyId?: string; yaml: string; - downloadLink: string; + downloadYaml: () => void; + apiKey: string | undefined; + onCreateApiKey: () => void; isComplete?: boolean; onCopy?: () => void; }): EuiContainedStepProps => { const core = useStartServices(); const { docLinks } = core; + const policyMsg = isK8s === 'IS_KUBERNETES' ? ( <FormattedMessage @@ -67,12 +74,23 @@ export const ConfigureStandaloneAgentStep = ({ ) : ( <FormattedMessage id="xpack.fleet.agentEnrollment.stepConfigureAgentDescription" - defaultMessage="Copy this policy to the {fileName} on the host where the Elastic Agent is installed. Modify {ESUsernameVariable} and {ESPasswordVariable} in the {outputSection} section of {fileName} to use your Elasticsearch credentials." + defaultMessage="Copy this policy to the {fileName} on the host where the Elastic Agent is installed. Either use an existing API key and modify {apiKeyVariable} in the {outputSection} section of {fileName} or click the button below to generate a new one. Refer to {guideLink} for details." values={{ fileName: <EuiCode>elastic-agent.yml</EuiCode>, - ESUsernameVariable: <EuiCode>ES_USERNAME</EuiCode>, - ESPasswordVariable: <EuiCode>ES_PASSWORD</EuiCode>, + apiKeyVariable: <EuiCode>API_KEY</EuiCode>, outputSection: <EuiCode>outputs</EuiCode>, + guideLink: ( + <EuiLink + external + href={docLinks.links.fleet.grantESAccessToStandaloneAgents} + target="_blank" + > + <FormattedMessage + id="xpack.fleet.fleet.agentEnrollment.standaloneAgentAccessLinkText" + defaultMessage="Grant standalone Elastic Agents access to Elasticsearch" + /> + </EuiLink> + ), }} /> ); @@ -89,6 +107,7 @@ export const ConfigureStandaloneAgentStep = ({ defaultMessage="Download Policy" /> ); + return { title: i18n.translate('xpack.fleet.agentEnrollment.stepConfigureAgentTitle', { defaultMessage: 'Configure the agent', @@ -99,7 +118,63 @@ export const ConfigureStandaloneAgentStep = ({ <EuiText> <>{policyMsg}</> <EuiSpacer size="m" /> + {apiKey && ( + <EuiCallOut + title={i18n.translate('xpack.fleet.agentEnrollment.apiKeyBanner.created', { + defaultMessage: 'API Key created.', + })} + color="success" + iconType="check" + data-test-subj="obltOnboardingLogsApiKeyCreated" + > + <p> + {i18n.translate('xpack.fleet.agentEnrollment.apiKeyBanner.created.description', { + defaultMessage: + 'Remember to store this information in a safe place. It won’t be displayed anymore after you continue.', + })} + </p> + <EuiFieldText + data-test-subj="apmAgentKeyCallOutFieldText" + readOnly + value={apiKey} + aria-label={i18n.translate( + 'xpack.fleet.agentEnrollment.apiKeyBanner.field.label', + { + defaultMessage: 'Api Key', + } + )} + append={ + <EuiCopy textToCopy={apiKey}> + {(copy) => ( + <EuiButtonIcon + iconType="copyClipboard" + onClick={copy} + color="success" + css={{ + '> svg.euiIcon': { + borderRadius: '0 !important', + }, + }} + aria-label={i18n.translate('xpack.fleet.apiKeyBanner.field.copyButton', { + defaultMessage: 'Copy to clipboard', + })} + /> + )} + </EuiCopy> + } + /> + </EuiCallOut> + )} + <EuiSpacer size="m" /> <EuiFlexGroup gutterSize="m"> + <EuiFlexItem grow={false}> + <EuiButton onClick={onCreateApiKey}> + <FormattedMessage + id="xpack.fleet.agentEnrollment.createApiKeyButton" + defaultMessage="Create API key" + /> + </EuiButton> + </EuiFlexItem> <EuiFlexItem grow={false}> <EuiCopy textToCopy={yaml}> {(copy) => ( @@ -119,14 +194,13 @@ export const ConfigureStandaloneAgentStep = ({ </EuiCopy> </EuiFlexItem> <EuiFlexItem grow={false}> - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} <EuiButton iconType="download" - href={downloadLink} onClick={() => { if (onCopy) onCopy(); + downloadYaml(); }} - isDisabled={!downloadLink} + isDisabled={!downloadYaml} > <>{downloadMsg}</> </EuiButton> diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/root_privileges_callout.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/root_privileges_callout.tsx index 3c115a78567a..50177698099d 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/root_privileges_callout.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/root_privileges_callout.tsx @@ -6,13 +6,16 @@ */ import React from 'react'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useStartServices } from '../../hooks'; + export const RootPrivilegesCallout: React.FC<{ rootIntegrations?: Array<{ name: string; title: string }>; }> = ({ rootIntegrations = [] }) => { + const { docLinks } = useStartServices(); return rootIntegrations.length > 0 ? ( <> <EuiCallOut @@ -26,7 +29,17 @@ export const RootPrivilegesCallout: React.FC<{ <FormattedMessage id="xpack.fleet.agentEnrollmentCallout.rootPrivilegesMessage" defaultMessage="This agent policy contains the following integrations that require Elastic Agents to have root privileges. - To ensure that all data required by the integrations can be collected, enroll the agents using an account with root privileges." + To ensure that all data required by the integrations can be collected, enroll the agents using an account with root privileges. For more information, see the {guideLink}" + values={{ + guideLink: ( + <EuiLink href={docLinks.links.fleet.unprivilegedMode} target="_blank" external> + <FormattedMessage + id="xpack.fleet.agentEnrollmentCallout.rootPrivilegesMessage.guideLink" + defaultMessage="Fleet and Elastic Agent Guide" + /> + </EuiLink> + ), + }} /> <ul> {rootIntegrations.map((item) => ( diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/unprivileged_info.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/unprivileged_info.tsx index d6f1fd368538..e8d36591998c 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/unprivileged_info.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/unprivileged_info.tsx @@ -6,20 +6,31 @@ */ import React from 'react'; -import { EuiCode, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiCode, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useStartServices } from '../../hooks'; + export const UnprivilegedInfo: React.FC = () => { + const { docLinks } = useStartServices(); return ( <> <EuiText> <p> <FormattedMessage id="xpack.fleet.agentEnrollmentFlyout.unprivilegedMessage" - defaultMessage="To install Elastic Agent without root privileges, add the {flag} flag to the {command} install command below." + defaultMessage="To install Elastic Agent without root privileges, add the {flag} flag to the {command} install command below. For more information, see the {guideLink}" values={{ flag: <EuiCode>--unprivileged</EuiCode>, command: <EuiCode>sudo ./elastic-agent</EuiCode>, + guideLink: ( + <EuiLink href={docLinks.links.fleet.unprivilegedMode} target="_blank" external> + <FormattedMessage + id="xpack.fleet.agentEnrollmentFlyout.unprivilegedMessage.guideLink" + defaultMessage="Fleet and Elastic Agent Guide" + /> + </EuiLink> + ), }} /> </p> diff --git a/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.test.tsx b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.test.tsx new file mode 100644 index 000000000000..fb550a157d8b --- /dev/null +++ b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.test.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { act } from '@testing-library/react'; + +import type { TestRenderer } from '../mock'; +import { createFleetTestRendererMock } from '../mock'; +import type { AgentPolicy } from '../types'; + +import { usePackagePolicyWithRelatedData } from '../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks'; + +import { useGetAgentPolicies } from '../hooks'; + +import { ManageAgentPoliciesModal } from './manage_agent_policies_modal'; + +jest.mock('../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks', () => ({ + ...jest.requireActual( + '../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks' + ), + usePackagePolicyWithRelatedData: jest.fn().mockReturnValue({ + packageInfo: {}, + packagePolicy: { name: 'Integration 1' }, + savePackagePolicy: jest.fn().mockResolvedValue({ error: undefined }), + }), +})); + +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useStartServices: jest.fn().mockReturnValue({ + notifications: { + toasts: { + addSuccess: jest.fn(), + addError: jest.fn(), + }, + }, + }), + useGetAgentPolicies: jest.fn(), + useGetPackagePolicies: jest.fn().mockReturnValue({ + data: { + items: [{ name: 'Integration 1', revision: 2, id: 'integration1', policy_ids: ['policy1'] }], + }, + isLoading: false, + }), + useGetOutputs: jest.fn().mockReturnValue({ + data: { + items: [ + { + id: 'logstash-1', + type: 'logstash', + }, + ], + }, + isLoading: false, + }), +})); + +describe('ManageAgentPoliciesModal', () => { + let testRenderer: TestRenderer; + const mockOnClose = jest.fn(); + const mockPolicies = [{ name: 'Test policy', revision: 2, id: 'policy1' }] as AgentPolicy[]; + + const render = (policies?: AgentPolicy[]) => + testRenderer.render( + <ManageAgentPoliciesModal + selectedAgentPolicies={policies || mockPolicies} + packagePolicyId="integration1" + onClose={mockOnClose} + onAgentPoliciesChange={jest.fn()} + /> + ); + + beforeEach(() => { + testRenderer = createFleetTestRendererMock(); + + (useGetAgentPolicies as jest.Mock).mockReturnValue({ + data: { + items: [ + { name: 'Test policy', revision: 2, id: 'policy1' }, + { name: 'Test policy 2', revision: 1, id: 'policy2' }, + ] as AgentPolicy[], + }, + isLoading: false, + }); + }); + + it('should update policy on submit', async () => { + const results = render(); + + expect(results.queryByTestId('manageAgentPoliciesModal')).toBeInTheDocument(); + expect(results.getByTestId('integrationNameText').textContent).toEqual( + 'Integration: Integration 1' + ); + + await act(async () => { + results.getByTestId('comboBoxToggleListButton').click(); + }); + await act(async () => { + results.getByText('Test policy 2').click(); + }); + expect(results.getByText('Confirm').getAttribute('disabled')).toBeNull(); + await act(async () => { + results.getByText('Confirm').click(); + }); + expect(usePackagePolicyWithRelatedData('', {}).savePackagePolicy).toHaveBeenCalledWith({ + policy_ids: ['policy1', 'policy2'], + }); + }); + + it('should keep managed policy when policies are changed', async () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ + data: { + items: [ + { name: 'Test policy', revision: 2, id: 'policy1', is_managed: true }, + { name: 'Test policy 2', revision: 1, id: 'policy2' }, + ] as AgentPolicy[], + }, + isLoading: false, + }); + const results = render([ + { name: 'Test policy', revision: 2, id: 'policy1', is_managed: true }, + ] as AgentPolicy[]); + + expect(results.queryByTestId('manageAgentPoliciesModal')).toBeInTheDocument(); + expect(results.getByTestId('integrationNameText').textContent).toEqual( + 'Integration: Integration 1' + ); + + await act(async () => { + results.getByTestId('comboBoxToggleListButton').click(); + }); + expect(results.queryByText('Test policy')).toBeNull(); + await act(async () => { + results.getByText('Test policy 2').click(); + }); + expect(results.getByText('Confirm').getAttribute('disabled')).toBeNull(); + await act(async () => { + results.getByText('Confirm').click(); + }); + expect(usePackagePolicyWithRelatedData('', {}).savePackagePolicy).toHaveBeenCalledWith({ + policy_ids: ['policy1', 'policy2'], + }); + }); + + it('should display callout and disable confirm if policy is removed', async () => { + const results = render(); + + await act(async () => { + results.getByTestId('comboBoxClearButton').click(); + }); + expect(results.getByText('Confirm').getAttribute('disabled')).toBeDefined(); + expect(results.getByTestId('confirmRemovePoliciesCallout')).toBeInTheDocument(); + expect(results.getByTestId('confirmRemovePoliciesCallout').textContent).toContain( + 'Test policy will no longer use this integration.' + ); + }); +}); diff --git a/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.tsx b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.tsx new file mode 100644 index 000000000000..26e9421ed86f --- /dev/null +++ b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.tsx @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiCallOut, + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiText, +} from '@elastic/eui'; +import React, { useState, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { i18n } from '@kbn/i18n'; + +import { isEqual } from 'lodash'; +import styled from 'styled-components'; + +import { AgentPolicyMultiSelect } from '../applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select'; +import { useAgentPoliciesOptions } from '../applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options'; +import type { AgentPolicy } from '../types'; +import { usePackagePolicyWithRelatedData } from '../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks'; +import { useStartServices } from '../hooks'; + +const StyledEuiConfirmModal = styled(EuiConfirmModal)` + min-width: 448px; +`; + +interface Props { + onClose: () => void; + selectedAgentPolicies: AgentPolicy[]; + packagePolicyId: string; + onAgentPoliciesChange: () => void; +} + +export const ManageAgentPoliciesModal: React.FunctionComponent<Props> = ({ + onClose, + selectedAgentPolicies, + packagePolicyId, + onAgentPoliciesChange, +}) => { + const initialPolicyIds = selectedAgentPolicies.map((policy) => policy.id); + + const [selectedPolicyIds, setSelectedPolicyIds] = useState<string[]>(initialPolicyIds); + const [isSubmitting, setIsSubmitting] = useState<boolean>(false); + const { notifications } = useStartServices(); + const { packageInfo, packagePolicy, savePackagePolicy } = usePackagePolicyWithRelatedData( + packagePolicyId, + {} + ); + + const removedPolicies = useMemo( + () => + selectedAgentPolicies + .filter((policy) => !selectedPolicyIds.find((id) => policy.id === id)) + .map((policy) => policy.name), + [selectedAgentPolicies, selectedPolicyIds] + ); + + const onCancel = () => { + onClose(); + }; + + const onConfirm = async () => { + setIsSubmitting(true); + const { error } = await savePackagePolicy({ + policy_ids: selectedPolicyIds, + }); + setIsSubmitting(false); + if (!error) { + onAgentPoliciesChange(); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.fleet.manageAgentPolicies.updatedNotificationTitle', { + defaultMessage: `Successfully updated ''{packagePolicyName}''`, + values: { + packagePolicyName: packagePolicy.name, + }, + }), + 'data-test-subj': 'policyUpdateSuccessToast', + }); + } else { + if (error.statusCode === 409) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.manageAgentPolicies.failedNotificationTitle', { + defaultMessage: `Error updating ''{packagePolicyName}''`, + values: { + packagePolicyName: packagePolicy.name, + }, + }), + toastMessage: i18n.translate( + 'xpack.fleet.manageAgentPolicies.failedConflictNotificationMessage', + { + defaultMessage: `Data is out of date. Refresh the page to get the latest policy.`, + } + ), + }); + } else { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.manageAgentPolicies.failedNotificationTitle', { + defaultMessage: `Error updating ''{packagePolicyName}''`, + values: { + packagePolicyName: packagePolicy.name, + }, + }), + }); + } + } + onClose(); + }; + + const { agentPolicyMultiOptions, isLoading } = useAgentPoliciesOptions(packageInfo); + + return ( + <StyledEuiConfirmModal + title={ + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.confirmModalTitle" + defaultMessage="Manage agent policies" + /> + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.confirmModalCancelButtonLabel" + defaultMessage="Cancel" + /> + } + confirmButtonText={ + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.confirmModalConfirmButtonLabel" + defaultMessage="Confirm" + /> + } + buttonColor="primary" + confirmButtonDisabled={ + selectedPolicyIds.length === 0 || + isSubmitting || + isEqual(initialPolicyIds, selectedPolicyIds) + } + data-test-subj="manageAgentPoliciesModal" + > + <EuiFlexGroup direction="column" gutterSize="m"> + <EuiFlexItem> + <EuiText> + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.confirmModalDescription" + defaultMessage="Agent policies sharing this integration" + /> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <EuiText data-test-subj="integrationNameText"> + <b> + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.integrationName" + defaultMessage="Integration: " + /> + </b> + {packagePolicy.name} + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <EuiFormRow + label={ + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.agentPoliciesLabel" + defaultMessage="Agent policies" + /> + } + > + <AgentPolicyMultiSelect + isLoading={isLoading} + selectedPolicyIds={selectedPolicyIds} + setSelectedPolicyIds={setSelectedPolicyIds} + agentPolicyMultiOptions={agentPolicyMultiOptions} + selectedAgentPolicies={selectedAgentPolicies} + /> + </EuiFormRow> + </EuiFlexItem> + {removedPolicies.length > 0 && ( + <EuiFlexItem> + <EuiCallOut + data-test-subj="confirmRemovePoliciesCallout" + title={ + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.calloutTitle" + defaultMessage="This action will update this integration" + /> + } + > + <EuiText size="s"> + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.calloutBody" + defaultMessage="{removedPolicies} will no longer use this integration." + values={{ removedPolicies: <b>{removedPolicies.join(', ')}</b> }} + /> + </EuiText> + </EuiCallOut> + </EuiFlexItem> + )} + <EuiFlexItem> + <EuiText> + <FormattedMessage + id="xpack.fleet.manageAgentPolicies.confirmText" + defaultMessage="Are you sure you wish to continue?" + /> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </StyledEuiConfirmModal> + ); +}; diff --git a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx index 0d88dcc4b44b..100a6f67ad83 100644 --- a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx +++ b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx @@ -19,16 +19,22 @@ describe('MultipleAgentPolicySummaryLine', () => { let testRenderer: TestRenderer; const render = (agentPolicies: AgentPolicy[]) => - testRenderer.render(<MultipleAgentPoliciesSummaryLine policies={agentPolicies} />); + testRenderer.render( + <MultipleAgentPoliciesSummaryLine + policies={agentPolicies} + packagePolicyId="policy1" + onAgentPoliciesChange={jest.fn()} + /> + ); beforeEach(() => { testRenderer = createFleetTestRendererMock(); }); - test('it should render only the policy name when there is only one policy', async () => { + test('it should only render the policy name when there is only one policy', async () => { const results = render([{ name: 'Test policy', revision: 2 }] as AgentPolicy[]); - expect(results.container.textContent).toBe('Test policy'); - expect(results.queryByTestId('agentPolicyNameBadge')).toBeInTheDocument(); + expect(results.container.textContent).toBe('Test policyrev. 2'); + expect(results.queryByTestId('agentPolicyNameLink')).toBeInTheDocument(); expect(results.queryByTestId('agentPoliciesNumberBadge')).not.toBeInTheDocument(); }); @@ -38,7 +44,7 @@ describe('MultipleAgentPolicySummaryLine', () => { { name: 'Test policy 2', id: '0002' }, { name: 'Test policy 3', id: '0003' }, ] as AgentPolicy[]); - expect(results.queryByTestId('agentPolicyNameBadge')).toBeInTheDocument(); + expect(results.queryByTestId('agentPolicyNameLink')).toBeInTheDocument(); expect(results.queryByTestId('agentPoliciesNumberBadge')).toBeInTheDocument(); expect(results.container.textContent).toBe('Test policy 1+2'); @@ -50,5 +56,11 @@ describe('MultipleAgentPolicySummaryLine', () => { expect(results.queryByTestId('policy-0001')).toBeInTheDocument(); expect(results.queryByTestId('policy-0002')).toBeInTheDocument(); expect(results.queryByTestId('policy-0003')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(results.getByTestId('agentPoliciesPopoverButton')); + }); + + expect(results.queryByTestId('manageAgentPoliciesModal')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx index 2a869f12bd81..0280989fb6ed 100644 --- a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx +++ b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx @@ -15,27 +15,41 @@ import { EuiButton, EuiListGroup, type EuiListGroupItemProps, + EuiLink, + EuiIconTip, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { CSSProperties } from 'react'; import { useMemo } from 'react'; import React, { memo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + import type { AgentPolicy } from '../../common/types'; -import { useLink } from '../hooks'; +import { useAuthz, useLink } from '../hooks'; + +import { ManageAgentPoliciesModal } from './manage_agent_policies_modal'; const MIN_WIDTH: CSSProperties = { minWidth: 0 }; +const NO_WRAP_WHITE_SPACE: CSSProperties = { whiteSpace: 'nowrap' }; export const MultipleAgentPoliciesSummaryLine = memo<{ policies: AgentPolicy[]; direction?: 'column' | 'row'; -}>(({ policies, direction = 'row' }) => { + packagePolicyId: string; + onAgentPoliciesChange: () => void; +}>(({ policies, direction = 'row', packagePolicyId, onAgentPoliciesChange }) => { const { getHref } = useLink(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const closePopover = () => setIsPopoverOpen(false); + const [policiesModalEnabled, setPoliciesModalEnabled] = useState(false); + const authz = useAuthz(); + const canManageAgentPolicies = + authz.integrations.writeIntegrationPolicies && authz.fleet.allAgentPolicies; // as default, show only the first policy const policy = policies[0]; - const { name, id } = policy; + const { name, id, is_managed: isManaged, revision } = policy; const listItems: EuiListGroupItemProps[] = useMemo(() => { return policies.map((p) => { @@ -61,67 +75,120 @@ export const MultipleAgentPoliciesSummaryLine = memo<{ }, [getHref, policies]); return ( - <EuiFlexGroup direction="column" gutterSize="xs"> - <EuiFlexItem> - <EuiFlexGroup - direction={direction} - gutterSize={direction === 'column' ? 'none' : 's'} - alignItems="baseline" - style={MIN_WIDTH} - responsive={false} - justifyContent={'flexStart'} - > - <EuiFlexItem grow={false} className="eui-textTruncate"> - <EuiFlexGroup style={MIN_WIDTH} gutterSize="s" alignItems="baseline" responsive={false}> - <EuiFlexItem grow={false} className="eui-textTruncate"> - <EuiBadge color="default" data-test-subj="agentPolicyNameBadge"> - {name || id} - </EuiBadge> - </EuiFlexItem> - {policies.length > 1 && ( - <EuiFlexItem grow={false}> - <EuiBadge - color="hollow" - data-test-subj="agentPoliciesNumberBadge" - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - onClickAriaLabel="Open agent policies popover" - > - {`+${policies.length - 1}`} - </EuiBadge> - <EuiPopover - data-test-subj="agentPoliciesPopover" - isOpen={isPopoverOpen} - closePopover={closePopover} - anchorPosition="downCenter" + <> + <EuiFlexGroup direction="column" gutterSize="xs"> + <EuiFlexItem> + <EuiFlexGroup + direction={direction} + gutterSize={direction === 'column' ? 'none' : 's'} + alignItems="baseline" + style={MIN_WIDTH} + responsive={false} + justifyContent={'flexStart'} + > + <EuiFlexItem grow={false} className="eui-textTruncate"> + <EuiFlexGroup + style={MIN_WIDTH} + gutterSize="s" + alignItems="baseline" + responsive={false} + > + <EuiFlexItem grow={false} className="eui-textTruncate"> + <EuiLink + className={`eui-textTruncate`} + href={getHref('policy_details', { policyId: id })} + title={name || id} + data-test-subj="agentPolicyNameLink" > - <EuiPopoverTitle> - {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.title', { - defaultMessage: 'This integration is shared by', - })} - </EuiPopoverTitle> - <div style={{ width: '280px' }}> - <EuiListGroup - listItems={listItems} - color="primary" - size="s" - gutterSize="none" + {name || id} + </EuiLink> + </EuiFlexItem> + {isManaged && ( + <EuiFlexItem grow={false}> + <EuiIconTip + title="Hosted agent policy" + content={i18n.translate( + 'xpack.fleet.agentPolicySummaryLine.hostedPolicyTooltip', + { + defaultMessage: + 'This policy is managed outside of Fleet. Most actions related to this policy are unavailable.', + } + )} + type="lock" + size="m" + color="subdued" + /> + </EuiFlexItem> + )} + {revision && ( + <EuiFlexItem grow={false}> + <EuiText color="subdued" size="xs" style={NO_WRAP_WHITE_SPACE}> + <FormattedMessage + id="xpack.fleet.agentPolicySummaryLine.revisionNumber" + defaultMessage="rev. {revNumber}" + values={{ revNumber: revision }} /> - </div> - <EuiPopoverFooter> - {/* TODO: implement missing onClick function */} - <EuiButton fullWidth size="s" data-test-subj="agentPoliciesPopoverButton"> - {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.button', { - defaultMessage: 'Manage agent policies', + </EuiText> + </EuiFlexItem> + )} + {policies.length > 1 && ( + <EuiFlexItem grow={false}> + <EuiBadge + color="hollow" + data-test-subj="agentPoliciesNumberBadge" + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + onClickAriaLabel="Open agent policies popover" + > + +{policies.length - 1} + </EuiBadge> + <EuiPopover + data-test-subj="agentPoliciesPopover" + isOpen={isPopoverOpen} + closePopover={closePopover} + anchorPosition="downCenter" + > + <EuiPopoverTitle> + {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.title', { + defaultMessage: 'This integration is shared by', })} - </EuiButton> - </EuiPopoverFooter> - </EuiPopover> - </EuiFlexItem> - )} - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> + </EuiPopoverTitle> + <div style={{ width: '280px' }}> + <EuiListGroup + listItems={listItems} + color="primary" + size="s" + gutterSize="none" + /> + </div> + <EuiPopoverFooter> + <EuiButton + fullWidth + size="s" + data-test-subj="agentPoliciesPopoverButton" + onClick={() => setPoliciesModalEnabled(true)} + isDisabled={!canManageAgentPolicies} + > + {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.button', { + defaultMessage: 'Manage agent policies', + })} + </EuiButton> + </EuiPopoverFooter> + </EuiPopover> + </EuiFlexItem> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + {policiesModalEnabled && ( + <ManageAgentPoliciesModal + onClose={() => setPoliciesModalEnabled(false)} + onAgentPoliciesChange={onAgentPoliciesChange} + selectedAgentPolicies={policies} + packagePolicyId={packagePolicyId} + /> + )} + </> ); }); diff --git a/x-pack/plugins/fleet/public/hooks/use_request/index.ts b/x-pack/plugins/fleet/public/hooks/use_request/index.ts index 448b934bb341..dc2f1292220c 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/index.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/index.ts @@ -12,6 +12,7 @@ export * from './data_stream'; export * from './agents'; export * from './enrollment_api_keys'; export * from './epm'; +export * from './standalone_agent_api_key'; export * from './outputs'; export * from './settings'; export * from './setup'; diff --git a/x-pack/plugins/fleet/public/hooks/use_request/standalone_agent_api_key.ts b/x-pack/plugins/fleet/public/hooks/use_request/standalone_agent_api_key.ts new file mode 100644 index 000000000000..3df53fd4f35f --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_request/standalone_agent_api_key.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + PostStandaloneAgentAPIKeyRequest, + PostStandaloneAgentAPIKeyResponse, +} from '../../types'; + +import { API_VERSIONS, CREATE_STANDALONE_AGENT_API_KEY_ROUTE } from '../../../common/constants'; + +import { sendRequest } from './use_request'; + +export function sendCreateStandaloneAgentAPIKey(body: PostStandaloneAgentAPIKeyRequest['body']) { + return sendRequest<PostStandaloneAgentAPIKeyResponse>({ + method: 'post', + path: CREATE_STANDALONE_AGENT_API_KEY_ROUTE, + version: API_VERSIONS.internal.v1, + body, + }); +} diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index 82c13c6fc0e5..aeb6d302adaa 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -80,6 +80,8 @@ export type { GetOneEnrollmentAPIKeyResponse, PostEnrollmentAPIKeyRequest, PostEnrollmentAPIKeyResponse, + PostStandaloneAgentAPIKeyRequest, + PostStandaloneAgentAPIKeyResponse, PostLogstashApiKeyResponse, GetOutputsResponse, GetCurrentUpgradesResponse, diff --git a/x-pack/plugins/fleet/public/types/ui_extensions.ts b/x-pack/plugins/fleet/public/types/ui_extensions.ts index b0445bf4e169..ec2a5c42e3b0 100644 --- a/x-pack/plugins/fleet/public/types/ui_extensions.ts +++ b/x-pack/plugins/fleet/public/types/ui_extensions.ts @@ -34,7 +34,7 @@ export type PackagePolicyReplaceDefineStepExtensionComponentProps = ( | (PackagePolicyCreateExtensionComponentProps & { isEditPage: false }) ) & { validationResults?: PackagePolicyValidationResults; - agentPolicy?: AgentPolicy; + agentPolicies?: AgentPolicy[]; packageInfo: PackageInfo; agentlessPolicy?: AgentPolicy; handleSetupTechnologyChange?: (setupTechnology: string) => void; diff --git a/x-pack/plugins/fleet/server/collectors/agent_policies.ts b/x-pack/plugins/fleet/server/collectors/agent_policies.ts index 5210e7f3b229..190c43f341ff 100644 --- a/x-pack/plugins/fleet/server/collectors/agent_policies.ts +++ b/x-pack/plugins/fleet/server/collectors/agent_policies.ts @@ -49,9 +49,11 @@ export const getAgentPoliciesUsage = async ( }); const uniqueOutputTypes = new Set( - Array.from(uniqueOutputIds).map((outputId) => { - return outputsById[outputId]?.attributes.type; - }) + Array.from(uniqueOutputIds) + .map((outputId) => { + return outputsById[outputId]?.attributes.type; + }) + .filter((outputType) => outputType) ); const [policiesWithGlobalDataTag, totalNumberOfGlobalDataTagFields] = agentPolicies.reduce( diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index adb785809486..d727cd30c638 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -38,6 +38,7 @@ export { PRECONFIGURATION_API_ROUTES, DOWNLOAD_SOURCE_API_ROOT, DOWNLOAD_SOURCE_API_ROUTES, + CREATE_STANDALONE_AGENT_API_KEY_ROUTE, FLEET_DEBUG_ROUTES, // Saved Object indices INGEST_SAVED_OBJECT_INDEX, diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index b6355bf0c7f7..8b7a93f6f332 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -377,7 +377,9 @@ export const getFullAgentPolicy: FleetRequestHandler< const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy( soClient, request.params.agentPolicyId, - { standalone: request.query.standalone === true } + { + standalone: request.query.standalone === true, + } ); if (fullAgentPolicy) { const body: GetFullAgentPolicyResponse = { diff --git a/x-pack/plugins/fleet/server/routes/index.ts b/x-pack/plugins/fleet/server/routes/index.ts index 77c4fa9eb424..29efa03967ea 100644 --- a/x-pack/plugins/fleet/server/routes/index.ts +++ b/x-pack/plugins/fleet/server/routes/index.ts @@ -26,6 +26,7 @@ import { registerRoutes as registerFleetServerHostRoutes } from './fleet_server_ import { registerRoutes as registerFleetProxiesRoutes } from './fleet_proxies'; import { registerRoutes as registerMessageSigningServiceRoutes } from './message_signing_service'; import { registerRoutes as registerUninstallTokenRoutes } from './uninstall_token'; +import { registerRoutes as registerStandaloneAgentApiKeyRoutes } from './standalone_agent_api_key'; import { registerRoutes as registerDebugRoutes } from './debug'; export function registerRoutes(fleetAuthzRouter: FleetAuthzRouter, config: FleetConfigType) { @@ -48,6 +49,7 @@ export function registerRoutes(fleetAuthzRouter: FleetAuthzRouter, config: Fleet registerHealthCheckRoutes(fleetAuthzRouter); registerMessageSigningServiceRoutes(fleetAuthzRouter); registerUninstallTokenRoutes(fleetAuthzRouter, config); + registerStandaloneAgentApiKeyRoutes(fleetAuthzRouter); registerDebugRoutes(fleetAuthzRouter); // Conditional config routes diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index a52af5683704..a26fc6760397 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -12,7 +12,7 @@ import type { RouteConfig } from '@kbn/core/server'; import type { FleetAuthzRouter } from '../../services/security'; import { PACKAGE_POLICY_API_ROUTES } from '../../../common/constants'; -import { appContextService, packagePolicyService } from '../../services'; +import { appContextService, licenseService, packagePolicyService } from '../../services'; import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; import type { PackagePolicyClient, FleetRequestHandlerContext } from '../..'; import type { @@ -190,6 +190,29 @@ describe('When calling package policy', () => { }, }); }); + + it('should throw if no enterprise license and multiple policy_ids is provided', async () => { + const request = getCreateKibanaRequest({ ...newPolicy, policy_ids: ['1', '2'] } as any); + await createPackagePolicyHandler(context, request as any, response); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 400, + body: { + message: 'Reusable integration policies are only available with an Enterprise license', + }, + }); + }); + + it('should not throw if enterprise license and multiple policy_ids is provided', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + const request = getCreateKibanaRequest({ ...newPolicy, policy_ids: ['1', '2'] } as any); + await createPackagePolicyHandler(context, request as any, response); + expect(response.customError).not.toHaveBeenCalledWith({ + statusCode: 400, + body: { + message: 'Reusable integration policies are only available with an Enterprise license', + }, + }); + }); }); describe('update api handler', () => { @@ -338,6 +361,25 @@ describe('When calling package policy', () => { body: { item: { ...existingPolicy, namespace: 'namespace' } }, }); }); + + it('should throw if no enterprise license and multiple policy_ids is provided', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(false); + const request = getUpdateKibanaRequest({ policy_ids: ['1', '2'] } as any); + await routeHandler(context, request, response); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 400, + body: { + message: 'Reusable integration policies are only available with an Enterprise license', + }, + }); + }); + + it('should not throw if enterprise license and multiple policy_ids is provided', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + const request = getUpdateKibanaRequest({ policy_ids: ['1', '2'] } as any); + await routeHandler(context, request, response); + expect(response.ok).toHaveBeenCalled(); + }); }); describe('list api handler', () => { diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index 8aa7770be397..abad84ef9db9 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -62,7 +62,11 @@ import { import type { SimplifiedPackagePolicy } from '../../../common/services/simplified_package_policy_helper'; -import { isSimplifiedCreatePackagePolicyRequest, removeFieldsFromInputSchema } from './utils'; +import { + canUseMultipleAgentPolicies, + isSimplifiedCreatePackagePolicyRequest, + removeFieldsFromInputSchema, +} from './utils'; export const isNotNull = <T>(value: T | null): value is T => value !== null; @@ -246,6 +250,11 @@ export const createPackagePolicyHandler: FleetRequestHandler< throw new PackagePolicyRequestError('Either policy_id or policy_ids must be provided'); } + const { canUseReusablePolicies, errorMessage } = canUseMultipleAgentPolicies(); + if ((newPolicy.policy_ids ?? []).length > 1 && !canUseReusablePolicies) { + throw new PackagePolicyRequestError(errorMessage); + } + let newPackagePolicy: NewPackagePolicy; if (isSimplifiedCreatePackagePolicyRequest(newPolicy)) { if (!pkg) { @@ -407,6 +416,11 @@ export const updatePackagePolicyHandler: FleetRequestHandler< newData.overrides = overrides; } } + const { canUseReusablePolicies, errorMessage } = canUseMultipleAgentPolicies(); + if ((newData.policy_ids ?? []).length > 1 && !canUseReusablePolicies) { + throw new PackagePolicyRequestError(errorMessage); + } + const updatedPackagePolicy = await packagePolicyService.update( soClient, esClient, diff --git a/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts b/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts index 324ff8f418a7..da1fca175b9c 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts @@ -8,7 +8,7 @@ import type { TypeOf } from '@kbn/config-schema'; import type { CreatePackagePolicyRequestSchema, PackagePolicyInput } from '../../../types'; - +import { licenseService } from '../../../services'; import type { SimplifiedPackagePolicy } from '../../../../common/services/simplified_package_policy_helper'; export function isSimplifiedCreatePackagePolicyRequest( @@ -39,3 +39,14 @@ export function removeFieldsFromInputSchema( return newInput; }); } + +const LICENCE_FOR_MULTIPLE_AGENT_POLICIES = 'enterprise'; + +export function canUseMultipleAgentPolicies() { + const hasEnterpriseLicence = licenseService.hasAtLeast(LICENCE_FOR_MULTIPLE_AGENT_POLICIES); + + return { + canUseReusablePolicies: hasEnterpriseLicence, + errorMessage: 'Reusable integration policies are only available with an Enterprise license', + }; +} diff --git a/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts new file mode 100644 index 000000000000..99c349899aaa --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; + +import { createStandaloneAgentApiKey } from '../../services/api_keys'; +import type { FleetRequestHandler, PostStandaloneAgentAPIKeyRequestSchema } from '../../types'; + +export const createStandaloneAgentApiKeyHandler: FleetRequestHandler< + undefined, + undefined, + TypeOf<typeof PostStandaloneAgentAPIKeyRequestSchema.body> +> = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const key = await createStandaloneAgentApiKey(esClient, request.body.name); + return response.ok({ + body: { + item: key, + }, + }); +}; diff --git a/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/index.ts b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/index.ts new file mode 100644 index 000000000000..9255f058aee4 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FleetAuthzRouter } from '../../services/security'; + +import { API_VERSIONS } from '../../../common/constants'; + +import { CREATE_STANDALONE_AGENT_API_KEY_ROUTE } from '../../constants'; + +import { PostStandaloneAgentAPIKeyRequestSchema } from '../../types'; + +import { createStandaloneAgentApiKeyHandler } from './handler'; + +export const registerRoutes = (router: FleetAuthzRouter) => { + router.versioned + .post({ + path: CREATE_STANDALONE_AGENT_API_KEY_ROUTE, + access: 'internal', + fleetAuthz: { + fleet: { all: true }, + }, + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: { request: PostStandaloneAgentAPIKeyRequestSchema }, + }, + createStandaloneAgentApiKeyHandler + ); +}; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts index 5705f569d9fa..5701a60b56d0 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts @@ -817,7 +817,7 @@ ssl.test: 123 `); }); - it('should return placeholder ES_USERNAME and ES_PASSWORD for elasticsearch output type in standalone ', () => { + it('should return placeholder API_KEY for elasticsearch output type in standalone ', () => { const policyOutput = transformOutputToFullPolicyOutput( { id: 'id123', @@ -833,18 +833,17 @@ ssl.test: 123 expect(policyOutput).toMatchInlineSnapshot(` Object { + "api_key": "\${API_KEY}", "hosts": Array [ "http://host.fr", ], - "password": "\${ES_PASSWORD}", "preset": "balanced", "type": "elasticsearch", - "username": "\${ES_USERNAME}", } `); }); - it('should not return placeholder ES_USERNAME and ES_PASSWORD for logstash output type in standalone ', () => { + it('should not return placeholder API_KEY for logstash output type in standalone ', () => { const policyOutput = transformOutputToFullPolicyOutput( { id: 'id123', diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts index b8e64be49465..efc3a732149d 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts @@ -491,8 +491,8 @@ export function transformOutputToFullPolicyOutput( } if (output.type === outputType.Elasticsearch && standalone) { - newOutput.username = '${ES_USERNAME}'; - newOutput.password = '${ES_PASSWORD}'; + // adding a place_holder as API_KEY + newOutput.api_key = '${API_KEY}'; } if (output.type === outputType.RemoteElasticsearch) { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index cd170e6ef6da..4cc816a908cd 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -530,31 +530,6 @@ describe('Agent policy', () => { }) ); }); - - it('should delete all integration polices', async () => { - mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([ - { - id: 'package-1', - policy_id: ['policy_1'], - policy_ids: ['policy_1', 'int_policy_2'], - }, - { - id: 'package-2', - policy_id: ['policy_1'], - policy_ids: ['policy_1'], - }, - { - id: 'package-3', - }, - ] as any); - await agentPolicyService.delete(soClient, esClient, 'mocked'); - expect(mockedPackagePolicyService.delete).toBeCalledWith( - expect.anything(), - expect.anything(), - ['package-1', 'package-2', 'package-3'], - expect.anything() - ); - }); }); describe('with enableReusableIntegrationPolicies enabled', () => { @@ -667,13 +642,24 @@ describe('Agent policy', () => { id: 'package-3', }, ] as any); - await agentPolicyService.delete(soClient, esClient, 'mocked'); + await agentPolicyService.delete(soClient, esClient, 'policy_1'); expect(mockedPackagePolicyService.delete).toBeCalledWith( expect.anything(), expect.anything(), ['package-2', 'package-3'], expect.anything() ); + expect(mockedPackagePolicyService.bulkUpdate).toBeCalledWith( + expect.anything(), + expect.anything(), + [ + { + id: 'package-1', + policy_id: 'int_policy_2', + policy_ids: ['int_policy_2'], + }, + ] + ); }); }); }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 9ce4e869819c..9a455fc71640 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -1008,22 +1008,45 @@ class AgentPolicyService { `Cannot delete agent policy ${id} that contains managed package policies` ); } - const packagePoliciesToDelete = this.packagePoliciesWithoutMultiplePolicies(packagePolicies); + const { policiesWithSingleAP: packagePoliciesToDelete, policiesWithMultipleAP } = + this.packagePoliciesWithSingleAndMultiplePolicies(packagePolicies); + + if (packagePoliciesToDelete.length > 0) { + await packagePolicyService.delete( + soClient, + esClient, + packagePoliciesToDelete.map((p) => p.id), + { + force: options?.force, + skipUnassignFromAgentPolicies: true, + } + ); + logger.debug( + `Deleted package policies with single agent policy with ids ${packagePoliciesToDelete + .map((policy) => policy.id) + .join(', ')}` + ); + } - await packagePolicyService.delete( - soClient, - esClient, - packagePoliciesToDelete.map((p) => p.id), - { - force: options?.force, - skipUnassignFromAgentPolicies: true, - } - ); - logger.debug( - `Deleted package policies with ids ${packagePoliciesToDelete - .map((policy) => policy.id) - .join(', ')}` - ); + if (policiesWithMultipleAP.length > 0) { + await packagePolicyService.bulkUpdate( + soClient, + esClient, + policiesWithMultipleAP.map((policy) => { + const newPolicyIds = policy.policy_ids.filter((policyId) => policyId !== id); + return { + ...policy, + policy_id: newPolicyIds[0], + policy_ids: newPolicyIds, + }; + }) + ); + logger.debug( + `Updated package policies with multiple agent policies with ids ${policiesWithMultipleAP + .map((policy) => policy.id) + .join(', ')}` + ); + } } if (agentPolicy.is_preconfigured && !options?.force) { @@ -1557,14 +1580,18 @@ class AgentPolicyService { } } - private packagePoliciesWithoutMultiplePolicies(packagePolicies: PackagePolicy[]) { + private packagePoliciesWithSingleAndMultiplePolicies(packagePolicies: PackagePolicy[]): { + policiesWithSingleAP: PackagePolicy[]; + policiesWithMultipleAP: PackagePolicy[]; + } { // Find package policies that don't have multiple agent policies and mark them for deletion - if (appContextService.getExperimentalFeatures().enableReusableIntegrationPolicies) { - return packagePolicies.filter( - (policy) => !policy?.policy_ids || policy?.policy_ids.length <= 1 - ); - } - return packagePolicies; + const policiesWithSingleAP = packagePolicies.filter( + (policy) => !policy?.policy_ids || policy?.policy_ids.length <= 1 + ); + const policiesWithMultipleAP = packagePolicies.filter( + (policy) => policy?.policy_ids && policy?.policy_ids.length > 1 + ); + return { policiesWithSingleAP, policiesWithMultipleAP }; } } diff --git a/x-pack/plugins/fleet/server/services/api_keys/create_standalone_agent_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/create_standalone_agent_api_key.ts new file mode 100644 index 000000000000..011d8dfe8ec8 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/api_keys/create_standalone_agent_api_key.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core/server'; + +export function createStandaloneAgentApiKey(esClient: ElasticsearchClient, name: string) { + // Based on https://www.elastic.co/guide/en/fleet/master/grant-access-to-elasticsearch.html#create-api-key-standalone-agent + return esClient.security.createApiKey({ + body: { + name: `standalone_agent-${name}`, + metadata: { + managed: true, + }, + role_descriptors: { + standalone_agent: { + cluster: ['monitor'], + indices: [ + { + names: ['logs-*-*', 'metrics-*-*', 'traces-*-*', 'synthetics-*-*'], + privileges: ['auto_configure', 'create_doc'], + }, + ], + }, + }, + }, + }); +} diff --git a/x-pack/plugins/fleet/server/services/api_keys/index.ts b/x-pack/plugins/fleet/server/services/api_keys/index.ts index 7b96d71c7ac9..6421de567b74 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/index.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/index.ts @@ -8,3 +8,4 @@ export { invalidateAPIKeys } from './security'; export { generateLogstashApiKey, canCreateLogstashApiKey } from './logstash_api_keys'; export * from './enrollment_api_key'; +export { createStandaloneAgentApiKey } from './create_standalone_agent_api_key'; diff --git a/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts b/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts index a97f578cfadf..8e4718007633 100644 --- a/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts +++ b/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts @@ -53,7 +53,9 @@ spec: image: docker.elastic.co/beats/elastic-agent:VERSION args: ["-c", "/etc/elastic-agent/agent.yml", "-e"] env: - # The basic authentication username used to connect to Elasticsearch + # The API Key with access privilleges to connect to Elasticsearch. https://www.elastic.co/guide/en/fleet/current/grant-access-to-elasticsearch.html#create-api-key-standalone-agent + - name: API_KEY + # The basic authentication username used to connect to Elasticsearch. Alternative to API_KEY access. # This user needs the privileges required to publish events to Elasticsearch. - name: ES_USERNAME value: "elastic" diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 7ea6fa0b692e..68664e6bc0f0 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -1805,6 +1805,59 @@ describe('EPM template', () => { }) ); }); + + it('should rollover on expected error when field subobjects in mappings changed', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.indices.getDataStream.mockResponse({ + data_streams: [{ name: 'test.prefix1-default' }], + } as any); + esClient.indices.get.mockResponse({ + 'test.prefix1-default': { + mappings: {}, + }, + } as any); + esClient.indices.simulateTemplate.mockResponse({ + template: { + settings: { index: {} }, + mappings: { subobjects: false }, + }, + } as any); + esClient.indices.putMapping.mockImplementation(() => { + throw new errors.ResponseError({ + body: { + error: { + message: + 'mapper_exception\n' + + '\tRoot causes:\n' + + "\t\tmapper_exception: the [subobjects] parameter can't be updated for the object mapping [_doc]", + }, + }, + } as any); + }); + + const logger = loggerMock.create(); + await updateCurrentWriteIndices(esClient, logger, [ + { + templateName: 'test', + indexTemplate: { + index_patterns: ['test.*-*'], + template: { + settings: { index: {} }, + mappings: {}, + }, + } as any, + }, + ]); + + expect(esClient.transport.request).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/test.prefix1-default/_rollover', + querystring: { + lazy: true, + }, + }) + ); + }); it('should skip rollover on expected error when flag is on', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); esClient.indices.getDataStream.mockResponse({ diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index c4984b119547..654ce7ea8ed8 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -1012,7 +1012,6 @@ const updateExistingDataStream = async ({ const existingDsConfig = Object.values(existingDs); const currentBackingIndexConfig = existingDsConfig.at(-1); - const currentIndexMode = currentBackingIndexConfig?.settings?.index?.mode; // @ts-expect-error Property 'mode' does not exist on type 'MappingSourceField' const currentSourceType = currentBackingIndexConfig.mappings?._source?.mode; @@ -1020,7 +1019,7 @@ const updateExistingDataStream = async ({ let settings: IndicesIndexSettings; let mappings: MappingTypeMapping; let lifecycle: any; - + let subobjectsFieldChanged: boolean = false; try { const simulateResult = await retryTransientEsErrors(async () => esClient.indices.simulateTemplate({ @@ -1040,7 +1039,9 @@ const updateExistingDataStream = async ({ delete mappings.properties.stream; delete mappings.properties.data_stream; } - + if (currentBackingIndexConfig?.mappings?.subobjects !== mappings.subobjects) { + subobjectsFieldChanged = true; + } logger.info(`Attempt to update the mappings for the ${dataStreamName} (write_index_only)`); await retryTransientEsErrors( () => @@ -1055,9 +1056,11 @@ const updateExistingDataStream = async ({ // if update fails, rollover data stream and bail out } catch (err) { if ( - isResponseError(err) && - err.statusCode === 400 && - err.body?.error?.type === 'illegal_argument_exception' + (isResponseError(err) && + err.statusCode === 400 && + err.body?.error?.type === 'illegal_argument_exception') || + // handling the case when subobjects field changed, it should also trigger a rollover + subobjectsFieldChanged ) { logger.info(`Mappings update for ${dataStreamName} failed due to ${err}`); if (options?.skipDataStreamRollover === true) { diff --git a/x-pack/plugins/fleet/server/services/telemetry/fleet_usage_sender.ts b/x-pack/plugins/fleet/server/services/telemetry/fleet_usage_sender.ts index 2d986b1e7bc6..17444a44a6f6 100644 --- a/x-pack/plugins/fleet/server/services/telemetry/fleet_usage_sender.ts +++ b/x-pack/plugins/fleet/server/services/telemetry/fleet_usage_sender.ts @@ -24,7 +24,7 @@ const FLEET_AGENTS_EVENT_TYPE = 'fleet_agents'; export class FleetUsageSender { private taskManager?: TaskManagerStartContract; - private taskVersion = '1.1.6'; + private taskVersion = '1.1.7'; private taskType = 'Fleet-Usage-Sender'; private wasStarted: boolean = false; private interval = '1h'; diff --git a/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts b/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts index 1720470b65ad..a579ba69bab8 100644 --- a/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts +++ b/x-pack/plugins/fleet/server/services/telemetry/fleet_usages_schema.ts @@ -357,6 +357,7 @@ export const fleetUsagesSchema: RootSchema<any> = { _meta: { description: 'Average number of global data tags defined per agent policy (accross policies using global data tags)', + optional: true, }, }, }, diff --git a/x-pack/plugins/fleet/server/types/rest_spec/index.ts b/x-pack/plugins/fleet/server/types/rest_spec/index.ts index ebdaa02902e3..04f932235410 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/index.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/index.ts @@ -23,3 +23,4 @@ export * from './tags'; export * from './health_check'; export * from './message_signing_service'; export * from './app'; +export * from './standalone_agent_api_key'; diff --git a/x-pack/plugins/fleet/server/types/rest_spec/standalone_agent_api_key.ts b/x-pack/plugins/fleet/server/types/rest_spec/standalone_agent_api_key.ts new file mode 100644 index 000000000000..f63db720a97c --- /dev/null +++ b/x-pack/plugins/fleet/server/types/rest_spec/standalone_agent_api_key.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const PostStandaloneAgentAPIKeyRequestSchema = { + body: schema.object({ + name: schema.string(), + }), +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index 020750cdd8b9..19490125e884 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -88,6 +88,9 @@ const appDependencies = { enableTogglingDataRetention: true, enableSemanticText: false, }, + overlays: { + openConfirm: jest.fn(), + }, } as any; export const kibanaVersion = new SemVer(MAJOR_VERSION); diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index f19575811725..9cc0a426f47e 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -18,6 +18,7 @@ import { ExecutionContextStart, HttpSetup, IUiSettingsClient, + OverlayStart, } from '@kbn/core/public'; import type { MlPluginStart } from '@kbn/ml-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; @@ -75,6 +76,7 @@ export interface AppDependencies { url: SharePluginStart['url']; docLinks: DocLinksStart; kibanaVersion: SemVer; + overlays: OverlayStart; } export const AppContextProvider = ({ diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.tsx index 24b99ef9e7c0..e9a4387f9120 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.tsx @@ -40,7 +40,7 @@ import { TrainedModelConfigResponse } from '@kbn/ml-plugin/common/types/trained_ import { getFieldConfig } from '../../../lib'; import { useAppContext } from '../../../../../app_context'; import { Form, UseField, useForm } from '../../../shared_imports'; -import { useLoadInferenceModels } from '../../../../../services/api'; +import { useLoadInferenceEndpoints } from '../../../../../services/api'; import { getTrainedModelStats } from '../../../../../../hooks/use_details_page_mappings_model_management'; import { InferenceToModelIdMap } from '../fields'; import { useMLModelNotificationToasts } from '../../../../../../hooks/use_ml_model_status_toasts'; @@ -134,7 +134,7 @@ export const SelectInferenceId = ({ ]; }, []); - const { isLoading, data: models } = useLoadInferenceModels(); + const { isLoading, data: models } = useLoadInferenceEndpoints(); const [options, setOptions] = useState<EuiSelectableOption[]>([...defaultInferenceIds]); const inferenceIdOptionsFromModels = useMemo(() => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts index 4833562e58e3..f9bc12a9022f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.test.ts @@ -104,7 +104,7 @@ jest.mock('../../../../../../component_templates/component_templates_context', ( })); jest.mock('../../../../../../../services/api', () => ({ - getInferenceModels: jest.fn().mockResolvedValue({ + getInferenceEndpoints: jest.fn().mockResolvedValue({ data: [ { model_id: 'e5', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts index 01a37275a54d..72be2636329a 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/semantic_text/use_semantic_text.ts @@ -17,7 +17,7 @@ import { FormHook } from '../../../../../shared_imports'; import { CustomInferenceEndpointConfig, DefaultInferenceModels, Field } from '../../../../../types'; import { useMLModelNotificationToasts } from '../../../../../../../../hooks/use_ml_model_status_toasts'; -import { getInferenceModels } from '../../../../../../../services/api'; +import { getInferenceEndpoints } from '../../../../../../../services/api'; interface UseSemanticTextProps { form: FormHook<Field, Field>; ml?: MlPluginStart; @@ -83,7 +83,7 @@ export function useSemanticText(props: UseSemanticTextProps) { if (data.inferenceId === undefined) { throw new Error( i18n.translate('xpack.idxMgmt.mappingsEditor.createField.undefinedInferenceIdError', { - defaultMessage: 'InferenceId is undefined while creating the inference endpoint.', + defaultMessage: 'Inference ID is undefined', }) ); } @@ -138,18 +138,17 @@ export function useSemanticText(props: UseSemanticTextProps) { dispatch({ type: 'field.addSemanticText', value: data }); try { - // if model exists already, do not create inference endpoint - const inferenceModels = await getInferenceModels(); + // if inference endpoint exists already, do not create inference endpoint + const inferenceModels = await getInferenceEndpoints(); const inferenceModel: InferenceAPIConfigResponse[] = inferenceModels.data.some( (e: InferenceAPIConfigResponse) => e.model_id === inferenceValue ); if (inferenceModel) { return; } - - if (trainedModelId) { - // show toasts only if it's elastic models - showSuccessToasts(); + // Only show toast if it's an internal Elastic model that hasn't been deployed yet + if (trainedModelId && inferenceData.isDeployable && !inferenceData.isDeployed) { + showSuccessToasts(trainedModelId); } await createInferenceEndpoint(trainedModelId, data, customInferenceEndpointConfig); diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index 48a19f58bbfa..72821d6a194a 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -80,7 +80,7 @@ export const IndexManagementAppContext: React.FC<IndexManagementAppContextProps> <KibanaRenderContextProvider {...core}> <KibanaReactContextProvider> <Provider store={indexManagementStore(services)}> - <AppContextProvider value={dependencies}> + <AppContextProvider value={{ ...dependencies, overlays }}> <MappingsEditorProvider> <ComponentTemplatesProvider value={componentTemplateProviderValues}> <GlobalFlyoutProvider>{children}</GlobalFlyoutProvider> diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index d0cd5b07eab0..da17bc4706c0 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -99,6 +99,7 @@ export function getIndexManagementDependencies({ url, docLinks, kibanaVersion, + overlays: core.overlays, }; } diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_mappings_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_mappings_content.tsx index effa53f717cd..88902ff517c3 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_mappings_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_mappings_content.tsx @@ -28,6 +28,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; import { ILicense } from '@kbn/licensing-plugin/public'; +import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt'; import { Index } from '../../../../../../common'; import { useDetailsPageMappingsModelManagement } from '../../../../../hooks/use_details_page_mappings_model_management'; import { useAppContext } from '../../../../app_context'; @@ -68,11 +69,14 @@ export const DetailsPageMappingsContent: FunctionComponent<{ services: { extensionsService }, core: { getUrlForApp, - application: { capabilities }, + application: { capabilities, navigateToUrl }, + http, }, plugins: { ml, licensing }, url, config, + overlays, + history, } = useAppContext(); const [isPlatinumLicense, setIsPlatinumLicense] = useState<boolean>(false); @@ -108,6 +112,22 @@ export const DetailsPageMappingsContent: FunctionComponent<{ }); const [isAddingFields, setAddingFields] = useState<boolean>(false); + + useUnsavedChangesPrompt({ + titleText: i18n.translate('xpack.idxMgmt.indexDetails.mappings.unsavedChangesPromptTitle', { + defaultMessage: 'Exit without saving changes?', + }), + messageText: i18n.translate('xpack.idxMgmt.indexDetails.mappings.unsavedChangesPromptMessage', { + defaultMessage: + 'Your changes will be lost if you leave this page without saving the mapping.', + }), + hasUnsavedChanges: isAddingFields, + openConfirm: overlays.openConfirm, + history, + http, + navigateToUrl, + }); + const newFieldsLength = useMemo(() => { return Object.keys(state.fields.byId).length; }, [state.fields.byId]); @@ -227,7 +247,7 @@ export const DetailsPageMappingsContent: FunctionComponent<{ if (!error) { notificationService.showSuccessToast( i18n.translate('xpack.idxMgmt.indexDetails.mappings.successfullyUpdatedIndexMappings', { - defaultMessage: 'Index Mapping was successfully updated', + defaultMessage: 'Updated index mapping', }) ); refetchMapping(); diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index ce6907219930..e071e0bf3c68 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -442,14 +442,14 @@ export function updateIndexMappings(indexName: string, newFields: Fields) { }); } -export function getInferenceModels() { +export function getInferenceEndpoints() { return sendRequest({ path: `${API_BASE_PATH}/inference/all`, method: 'get', }); } -export function useLoadInferenceModels() { +export function useLoadInferenceEndpoints() { return useRequest<InferenceAPIConfigResponse[]>({ path: `${API_BASE_PATH}/inference/all`, method: 'get', diff --git a/x-pack/plugins/index_management/public/application/services/index.ts b/x-pack/plugins/index_management/public/application/services/index.ts index 34e1d8cedf78..5c34d83186c6 100644 --- a/x-pack/plugins/index_management/public/application/services/index.ts +++ b/x-pack/plugins/index_management/public/application/services/index.ts @@ -28,7 +28,7 @@ export { loadIndexStatistics, useLoadIndexSettings, createIndex, - useLoadInferenceModels, + useLoadInferenceEndpoints, } from './api'; export { sortTable } from './sort_table'; diff --git a/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.test.ts b/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.test.ts index 1517b9664f3e..c3d74c21ec52 100644 --- a/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.test.ts +++ b/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.test.ts @@ -36,7 +36,7 @@ jest.mock('../application/app_context', () => ({ })); jest.mock('../application/services/api', () => ({ - getInferenceModels: jest.fn().mockResolvedValue({ + getInferenceEndpoints: jest.fn().mockResolvedValue({ data: [ { model_id: 'e5', diff --git a/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.ts b/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.ts index 38cf20b9bf53..125892bdf697 100644 --- a/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.ts +++ b/x-pack/plugins/index_management/public/hooks/use_details_page_mappings_model_management.ts @@ -18,7 +18,7 @@ import { DeploymentState, NormalizedFields, } from '../application/components/mappings_editor/types'; -import { getInferenceModels } from '../application/services/api'; +import { getInferenceEndpoints } from '../application/services/api'; interface InferenceModel { data: InferenceAPIConfigResponse[]; @@ -91,7 +91,7 @@ export const useDetailsPageMappingsModelManagement = ( const dispatch = useDispatch(); const fetchInferenceModelsAndTrainedModelStats = useCallback(async () => { - const inferenceModels = await getInferenceModels(); + const inferenceModels = await getInferenceEndpoints(); const trainedModelStats = await ml?.mlApi?.trainedModels.getTrainedModelStats(); diff --git a/x-pack/plugins/index_management/public/hooks/use_ml_model_status_toasts.ts b/x-pack/plugins/index_management/public/hooks/use_ml_model_status_toasts.ts index c9a0c37a37fc..cba440186a1d 100644 --- a/x-pack/plugins/index_management/public/hooks/use_ml_model_status_toasts.ts +++ b/x-pack/plugins/index_management/public/hooks/use_ml_model_status_toasts.ts @@ -11,7 +11,7 @@ import { useComponentTemplatesContext } from '../application/components/componen export function useMLModelNotificationToasts() { const { toasts } = useComponentTemplatesContext(); - const showSuccessToasts = () => { + const showSuccessToasts = (modelName: string) => { return toasts.addSuccess({ title: i18n.translate( 'xpack.idxMgmt.mappingsEditor.createField.modelDeploymentStartedNotification', @@ -20,7 +20,10 @@ export function useMLModelNotificationToasts() { } ), text: i18n.translate('xpack.idxMgmt.mappingsEditor.createField.modelDeploymentNotification', { - defaultMessage: '1 model is being deployed on your ml_node.', + defaultMessage: 'Model {modelName} is being deployed on your machine learning node.', + values: { + modelName, + }, }), }); }; diff --git a/x-pack/plugins/index_management/tsconfig.json b/x-pack/plugins/index_management/tsconfig.json index e5d24269ba47..c734e465c003 100644 --- a/x-pack/plugins/index_management/tsconfig.json +++ b/x-pack/plugins/index_management/tsconfig.json @@ -53,6 +53,7 @@ "@kbn/react-kibana-mount", "@kbn/rollup", "@kbn/ml-error-utils", + "@kbn/unsaved-changes-prompt", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/integration_assistant/common/constants.ts b/x-pack/plugins/integration_assistant/common/constants.ts index 7e365342adb8..69b383d88286 100644 --- a/x-pack/plugins/integration_assistant/common/constants.ts +++ b/x-pack/plugins/integration_assistant/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { LicenseType } from '@kbn/licensing-plugin/common/types'; + // Plugin information export const PLUGIN_ID = 'integrationAssistant'; @@ -20,3 +22,6 @@ export const RELATED_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/related`; export const CHECK_PIPELINE_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/pipeline`; export const INTEGRATION_BUILDER_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/build`; export const FLEET_PACKAGES_PATH = `/api/fleet/epm/packages`; + +// License +export const MINIMUM_LICENSE_TYPE: LicenseType = 'enterprise'; diff --git a/x-pack/plugins/integration_assistant/kibana.jsonc b/x-pack/plugins/integration_assistant/kibana.jsonc index a70120d9cefb..b2ef3045e12b 100644 --- a/x-pack/plugins/integration_assistant/kibana.jsonc +++ b/x-pack/plugins/integration_assistant/kibana.jsonc @@ -13,6 +13,7 @@ ], "requiredPlugins": [ "kibanaReact", + "licensing", "triggersActionsUi", "actions", "stackConnectors", diff --git a/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/availability_wrapper.tsx b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/availability_wrapper.tsx new file mode 100644 index 000000000000..d9f4a8b2e6c9 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/availability_wrapper.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { type PropsWithChildren } from 'react'; +import { LicensePaywallCard } from './license_paywall_card'; +import { useAvailability } from '../../hooks/use_availability'; + +type AvailabilityWrapperProps = PropsWithChildren<{}>; +export const AvailabilityWrapper = React.memo<AvailabilityWrapperProps>(({ children }) => { + const { hasLicense, renderUpselling } = useAvailability(); + if (renderUpselling) { + return <>{renderUpselling}</>; + } + if (!hasLicense) { + return <LicensePaywallCard />; + } + return <>{children}</>; +}); +AvailabilityWrapper.displayName = 'AvailabilityWrapper'; diff --git a/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/index.ts b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/index.ts new file mode 100644 index 000000000000..d8f08f5a132d --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/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 { AvailabilityWrapper } from './availability_wrapper'; diff --git a/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/license_paywall_card.tsx b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/license_paywall_card.tsx new file mode 100644 index 000000000000..937f9c2411eb --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/license_paywall_card.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { + EuiCard, + EuiIcon, + EuiTextColor, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import { useKibana } from '../../hooks/use_kibana'; +import * as i18n from './translations'; + +export const LicensePaywallCard = React.memo(() => { + const { getUrlForApp } = useKibana().services.application; + return ( + <> + <EuiSpacer size="m" /> + <EuiCard + data-test-subj={'LicensePaywallCard'} + betaBadgeProps={{ + label: i18n.ENTERPRISE_LICENSE_LABEL, + }} + isDisabled={true} + icon={<EuiIcon size="xl" type="lock" />} + title={ + <h3> + <strong>{i18n.ENTERPRISE_LICENSE_TITLE}</strong> + </h3> + } + description={false} + > + <EuiFlexGroup className="lockedCardDescription" direction="column" justifyContent="center"> + <EuiFlexItem> + <EuiSpacer size="s" /> + <EuiText> + <h4> + <EuiTextColor color="subdued">{i18n.ENTERPRISE_LICENSE_UPGRADE_TITLE}</EuiTextColor> + </h4> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <EuiText>{i18n.ENTERPRISE_LICENSE_UPGRADE_DESCRIPTION}</EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <div> + <EuiButton + href={getUrlForApp('management', { path: 'stack/license_management' })} + fill + > + {i18n.ENTERPRISE_LICENSE_UPGRADE_BUTTON} + </EuiButton> + </div> + </EuiFlexItem> + </EuiFlexGroup> + </EuiCard> + </> + ); +}); +LicensePaywallCard.displayName = 'LicensePaywallCard'; diff --git a/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/translations.ts b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/translations.ts new file mode 100644 index 000000000000..e7774f9ecb46 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/common/components/availability_wrapper/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const ENTERPRISE_LICENSE_LABEL = i18n.translate( + 'xpack.integrationAssistant.license.enterprise.label', + { + defaultMessage: 'Enterprise', + } +); + +export const ENTERPRISE_LICENSE_TITLE = i18n.translate( + 'xpack.integrationAssistant.license.enterprise.title', + { + defaultMessage: 'Enterprise License Required', + } +); +export const ENTERPRISE_LICENSE_UPGRADE_TITLE = i18n.translate( + 'xpack.integrationAssistant.license.enterprise.upgradeTitle', + { + defaultMessage: 'Upgrade to Elastic Enterprise', + } +); +export const ENTERPRISE_LICENSE_UPGRADE_DESCRIPTION = i18n.translate( + 'xpack.integrationAssistant.license.enterprise.upgradeDescription', + { + defaultMessage: + 'To turn on this feature, you must upgrade your license to Enterprise or start a free 30-day trial', + } +); +export const ENTERPRISE_LICENSE_UPGRADE_BUTTON = i18n.translate( + 'xpack.integrationAssistant.license.enterprise.upgradeButton', + { + defaultMessage: 'Upgrade license', + } +); diff --git a/x-pack/plugins/integration_assistant/public/common/hooks/use_availability.ts b/x-pack/plugins/integration_assistant/public/common/hooks/use_availability.ts new file mode 100644 index 000000000000..9681ea700062 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/common/hooks/use_availability.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; +import { MINIMUM_LICENSE_TYPE } from '../../../common/constants'; +import { useKibana } from './use_kibana'; +import type { RenderUpselling } from '../../services'; + +export const useAvailability = (): { + hasLicense: boolean; + renderUpselling: RenderUpselling | undefined; +} => { + const { licensing, renderUpselling$ } = useKibana().services; + const licenseService = useObservable(licensing.license$); + const renderUpselling = useObservable(renderUpselling$); + const hasLicense = useMemo( + () => licenseService?.hasAtLeast(MINIMUM_LICENSE_TYPE) ?? true, + [licenseService] + ); + return { hasLicense, renderUpselling }; +}; + +export const useIsAvailable = (): boolean => { + const { hasLicense, renderUpselling } = useAvailability(); + return hasLicense && !renderUpselling; +}; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx index e915ac920d7d..494bc94d8c58 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React from 'react'; -import { Switch } from 'react-router-dom'; +import { Redirect, Switch } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { Services } from '../../services'; @@ -15,6 +15,7 @@ import { CreateIntegrationUpload } from './create_integration_upload'; import { CreateIntegrationAssistant } from './create_integration_assistant'; import { Page, PagePath } from '../../common/constants'; import { useRoutesAuthorization } from '../../common/hooks/use_authorization'; +import { useIsAvailable } from '../../common/hooks/use_availability'; interface CreateIntegrationProps { services: Services; @@ -26,21 +27,23 @@ export const CreateIntegration = React.memo<CreateIntegrationProps>(({ services </TelemetryContextProvider> </KibanaContextProvider> )); - CreateIntegration.displayName = 'CreateIntegration'; const CreateIntegrationRouter = React.memo(() => { const { canUseIntegrationAssistant, canUseIntegrationUpload } = useRoutesAuthorization(); - + const isAvailable = useIsAvailable(); return ( <Switch> - {canUseIntegrationAssistant && ( - <Route path={PagePath[Page.assistant]} component={CreateIntegrationAssistant} /> + {isAvailable && canUseIntegrationAssistant && ( + <Route path={PagePath[Page.assistant]} exact component={CreateIntegrationAssistant} /> )} - {canUseIntegrationUpload && ( - <Route path={PagePath[Page.upload]} component={CreateIntegrationUpload} /> + {isAvailable && canUseIntegrationUpload && ( + <Route path={PagePath[Page.upload]} exact component={CreateIntegrationUpload} /> )} - <Route path={PagePath[Page.landing]} component={CreateIntegrationLanding} /> + + <Route path={PagePath[Page.landing]} exact component={CreateIntegrationLanding} /> + + <Route render={() => <Redirect to={PagePath[Page.landing]} />} /> </Switch> ); }); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/generation_modal.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/generation_modal.tsx index 08df5d3d2d74..fe5a1dabb137 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/generation_modal.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/generation_modal.tsx @@ -6,6 +6,8 @@ */ import { + EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, @@ -20,7 +22,7 @@ import { useEuiTheme, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/react'; import type { CategorizationRequestBody, @@ -62,9 +64,11 @@ export const useGeneration = ({ const { http, notifications } = useKibana().services; const [progress, setProgress] = useState<ProgressItem>(); const [error, setError] = useState<null | string>(null); + const [isRequesting, setIsRequesting] = useState<boolean>(true); useEffect(() => { if ( + !isRequesting || http == null || connector == null || integrationSettings == null || @@ -122,7 +126,9 @@ export const useGeneration = ({ onComplete(relatedGraphResult.results); } catch (e) { if (abortController.signal.aborted) return; - const errorMessage = e.body?.message ?? e.message; + const errorMessage = `${e.message}${ + e.body ? ` (${e.body.statusCode}): ${e.body.message}` : '' + }`; reportGenerationComplete({ connector, @@ -131,13 +137,16 @@ export const useGeneration = ({ error: errorMessage, }); - setError(`Error: ${errorMessage}`); + setError(errorMessage); + } finally { + setIsRequesting(false); } })(); return () => { abortController.abort(); }; }, [ + isRequesting, onComplete, setProgress, connector, @@ -147,9 +156,15 @@ export const useGeneration = ({ notifications?.toasts, ]); + const retry = useCallback(() => { + setError(null); + setIsRequesting(true); + }, []); + return { progress, error, + retry, }; }; @@ -176,7 +191,7 @@ interface GenerationModalProps { export const GenerationModal = React.memo<GenerationModalProps>( ({ integrationSettings, connector, onComplete, onClose }) => { const { headerCss, bodyCss } = useModalCss(); - const { progress, error } = useGeneration({ + const { progress, error, retry } = useGeneration({ integrationSettings, connector, onComplete, @@ -196,41 +211,57 @@ export const GenerationModal = React.memo<GenerationModalProps>( <EuiFlexGroup direction="column" gutterSize="l" justifyContent="center"> {progress && ( <> - <EuiFlexItem> - <EuiFlexGroup - direction="row" - gutterSize="s" - alignItems="center" - justifyContent="center" - > - {!error && ( - <EuiFlexItem grow={false}> - <EuiLoadingSpinner size="s" /> - </EuiFlexItem> - )} - <EuiFlexItem grow={false}> - <EuiText size="xs" color="subdued"> - {progressText[progress]} - </EuiText> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - <EuiFlexItem> - <EuiProgress value={progressValue} max={4} color="primary" size="m" /> - </EuiFlexItem> - {error && ( + {error ? ( <EuiFlexItem> - <EuiText color="danger" size="xs"> + <EuiCallOut + title={i18n.GENERATION_ERROR(progressText[progress])} + color="danger" + iconType="alert" + > {error} - </EuiText> + </EuiCallOut> </EuiFlexItem> + ) : ( + <> + <EuiFlexItem> + <EuiFlexGroup + direction="row" + gutterSize="s" + alignItems="center" + justifyContent="center" + > + <EuiFlexItem grow={false}> + <EuiLoadingSpinner size="s" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiText size="xs" color="subdued"> + {progressText[progress]} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem /> + <EuiFlexItem> + <EuiProgress value={progressValue} max={4} color="primary" size="m" /> + </EuiFlexItem> + </> )} </> )} </EuiFlexGroup> </EuiModalBody> <EuiModalFooter> - <EuiSpacer size="xl" /> + {error ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty iconType="refresh" onClick={retry}> + {i18n.RETRY} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + ) : ( + <EuiSpacer size="xl" /> + )} </EuiModalFooter> </EuiModal> ); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/translations.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/translations.ts index 396e6a799015..e4cc004e2673 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/translations.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/translations.ts @@ -166,3 +166,12 @@ export const PROGRESS_RELATED_GRAPH = i18n.translate( defaultMessage: 'Generating related fields', } ); +export const GENERATION_ERROR = (progressStep: string) => + i18n.translate('xpack.integrationAssistant.step.dataStream.generationError', { + values: { progressStep }, + defaultMessage: 'An error occurred during: {progressStep}', + }); + +export const RETRY = i18n.translate('xpack.integrationAssistant.step.dataStream.retryButtonLabel', { + defaultMessage: 'Retry', +}); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/default_logo.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/default_logo.ts deleted file mode 100644 index 26f4b8ca6ddc..000000000000 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/default_logo.ts +++ /dev/null @@ -1,9 +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. - */ -/* geoToken icon svg base64 encoded */ -export const defaultLogoEncoded = - 'PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHZpZXdCb3g9IjAgMCAzMSAzMSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzk4XzcwMDApIj4KPHJlY3Qgd2lkdGg9IjMxIiBoZWlnaHQ9IjMxIiByeD0iMyIgZmlsbD0id2hpdGUiLz4KPHJlY3Qgb3BhY2l0eT0iMC4xIiB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHJ4PSIzIiBmaWxsPSIjRDZCRjU3Ii8+CjxyZWN0IG9wYWNpdHk9IjAuMyIgeD0iMC41IiB5PSIwLjUiIHdpZHRoPSIzMCIgaGVpZ2h0PSIzMCIgcng9IjIuNSIgc3Ryb2tlPSIjRDZCRjU3Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTUuNSA1LjgxMjVDMTguNjY5MiA1LjgxMjUgMjEuNDgzIDcuMzM0MzUgMjMuMjUwNCA5LjY4NzEyTDIzLjI1IDkuNjg3NUMyNC40NjczIDExLjMwNzkgMjUuMTg3NSAxMy4zMTk4IDI1LjE4NzUgMTUuNUMyNS4xODc1IDE3LjY4MDIgMjQuNDY3MyAxOS42OTIxIDIzLjI1MTkgMjEuMzEwOUwyMy4yNSAyMS4zMTI1QzIxLjQ4MTUgMjMuNjY2NSAxOC42Njg0IDI1LjE4NzUgMTUuNSAyNS4xODc1QzEyLjMzMTYgMjUuMTg3NSA5LjUxODU0IDIzLjY2NjUgNy43NTEwNCAyMS4zMTQ4TDcuNzUgMjEuMzEyNUM2LjUzMzI1IDE5LjY5MzcgNS44MTI1IDE3LjY4MSA1LjgxMjUgMTUuNUM1LjgxMjUgMTAuMTQ5NyAxMC4xNDk3IDUuODEyNSAxNS41IDUuODEyNVpNMTcuMzM2NSAyMS4zMTM1SDEzLjY2MzVDMTQuMjAwNSAyMi41MjQ2IDE0Ljg2OTUgMjMuMjUgMTUuNSAyMy4yNUMxNi4xMzA1IDIzLjI1IDE2Ljc5OTUgMjIuNTI0NiAxNy4zMzY1IDIxLjMxMzVaTTExLjYyNTIgMjEuMzEzOUwxMC4zNzU4IDIxLjMxNDRDMTAuOTA2NSAyMS43ODI0IDExLjUwMTcgMjIuMTc4OSAxMi4xNDY0IDIyLjQ4ODhDMTEuOTU3IDIyLjEyNjUgMTEuNzgyOCAyMS43MzM0IDExLjYyNTIgMjEuMzEzOVpNMjAuNjI0MiAyMS4zMTQ0TDE5LjM3NDggMjEuMzEzOUMxOS4yMTcyIDIxLjczMzQgMTkuMDQzIDIyLjEyNjUgMTguODU0IDIyLjQ4OTJDMTkuNDk4MyAyMi4xNzg5IDIwLjA5MzUgMjEuNzgyNCAyMC42MjQyIDIxLjMxNDRaTTEwLjY4MDIgMTYuNDY5M0w3LjgxMDE0IDE2LjQ3MDJDNy45NDEwOCAxNy41MTg3IDguMjgxNDUgMTguNTAyIDguNzg4MDYgMTkuMzc3MUwxMS4wNTk3IDE5LjM3NjdDMTAuODYxOCAxOC40NzEzIDEwLjczMTEgMTcuNDkzOCAxMC42ODAyIDE2LjQ2OTNaTTE4LjM4MDUgMTYuNDcxSDEyLjYxOTVDMTIuNjc1NSAxNy41MjQ2IDEyLjgyMDYgMTguNTA1NyAxMy4wMjczIDE5LjM3NkgxNy45NzI3QzE4LjE3OTQgMTguNTA1NyAxOC4zMjQ1IDE3LjUyNDYgMTguMzgwNSAxNi40NzFaTTIzLjE4OTkgMTYuNDcwMkwyMC4zMTk4IDE2LjQ2OTNDMjAuMjY4OSAxNy40OTM4IDIwLjEzODIgMTguNDcxMyAxOS45NDAzIDE5LjM3NjdMMjIuMjExOSAxOS4zNzcxQzIyLjcxODUgMTguNTAyIDIzLjA1ODkgMTcuNTE4NyAyMy4xODk5IDE2LjQ3MDJaTTExLjA1OTIgMTEuNjI1M0w4Ljc4Njk1IDExLjYyNDhDOC4yODA2NCAxMi40OTk5IDcuOTQwNTcgMTMuNDgzMyA3LjgwOTkgMTQuNTMxN0wxMC42ODAxIDE0LjUzMjZDMTAuNzMwOSAxMy41MDgyIDEwLjg2MTUgMTIuNTMwNiAxMS4wNTkyIDExLjYyNTNaTTE3Ljk3MzIgMTEuNjI2SDEzLjAyNjhDMTIuODIwMiAxMi40OTYzIDEyLjY3NTMgMTMuNDc3NCAxMi42MTk0IDE0LjUzMUgxOC4zODA2QzE4LjMyNDcgMTMuNDc3NCAxOC4xNzk4IDEyLjQ5NjMgMTcuOTczMiAxMS42MjZaTTIyLjIxMzEgMTEuNjI0OEwxOS45NDA4IDExLjYyNTNDMjAuMTM4NSAxMi41MzA2IDIwLjI2OTEgMTMuNTA4MiAyMC4zMTk5IDE0LjUzMjZMMjMuMTkwMSAxNC41MzE3QzIzLjA1OTQgMTMuNDgzMyAyMi43MTk0IDEyLjQ5OTkgMjIuMjEzMSAxMS42MjQ4Wk0xMi4xNDYgOC41MTA3NUwxMS45MDYyIDguNjMxODZDMTEuMzUyNiA4LjkyMjEyIDEwLjgzODQgOS4yNzczNCAxMC4zNzM4IDkuNjg3MzlMMTEuNjI0NCA5LjY4ODA0QzExLjc4MjIgOS4yNjc4IDExLjk1NjcgOC44NzQwNiAxMi4xNDYgOC41MTA3NVpNMTUuNSA3Ljc1QzE0Ljg2OTEgNy43NSAxNC4xOTk3IDguNDc2MjEgMTMuNjYyNiA5LjY4ODQ4SDE3LjMzNzRDMTYuODAwMyA4LjQ3NjIxIDE2LjEzMDkgNy43NSAxNS41IDcuNzVaTTE4Ljg1MzYgOC41MTExN0wxOC45MjUgOC42NDk5QzE5LjA4NzEgOC45NzM5NyAxOS4yMzc3IDkuMzIwODkgMTkuMzc1NiA5LjY4ODA0TDIwLjYyNjIgOS42ODczOUMyMC4wOTUgOS4yMTg2MSAxOS40OTkxIDguODIxNDkgMTguODUzNiA4LjUxMTE3WiIgZmlsbD0iIzgwNzIzNCIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzk4XzcwMDAiPgo8cmVjdCB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHJ4PSIzIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo='; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts index f036562edc9d..ca5385d70196 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts @@ -10,7 +10,6 @@ import { useKibana } from '../../../../../common/hooks/use_kibana'; import type { BuildIntegrationRequestBody } from '../../../../../../common'; import type { State } from '../../state'; import { runBuildIntegration, runInstallPackage } from '../../../../../common/lib/api'; -import { defaultLogoEncoded } from '../default_logo'; import { getIntegrationNameFromResponse } from '../../../../../common/lib/api_parsers'; import { useTelemetry } from '../../../telemetry'; @@ -20,8 +19,6 @@ interface PipelineGenerationProps { connector: State['connector']; } -export type ProgressItem = 'build' | 'install'; - export const useDeployIntegration = ({ integrationSettings, result, @@ -54,7 +51,7 @@ export const useDeployIntegration = ({ title: integrationSettings.title ?? '', description: integrationSettings.description ?? '', name: integrationSettings.name ?? '', - logo: integrationSettings.logo ?? defaultLogoEncoded, + logo: integrationSettings.logo, dataStreams: [ { title: integrationSettings.dataStreamTitle ?? '', diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/integration_step/package_card_preview.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/integration_step/package_card_preview.tsx index b73a796f4cb1..9ca163f44b7d 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/integration_step/package_card_preview.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/integration_step/package_card_preview.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { useEuiTheme, EuiCard, EuiIcon } from '@elastic/eui'; import { css } from '@emotion/react'; -import { defaultLogoEncoded } from '../default_logo'; import type { IntegrationSettings } from '../../types'; import * as i18n from './translations'; @@ -50,7 +49,11 @@ export const PackageCardPreview = React.memo<PackageCardPreviewProps>(({ integra icon={ <EuiIcon size={'xl'} - type={`data:image/svg+xml;base64,${integrationSettings?.logo ?? defaultLogoEncoded}`} + type={ + integrationSettings?.logo + ? `data:image/svg+xml;base64,${integrationSettings.logo}` + : 'package' + } /> } betaBadgeProps={{ 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 b1e29dc25db9..39cbd2cea102 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 @@ -6,100 +6,18 @@ */ import React from 'react'; -import { - EuiButton, - EuiCard, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiLink, - EuiSpacer, - EuiText, - EuiTitle, - useEuiTheme, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import { AssistantAvatar } from '@kbn/elastic-assistant'; -import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useAuthorization } from '../../../common/hooks/use_authorization'; -import { - AuthorizationWrapper, - MissingPrivilegesTooltip, -} from '../../../common/components/authorization'; +import { AuthorizationWrapper } from '../../../common/components/authorization'; +import { AvailabilityWrapper } from '../../../common/components/availability_wrapper'; import { IntegrationImageHeader } from '../../../common/components/integration_image_header'; import { ButtonsFooter } from '../../../common/components/buttons_footer'; import { SectionWrapper } from '../../../common/components/section_wrapper'; import { useNavigate, Page } from '../../../common/hooks/use_navigate'; +import { IntegrationAssistantCard } from './integration_assistant_card'; import * as i18n from './translations'; -const useAssistantCardCss = () => { - const { euiTheme } = useEuiTheme(); - return css` - /* compensate for EuiCard children margin-block-start */ - margin-block-start: calc(${euiTheme.size.s} * -2); - `; -}; - -const IntegrationAssistantCard = React.memo(() => { - const { canExecuteConnectors } = useAuthorization(); - const navigate = useNavigate(); - const assistantCardCss = useAssistantCardCss(); - return ( - <EuiCard - display="plain" - hasBorder={true} - paddingSize="l" - title={''} // title shown inside the child component - betaBadgeProps={{ - label: i18n.TECH_PREVIEW, - tooltipContent: i18n.TECH_PREVIEW_TOOLTIP, - }} - > - <EuiFlexGroup - direction="row" - gutterSize="l" - alignItems="center" - justifyContent="center" - css={assistantCardCss} - > - <EuiFlexItem grow={false}> - <AssistantAvatar /> - </EuiFlexItem> - <EuiFlexItem> - <EuiFlexGroup - direction="column" - gutterSize="s" - alignItems="flexStart" - justifyContent="flexStart" - > - <EuiFlexItem> - <EuiTitle size="xs"> - <h3>{i18n.ASSISTANT_TITLE}</h3> - </EuiTitle> - </EuiFlexItem> - <EuiFlexItem> - <EuiText size="s" color="subdued" textAlign="left"> - {i18n.ASSISTANT_DESCRIPTION} - </EuiText> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - <EuiFlexItem grow={false}> - {canExecuteConnectors ? ( - <EuiButton onClick={() => navigate(Page.assistant)}>{i18n.ASSISTANT_BUTTON}</EuiButton> - ) : ( - <MissingPrivilegesTooltip canExecuteConnectors> - <EuiButton disabled>{i18n.ASSISTANT_BUTTON}</EuiButton> - </MissingPrivilegesTooltip> - )} - </EuiFlexItem> - </EuiFlexGroup> - </EuiCard> - ); -}); -IntegrationAssistantCard.displayName = 'IntegrationAssistantCard'; - export const CreateIntegrationLanding = React.memo(() => { const navigate = useNavigate(); return ( @@ -107,49 +25,51 @@ export const CreateIntegrationLanding = React.memo(() => { <IntegrationImageHeader /> <KibanaPageTemplate.Section grow> <SectionWrapper title={i18n.LANDING_TITLE} subtitle={i18n.LANDING_DESCRIPTION}> - <AuthorizationWrapper canCreateIntegrations> - <EuiFlexGroup - direction="column" - gutterSize="l" - alignItems="center" - justifyContent="flexStart" - > - <EuiFlexItem> - <EuiSpacer size="l" /> - <IntegrationAssistantCard /> - </EuiFlexItem> - <EuiFlexItem> - <EuiFlexGroup - direction="row" - gutterSize="s" - alignItems="center" - justifyContent="flexStart" - > - <EuiFlexItem grow={false}> - <EuiIcon type="package" size="l" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiText size="s" color="subdued"> - <FormattedMessage - id="xpack.integrationAssistant.createIntegrationLanding.uploadPackageDescription" - defaultMessage="If you have an existing integration package, {link}" - values={{ - link: ( - <EuiLink onClick={() => navigate(Page.upload)}> - <FormattedMessage - id="xpack.integrationAssistant.createIntegrationLanding.uploadPackageLink" - defaultMessage="upload it as a .zip" - /> - </EuiLink> - ), - }} - /> - </EuiText> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </AuthorizationWrapper> + <AvailabilityWrapper> + <AuthorizationWrapper canCreateIntegrations> + <EuiFlexGroup + direction="column" + gutterSize="l" + alignItems="center" + justifyContent="flexStart" + > + <EuiFlexItem> + <EuiSpacer size="l" /> + <IntegrationAssistantCard /> + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup + direction="row" + gutterSize="s" + alignItems="center" + justifyContent="flexStart" + > + <EuiFlexItem grow={false}> + <EuiIcon type="package" size="l" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiText size="s" color="subdued"> + <FormattedMessage + id="xpack.integrationAssistant.createIntegrationLanding.uploadPackageDescription" + defaultMessage="If you have an existing integration package, {link}" + values={{ + link: ( + <EuiLink onClick={() => navigate(Page.upload)}> + <FormattedMessage + id="xpack.integrationAssistant.createIntegrationLanding.uploadPackageLink" + defaultMessage="upload it as a .zip" + /> + </EuiLink> + ), + }} + /> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </AuthorizationWrapper> + </AvailabilityWrapper> </SectionWrapper> </KibanaPageTemplate.Section> <ButtonsFooter /> diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_landing/integration_assistant_card.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_landing/integration_assistant_card.tsx new file mode 100644 index 000000000000..c616cf523327 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_landing/integration_assistant_card.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiButton, + EuiCard, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import { AssistantAvatar } from '@kbn/elastic-assistant'; +import { css } from '@emotion/react'; +import { useAuthorization } from '../../../common/hooks/use_authorization'; +import { MissingPrivilegesTooltip } from '../../../common/components/authorization'; +import { useNavigate, Page } from '../../../common/hooks/use_navigate'; +import * as i18n from './translations'; + +const useAssistantCardCss = () => { + const { euiTheme } = useEuiTheme(); + return css` + /* compensate for EuiCard children margin-block-start */ + margin-block-start: calc(${euiTheme.size.s} * -2); + `; +}; + +export const IntegrationAssistantCard = React.memo(() => { + const { canExecuteConnectors } = useAuthorization(); + const navigate = useNavigate(); + const assistantCardCss = useAssistantCardCss(); + return ( + <EuiCard + display="plain" + hasBorder={true} + paddingSize="l" + title={''} // title shown inside the child component + betaBadgeProps={{ + label: i18n.TECH_PREVIEW, + tooltipContent: i18n.TECH_PREVIEW_TOOLTIP, + }} + > + <EuiFlexGroup + direction="row" + gutterSize="l" + alignItems="center" + justifyContent="center" + css={assistantCardCss} + > + <EuiFlexItem grow={false}> + <AssistantAvatar /> + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup + direction="column" + gutterSize="s" + alignItems="flexStart" + justifyContent="flexStart" + > + <EuiFlexItem> + <EuiTitle size="xs"> + <h3>{i18n.ASSISTANT_TITLE}</h3> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem> + <EuiText size="s" color="subdued" textAlign="left"> + {i18n.ASSISTANT_DESCRIPTION} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {canExecuteConnectors ? ( + <EuiButton onClick={() => navigate(Page.assistant)}>{i18n.ASSISTANT_BUTTON}</EuiButton> + ) : ( + <MissingPrivilegesTooltip canExecuteConnectors> + <EuiButton disabled>{i18n.ASSISTANT_BUTTON}</EuiButton> + </MissingPrivilegesTooltip> + )} + </EuiFlexItem> + </EuiFlexGroup> + </EuiCard> + ); +}); +IntegrationAssistantCard.displayName = 'IntegrationAssistantCard'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration_card_button/create_integration_card_button.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration_card_button/create_integration_card_button.tsx index cb6e64a1aa55..55baee561bb4 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration_card_button/create_integration_card_button.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration_card_button/create_integration_card_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiLink, EuiPanel, @@ -15,41 +15,53 @@ import { EuiIcon, EuiText, EuiTitle, - useEuiTheme, + useEuiPaddingCSS, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import integrationsImage from '../../common/images/integrations_light.svg'; +import { useKibana } from '../../common/hooks/use_kibana'; -const useStyles = () => { - const { euiTheme } = useEuiTheme(); +const useStyles = (compressed: boolean) => { + const paddings = useEuiPaddingCSS(); return { image: css` - width: 160px; - height: 155px; + width: ${compressed ? '140px' : '160px'}; + height: ${compressed ? '90px' : '155px'}; object-fit: cover; object-position: left center; `, container: css` - height: 135px; + height: ${compressed ? '80px' : '135px'}; `, textContainer: css` height: 100%; - padding: ${euiTheme.size.l} 0 ${euiTheme.size.l} ${euiTheme.size.l}; + ${compressed ? `${paddings.m.styles}` : `${paddings.l.styles} padding-right: 0;`} `, }; }; export interface CreateIntegrationCardButtonProps { - href: string; + compressed?: boolean; } export const CreateIntegrationCardButton = React.memo<CreateIntegrationCardButtonProps>( - ({ href }) => { - const styles = useStyles(); + ({ compressed = false }) => { + const { getUrlForApp, navigateToUrl } = useKibana().services.application; + const styles = useStyles(compressed); + + const href = useMemo(() => getUrlForApp('integrations', { path: '/create' }), [getUrlForApp]); + const navigate = useCallback( + (ev) => { + ev.preventDefault(); + navigateToUrl(href); + }, + [href, navigateToUrl] + ); + return ( <EuiPanel hasShadow={false} hasBorder paddingSize="none"> <EuiFlexGroup - justifyContent="flexEnd" + justifyContent="spaceBetween" gutterSize="none" css={styles.container} responsive={false} @@ -70,18 +82,26 @@ export const CreateIntegrationCardButton = React.memo<CreateIntegrationCardButto /> </h2> </EuiTitle> - <EuiText size="s"> - <FormattedMessage - id="xpack.integrationAssistant.createIntegrationDescription" - defaultMessage="Create a custom one to fit your requirements" - /> - </EuiText> + {!compressed && ( + <EuiText size="s"> + <FormattedMessage + id="xpack.integrationAssistant.createIntegrationDescription" + defaultMessage="Create a custom one to fit your requirements" + /> + </EuiText> + )} </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiLink color="primary" href={href}> - <EuiFlexGroup justifyContent="center" gutterSize="s" responsive={false}> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + <EuiLink color="primary" href={href} onClick={navigate}> + <EuiFlexGroup + justifyContent="center" + alignItems="center" + gutterSize={compressed ? 'xs' : 's'} + responsive={false} + > <EuiFlexItem grow={false}> - <EuiIcon type="plusInCircle" /> + <EuiIcon type="plusInCircle" size={compressed ? 's' : 'm'} /> </EuiFlexItem> <EuiFlexItem> <FormattedMessage diff --git a/x-pack/plugins/integration_assistant/public/plugin.ts b/x-pack/plugins/integration_assistant/public/plugin.ts index 9eb03950062c..ecc55565a472 100644 --- a/x-pack/plugins/integration_assistant/public/plugin.ts +++ b/x-pack/plugins/integration_assistant/public/plugin.ts @@ -6,6 +6,7 @@ */ import type { CoreStart, Plugin, CoreSetup } from '@kbn/core/public'; +import { BehaviorSubject } from 'rxjs'; import type { IntegrationAssistantPluginSetup, IntegrationAssistantPluginStart, @@ -13,12 +14,13 @@ import type { } from './types'; import { getCreateIntegrationLazy } from './components/create_integration'; import { getCreateIntegrationCardButtonLazy } from './components/create_integration_card_button'; -import { Telemetry, type Services } from './services'; +import { Telemetry, type Services, type RenderUpselling } from './services'; export class IntegrationAssistantPlugin implements Plugin<IntegrationAssistantPluginSetup, IntegrationAssistantPluginStart> { private telemetry = new Telemetry(); + private renderUpselling$ = new BehaviorSubject<RenderUpselling | undefined>(undefined); public setup(core: CoreSetup): IntegrationAssistantPluginSetup { this.telemetry.setup(core.analytics); @@ -33,11 +35,17 @@ export class IntegrationAssistantPlugin ...core, ...dependencies, telemetry: this.telemetry.start(), + renderUpselling$: this.renderUpselling$.asObservable(), }; return { - CreateIntegration: getCreateIntegrationLazy(services), - CreateIntegrationCardButton: getCreateIntegrationCardButtonLazy(), + components: { + CreateIntegration: getCreateIntegrationLazy(services), + CreateIntegrationCardButton: getCreateIntegrationCardButtonLazy(), + }, + renderUpselling: (renderUpselling) => { + this.renderUpselling$.next(renderUpselling); + }, }; } diff --git a/x-pack/plugins/integration_assistant/public/services/index.ts b/x-pack/plugins/integration_assistant/public/services/index.ts index 346c3e2d04a3..d3a5e7e0f688 100644 --- a/x-pack/plugins/integration_assistant/public/services/index.ts +++ b/x-pack/plugins/integration_assistant/public/services/index.ts @@ -4,12 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { Observable } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; import type { IntegrationAssistantPluginStartDependencies } from '../types'; import type { TelemetryService } from './telemetry/service'; export { Telemetry } from './telemetry/service'; +export type RenderUpselling = React.ReactNode; + export type Services = CoreStart & - IntegrationAssistantPluginStartDependencies & { telemetry: TelemetryService }; + IntegrationAssistantPluginStartDependencies & { + telemetry: TelemetryService; + renderUpselling$: Observable<RenderUpselling | undefined>; + }; diff --git a/x-pack/plugins/integration_assistant/public/types.ts b/x-pack/plugins/integration_assistant/public/types.ts index 35f108f84883..e3b2954cccd6 100644 --- a/x-pack/plugins/integration_assistant/public/types.ts +++ b/x-pack/plugins/integration_assistant/public/types.ts @@ -4,25 +4,43 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '@kbn/triggers-actions-ui-plugin/public'; import type { CreateIntegrationComponent } from './components/create_integration/types'; import type { CreateIntegrationCardButtonComponent } from './components/create_integration_card_button/types'; +import type { RenderUpselling } from './services'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface IntegrationAssistantPluginSetup {} export interface IntegrationAssistantPluginStart { - CreateIntegration: CreateIntegrationComponent; - CreateIntegrationCardButton: CreateIntegrationCardButtonComponent; + components: { + /** + * Component that allows the user to create an integration. + */ + CreateIntegration: CreateIntegrationComponent; + /** + * Component that links the user to the create integration component. + */ + CreateIntegrationCardButton: CreateIntegrationCardButtonComponent; + }; + /** + * Sets the upselling to be rendered in the UI. + * If defined, the section will be displayed and it will prevent + * the user from interacting with the rest of the UI. + */ + renderUpselling: (upselling: RenderUpselling | undefined) => void; } export interface IntegrationAssistantPluginSetupDependencies { triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + licensing: LicensingPluginSetup; } export interface IntegrationAssistantPluginStartDependencies { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + licensing: LicensingPluginStart; } diff --git a/x-pack/plugins/integration_assistant/server/__mocks__/mock_server.ts b/x-pack/plugins/integration_assistant/server/__mocks__/mock_server.ts index 35eb7fd28103..d2ece6e91ead 100644 --- a/x-pack/plugins/integration_assistant/server/__mocks__/mock_server.ts +++ b/x-pack/plugins/integration_assistant/server/__mocks__/mock_server.ts @@ -91,7 +91,6 @@ class MockServer { public async inject(request: KibanaRequest, context: RequestHandlerContext = this.contextMock) { const validatedRequest = this.validateRequest(request); - const [rejection] = this.resultMock.badRequest.mock.calls; if (rejection) { throw new Error(`Request was rejected with message: '${rejection}'`); diff --git a/x-pack/plugins/integration_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/integration_assistant/server/__mocks__/request_context.ts index f7b27c353e2a..95292e491cc7 100644 --- a/x-pack/plugins/integration_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/integration_assistant/server/__mocks__/request_context.ts @@ -72,6 +72,7 @@ const createRequestContextMock = (clients: MockClients = createMockClients()) => ]; } ), + isAvailable: jest.fn((): boolean => true), logger: loggerMock.create(), }, }; diff --git a/x-pack/plugins/integration_assistant/server/__mocks__/test_adapters.ts b/x-pack/plugins/integration_assistant/server/__mocks__/test_adapters.ts index 8972c5f102bd..61fbf03e4c14 100644 --- a/x-pack/plugins/integration_assistant/server/__mocks__/test_adapters.ts +++ b/x-pack/plugins/integration_assistant/server/__mocks__/test_adapters.ts @@ -40,6 +40,11 @@ const buildResponses = (method: Method, calls: MockCall[]): ResponseCall[] => { status: 400, body: call.body, })); + case 'notFound': + return calls.map(([call]) => ({ + status: 404, + body: call.body, + })); default: throw new Error(`Encountered unexpected call to response.${method}`); } diff --git a/x-pack/plugins/integration_assistant/server/graphs/categorization/constants.ts b/x-pack/plugins/integration_assistant/server/graphs/categorization/constants.ts index ca875c15f026..aef92f0a2a78 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/categorization/constants.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/categorization/constants.ts @@ -123,13 +123,13 @@ export type EventCategories = | 'iam' | 'intrusion_detection' | 'library' + | 'malware' | 'network' | 'package' | 'process' | 'registry' | 'session' | 'threat' - | 'user' | 'vulnerability' | 'web'; @@ -153,21 +153,21 @@ export const ECS_EVENT_TYPES_PER_CATEGORY: { configuration: ['access', 'change', 'creation', 'deletion', 'info'], database: ['access', 'change', 'info', 'error'], driver: ['change', 'end', 'info', 'start'], - email: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - file: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - host: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - iam: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - intrusion_detection: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - library: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - network: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - package: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - process: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - registry: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - session: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - threat: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - user: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - vulnerability: ['access', 'change', 'creation', 'deletion', 'info', 'start'], - web: ['access', 'change', 'creation', 'deletion', 'info', 'start'], + email: ['info'], + file: ['access', 'change', 'creation', 'deletion', 'info'], + host: ['access', 'change', 'end', 'info', 'start'], + iam: ['admin', 'change', 'creation', 'deletion', 'group', 'info', 'user'], + intrusion_detection: ['allowed', 'denied', 'info'], + library: ['start'], + malware: ['info'], + network: ['access', 'allowed', 'connection', 'denied', 'end', 'info', 'protocol', 'start'], + package: ['access', 'change', 'deletion', 'info', 'installation', 'start'], + process: ['access', 'change', 'end', 'info', 'start'], + registry: ['access', 'change', 'creation', 'deletion'], + session: ['start', 'end', 'info'], + threat: ['indicator'], + vulnerability: ['info'], + web: ['access', 'error', 'info'], }; export const CATEGORIZATION_EXAMPLE_PROCESSORS = ` diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts b/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts index 064fc5cd3ca8..9422b99b0ab2 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.ts @@ -10,7 +10,7 @@ import nunjucks from 'nunjucks'; import { tmpdir } from 'os'; import { join as joinPath } from 'path'; import type { DataStream, Integration } from '../../common'; -import { copySync, createSync, ensureDirSync, generateUniqueId } from '../util'; +import { createSync, ensureDirSync, generateUniqueId } from '../util'; import { createAgentInput } from './agent'; import { createDataStream } from './data_stream'; import { createFieldMapping } from './fields'; @@ -63,20 +63,17 @@ function createPackage(packageDir: string, integration: Integration): void { createPackageManifest(packageDir, integration); // Skipping creation of system tests temporarily for custom package generation // createPackageSystemTests(packageDir, integration); - createLogo(packageDir, integration); + if (integration?.logo !== undefined) { + createLogo(packageDir, integration.logo); + } } -function createLogo(packageDir: string, integration: Integration): void { +function createLogo(packageDir: string, logo: string): void { const logoDir = joinPath(packageDir, 'img'); ensureDirSync(logoDir); - if (integration?.logo !== undefined) { - const buffer = Buffer.from(integration.logo, 'base64'); - createSync(joinPath(logoDir, 'logo.svg'), buffer); - } else { - const imgTemplateDir = joinPath(__dirname, '../templates/img'); - copySync(joinPath(imgTemplateDir, 'logo.svg'), joinPath(logoDir, 'logo.svg')); - } + const buffer = Buffer.from(logo, 'base64'); + createSync(joinPath(logoDir, 'logo.svg'), buffer); } function createBuildFile(packageDir: string): void { @@ -137,6 +134,7 @@ function createPackageManifest(packageDir: string, integration: Integration): vo package_name: integration.name, package_version: '0.1.0', package_description: integration.description, + package_logo: integration.logo, package_owner: '@elastic/custom-integrations', min_version: '^8.13.0', inputs: uniqueInputsList, diff --git a/x-pack/plugins/integration_assistant/server/plugin.ts b/x-pack/plugins/integration_assistant/server/plugin.ts index 4f7dfa87291c..64989d23e7dd 100644 --- a/x-pack/plugins/integration_assistant/server/plugin.ts +++ b/x-pack/plugins/integration_assistant/server/plugin.ts @@ -14,14 +14,20 @@ import type { CustomRequestHandlerContext, } from '@kbn/core/server'; import type { PluginStartContract as ActionsPluginsStart } from '@kbn/actions-plugin/server/plugin'; +import { MINIMUM_LICENSE_TYPE } from '../common/constants'; import { registerRoutes } from './routes'; -import type { IntegrationAssistantPluginSetup, IntegrationAssistantPluginStart } from './types'; +import type { + IntegrationAssistantPluginSetup, + IntegrationAssistantPluginStart, + IntegrationAssistantPluginStartDependencies, +} from './types'; export type IntegrationAssistantRouteHandlerContext = CustomRequestHandlerContext<{ integrationAssistant: { getStartServices: CoreSetup<{ actions: ActionsPluginsStart; }>['getStartServices']; + isAvailable: () => boolean; logger: Logger; }; }>; @@ -30,20 +36,26 @@ export class IntegrationAssistantPlugin implements Plugin<IntegrationAssistantPluginSetup, IntegrationAssistantPluginStart> { private readonly logger: Logger; + private isAvailable: boolean; + private hasLicense: boolean; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); + this.isAvailable = true; + this.hasLicense = false; } + public setup( core: CoreSetup<{ actions: ActionsPluginsStart; }> - ) { + ): IntegrationAssistantPluginSetup { core.http.registerRouteHandlerContext< IntegrationAssistantRouteHandlerContext, 'integrationAssistant' >('integrationAssistant', () => ({ getStartServices: core.getStartServices, + isAvailable: () => this.isAvailable && this.hasLicense, logger: this.logger, })); const router = core.http.createRouter<IntegrationAssistantRouteHandlerContext>(); @@ -51,11 +63,26 @@ export class IntegrationAssistantPlugin registerRoutes(router); - return {}; + return { + setIsAvailable: (isAvailable: boolean) => { + if (!isAvailable) { + this.isAvailable = false; + } + }, + }; } - public start(core: CoreStart) { + public start( + _: CoreStart, + dependencies: IntegrationAssistantPluginStartDependencies + ): IntegrationAssistantPluginStart { this.logger.debug('integrationAssistant api: Started'); + const { licensing } = dependencies; + + licensing.license$.subscribe((license) => { + this.hasLicense = license.hasAtLeast(MINIMUM_LICENSE_TYPE); + }); + return {}; } diff --git a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts index 6809d826cb81..3b73e4afb1c9 100644 --- a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.test.ts @@ -58,4 +58,15 @@ describe('registerIntegrationBuilderRoutes', () => { expect(response.body).toEqual({ test: 'test' }); expect(response.status).toEqual(200); }); + + describe('when the integration assistant is not available', () => { + beforeEach(() => { + context.integrationAssistant.isAvailable.mockReturnValue(false); + }); + + it('returns a 404', async () => { + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.status).toEqual(404); + }); + }); }); diff --git a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts index e9f0d000e040..d4b4424f5c84 100644 --- a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts @@ -10,6 +10,7 @@ import { BuildIntegrationRequestBody, INTEGRATION_BUILDER_PATH } from '../../com import { buildPackage } from '../integration_builder'; import type { IntegrationAssistantRouteHandlerContext } from '../plugin'; import { buildRouteValidationWithZod } from '../util/route_validation'; +import { withAvailability } from './with_availability'; export function registerIntegrationBuilderRoutes( router: IRouter<IntegrationAssistantRouteHandlerContext> @@ -28,7 +29,7 @@ export function registerIntegrationBuilderRoutes( }, }, }, - async (_, request, response) => { + withAvailability(async (_, request, response) => { const { integration } = request.body; try { const zippedIntegration = await buildPackage(integration); @@ -40,6 +41,6 @@ export function registerIntegrationBuilderRoutes( } catch (e) { return response.customError({ statusCode: 500, body: e }); } - } + }) ); } diff --git a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts index 5a62162eae54..1f8038d3e75c 100644 --- a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts @@ -64,4 +64,15 @@ describe('registerCategorizationRoute', () => { const response = await server.inject(req, requestContextMock.convertContext(context)); expect(response.status).toEqual(400); }); + + describe('when the integration assistant is not available', () => { + beforeEach(() => { + context.integrationAssistant.isAvailable.mockReturnValue(false); + }); + + it('returns a 404', async () => { + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.status).toEqual(404); + }); + }); }); diff --git a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts index 6654898bd023..2dbdc63210a5 100644 --- a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts @@ -20,6 +20,7 @@ import { ROUTE_HANDLER_TIMEOUT } from '../constants'; import { getCategorizationGraph } from '../graphs/categorization'; import type { IntegrationAssistantRouteHandlerContext } from '../plugin'; import { buildRouteValidationWithZod } from '../util/route_validation'; +import { withAvailability } from './with_availability'; export function registerCategorizationRoutes( router: IRouter<IntegrationAssistantRouteHandlerContext> @@ -43,49 +44,50 @@ export function registerCategorizationRoutes( }, }, }, - async (context, req, res): Promise<IKibanaResponse<CategorizationResponse>> => { - const { packageName, dataStreamName, rawSamples, currentPipeline } = req.body; - const services = await context.resolve(['core']); - const { client } = services.core.elasticsearch; - const { getStartServices, logger } = await context.integrationAssistant; - const [, { actions: actionsPlugin }] = await getStartServices(); + withAvailability( + async (context, req, res): Promise<IKibanaResponse<CategorizationResponse>> => { + const { packageName, dataStreamName, rawSamples, currentPipeline } = req.body; + const services = await context.resolve(['core']); + const { client } = services.core.elasticsearch; + const { getStartServices, logger } = await context.integrationAssistant; + const [, { actions: actionsPlugin }] = await getStartServices(); - try { - const actionsClient = await actionsPlugin.getActionsClientWithRequest(req); - const connector = req.body.connectorId - ? await actionsClient.get({ id: req.body.connectorId }) - : (await actionsClient.getAll()).filter( - (connectorItem) => connectorItem.actionTypeId === '.bedrock' - )[0]; + try { + const actionsClient = await actionsPlugin.getActionsClientWithRequest(req); + const connector = req.body.connectorId + ? await actionsClient.get({ id: req.body.connectorId }) + : (await actionsClient.getAll()).filter( + (connectorItem) => connectorItem.actionTypeId === '.bedrock' + )[0]; - const abortSignal = getRequestAbortedSignal(req.events.aborted$); - const isOpenAI = connector.actionTypeId === '.gen-ai'; - const llmClass = isOpenAI ? ActionsClientChatOpenAI : ActionsClientSimpleChatModel; + const abortSignal = getRequestAbortedSignal(req.events.aborted$); + const isOpenAI = connector.actionTypeId === '.gen-ai'; + const llmClass = isOpenAI ? ActionsClientChatOpenAI : ActionsClientSimpleChatModel; - const model = new llmClass({ - actions: actionsPlugin, - connectorId: connector.id, - request: req, - logger, - llmType: isOpenAI ? 'openai' : 'bedrock', - model: connector.config?.defaultModel, - temperature: 0.05, - maxTokens: 4096, - signal: abortSignal, - streaming: false, - }); + const model = new llmClass({ + actionsClient, + connectorId: connector.id, + logger, + llmType: isOpenAI ? 'openai' : 'bedrock', + model: connector.config?.defaultModel, + temperature: 0.05, + maxTokens: 4096, + signal: abortSignal, + streaming: false, + }); - const graph = await getCategorizationGraph(client, model); - const results = await graph.invoke({ - packageName, - dataStreamName, - rawSamples, - currentPipeline, - }); - return res.ok({ body: CategorizationResponse.parse(results) }); - } catch (e) { - return res.badRequest({ body: e }); + const graph = await getCategorizationGraph(client, model); + const results = await graph.invoke({ + packageName, + dataStreamName, + rawSamples, + currentPipeline, + }); + return res.ok({ body: CategorizationResponse.parse(results) }); + } catch (e) { + return res.badRequest({ body: e }); + } } - } + ) ); } diff --git a/x-pack/plugins/integration_assistant/server/routes/ecs_routes.test.ts b/x-pack/plugins/integration_assistant/server/routes/ecs_routes.test.ts index 70d0068eb963..b68da3c2d56e 100644 --- a/x-pack/plugins/integration_assistant/server/routes/ecs_routes.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/ecs_routes.test.ts @@ -69,4 +69,15 @@ describe('registerEcsRoute', () => { const response = await server.inject(req, requestContextMock.convertContext(context)); expect(response.status).toEqual(400); }); + + describe('when the integration assistant is not available', () => { + beforeEach(() => { + context.integrationAssistant.isAvailable.mockReturnValue(false); + }); + + it('returns a 404', async () => { + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.status).toEqual(404); + }); + }); }); diff --git a/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts b/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts index ee461b94feba..d177aeb4b2cd 100644 --- a/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts @@ -16,6 +16,7 @@ import { ROUTE_HANDLER_TIMEOUT } from '../constants'; import { getEcsGraph } from '../graphs/ecs'; import type { IntegrationAssistantRouteHandlerContext } from '../plugin'; import { buildRouteValidationWithZod } from '../util/route_validation'; +import { withAvailability } from './with_availability'; export function registerEcsRoutes(router: IRouter<IntegrationAssistantRouteHandlerContext>) { router.versioned @@ -37,9 +38,10 @@ export function registerEcsRoutes(router: IRouter<IntegrationAssistantRouteHandl }, }, }, - async (context, req, res): Promise<IKibanaResponse<EcsMappingResponse>> => { + withAvailability(async (context, req, res): Promise<IKibanaResponse<EcsMappingResponse>> => { const { packageName, dataStreamName, rawSamples, mapping } = req.body; const { getStartServices, logger } = await context.integrationAssistant; + const [, { actions: actionsPlugin }] = await getStartServices(); try { const actionsClient = await actionsPlugin.getActionsClientWithRequest(req); @@ -54,9 +56,8 @@ export function registerEcsRoutes(router: IRouter<IntegrationAssistantRouteHandl const llmClass = isOpenAI ? ActionsClientChatOpenAI : ActionsClientSimpleChatModel; const model = new llmClass({ - actions: actionsPlugin, + actionsClient, connectorId: connector.id, - request: req, logger, llmType: isOpenAI ? 'openai' : 'bedrock', model: connector.config?.defaultModel, @@ -86,6 +87,6 @@ export function registerEcsRoutes(router: IRouter<IntegrationAssistantRouteHandl } catch (e) { return res.badRequest({ body: e }); } - } + }) ); } diff --git a/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.test.ts b/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.test.ts index c40472c1131d..7cde38383f97 100644 --- a/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.test.ts @@ -54,4 +54,15 @@ describe('registerPipelineRoutes', () => { }); expect(response.status).toEqual(200); }); + + describe('when the integration assistant is not available', () => { + beforeEach(() => { + context.integrationAssistant.isAvailable.mockReturnValue(false); + }); + + it('returns a 404', async () => { + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.status).toEqual(404); + }); + }); }); diff --git a/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.ts b/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.ts index 0bf04e566c64..37d369292306 100644 --- a/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/pipeline_routes.ts @@ -11,6 +11,7 @@ import { ROUTE_HANDLER_TIMEOUT } from '../constants'; import type { IntegrationAssistantRouteHandlerContext } from '../plugin'; import { testPipeline } from '../util/pipeline'; import { buildRouteValidationWithZod } from '../util/route_validation'; +import { withAvailability } from './with_availability'; export function registerPipelineRoutes(router: IRouter<IntegrationAssistantRouteHandlerContext>) { router.versioned @@ -32,21 +33,23 @@ export function registerPipelineRoutes(router: IRouter<IntegrationAssistantRoute }, }, }, - async (context, req, res): Promise<IKibanaResponse<CheckPipelineResponse>> => { - const { rawSamples, pipeline } = req.body; - const services = await context.resolve(['core']); - const { client } = services.core.elasticsearch; - try { - const { errors, pipelineResults } = await testPipeline(rawSamples, pipeline, client); - if (errors?.length) { - return res.badRequest({ body: JSON.stringify(errors) }); + withAvailability( + async (context, req, res): Promise<IKibanaResponse<CheckPipelineResponse>> => { + const { rawSamples, pipeline } = req.body; + const services = await context.resolve(['core']); + const { client } = services.core.elasticsearch; + try { + const { errors, pipelineResults } = await testPipeline(rawSamples, pipeline, client); + if (errors?.length) { + return res.badRequest({ body: JSON.stringify(errors) }); + } + return res.ok({ + body: CheckPipelineResponse.parse({ results: { docs: pipelineResults } }), + }); + } catch (e) { + return res.badRequest({ body: e }); } - return res.ok({ - body: CheckPipelineResponse.parse({ results: { docs: pipelineResults } }), - }); - } catch (e) { - return res.badRequest({ body: e }); } - } + ) ); } diff --git a/x-pack/plugins/integration_assistant/server/routes/related_routes.test.ts b/x-pack/plugins/integration_assistant/server/routes/related_routes.test.ts index f393b36a3059..aeefa48c2e4d 100644 --- a/x-pack/plugins/integration_assistant/server/routes/related_routes.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/related_routes.test.ts @@ -64,4 +64,15 @@ describe('registerRelatedRoutes', () => { const response = await server.inject(req, requestContextMock.convertContext(context)); expect(response.status).toEqual(400); }); + + describe('when the integration assistant is not available', () => { + beforeEach(() => { + context.integrationAssistant.isAvailable.mockReturnValue(false); + }); + + it('returns a 404', async () => { + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.status).toEqual(404); + }); + }); }); diff --git a/x-pack/plugins/integration_assistant/server/routes/related_routes.ts b/x-pack/plugins/integration_assistant/server/routes/related_routes.ts index e612b4d3a8d5..16c4fba5ac8d 100644 --- a/x-pack/plugins/integration_assistant/server/routes/related_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/related_routes.ts @@ -16,6 +16,7 @@ import { ROUTE_HANDLER_TIMEOUT } from '../constants'; import { getRelatedGraph } from '../graphs/related'; import type { IntegrationAssistantRouteHandlerContext } from '../plugin'; import { buildRouteValidationWithZod } from '../util/route_validation'; +import { withAvailability } from './with_availability'; export function registerRelatedRoutes(router: IRouter<IntegrationAssistantRouteHandlerContext>) { router.versioned @@ -37,7 +38,7 @@ export function registerRelatedRoutes(router: IRouter<IntegrationAssistantRouteH }, }, }, - async (context, req, res): Promise<IKibanaResponse<RelatedResponse>> => { + withAvailability(async (context, req, res): Promise<IKibanaResponse<RelatedResponse>> => { const { packageName, dataStreamName, rawSamples, currentPipeline } = req.body; const services = await context.resolve(['core']); const { client } = services.core.elasticsearch; @@ -56,9 +57,8 @@ export function registerRelatedRoutes(router: IRouter<IntegrationAssistantRouteH const abortSignal = getRequestAbortedSignal(req.events.aborted$); const model = new llmClass({ - actions: actionsPlugin, + actionsClient, connectorId: connector.id, - request: req, logger, llmType: isOpenAI ? 'openai' : 'bedrock', model: connector.config?.defaultModel, @@ -79,6 +79,6 @@ export function registerRelatedRoutes(router: IRouter<IntegrationAssistantRouteH } catch (e) { return res.badRequest({ body: e }); } - } + }) ); } diff --git a/x-pack/plugins/integration_assistant/server/routes/with_availability.ts b/x-pack/plugins/integration_assistant/server/routes/with_availability.ts new file mode 100644 index 000000000000..c9f04421bb34 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/routes/with_availability.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 { RequestHandler, RouteMethod } from '@kbn/core/server'; +import { IntegrationAssistantRouteHandlerContext } from '../plugin'; + +/** + * Wraps a request handler with a check for whether the API route is available. + * The `isAvailable` flag must be provided by the context and be consistent with the required + * license (stateful) or product type (serverless). + */ +export const withAvailability = < + P = unknown, + Q = unknown, + B = unknown, + Method extends RouteMethod = never +>( + handler: RequestHandler<P, Q, B, IntegrationAssistantRouteHandlerContext, Method> +): RequestHandler<P, Q, B, IntegrationAssistantRouteHandlerContext, Method> => { + return async (context, req, res) => { + const { isAvailable } = await context.integrationAssistant; + if (!isAvailable()) { + return res.notFound({ + body: { message: 'This API route is not available using your current license/tier.' }, + }); + } + return handler(context, req, res); + }; +}; diff --git a/x-pack/plugins/integration_assistant/server/templates/manifest/package_manifest.yml.njk b/x-pack/plugins/integration_assistant/server/templates/manifest/package_manifest.yml.njk index 5d18001fb16a..d9cf502144a9 100644 --- a/x-pack/plugins/integration_assistant/server/templates/manifest/package_manifest.yml.njk +++ b/x-pack/plugins/integration_assistant/server/templates/manifest/package_manifest.yml.njk @@ -12,11 +12,11 @@ categories: conditions: kibana: version: {{ min_version }} -icons: +{% if package_logo %}icons: - src: /img/logo.svg title: "{{ package_name }} Logo" size: 32x32 - type: image/svg+xml + type: image/svg+xml{% endif %} policy_templates: - name: {{ package_name }} title: | 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 74520da051b8..02bf606ab386 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,24 +1,38 @@ # {{ package_name }} Integration -This integration is for ingesting data from [{{ package_name }}](https://example.com/). -{% for data_stream in data_streams %} -- `{{ data_stream.name }}`: {{ data_stream.description }} -{% endfor %} -See [Link to docs](https://example.com/docs) for more information. +## Overview + +Explain what the integration is, define the third-party product that is providing data, establish its relationship to the larger ecosystem of Elastic products, and help the reader understand how it can be used to solve a tangible problem. +Check the [overview guidelines](https://www.elastic.co/guide/en/integrations-developer/current/documentation-guidelines.html#idg-docs-guidelines-overview) for more information. + +## Datastreams + +Provide a high-level overview of the kind of data that is collected by the integration. +Check the [datastreams guidelines](https://www.elastic.co/guide/en/integrations-developer/current/documentation-guidelines.html#idg-docs-guidelines-datastreams) for more information. -## Compatibility +## Requirements -Insert compatibility information here. This could for example be which versions of the product it was tested with. +The requirements section helps readers to confirm that the integration will work with their systems. +Check the [requirements guidelines](https://www.elastic.co/guide/en/integrations-developer/current/documentation-guidelines.html#idg-docs-guidelines-requirements) for more information. ## Setup -Insert how to configure the vendor side of the integration here, for example how to configure the API, create a syslog remote destination etc. +Point the reader to the [Observability Getting started guide](https://www.elastic.co/guide/en/observability/master/observability-get-started.html) for generic, step-by-step instructions. Include any additional setup instructions beyond what’s included in the guide, which may include instructions to update the configuration of a third-party service. +Check the [setup guidelines](https://www.elastic.co/guide/en/integrations-developer/current/documentation-guidelines.html#idg-docs-guidelines-setup) for more information. + +## Troubleshooting (optional) + +Provide information about special cases and exceptions that aren’t necessary for getting started or won’t be applicable to all users. Check the [troubleshooting guidelines](https://www.elastic.co/guide/en/integrations-developer/current/documentation-guidelines.html#idg-docs-guidelines-troubleshooting) for more information. + +## Reference + +Provide detailed information about the log or metric types we support within the integration. Check the [reference guidelines](https://www.elastic.co/guide/en/integrations-developer/current/documentation-guidelines.html#idg-docs-guidelines-reference) for more information. ## Logs {% for data_stream in data_streams %} ### {{ data_stream.name }} -Insert a description of the data stream here. +Insert a description of the datastream here. {% raw %}{{fields {% endraw %}"{{ data_stream.name }}"{% raw %}}}{% endraw %} {% endfor %} diff --git a/x-pack/plugins/integration_assistant/server/types.ts b/x-pack/plugins/integration_assistant/server/types.ts index 54713df18b0d..503c318648ba 100644 --- a/x-pack/plugins/integration_assistant/server/types.ts +++ b/x-pack/plugins/integration_assistant/server/types.ts @@ -5,11 +5,21 @@ * 2.0. */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface IntegrationAssistantPluginSetup {} +import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server'; + +export interface IntegrationAssistantPluginSetup { + setIsAvailable: (isAvailable: boolean) => void; +} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface IntegrationAssistantPluginStart {} +export interface IntegrationAssistantPluginSetupDependencies { + licensing: LicensingPluginSetup; +} +export interface IntegrationAssistantPluginStartDependencies { + licensing: LicensingPluginStart; +} + export interface CategorizationState { rawSamples: string[]; samples: string[]; diff --git a/x-pack/plugins/integration_assistant/tsconfig.json b/x-pack/plugins/integration_assistant/tsconfig.json index ec7a7e094997..47087c83731a 100644 --- a/x-pack/plugins/integration_assistant/tsconfig.json +++ b/x-pack/plugins/integration_assistant/tsconfig.json @@ -33,6 +33,7 @@ "@kbn/stack-connectors-plugin", "@kbn/core-analytics-browser", "@kbn/logging-mocks", + "@kbn/licensing-plugin", "@kbn/core-http-request-handler-context-server", "@kbn/core-http-router-server-mocks", "@kbn/core-http-server" diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts index e5d678b88e5a..5a70c4385784 100644 --- a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts @@ -279,4 +279,56 @@ describe('map_to_columns', () => { } `); }); + + describe('map_to_columns_text_based', () => { + it('should keep columns that exist in idMap only', async () => { + const input: Datatable = { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'number' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + { id: 'c', name: 'C', meta: { type: 'string' } }, + ], + rows: [ + { a: 1, b: 2, c: '3' }, + { a: 3, b: 4, c: '5' }, + { a: 5, b: 6, c: '7' }, + { a: 7, b: 8, c: '9' }, + ], + }; + + const idMap = { + a: [ + { + id: 'a', + label: 'A', + }, + ], + b: [ + { + id: 'b', + label: 'B', + }, + ], + }; + + const result = await mapToColumns.fn( + input, + { idMap: JSON.stringify(idMap), isTextBased: true }, + createMockExecutionContext() + ); + + expect(result.columns).toStrictEqual([ + { id: 'a', name: 'A', meta: { type: 'number' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + ]); + + expect(result.rows).toStrictEqual([ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ]); + }); + }); }); diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts index 3315cd4170dd..0faa4de4fac4 100644 --- a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts @@ -22,11 +22,21 @@ export const mapToColumns: MapToColumnsExpressionFunction = { 'A JSON encoded object in which keys are the datatable column ids and values are the Lens column definitions. Any datatable columns not mentioned within the ID map will be kept unmapped.', }), }, + isTextBased: { + types: ['boolean'], + help: i18n.translate('xpack.lens.functions.mapToColumns.isESQL.help', { + defaultMessage: 'An optional flag to indicate if this is about the text based datasource.', + }), + }, }, inputTypes: ['datatable'], async fn(...args) { /** Build optimization: prevent adding extra code into initial bundle **/ const { mapToOriginalColumns } = await import('./map_to_columns_fn'); - return mapToOriginalColumns(...args); + const { mapToOriginalColumnsTextBased } = await import('./map_to_columns_fn_textbased'); + + return args?.[1]?.isTextBased + ? mapToOriginalColumnsTextBased(...args) + : mapToOriginalColumns(...args); }, }; diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns_fn_textbased.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns_fn_textbased.ts new file mode 100644 index 000000000000..cbaa8b8888df --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns_fn_textbased.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { OriginalColumn, MapToColumnsExpressionFunction } from './types'; + +export const mapToOriginalColumnsTextBased: MapToColumnsExpressionFunction['fn'] = ( + data, + { idMap: encodedIdMap } +) => { + const idMap = JSON.parse(encodedIdMap) as Record<string, OriginalColumn[]>; + + return { + ...data, + rows: data.rows.map((row) => { + const mappedRow: Record<string, unknown> = {}; + + for (const id in row) { + if (id in idMap) { + for (const cachedEntry of idMap[id]) { + mappedRow[cachedEntry.id] = row[id]; // <= I wrote idMap rather than mappedRow + } + } + } + + return mappedRow; + }), + columns: data.columns.flatMap((column) => { + if (!(column.id in idMap)) { + return []; + } + return idMap[column.id].map((originalColumn) => ({ ...column, id: originalColumn.id })); + }), + }; +}; diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts index 4623f435e5a6..e6617c38863b 100644 --- a/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts @@ -17,6 +17,7 @@ export type MapToColumnsExpressionFunction = ExpressionFunctionDefinition< Datatable, { idMap: string; + isTextBased?: boolean; }, Datatable | Promise<Datatable> >; diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts index 8da4c8760798..e0f6654287a3 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts @@ -42,7 +42,7 @@ export const getSuggestions = async ( signal: abortController?.signal, }); const context = { - dataViewSpec: dataView?.toSpec(), + dataViewSpec: dataView?.toSpec(false), fieldName: '', textBasedColumns: columns, query, 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 d55cfddf5a48..5c163df2c071 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 @@ -117,8 +117,7 @@ export function LensEditConfigurationFlyout({ // there are cases where a query can return a big amount of columns // at this case we don't suggest all columns in a table but the first // MAX_NUM_OF_COLUMNS - const columns = Object.keys(table.rows?.[0]) ?? []; - setSuggestsLimitedColumns(columns.length >= MAX_NUM_OF_COLUMNS); + setSuggestsLimitedColumns(table.columns.length >= MAX_NUM_OF_COLUMNS); layers.forEach((layer) => { activeData[layer] = table; }); diff --git a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx index 0f82a65fc1ff..d81ad37a2203 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx @@ -5,15 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; -import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { ExpressionsStart, DatatableColumn } from '@kbn/expressions-plugin/public'; +import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import type { DatasourceDimensionEditorProps, DataType } from '../../../types'; import { FieldSelect } from './field_select'; import type { TextBasedPrivateState } from '../types'; -import { retrieveLayerColumnsFromCache, getColumnsFromCache } from '../fieldlist_cache'; import { isNotNumeric, isNumeric } from '../utils'; export type TextBasedDimensionEditorProps = @@ -22,30 +22,55 @@ export type TextBasedDimensionEditorProps = }; export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) { + const [allColumns, setAllColumns] = useState<DatatableColumn[]>([]); const query = props.state.layers[props.layerId]?.query; - const allColumns = retrieveLayerColumnsFromCache( - props.state.layers[props.layerId]?.columns ?? [], - query - ); - const allFields = query ? getColumnsFromCache(query) : []; + useEffect(() => { + // in case the columns are not in the cache, I refetch them + async function fetchColumns() { + if (query) { + const table = await fetchFieldsFromESQL( + { esql: `${query.esql} | limit 0` }, + props.expressions + ); + if (table) { + setAllColumns(table.columns); + } + } + } + fetchColumns(); + }, [props.expressions, query]); + const hasNumberTypeColumns = allColumns?.some(isNumeric); - const fields = allFields.map((col) => { - return { - id: col.id, - name: col.name, - meta: col?.meta ?? { type: 'number' }, - compatible: - props.isMetricDimension && hasNumberTypeColumns - ? props.filterOperations({ - dataType: col?.meta?.type as DataType, - isBucketed: Boolean(isNotNumeric(col)), - scale: 'ordinal', - }) - : true, - }; - }); - const selectedField = allColumns?.find((column) => column.columnId === props.columnId); + const fields = useMemo(() => { + return allColumns.map((col) => { + return { + id: col.id, + name: col.name, + meta: col?.meta ?? { type: 'number' }, + compatible: + props.isMetricDimension && hasNumberTypeColumns + ? props.filterOperations({ + dataType: col?.meta?.type as DataType, + isBucketed: Boolean(isNotNumeric(col)), + scale: 'ordinal', + }) + : true, + }; + }); + }, [allColumns, hasNumberTypeColumns, props]); + + const selectedField = useMemo(() => { + const field = fields?.find((column) => column.id === props.columnId); + if (field) { + return { + fieldName: field.name, + meta: field.meta, + columnId: field.id, + }; + } + return undefined; + }, [fields, props.columnId]); return ( <> diff --git a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx index f6062068cee7..922c0b2ba9fa 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx @@ -5,18 +5,12 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import { DimensionTrigger } from '@kbn/visualization-ui-components'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { DatasourceDimensionTriggerProps } from '../../../types'; import type { TextBasedPrivateState } from '../types'; -import { - getColumnsFromCache, - addColumnsToCache, - retrieveLayerColumnsFromCache, -} from '../fieldlist_cache'; export type TextBasedDimensionTrigger = DatasourceDimensionTriggerProps<TextBasedPrivateState> & { columnLabelMap: Record<string, string>; @@ -24,35 +18,12 @@ export type TextBasedDimensionTrigger = DatasourceDimensionTriggerProps<TextBase }; export function TextBasedDimensionTrigger(props: TextBasedDimensionTrigger) { - const [dataHasLoaded, setDataHasLoaded] = useState(false); - const query = props.state.layers[props.layerId]?.query; - useEffect(() => { - // in case the columns are not in the cache, I refetch them - async function fetchColumns() { - const fieldList = query ? getColumnsFromCache(query) : []; + const customLabel: string | undefined = props.columnLabelMap[props.columnId]; - if (fieldList.length === 0 && query) { - const table = await fetchFieldsFromESQL(query, props.expressions); - if (table) { - addColumnsToCache(query, table.columns); - } - } - setDataHasLoaded(true); - } - fetchColumns(); - }, [props.expressions, query]); - const allColumns = dataHasLoaded - ? retrieveLayerColumnsFromCache(props.state.layers[props.layerId]?.columns ?? [], query) - : []; - const selectedField = allColumns?.find((column) => column.columnId === props.columnId); - let customLabel: string | undefined = props.columnLabelMap[props.columnId]; - if (!customLabel) { - customLabel = selectedField?.fieldName; - } return ( <DimensionTrigger id={props.columnId} - color={customLabel && selectedField ? 'primary' : 'danger'} + color={customLabel ? 'primary' : 'danger'} dataTestSubj="lns-dimensionTrigger-textBased" label={ customLabel ?? diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts index e4bc8c955a4e..ab96f6d802a9 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts @@ -747,6 +747,9 @@ describe('Textbased Data Source', () => { "idMap": Array [ "{\\"Test 1\\":[{\\"id\\":\\"a\\",\\"label\\":\\"Test 1\\"}],\\"Test 2\\":[{\\"id\\":\\"b\\",\\"label\\":\\"Test 2\\"}]}", ], + "isTextBased": Array [ + true, + ], }, "function": "lens_map_to_columns", "type": "function", diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx index 8cf8ce7ebd36..411583d88ef1 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx @@ -11,7 +11,7 @@ import { CoreStart } from '@kbn/core/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { AggregateQuery, isOfAggregateQueryType, getAggregateQueryMode } from '@kbn/es-query'; import type { SavedObjectReference } from '@kbn/core/public'; -import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { ExpressionsStart, DatatableColumn } from '@kbn/expressions-plugin/public'; import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import memoizeOne from 'memoize-one'; @@ -180,6 +180,15 @@ export function getTextBasedDatasource({ return Object.entries(state.layers)?.flatMap(([id, layer]) => { const allColumns = retrieveLayerColumnsFromCache(layer.columns, layer.query); + if (!allColumns.length && layer.query) { + const layerColumns = layer.columns.map((c) => ({ + id: c.columnId, + name: c.fieldName, + meta: c.meta, + })) as DatatableColumn[]; + addColumnsToCache(layer.query, layerColumns); + } + const unchangedSuggestionTable = getUnchangedSuggestionTable(state, allColumns, id); // we are trying here to cover the most common cases for the charts we offer @@ -214,7 +223,7 @@ export function getTextBasedDatasource({ if (fieldName) return []; if (context && 'dataViewSpec' in context && context.dataViewSpec.title && context.query) { const newLayerId = generateId(); - const textBasedQueryColumns = context.textBasedColumns ?? []; + const textBasedQueryColumns = context.textBasedColumns?.slice(0, MAX_NUM_OF_COLUMNS) ?? []; // Number fields are assigned automatically as metrics (!isBucketed). There are cases where the query // will not return number fields. In these cases we want to suggest a datatable // Datatable works differently in this case. On the metrics dimension can be all type of fields @@ -258,7 +267,7 @@ export function getTextBasedDatasource({ [newLayerId]: { index, query, - columns: newColumns.slice(0, MAX_NUM_OF_COLUMNS) ?? [], + columns: newColumns ?? [], timeField: context.dataViewSpec.timeFieldName, }, }, @@ -275,7 +284,7 @@ export function getTextBasedDatasource({ notAssignedMetrics: !hasNumberTypeColumns, layerId: newLayerId, columns: - newColumns?.slice(0, MAX_NUM_OF_COLUMNS)?.map((f) => { + newColumns?.map((f) => { return { columnId: f.columnId, operation: { diff --git a/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts b/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts index 148a16232c98..a175f191d591 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts @@ -59,6 +59,7 @@ function getExpressionForLayer( function: 'lens_map_to_columns', arguments: { idMap: [JSON.stringify(idMapper)], + isTextBased: [true], }, }); return textBasedQueryToAst; diff --git a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts index 3a7f35a254b2..d11a32bbd5e0 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts @@ -63,7 +63,7 @@ export async function executeCreateAction({ const defaultIndex = dataView.getIndexPattern(); const defaultEsqlQuery = { - esql: `from ${defaultIndex} | limit 10`, + esql: `FROM ${defaultIndex} | LIMIT 10`, }; // For the suggestions api we need only the columns @@ -78,7 +78,7 @@ export async function executeCreateAction({ }); const context = { - dataViewSpec: dataView.toSpec(), + dataViewSpec: dataView.toSpec(false), fieldName: '', textBasedColumns: columns, query: defaultEsqlQuery, diff --git a/x-pack/plugins/lists/server/get_user.test.ts b/x-pack/plugins/lists/server/get_user.test.ts index 429e0a71dc93..f44718b4ab1f 100644 --- a/x-pack/plugins/lists/server/get_user.test.ts +++ b/x-pack/plugins/lists/server/get_user.test.ts @@ -5,17 +5,17 @@ * 2.0. */ -import { httpServerMock } from '@kbn/core/server/mocks'; -import { CoreKibanaRequest } from '@kbn/core/server'; -import { securityMock } from '@kbn/security-plugin/server/mocks'; +import { securityServiceMock } from '@kbn/core/server/mocks'; +import { SecurityRequestHandlerContext } from '@kbn/core-security-server'; import { getUser } from './get_user'; describe('get_user', () => { - let request = CoreKibanaRequest.from(httpServerMock.createRawRequest({})); + let security: SecurityRequestHandlerContext; + beforeEach(() => { jest.clearAllMocks(); - request = CoreKibanaRequest.from(httpServerMock.createRawRequest({})); + security = securityServiceMock.createRequestHandlerContext(); }); afterEach(() => { @@ -23,44 +23,38 @@ describe('get_user', () => { }); test('it returns "bob" as the user given a security request with "bob"', () => { - const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'bob' }); - const user = getUser({ request, security }); + const user = getUser({ security }); expect(user).toEqual('bob'); }); test('it returns "alice" as the user given a security request with "alice"', () => { - const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'alice' }); - const user = getUser({ request, security }); + const user = getUser({ security }); expect(user).toEqual('alice'); }); test('it returns "elastic" as the user given null as the current user', () => { - const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(null); - const user = getUser({ request, security }); + const user = getUser({ security }); expect(user).toEqual('elastic'); }); test('it returns "elastic" as the user given undefined as the current user', () => { - const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined); - const user = getUser({ request, security }); + const user = getUser({ security }); expect(user).toEqual('elastic'); }); test('it returns "elastic" as the user given undefined as the plugin', () => { - const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined); - const user = getUser({ request, security: undefined }); + const user = getUser({ security }); expect(user).toEqual('elastic'); }); test('it returns "elastic" as the user given null as the plugin', () => { - const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined); - const user = getUser({ request, security: null }); + const user = getUser({ security }); expect(user).toEqual('elastic'); }); }); diff --git a/x-pack/plugins/lists/server/get_user.ts b/x-pack/plugins/lists/server/get_user.ts index a3adb05ae5ef..6ea64ce6ff73 100644 --- a/x-pack/plugins/lists/server/get_user.ts +++ b/x-pack/plugins/lists/server/get_user.ts @@ -5,17 +5,15 @@ * 2.0. */ -import { KibanaRequest } from '@kbn/core/server'; -import { SecurityPluginStart } from '@kbn/security-plugin/server'; +import { SecurityRequestHandlerContext } from '@kbn/core-security-server'; export interface GetUserOptions { - security: SecurityPluginStart | null | undefined; - request: KibanaRequest; + security: SecurityRequestHandlerContext; } -export const getUser = ({ security, request }: GetUserOptions): string => { +export const getUser = ({ security }: GetUserOptions): string => { if (security != null) { - const authenticatedUser = security.authc.getCurrentUser(request); + const authenticatedUser = security.authc.getCurrentUser(); if (authenticatedUser != null) { return authenticatedUser.username; } else { diff --git a/x-pack/plugins/lists/server/plugin.ts b/x-pack/plugins/lists/server/plugin.ts index 688469ea063b..5878eb45adfa 100644 --- a/x-pack/plugins/lists/server/plugin.ts +++ b/x-pack/plugins/lists/server/plugin.ts @@ -12,7 +12,6 @@ import type { Plugin, PluginInitializerContext, } from '@kbn/core/server'; -import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import type { SpacesServiceStart } from '@kbn/spaces-plugin/server'; import { ConfigType } from './config'; @@ -41,7 +40,6 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {}, private readonly config: ConfigType; private readonly extensionPoints: ExtensionPointStorageInterface; private spaces: SpacesServiceStart | undefined | null; - private security: SecurityPluginStart | undefined | null; constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -90,7 +88,6 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {}, public start(core: CoreStart, plugins: PluginsStart): ListsPluginStart { this.logger.debug('Starting plugin'); - this.security = plugins.security; this.spaces = plugins.spaces?.spacesService; } @@ -101,8 +98,9 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {}, private createRouteHandlerContext = (): ContextProvider => { return async (context, request): ContextProviderReturn => { - const { spaces, config, security, extensionPoints } = this; + const { spaces, config, extensionPoints } = this; const { + security, savedObjects: { client: savedObjectsClient }, elasticsearch: { client: { asCurrentUser: esClient }, @@ -112,7 +110,7 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {}, throw new TypeError('Configuration is required for this plugin to operate'); } else { const spaceId = getSpaceId({ request, spaces }); - const user = getUser({ request, security }); + const user = getUser({ security }); return { getExceptionListClient: (): ExceptionListClient => new ExceptionListClient({ diff --git a/x-pack/plugins/lists/tsconfig.json b/x-pack/plugins/lists/tsconfig.json index 47dc15c00ec8..8371f9de72c8 100644 --- a/x-pack/plugins/lists/tsconfig.json +++ b/x-pack/plugins/lists/tsconfig.json @@ -39,7 +39,8 @@ "@kbn/utility-types", "@kbn/core-elasticsearch-client-server-mocks", "@kbn/core-saved-objects-server", - "@kbn/zod-helpers" + "@kbn/zod-helpers", + "@kbn/core-security-server" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/logstash/server/plugin.ts b/x-pack/plugins/logstash/server/plugin.ts index 13cd3ba3fc47..24b8fa5a23b2 100644 --- a/x-pack/plugins/logstash/server/plugin.ts +++ b/x-pack/plugins/logstash/server/plugin.ts @@ -8,12 +8,10 @@ import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; import { LicensingPluginSetup } from '@kbn/licensing-plugin/server'; import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server'; -import { SecurityPluginSetup } from '@kbn/security-plugin/server'; import { registerRoutes } from './routes'; interface SetupDeps { licensing: LicensingPluginSetup; - security?: SecurityPluginSetup; features: FeaturesPluginSetup; } @@ -27,7 +25,7 @@ export class LogstashPlugin implements Plugin { setup(core: CoreSetup, deps: SetupDeps) { this.logger.debug('Setting up Logstash plugin'); - registerRoutes(core.http.createRouter(), deps.security); + registerRoutes(core.http.createRouter()); deps.features.registerElasticsearchFeature({ id: 'pipelines', diff --git a/x-pack/plugins/logstash/server/routes/index.ts b/x-pack/plugins/logstash/server/routes/index.ts index 63b2febd3eda..08e8c0732edc 100644 --- a/x-pack/plugins/logstash/server/routes/index.ts +++ b/x-pack/plugins/logstash/server/routes/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { SecurityPluginSetup } from '@kbn/security-plugin/server'; import type { LogstashPluginRouter } from '../types'; import { registerClusterLoadRoute } from './cluster'; import { @@ -15,12 +14,12 @@ import { } from './pipeline'; import { registerPipelinesListRoute, registerPipelinesDeleteRoute } from './pipelines'; -export function registerRoutes(router: LogstashPluginRouter, security?: SecurityPluginSetup) { +export function registerRoutes(router: LogstashPluginRouter) { registerClusterLoadRoute(router); registerPipelineDeleteRoute(router); registerPipelineLoadRoute(router); - registerPipelineSaveRoute(router, security); + registerPipelineSaveRoute(router); registerPipelinesListRoute(router); registerPipelinesDeleteRoute(router); diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts index 9e837bf9bd41..4d76518d7376 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/save.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/save.ts @@ -9,15 +9,11 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { wrapRouteWithLicenseCheck } from '@kbn/licensing-plugin/server'; -import { SecurityPluginSetup } from '@kbn/security-plugin/server'; import { Pipeline } from '../../models/pipeline'; import { checkLicense } from '../../lib/check_license'; import type { LogstashPluginRouter } from '../../types'; -export function registerPipelineSaveRoute( - router: LogstashPluginRouter, - security?: SecurityPluginSetup -) { +export function registerPipelineSaveRoute(router: LogstashPluginRouter) { router.put( { path: '/api/logstash/pipeline/{id}', @@ -39,14 +35,12 @@ export function registerPipelineSaveRoute( wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { + const coreContext = await context.core; try { - let username: string | undefined; - if (security) { - const user = await security.authc.getCurrentUser(request); - username = user?.username; - } + const user = coreContext.security.authc.getCurrentUser(); + const username = user?.username; - const { client } = (await context.core).elasticsearch; + const { client } = coreContext.elasticsearch; const pipeline = Pipeline.fromDownstreamJSON(request.body, request.params.id, username); await client.asCurrentUser.logstash.putPipeline({ diff --git a/x-pack/plugins/logstash/tsconfig.json b/x-pack/plugins/logstash/tsconfig.json index f6d4b28a8d89..7c5af6106c6f 100644 --- a/x-pack/plugins/logstash/tsconfig.json +++ b/x-pack/plugins/logstash/tsconfig.json @@ -16,7 +16,6 @@ "@kbn/features-plugin", "@kbn/licensing-plugin", - "@kbn/security-plugin", "@kbn/i18n", "@kbn/i18n-react", "@kbn/test-jest-helpers", diff --git a/x-pack/plugins/ml/kibana.jsonc b/x-pack/plugins/ml/kibana.jsonc index e2e4e5965d67..4ec2cf57312a 100644 --- a/x-pack/plugins/ml/kibana.jsonc +++ b/x-pack/plugins/ml/kibana.jsonc @@ -41,6 +41,7 @@ "maps", "security", "spaces", + "observabilityAIAssistant", "usageCollection", "cases" ], diff --git a/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx b/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx index b8f04a24f965..f97387fa4c50 100644 --- a/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx +++ b/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx @@ -19,7 +19,7 @@ import { useEnabledFeatures } from '../contexts/ml'; export const LogRateAnalysisPage: FC = () => { const { services } = useMlKibana(); - const { showNodeInfo } = useEnabledFeatures(); + const { showContextualInsights, showNodeInfo } = useEnabledFeatures(); const { selectedDataView: dataView, selectedSavedSearch: savedSearch } = useDataSource(); @@ -35,6 +35,7 @@ export const LogRateAnalysisPage: FC = () => { <LogRateAnalysis dataView={dataView} savedSearch={savedSearch} + showContextualInsights={showContextualInsights} showFrozenDataTierChoice={showNodeInfo} appDependencies={pick(services, [ 'analytics', @@ -53,6 +54,7 @@ export const LogRateAnalysisPage: FC = () => { 'uiActions', 'uiSettings', 'unifiedSearch', + 'observabilityAIAssistant', ])} /> )} diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 7a965a154185..cfc9826e4280 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -86,6 +86,7 @@ const App: FC<AppProps> = ({ lens: deps.lens, licenseManagement: deps.licenseManagement, maps: deps.maps, + observabilityAIAssistant: deps.observabilityAIAssistant, presentationUtil: deps.presentationUtil, savedObjectsManagement: deps.savedObjectsManagement, savedSearch: deps.savedSearch, diff --git a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts index f974ace6f4d0..e7102560e0e0 100644 --- a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts @@ -48,6 +48,7 @@ export class MlCapabilitiesService { private _isPlatinumOrTrialLicense$ = new BehaviorSubject<boolean | null>(null); private _mlFeatureEnabledInSpace$ = new BehaviorSubject<boolean | null>(null); + private _isUpgradeInProgress$ = new BehaviorSubject<boolean | null>(null); public capabilities$ = this._capabilities$.pipe(distinctUntilChanged(isEqual)); @@ -73,6 +74,7 @@ export class MlCapabilitiesService { this._capabilities$.next(results.capabilities); this._isPlatinumOrTrialLicense$.next(results.isPlatinumOrTrialLicense); this._mlFeatureEnabledInSpace$.next(results.mlFeatureEnabledInSpace); + this._isUpgradeInProgress$.next(results.upgradeInProgress); this._isLoading$.next(false); /** @@ -94,6 +96,14 @@ export class MlCapabilitiesService { return this._mlFeatureEnabledInSpace$.getValue(); } + public isUpgradeInProgress$() { + return this._isUpgradeInProgress$; + } + + public isUpgradeInProgress(): boolean | null { + return this._isUpgradeInProgress$.getValue(); + } + public getCapabilities$() { return this._capabilitiesObs$; } @@ -137,6 +147,23 @@ export function usePermissionCheck<T extends MlCapabilitiesKey | MlCapabilitiesK }, [capabilities]); } +/** + * Check whether upgrade mode has been set. + */ +export function useUpgradeCheck(): boolean { + const { + services: { + mlServices: { mlCapabilities: mlCapabilitiesService }, + }, + } = useMlKibana(); + + const isUpgradeInProgress = useObservable( + mlCapabilitiesService.isUpgradeInProgress$(), + mlCapabilitiesService.isUpgradeInProgress() + ); + return isUpgradeInProgress ?? false; +} + export function checkGetManagementMlJobsResolver({ mlCapabilities }: MlGlobalServices) { return new Promise<void>(async (resolve, reject) => { try { @@ -160,6 +187,7 @@ export function checkGetManagementMlJobsResolver({ mlCapabilities }: MlGlobalSer capabilities, isPlatinumOrTrialLicense: mlCapabilities.isPlatinumOrTrialLicense(), mlFeatureEnabledInSpace: mlCapabilities.mlFeatureEnabledInSpace(), + isUpgradeInProgress: mlCapabilities.isUpgradeInProgress(), }); } } catch (error) { diff --git a/x-pack/plugins/ml/public/application/capabilities/get_capabilities.ts b/x-pack/plugins/ml/public/application/capabilities/get_capabilities.ts index 395dce9312dc..ed4725b9ffde 100644 --- a/x-pack/plugins/ml/public/application/capabilities/get_capabilities.ts +++ b/x-pack/plugins/ml/public/application/capabilities/get_capabilities.ts @@ -7,20 +7,8 @@ import { ml } from '../services/ml_api_service'; -import { setUpgradeInProgress } from '../services/upgrade_service'; import type { MlCapabilitiesResponse } from '../../../common/types/capabilities'; export function getCapabilities(): Promise<MlCapabilitiesResponse> { - return new Promise((resolve, reject) => { - ml.checkMlCapabilities() - .then((resp: MlCapabilitiesResponse) => { - if (resp.upgradeInProgress === true) { - setUpgradeInProgress(true); - } - resolve(resp); - }) - .catch(() => { - reject(); - }); - }); + return ml.checkMlCapabilities(); } diff --git a/x-pack/plugins/ml/public/application/components/upgrade/upgrade_warning.tsx b/x-pack/plugins/ml/public/application/components/upgrade/upgrade_warning.tsx index 6ffe1dea2676..7863b542f618 100644 --- a/x-pack/plugins/ml/public/application/components/upgrade/upgrade_warning.tsx +++ b/x-pack/plugins/ml/public/application/components/upgrade/upgrade_warning.tsx @@ -11,10 +11,12 @@ import React from 'react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { isUpgradeInProgress } from '../../services/upgrade_service'; +import { useUpgradeCheck } from '../../capabilities/check_capabilities'; export const UpgradeWarning: FC = () => { - if (isUpgradeInProgress() === true) { + const isUpgradeInProgress = useUpgradeCheck(); + + if (isUpgradeInProgress === true) { return ( <React.Fragment> <EuiCallOut diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index d375269cb39c..1fd9b62559d9 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { CoreStart } from '@kbn/core/public'; @@ -47,6 +48,7 @@ interface StartPlugins { lens: LensPublicStart; licenseManagement?: LicenseManagementUIPluginSetup; maps?: MapsStartApi; + observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; presentationUtil: PresentationUtilPluginStart; savedObjectsManagement: SavedObjectsManagementPluginStart; savedSearch: SavedSearchPublicPluginStart; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/serverless_context.tsx b/x-pack/plugins/ml/public/application/contexts/ml/serverless_context.tsx index bf39fe5df969..0d27a636cbbd 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/serverless_context.tsx +++ b/x-pack/plugins/ml/public/application/contexts/ml/serverless_context.tsx @@ -10,6 +10,7 @@ import React, { createContext, useContext, useMemo } from 'react'; import type { ExperimentalFeatures, MlFeatures } from '../../../../common/constants/app'; export interface EnabledFeatures { + showContextualInsights: boolean; showNodeInfo: boolean; showMLNavMenu: boolean; showLicenseInfo: boolean; @@ -18,13 +19,15 @@ export interface EnabledFeatures { isNLPEnabled: boolean; showRuleFormV2: boolean; } -export const EnabledFeaturesContext = createContext({ +export const EnabledFeaturesContext = createContext<EnabledFeatures>({ + showContextualInsights: true, showNodeInfo: true, showMLNavMenu: true, showLicenseInfo: true, isADEnabled: true, isDFAEnabled: true, isNLPEnabled: true, + showRuleFormV2: true, }); interface Props { @@ -42,6 +45,7 @@ export const EnabledFeaturesContextProvider: FC<PropsWithChildren<Props>> = ({ experimentalFeatures, }) => { const features: EnabledFeatures = { + showContextualInsights: isServerless, showNodeInfo: !isServerless, showMLNavMenu, showLicenseInfo: !isServerless, diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index ca0617bedc1c..6c31aa8d043b 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -29,6 +29,7 @@ import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { SpacesContextProps, SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import { UpgradeWarning } from '../../../../components/upgrade/upgrade_warning'; import { getMlGlobalServices } from '../../../../util/get_services'; import { EnabledFeaturesContextProvider } from '../../../../contexts/ml'; import { type MlFeatures, PLUGIN_ID } from '../../../../../../common/constants/app'; @@ -71,6 +72,7 @@ export const JobsListPage: FC<Props> = ({ }) => { const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); + const [isUpgradeInProgress, setIsUpgradeInProgress] = useState(false); const [isPlatinumOrTrialLicense, setIsPlatinumOrTrialLicense] = useState(true); const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [currentTabId, setCurrentTabId] = useState<MlSavedObjectType>('anomaly-detector'); @@ -88,6 +90,8 @@ export const JobsListPage: FC<Props> = ({ } catch (e) { if (e.mlFeatureEnabledInSpace && e.isPlatinumOrTrialLicense === false) { setIsPlatinumOrTrialLicense(false); + } else if (e.isUpgradeInProgress) { + setIsUpgradeInProgress(true); } else { setAccessDenied(true); } @@ -117,6 +121,28 @@ export const JobsListPage: FC<Props> = ({ setShowSyncFlyout(false); } + if (isUpgradeInProgress) { + return ( + <I18nProvider> + <KibanaRenderContextProvider {...coreStart}> + <KibanaContextProvider + services={{ + ...coreStart, + share, + data, + usageCollection, + fieldFormats, + spacesApi, + mlServices, + }} + > + <UpgradeWarning /> + </KibanaContextProvider> + </KibanaRenderContextProvider> + </I18nProvider> + ); + } + if (accessDenied) { return ( <I18nProvider> diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 27d158dfb130..5be55fead11e 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -57,6 +57,9 @@ import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_u import { LinksMenuUI } from '../../../components/anomalies_table/links_menu'; import { RuleEditorFlyout } from '../../../components/rule_editor'; +const percentFocusChartHeight = 0.634; +const minSvgHeight = 350; + const focusZoomPanelHeight = 25; const focusChartHeight = 310; const focusHeight = focusZoomPanelHeight + focusChartHeight; @@ -90,10 +93,27 @@ const anomalyGrayScale = d3.scale .domain([3, 25, 50, 75, 100]) .range(['#dce7ed', '#b0c5d6', '#b1a34e', '#b17f4e', '#c88686']); -function getSvgHeight(showAnnotations) { +function getChartHeights(height) { + const actualHeight = height < minSvgHeight ? minSvgHeight : height; + const focusChartHeight = Math.round(actualHeight * percentFocusChartHeight); + + const heights = { + focusChartHeight, + focusHeight: focusZoomPanelHeight + focusChartHeight, + }; + return heights; +} + +function getSvgHeight(showAnnotations, incomingHeight) { const adjustedAnnotationHeight = showAnnotations ? annotationHeight : 0; + const incomingHeightActual = + incomingHeight && incomingHeight < minSvgHeight ? minSvgHeight : incomingHeight; + const { focusHeight: focusHeightIncoming } = incomingHeight + ? getChartHeights(incomingHeightActual) + : {}; + return ( - focusHeight + + (focusHeightIncoming ?? focusHeight) + contextChartHeight + swimlaneHeight + adjustedAnnotationHeight + @@ -168,13 +188,18 @@ class TimeseriesChartIntl extends Component { this.context.services.uiSettings ).getTimeBuckets; - const { svgWidth } = this.props; + const { svgWidth, svgHeight } = this.props; + const { focusHeight: focusHeightIncoming, focusChartHeight: focusChartIncoming } = svgHeight + ? getChartHeights(svgHeight) + : {}; this.vizWidth = svgWidth - margin.left - margin.right; const vizWidth = this.vizWidth; this.focusXScale = d3.time.scale().range([0, vizWidth]); - this.focusYScale = d3.scale.linear().range([focusHeight, focusZoomPanelHeight]); + this.focusYScale = d3.scale + .linear() + .range([focusHeightIncoming ?? focusHeight, focusZoomPanelHeight]); const focusXScale = this.focusXScale; const focusYScale = this.focusYScale; @@ -182,7 +207,7 @@ class TimeseriesChartIntl extends Component { .axis() .scale(focusXScale) .orient('bottom') - .innerTickSize(-focusChartHeight) + .innerTickSize(-(focusChartIncoming ?? focusChartHeight)) .outerTickSize(0) .tickPadding(10); this.focusYAxis = d3.svg @@ -292,6 +317,7 @@ class TimeseriesChartIntl extends Component { modelPlotEnabled, selectedJob, svgWidth, + svgHeight: incomingSvgHeight, showAnnotations, } = this.props; @@ -301,7 +327,10 @@ class TimeseriesChartIntl extends Component { const focusYAxis = this.focusYAxis; const focusYScale = this.focusYScale; - const svgHeight = getSvgHeight(showAnnotations); + const svgHeight = getSvgHeight(showAnnotations, incomingSvgHeight); + const { focusHeight: focusHeightIncoming } = incomingSvgHeight + ? getChartHeights(incomingSvgHeight) + : {}; // Clear any existing elements from the visualization, // then build the svg elements for the bubble chart. @@ -391,6 +420,10 @@ class TimeseriesChartIntl extends Component { focusXScale.range([0, this.vizWidth]); focusYAxis.innerTickSize(-this.vizWidth); + if (focusHeightIncoming !== undefined) { + focusYScale.range([focusHeightIncoming, focusZoomPanelHeight]); + } + const focus = svg .append('g') .attr('class', 'focus-chart') @@ -401,7 +434,11 @@ class TimeseriesChartIntl extends Component { .attr('class', 'context-chart') .attr( 'transform', - 'translate(' + margin.left + ',' + (focusHeight + margin.top + chartSpacing) + ')' + 'translate(' + + margin.left + + ',' + + ((focusHeightIncoming ?? focusHeight) + margin.top + chartSpacing) + + ')' ); // Mask to hide annotations overflow @@ -412,11 +449,11 @@ class TimeseriesChartIntl extends Component { .attr('x', 0) .attr('y', 0) .attr('width', this.vizWidth) - .attr('height', focusHeight) + .attr('height', focusHeightIncoming ?? focusHeight) .style('fill', 'white'); // Draw each of the component elements. - createFocusChart(focus, this.vizWidth, focusHeight); + createFocusChart(focus, this.vizWidth, focusHeightIncoming ?? focusHeight); drawContextElements( context, this.vizWidth, @@ -491,6 +528,9 @@ class TimeseriesChartIntl extends Component { // as we want to re-render the paths and points when the zoom area changes. const { contextForecastData } = this.props; + const { focusChartHeight: focusChartIncoming } = this.props.svgHeight + ? getChartHeights(this.props.svgHeight) + : {}; // Add a group at the top to display info on the chart aggregation interval // and links to set the brush span to 1h, 1d, 1w etc. @@ -524,7 +564,7 @@ class TimeseriesChartIntl extends Component { .attr('x', brushX) .attr('y', focusZoomPanelHeight) .attr('width', brushWidth) - .attr('height', focusChartHeight); + .attr('height', focusChartIncoming ?? focusChartHeight); fcsGroup.append('g').classed('mlAnnotations', true); @@ -534,7 +574,7 @@ class TimeseriesChartIntl extends Component { .attr('x', 0) .attr('y', focusZoomPanelHeight) .attr('width', fcsWidth) - .attr('height', focusChartHeight) + .attr('height', focusChartIncoming ?? focusChartHeight) .attr('class', 'chart-border'); // Add background for x axis. @@ -767,11 +807,16 @@ class TimeseriesChartIntl extends Component { .classed('hidden', !showModelBounds); } + const { focusChartHeight: focusChartIncoming, focusHeight: focusHeightIncoming } = this.props + .svgHeight + ? getChartHeights(this.props.svgHeight) + : {}; + renderAnnotations( focusChart, focusAnnotationData, focusZoomPanelHeight, - focusChartHeight, + focusChartIncoming ?? focusChartHeight, this.focusXScale, showAnnotations, showFocusChartTooltip, @@ -904,7 +949,7 @@ class TimeseriesChartIntl extends Component { .attr('x', (d) => this.focusXScale(d.date) - LINE_CHART_ANOMALY_RADIUS) .attr('y', (d) => { const focusYValue = this.focusYScale(d.value); - return isNaN(focusYValue) ? -focusHeight - 3 : focusYValue - 3; + return isNaN(focusYValue) ? -(focusHeightIncoming ?? focusHeight) - 3 : focusYValue - 3; }); // Plot any forecast data in scope. diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx index 1c9bbc0f9afe..3a5fe63f7a81 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx @@ -20,7 +20,6 @@ import { useTimeBucketsService } from '../../../util/time_buckets_service'; import { getControlsForDetector } from '../../get_controls_for_detector'; import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context'; import type { SourceIndicesWithGeoFields } from '../../../explorer/explorer_utils'; - interface TimeSeriesChartWithTooltipsProps { bounds: any; detectorIndex: number; @@ -137,6 +136,11 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> = contextAggregationInterval, ]); + if (chartProps.svgHeight) { + // 32 accounts for the height of the chart title + chartProps.svgHeight -= 32; + } + return ( <div className="ml-timeseries-chart" data-test-subj="mlSingleMetricViewerChart"> <MlTooltipComponent> diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js index 4c09d4fe4adb..06ae1979c255 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js @@ -78,6 +78,7 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component { autoZoomDuration: PropTypes.number.isRequired, bounds: PropTypes.object.isRequired, chartWidth: PropTypes.number.isRequired, + chartHeight: PropTypes.number, lastRefresh: PropTypes.number.isRequired, onRenderComplete: PropTypes.func, previousRefresh: PropTypes.number.isRequired, @@ -752,6 +753,7 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component { autoZoomDuration, bounds, chartWidth, + chartHeight, lastRefresh, selectedDetectorIndex, selectedJob, @@ -798,6 +800,7 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component { focusForecastData, focusAggregationInterval, svgWidth: chartWidth, + svgHeight: chartHeight, zoomFrom, zoomTo, zoomFromFocusLoaded, diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 81c73c7cf967..0a7e44959a26 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -16,6 +16,7 @@ import type { import { BehaviorSubject, mergeMap } from 'rxjs'; import { take } from 'rxjs'; +import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { ManagementSetup } from '@kbn/management-plugin/public'; import type { LocatorPublic, SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; @@ -88,6 +89,7 @@ export interface MlStartDependencies { lens: LensPublicStart; licensing: LicensingPluginStart; maps?: MapsStartApi; + observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; presentationUtil: PresentationUtilPluginStart; savedObjectsManagement: SavedObjectsManagementPluginStart; savedSearch: SavedSearchPublicPluginStart; @@ -180,6 +182,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> { licensing: pluginsStart.licensing, management: pluginsSetup.management, maps: pluginsStart.maps, + observabilityAIAssistant: pluginsStart.observabilityAIAssistant, presentationUtil: pluginsStart.presentationUtil, savedObjectsManagement: pluginsStart.savedObjectsManagement, savedSearch: pluginsStart.savedSearch, diff --git a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx index a4c6681b45ef..25cd21855912 100644 --- a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx +++ b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx @@ -84,7 +84,10 @@ const SingleMetricViewerWrapper: FC<SingleMetricViewerPropsWithDeps> = ({ selectedJobId, uuid, }) => { - const [chartWidth, setChartWidth] = useState<number>(0); + const [chartDimensions, setChartDimensions] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); const [zoom, setZoom] = useState<Zoom>(); const [selectedForecastId, setSelectedForecastId] = useState<ForecastId>(); const [selectedJob, setSelectedJob] = useState<MlJob | undefined>(); @@ -150,11 +153,14 @@ const SingleMetricViewerWrapper: FC<SingleMetricViewerPropsWithDeps> = ({ // eslint-disable-next-line react-hooks/exhaustive-deps const resizeHandler = useCallback( throttle((e: { width: number; height: number }) => { - if (Math.abs(chartWidth - e.width) > minElemAndChartDiff) { - setChartWidth(e.width); + if ( + Math.abs(chartDimensions.width - e.width) > minElemAndChartDiff || + Math.abs(chartDimensions.height - e.height) > minElemAndChartDiff + ) { + setChartDimensions(e); } }, RESIZE_THROTTLE_TIME_MS), - [chartWidth] + [chartDimensions.width, chartDimensions.height] ); const autoZoomDuration = useMemo(() => { @@ -220,7 +226,8 @@ const SingleMetricViewerWrapper: FC<SingleMetricViewerPropsWithDeps> = ({ jobsLoaded && selectedJobId === selectedJob?.job_id && ( <TimeSeriesExplorerEmbeddableChart - chartWidth={chartWidth - containerPadding} + chartWidth={chartDimensions.width - containerPadding} + chartHeight={chartDimensions.height - containerPadding} dataViewsService={pluginStart.data.dataViews} toastNotificationService={toastNotificationService} appStateHandler={appStateHandler} diff --git a/x-pack/plugins/ml/server/models/model_management/models_provider.ts b/x-pack/plugins/ml/server/models/model_management/models_provider.ts index c56e6ba599d0..e76fa13dc8f6 100644 --- a/x-pack/plugins/ml/server/models/model_management/models_provider.ts +++ b/x-pack/plugins/ml/server/models/model_management/models_provider.ts @@ -597,11 +597,28 @@ export class ModelsProvider { taskType: InferenceTaskType, modelConfig: InferenceModelConfig ) { - return await this._client.asCurrentUser.inference.putModel({ - inference_id: inferenceId, - task_type: taskType, - model_config: modelConfig, - }); + try { + const result = await this._client.asCurrentUser.inference.putModel( + { + inference_id: inferenceId, + task_type: taskType, + model_config: modelConfig, + }, + { maxRetries: 0 } + ); + return result; + } catch (error) { + // Request timeouts will usually occur when the model is being downloaded/deployed + // Erroring out is misleading in these cases, so we return the model_id and task_type + if (error.name === 'TimeoutError') { + return { + model_id: modelConfig.service, + task_type: taskType, + }; + } else { + throw error; + } + } } async getModelsDownloadStatus() { diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index 1d186a66893a..ba985c8b0d39 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -147,7 +147,7 @@ export function systemRoutes( path: `${ML_INTERNAL_BASE_PATH}/ml_node_count`, access: 'internal', options: { - tags: ['access:ml:canGetJobs', 'access:ml:canGetDatafeeds'], + tags: ['access:ml:canGetMlInfo'], }, }) .addVersion( diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index d20516c7d99e..516d156cf04d 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -127,6 +127,7 @@ "@kbn/react-kibana-context-render", "@kbn/esql-utils", "@kbn/core-lifecycle-browser", - "@kbn/json-schemas", - ], + "@kbn/observability-ai-assistant-plugin", + "@kbn/json-schemas" + ] } diff --git a/x-pack/plugins/observability_solution/apm/common/agent_configuration/setting_definitions/java_settings.ts b/x-pack/plugins/observability_solution/apm/common/agent_configuration/setting_definitions/java_settings.ts index b9f50ec9e85f..efba2ca061e7 100644 --- a/x-pack/plugins/observability_solution/apm/common/agent_configuration/setting_definitions/java_settings.ts +++ b/x-pack/plugins/observability_solution/apm/common/agent_configuration/setting_definitions/java_settings.ts @@ -49,7 +49,7 @@ export const javaSettings: RawSettingDefinition[] = [ { key: 'circuit_breaker_enabled', label: i18n.translate('xpack.apm.agentConfig.circuitBreakerEnabled.label', { - defaultMessage: 'Cirtcuit breaker enabled', + defaultMessage: 'Circuit breaker enabled', }), type: 'boolean', category: 'Circuit-Breaker', diff --git a/x-pack/plugins/observability_solution/apm/common/correlations/constants.ts b/x-pack/plugins/observability_solution/apm/common/correlations/constants.ts index f42c5b1c4a81..26421839b10a 100644 --- a/x-pack/plugins/observability_solution/apm/common/correlations/constants.ts +++ b/x-pack/plugins/observability_solution/apm/common/correlations/constants.ts @@ -67,8 +67,6 @@ export const FIELD_PREFIX_TO_ADD_AS_CANDIDATE = ['cloud.', 'labels.', 'user_agen /** * Other constants */ -export const POPULATED_DOC_COUNT_SAMPLE_SIZE = 1000; - export const PERCENTILES_STEP = 2; export const TERMS_SIZE = 20; export const SIGNIFICANT_FRACTION = 3; diff --git a/x-pack/plugins/observability_solution/apm/common/entities/types.ts b/x-pack/plugins/observability_solution/apm/common/entities/types.ts index 7b6d6fa7467c..43b5531d211a 100644 --- a/x-pack/plugins/observability_solution/apm/common/entities/types.ts +++ b/x-pack/plugins/observability_solution/apm/common/entities/types.ts @@ -9,6 +9,7 @@ import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; export enum SignalTypes { METRICS = 'metrics', + TRACES = 'traces', LOGS = 'logs', } diff --git a/x-pack/plugins/observability_solution/apm/public/utils/flatten_object.test.ts b/x-pack/plugins/observability_solution/apm/common/utils/flatten_object.test.ts similarity index 100% rename from x-pack/plugins/observability_solution/apm/public/utils/flatten_object.test.ts rename to x-pack/plugins/observability_solution/apm/common/utils/flatten_object.test.ts diff --git a/x-pack/plugins/observability_solution/apm/public/utils/flatten_object.ts b/x-pack/plugins/observability_solution/apm/common/utils/flatten_object.ts similarity index 100% rename from x-pack/plugins/observability_solution/apm/public/utils/flatten_object.ts rename to x-pack/plugins/observability_solution/apm/common/utils/flatten_object.ts diff --git a/x-pack/plugins/observability_solution/apm/common/utils/formatters/formatters.ts b/x-pack/plugins/observability_solution/apm/common/utils/formatters/formatters.ts index 5093f22ee754..b19afc1efb3d 100644 --- a/x-pack/plugins/observability_solution/apm/common/utils/formatters/formatters.ts +++ b/x-pack/plugins/observability_solution/apm/common/utils/formatters/formatters.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { asPercent as obltAsPercent } from '@kbn/observability-plugin/common'; import numeral from '@elastic/numeral'; import { Maybe } from '../../../typings/common'; import { NOT_AVAILABLE_LABEL } from '../../i18n'; @@ -82,3 +83,7 @@ export function asBigNumber(value: number): string { return `${asInteger(value / 1e12)}t`; } + +export const yLabelAsPercent = (y?: number | null) => { + return obltAsPercent(y || 0, 1); +}; diff --git a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/dependencies/dependencies.cy.ts b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/dependencies/dependencies.cy.ts index 9fd6dc9bb48b..d5e22e38c5d5 100644 --- a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/dependencies/dependencies.cy.ts +++ b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/dependencies/dependencies.cy.ts @@ -112,8 +112,7 @@ describe('Dependencies', () => { }); }); -// FLAKY: https://github.com/elastic/kibana/issues/179083 -describe.skip('Dependencies with high volume of data', () => { +describe('Dependencies with high volume of data', () => { before(() => { synthtrace.index( generateManyDependencies({ diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx index d781e9cbf4e5..670521bb6086 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx @@ -27,6 +27,7 @@ interface CorrelationsTableProps<T extends FieldValuePair> { selectedTerm?: FieldValuePair; onFilter?: () => void; columns: Array<EuiBasicTableColumn<T>>; + rowHeader?: string; onTableChange: (c: Criteria<T>) => void; sorting?: EuiTableSortingType<T>; } @@ -40,6 +41,7 @@ export function CorrelationsTable<T extends FieldValuePair>({ selectedTerm, onTableChange, sorting, + rowHeader, }: CorrelationsTableProps<T>) { const euiTheme = useTheme(); const trackApmEvent = useUiTracker({ app: 'apm' }); @@ -85,6 +87,7 @@ export function CorrelationsTable<T extends FieldValuePair>({ loading={status === FETCH_STATUS.LOADING} error={status === FETCH_STATUS.FAILURE ? errorMessage : ''} columns={columns} + rowHeader={rowHeader} rowProps={(term) => { return { onClick: () => { diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx index d91f9e1935f7..9149be49e298 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -14,10 +14,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiIcon, EuiTitle, EuiBadge, - EuiToolTip, EuiSwitch, EuiIconTip, } from '@elastic/eui'; @@ -108,30 +106,28 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v width: '100px', field: 'pValue', name: ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.pValueDescription', + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel', { - defaultMessage: - 'The chance of getting at least this amount of field name and value for failed transactions given its prevalence in successful transactions.', + defaultMessage: 'p-value', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel', +   + <EuiIconTip + size="s" + color="subdued" + type="questionInCircle" + className="eui-alignTop" + content={i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.pValueDescription', { - defaultMessage: 'p-value', + defaultMessage: + 'The chance of getting at least this amount of field name and value for failed transactions given its prevalence in successful transactions.', } )} - <EuiIcon - size="s" - color="subdued" - type="questionInCircle" - className="eui-alignTop" - /> - </> - </EuiToolTip> + /> + </> ), render: (pValue: number) => pValue.toPrecision(3), @@ -141,29 +137,27 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v width: '100px', field: 'failurePercentage', name: ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.failurePercentageDescription', + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.failurePercentageLabel', { - defaultMessage: 'Percentage of time the term appear in failed transactions.', + defaultMessage: 'Failure %', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.failurePercentageLabel', +   + <EuiIconTip + size="s" + color="subdued" + type="questionInCircle" + className="eui-alignTop" + content={i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.failurePercentageDescription', { - defaultMessage: 'Failure %', + defaultMessage: 'Percentage of time the term appear in failed transactions.', } )} - <EuiIcon - size="s" - color="subdued" - type="questionInCircle" - className="eui-alignTop" - /> - </> - </EuiToolTip> + /> + </> ), render: (_, { failurePercentage }) => asPercent(failurePercentage, 1), sortable: true, @@ -172,32 +166,29 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v field: 'successPercentage', width: '100px', name: ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.successPercentageDescription', + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.successPercentageLabel', { - defaultMessage: - 'Percentage of time the term appear in successful transactions.', + defaultMessage: 'Success %', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.successPercentageLabel', +   + <EuiIconTip + size="s" + color="subdued" + type="questionInCircle" + className="eui-alignTop" + content={i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.successPercentageDescription', { - defaultMessage: 'Success %', + defaultMessage: + 'Percentage of time the term appear in successful transactions.', } )} - <EuiIcon - size="s" - color="subdued" - type="questionInCircle" - className="eui-alignTop" - /> - </> - </EuiToolTip> + /> + </> ), - render: (_, { successPercentage }) => asPercent(successPercentage, 1), sortable: true, }, @@ -208,25 +199,28 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v width: '116px', field: 'normalizedScore', name: ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreTooltip', + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', { - defaultMessage: - 'The score [0-1] of an attribute; the greater the score, the more an attribute contributes to failed transactions.', + defaultMessage: 'Score', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', +   + <EuiIconTip + size="s" + color="subdued" + type="questionInCircle" + className="eui-alignTop" + content={i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreTooltip', { - defaultMessage: 'Score', + defaultMessage: + 'The score [0-1] of an attribute; the greater the score, the more an attribute contributes to failed transactions.', } )} - <EuiIcon size="s" color="subdued" type="questionInCircle" className="eui-alignTop" /> - </> - </EuiToolTip> + /> + </> ), render: (_, { normalizedScore }) => { return <div>{asPreciseDecimal(normalizedScore, 2)}</div>; @@ -515,6 +509,7 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v {showCorrelationsTable && ( <CorrelationsTable<FailedTransactionsCorrelation> columns={failedTransactionsCorrelationsColumns} + rowHeader="normalizedScore" significantTerms={correlationTerms} status={progress.isRunning ? FETCH_STATUS.LOADING : FETCH_STATUS.SUCCESS} setPinnedSignificantTerm={setPinnedSignificantTerm} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx index 33310928a398..1183338b0f4b 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx @@ -10,14 +10,13 @@ import { useHistory } from 'react-router-dom'; import { orderBy } from 'lodash'; import { - EuiIcon, EuiBasicTableColumn, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, - EuiToolTip, EuiBadge, + EuiIconTip, } from '@elastic/eui'; import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; @@ -123,25 +122,28 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { width: '116px', field: 'correlation', name: ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationColumnDescription', + <> + {i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel', { - defaultMessage: - 'The correlation score [0-1] of an attribute; the greater the score, the more an attribute increases latency.', + defaultMessage: 'Correlation', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel', +   + <EuiIconTip + content={i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationColumnDescription', { - defaultMessage: 'Correlation', + defaultMessage: + 'The correlation score [0-1] of an attribute; the greater the score, the more an attribute increases latency.', } )} - <EuiIcon size="s" color="subdued" type="questionInCircle" className="eui-alignTop" /> - </> - </EuiToolTip> + size="s" + color="subdued" + type="questionInCircle" + className="eui-alignTop" + /> + </> ), render: (_, { correlation }) => { return <div>{asPreciseDecimal(correlation, 2)}</div>; @@ -364,6 +366,7 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { {showCorrelationsTable && ( <CorrelationsTable<LatencyCorrelation> columns={mlCorrelationColumns} + rowHeader="correlation" significantTerms={histogramTerms} status={progress.isRunning ? FETCH_STATUS.LOADING : FETCH_STATUS.SUCCESS} setPinnedSignificantTerm={setPinnedSignificantTerm} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/entities/charts/log_error_rate_chart.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/entities/charts/log_error_rate_chart.tsx new file mode 100644 index 000000000000..56daf2d42d79 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/entities/charts/log_error_rate_chart.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiPanel, EuiTitle, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { getTimeSeriesColor, ChartType } from '../../../shared/charts/helper/get_timeseries_color'; +import { TimeseriesChartWithContext } from '../../../shared/charts/timeseries_chart_with_context'; +import { yLabelAsPercent } from '../../../../../common/utils/formatters'; + +type LogErrorRateReturnType = + APIReturnType<'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries'>; + +const INITIAL_STATE: LogErrorRateReturnType = { + currentPeriod: {}, +}; + +export function LogErrorRateChart({ height }: { height: number }) { + const { + query: { rangeFrom, rangeTo, environment, kuery }, + path: { serviceName }, + } = useApmParams('/logs-services/{serviceName}'); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { data = INITIAL_STATE, status } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi( + 'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries', + { + params: { + path: { + serviceName, + }, + query: { + environment, + kuery, + start, + end, + }, + }, + } + ); + } + }, + [environment, kuery, serviceName, start, end] + ); + const { currentPeriodColor } = getTimeSeriesColor(ChartType.LOG_ERROR_RATE); + + const timeseries = [ + { + data: data?.currentPeriod?.[serviceName] ?? [], + type: 'linemark', + color: currentPeriodColor, + title: i18n.translate('xpack.apm.logs.chart.logsErrorRate', { + defaultMessage: 'Log Error Rate', + }), + }, + ]; + + return ( + <EuiPanel hasBorder> + <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}> + <EuiFlexItem grow={false}> + <EuiTitle size="xs"> + <h2> + {i18n.translate('xpack.apm.logErrorRate', { + defaultMessage: 'Log error rate', + })} + </h2> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + + <TimeseriesChartWithContext + id="logErrorRate" + height={height} + showAnnotations={false} + fetchStatus={status} + timeseries={timeseries} + yLabelFormat={yLabelAsPercent} + yDomain={{ min: 0, max: 1 }} + /> + </EuiPanel> + ); +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/entities/charts/log_rate_chart.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/entities/charts/log_rate_chart.tsx new file mode 100644 index 000000000000..a52b4c9f73d4 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/entities/charts/log_rate_chart.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiPanel, EuiTitle, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { getTimeSeriesColor, ChartType } from '../../../shared/charts/helper/get_timeseries_color'; +import { TimeseriesChartWithContext } from '../../../shared/charts/timeseries_chart_with_context'; +import { yLabelAsPercent } from '../../../../../common/utils/formatters'; + +type LogRateReturnType = + APIReturnType<'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries'>; + +const INITIAL_STATE: LogRateReturnType = { + currentPeriod: {}, +}; + +export function LogRateChart({ height }: { height: number }) { + const { + query: { rangeFrom, rangeTo, environment, kuery }, + path: { serviceName }, + } = useApmParams('/logs-services/{serviceName}'); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { data = INITIAL_STATE, status } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi( + 'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries', + { + params: { + path: { + serviceName, + }, + query: { + environment, + kuery, + start, + end, + }, + }, + } + ); + } + }, + [environment, kuery, serviceName, start, end] + ); + const { currentPeriodColor } = getTimeSeriesColor(ChartType.LOG_RATE); + + const timeseries = [ + { + data: data?.currentPeriod?.[serviceName] ?? [], + type: 'linemark', + color: currentPeriodColor, + title: i18n.translate('xpack.apm.logs.chart.logRate', { + defaultMessage: 'Log Rate', + }), + }, + ]; + + return ( + <EuiPanel hasBorder> + <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}> + <EuiFlexItem grow={false}> + <EuiTitle size="xs"> + <h2> + {i18n.translate('xpack.apm.logRate', { + defaultMessage: 'Log rate', + })} + </h2> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + + <TimeseriesChartWithContext + id="logRate" + height={height} + showAnnotations={false} + fetchStatus={status} + timeseries={timeseries} + yLabelFormat={yLabelAsPercent} + yDomain={{ min: 0, max: 1 }} + /> + </EuiPanel> + ); +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/entities/logs/add_apm_callout.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/entities/logs/add_apm_callout.tsx new file mode 100644 index 000000000000..77bec8cebb18 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/entities/logs/add_apm_callout.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + EuiButtonEmpty, + useEuiTheme, +} from '@elastic/eui'; +import { apmLight } from '@kbn/shared-svg'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; + +export function AddAPMCallOut() { + const { core } = useApmPluginContext(); + const { euiTheme } = useEuiTheme(); + + return ( + <EuiPanel color="subdued" hasShadow={false}> + <EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexStart"> + <EuiFlexItem grow={0}> + <EuiImage + css={{ + background: euiTheme.colors.emptyShade, + }} + width="160" + height="100" + size="m" + src={apmLight} + alt="apm-logo" + /> + </EuiFlexItem> + <EuiFlexItem grow={4}> + <EuiTitle size="xs"> + <h1> + <FormattedMessage + id="xpack.apm.addAPMCallOut.title" + defaultMessage="Detect and resolve issues faster with deep visibility into your application" + /> + </h1> + </EuiTitle> + + <EuiSpacer size="m" /> + + <EuiText size="s"> + <p> + <FormattedMessage + id="xpack.apm.addAPMCallOut.description" + defaultMessage="Understanding your application performance, relationships and dependencies by + instrumenting with APM." + /> + </p> + </EuiText> + <EuiSpacer size="s" /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <div> + <EuiButton + data-test-subj="apmAddApmCallOutButton" + href={core.http.basePath.prepend('/app/apm/tutorial')} + > + {i18n.translate('xpack.apm.logsServiceOverview.callout.addApm', { + defaultMessage: 'Add APM', + })} + </EuiButton> + </div> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + data-test-subj="apmAddApmCallOutLearnMoreButton" + iconType="popout" + iconSide="right" + href="https://www.elastic.co/observability/application-performance-monitoring" + > + {i18n.translate('xpack.apm.addAPMCallOut.linkToElasticcoButtonEmptyLabel', { + defaultMessage: 'Learn more', + })} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + ); +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/entities/logs/logs_service_overview.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/entities/logs/logs_service_overview.tsx new file mode 100644 index 000000000000..d1f08b16eaf1 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/entities/logs/logs_service_overview.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. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { EuiFlexGroupProps, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { AnnotationsContextProvider } from '../../../../context/annotations/annotations_context'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context'; +import { useBreakpoints } from '../../../../hooks/use_breakpoints'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { AddAPMCallOut } from './add_apm_callout'; +import { LogRateChart } from '../charts/log_rate_chart'; +import { LogErrorRateChart } from '../charts/log_error_rate_chart'; +/** + * The height a chart should be if it's next to a table with 5 rows and a title. + * Add the height of the pagination row. + */ + +const chartHeight = 400; + +export function LogsServiceOverview() { + const { serviceName } = useApmServiceContext(); + + const { + query: { environment, rangeFrom, rangeTo }, + } = useApmParams('/logs-services/{serviceName}/overview'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { isLarge } = useBreakpoints(); + const isSingleColumn = isLarge; + + const rowDirection: EuiFlexGroupProps['direction'] = isSingleColumn ? 'column' : 'row'; + + return ( + <AnnotationsContextProvider + serviceName={serviceName} + environment={environment} + start={start} + end={end} + > + <ChartPointerEventContextProvider> + <AddAPMCallOut /> + <EuiSpacer size="l" /> + + <EuiFlexGroup direction="column" gutterSize="s"> + <EuiFlexItem> + <EuiFlexGroup direction={rowDirection} gutterSize="s" responsive={false}> + <EuiFlexItem grow={4}> + <LogRateChart height={chartHeight} /> + </EuiFlexItem> + <EuiFlexItem grow={4}> + <LogErrorRateChart height={chartHeight} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </ChartPointerEventContextProvider> + </AnnotationsContextProvider> + ); +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/error_group_details/top_erroneous_transactions/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/error_group_details/top_erroneous_transactions/index.tsx index 50d63b305a64..f81d6b8d7abf 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/error_group_details/top_erroneous_transactions/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/error_group_details/top_erroneous_transactions/index.tsx @@ -154,6 +154,7 @@ export function TopErroneousTransactions({ serviceName }: Props) { <EuiBasicTable items={data.topErroneousTransactions} columns={columns} + rowHeader="transactionName" loading={loading} data-test-subj="topErroneousTransactionsTable" error={ diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/index.tsx index 0a9a86f7cbd7..db8a2c1bac8c 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/index.tsx @@ -45,13 +45,14 @@ export interface MergedServiceDashboard extends SavedApmCustomDashboard { title: string; } -export function ServiceDashboards() { +export function ServiceDashboards({ checkForEntities = false }: { checkForEntities?: boolean }) { const { path: { serviceName }, query: { environment, kuery, rangeFrom, rangeTo, dashboardId }, } = useAnyOfApmParams( '/services/{serviceName}/dashboards', - '/mobile-services/{serviceName}/dashboards' + '/mobile-services/{serviceName}/dashboards', + '/logs-services/{serviceName}/dashboards' ); const [dashboard, setDashboard] = useState<AwaitingDashboardAPI>(); const [serviceDashboards, setServiceDashboards] = useState<MergedServiceDashboard[]>([]); @@ -68,12 +69,12 @@ export function ServiceDashboards() { isCachable: false, params: { path: { serviceName }, - query: { start, end }, + query: { start, end, checkFor: checkForEntities ? 'entities' : 'services' }, }, }); } }, - [serviceName, start, end] + [serviceName, start, end, checkForEntities] ); useEffect(() => { diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/apm_signal_inventory/service_list/apm_services_table.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/apm_signal_inventory/service_list/apm_services_table.tsx index 50f8a873b6c8..de2c45862d30 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/apm_signal_inventory/service_list/apm_services_table.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/apm_signal_inventory/service_list/apm_services_table.tsx @@ -9,7 +9,6 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiIconTip, EuiLink, EuiSpacer, @@ -436,15 +435,15 @@ export function ApmServicesTable({ )} {maxCountExceeded && ( <EuiFlexItem grow={false}> - <EuiToolTip + <EuiIconTip position="top" + type="warning" + color="danger" content={i18n.translate('xpack.apm.servicesTable.tooltip.maxCountExceededWarning', { defaultMessage: 'The limit of 1,000 services is exceeded. Please use the query bar to narrow down the results or create service groups.', })} - > - <EuiIcon type="warning" color="danger" /> - </EuiToolTip> + /> </EuiFlexItem> )} <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx index 349fc043c6f1..b68201894b0d 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx @@ -30,6 +30,7 @@ import { NotAvailableApmMetrics } from '../../../../shared/not_available_apm_met import { TruncateWithTooltip } from '../../../../shared/truncate_with_tooltip'; import { ServiceInventoryFieldName } from './multi_signal_services_table'; import { EntityServiceListItem, SignalTypes } from '../../../../../../common/entities/types'; +import { isApmSignal } from '../../../../../utils/get_signal_type'; export function getServiceColumns({ query, breakpoints, @@ -46,14 +47,19 @@ export function getServiceColumns({ defaultMessage: 'Name', }), sortable: true, - render: (_, { serviceName, agentName }) => ( + render: (_, { serviceName, agentName, signalTypes }) => ( <TruncateWithTooltip data-test-subj="apmServiceListAppLink" text={serviceName} content={ <EuiFlexGroup gutterSize="s" justifyContent="flexStart"> <EuiFlexItem grow={false}> - <ServiceLink serviceName={serviceName} agentName={agentName} query={query} /> + <ServiceLink + signalTypes={signalTypes} + serviceName={serviceName} + agentName={agentName} + query={query} + /> </EuiFlexItem> </EuiFlexGroup> } @@ -68,7 +74,12 @@ export function getServiceColumns({ sortable: true, width: `${unit * 9}px`, dataType: 'number', - render: (_, { environments }) => <EnvironmentBadge environments={environments} />, + render: (_, { environments, signalTypes }) => ( + <EnvironmentBadge + environments={environments} + isMetricsSignalType={signalTypes.includes(SignalTypes.METRICS)} + /> + ), align: RIGHT_ALIGNMENT, }, { @@ -82,7 +93,7 @@ export function getServiceColumns({ render: (_, { metrics, signalTypes }) => { const { currentPeriodColor } = getTimeSeriesColor(ChartType.LATENCY_AVG); - return !signalTypes.includes(SignalTypes.METRICS) ? ( + return !isApmSignal(signalTypes) ? ( <NotAvailableApmMetrics /> ) : ( <ListMetric @@ -105,7 +116,7 @@ export function getServiceColumns({ render: (_, { metrics, signalTypes }) => { const { currentPeriodColor } = getTimeSeriesColor(ChartType.THROUGHPUT); - return !signalTypes.includes(SignalTypes.METRICS) ? ( + return !isApmSignal(signalTypes) ? ( <NotAvailableApmMetrics /> ) : ( <ListMetric @@ -128,7 +139,7 @@ export function getServiceColumns({ render: (_, { metrics, signalTypes }) => { const { currentPeriodColor } = getTimeSeriesColor(ChartType.FAILED_TRANSACTION_RATE); - return !signalTypes.includes(SignalTypes.METRICS) ? ( + return !isApmSignal(signalTypes) ? ( <NotAvailableApmMetrics /> ) : ( <ListMetric 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 ab237154aea7..90f31e29b596 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 @@ -14,7 +14,7 @@ import { useApmServiceContext } from '../../../context/apm_service/use_apm_servi import { APIReturnType } from '../../../services/rest/create_call_apm_api'; import { CONTAINER_ID, SERVICE_ENVIRONMENT, SERVICE_NAME } from '../../../../common/es_fields/apm'; -import { useApmParams } from '../../../hooks/use_apm_params'; +import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; import { useTimeRange } from '../../../hooks/use_time_range'; export function ServiceLogs() { @@ -22,7 +22,7 @@ export function ServiceLogs() { const { query: { environment, kuery, rangeFrom, rangeTo }, - } = useApmParams('/services/{serviceName}/logs'); + } = useAnyOfApmParams('/services/{serviceName}/logs', '/logs-services/{serviceName}/logs'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx index 36857d601082..4fe5f5f583f1 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx @@ -16,6 +16,7 @@ import { TransactionLink } from '../app/transaction_link'; import { homeRoute } from './home'; import { serviceDetailRoute } from './service_detail'; import { mobileServiceDetailRoute } from './mobile_service_detail'; +import { logsServiceDetailsRoute } from './entities/logs_service_details'; import { settingsRoute } from './settings'; import { onboarding } from './onboarding'; import { tutorialRedirectRoute } from './onboarding/redirect'; @@ -130,6 +131,7 @@ const apmRoutes = { ...settingsRoute, ...serviceDetailRoute, ...mobileServiceDetailRoute, + ...logsServiceDetailsRoute, ...homeRoute, }, }, diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/entities/logs_service_details/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/entities/logs_service_details/index.tsx new file mode 100644 index 000000000000..90cf280688ec --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/entities/logs_service_details/index.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { toBooleanRt, toNumberRt } from '@kbn/io-ts-utils'; +import { Outlet } from '@kbn/typed-react-router-config'; +import * as t from 'io-ts'; +import React from 'react'; +import { LogsServiceTemplate } from '../../templates/entities/logs_service_template'; +import { offsetRt } from '../../../../../common/comparison_rt'; +import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; +import { environmentRt } from '../../../../../common/environment_rt'; +import { ApmTimeRangeMetadataContextProvider } from '../../../../context/time_range_metadata/time_range_metadata_context'; +import { ServiceDashboards } from '../../../app/service_dashboards'; +import { ServiceLogs } from '../../../app/service_logs'; +import { LogsServiceOverview } from '../../../app/entities/logs/logs_service_overview'; +import { RedirectToDefaultLogsServiceRouteView } from '../../service_detail/redirect_to_default_service_route_view'; + +export function page({ + title, + tabKey, + element, + searchBarOptions, +}: { + title: string; + tabKey: React.ComponentProps<typeof LogsServiceTemplate>['selectedTabKey']; + element: React.ReactElement<any, any>; + searchBarOptions?: { + showUnifiedSearchBar?: boolean; + showTransactionTypeSelector?: boolean; + showTimeComparison?: boolean; + showMobileFilters?: boolean; + hidden?: boolean; + }; +}): { + element: React.ReactElement<any, any>; +} { + return { + element: ( + <LogsServiceTemplate + title={title} + selectedTabKey={tabKey} + searchBarOptions={searchBarOptions} + > + {element} + </LogsServiceTemplate> + ), + }; +} + +export const logsServiceDetailsRoute = { + '/logs-services/{serviceName}': { + element: ( + <ApmTimeRangeMetadataContextProvider> + <Outlet /> + </ApmTimeRangeMetadataContextProvider> + ), + params: t.intersection([ + t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + t.type({ + query: t.intersection([ + environmentRt, + t.type({ + rangeFrom: t.string, + rangeTo: t.string, + kuery: t.string, + serviceGroup: t.string, + comparisonEnabled: toBooleanRt, + }), + t.partial({ + transactionType: t.string, + refreshPaused: t.union([t.literal('true'), t.literal('false')]), + refreshInterval: t.string, + }), + offsetRt, + ]), + }), + ]), + defaults: { + query: { + kuery: '', + environment: ENVIRONMENT_ALL.value, + serviceGroup: '', + }, + }, + children: { + '/logs-services/{serviceName}/overview': { + ...page({ + element: <LogsServiceOverview />, + tabKey: 'overview', + title: i18n.translate('xpack.apm.views.overview.title', { + defaultMessage: 'Overview', + }), + searchBarOptions: { + showUnifiedSearchBar: true, + }, + }), + params: t.partial({ + query: t.partial({ + page: toNumberRt, + pageSize: toNumberRt, + sortField: t.string, + sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + }), + }), + }, + '/logs-services/{serviceName}/logs': { + ...page({ + tabKey: 'logs', + title: i18n.translate('xpack.apm.views.logs.title', { + defaultMessage: 'Logs', + }), + element: <ServiceLogs />, + searchBarOptions: { + showUnifiedSearchBar: false, + }, + }), + }, + '/logs-services/{serviceName}/dashboards': { + ...page({ + tabKey: 'dashboards', + title: i18n.translate('xpack.apm.views.dashboard.title', { + defaultMessage: 'Dashboards', + }), + element: <ServiceDashboards checkForEntities />, + searchBarOptions: { + showUnifiedSearchBar: false, + }, + }), + params: t.partial({ + query: t.partial({ + dashboardId: t.string, + }), + }), + }, + '/logs-services/{serviceName}/': { + element: <RedirectToDefaultLogsServiceRouteView />, + }, + }, + }, +}; diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/redirect_to_default_service_route_view.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/redirect_to_default_service_route_view.tsx index bcf0fbd435ae..4a61888b919f 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/redirect_to_default_service_route_view.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/redirect_to_default_service_route_view.tsx @@ -19,3 +19,14 @@ export function RedirectToDefaultServiceRouteView() { return <Redirect to={{ pathname: `/services/${serviceName}/overview`, search }} />; } + +export function RedirectToDefaultLogsServiceRouteView() { + const { + path: { serviceName }, + query, + } = useApmParams('/logs-services/{serviceName}/*'); + + const search = qs.stringify(query); + + return <Redirect to={{ pathname: `/logs-services/${serviceName}/overview`, search }} />; +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/templates/entities/logs_service_template/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/entities/logs_service_template/index.tsx new file mode 100644 index 000000000000..71aca162c1ae --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/entities/logs_service_template/index.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPageHeaderProps, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { omit } from 'lodash'; +import React from 'react'; +import { ApmServiceContextProvider } from '../../../../../context/apm_service/apm_service_context'; +import { useBreadcrumb } from '../../../../../context/breadcrumbs/use_breadcrumb'; +import { ServiceAnomalyTimeseriesContextProvider } from '../../../../../context/service_anomaly_timeseries/service_anomaly_timeseries_context'; +import { useApmParams } from '../../../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../../../hooks/use_apm_router'; +import { useTimeRange } from '../../../../../hooks/use_time_range'; +import { MobileSearchBar } from '../../../../app/mobile/search_bar'; +import { SearchBar } from '../../../../shared/search_bar/search_bar'; +import { ServiceIcons } from '../../../../shared/service_icons'; +import { TechnicalPreviewBadge } from '../../../../shared/technical_preview_badge'; +import { ApmMainTemplate } from '../../apm_main_template'; + +type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & { + key: 'overview' | 'logs' | 'dashboards'; + hidden?: boolean; +}; + +interface Props { + title: string; + children: React.ReactChild; + selectedTabKey: Tab['key']; + searchBarOptions?: React.ComponentProps<typeof MobileSearchBar>; +} + +export function LogsServiceTemplate(props: Props) { + return ( + <ApmServiceContextProvider> + <TemplateWithContext {...props} /> + </ApmServiceContextProvider> + ); +} + +function TemplateWithContext({ title, children, selectedTabKey, searchBarOptions }: Props) { + const { + path: { serviceName }, + query, + query: { rangeFrom, rangeTo, environment }, + } = useApmParams('/logs-services/{serviceName}/*'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const router = useApmRouter(); + + const tabs = useTabs({ selectedTabKey }); + const selectedTab = tabs?.find(({ isSelected }) => isSelected); + + const servicesLink = router.link('/services', { + query: { ...query }, + }); + + useBreadcrumb( + () => [ + { + title: i18n.translate('xpack.apm.logServices.breadcrumb.title', { + defaultMessage: 'Services', + }), + href: servicesLink, + }, + ...(selectedTab + ? [ + { + title: serviceName, + href: router.link('/logs-services/{serviceName}', { + path: { serviceName }, + query, + }), + }, + { + title: selectedTab.label, + href: selectedTab.href, + } as { title: string; href: string }, + ] + : []), + ], + [query, router, selectedTab, serviceName, servicesLink] + ); + + return ( + <ApmMainTemplate + pageHeader={{ + tabs, + pageTitle: ( + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <EuiTitle size="l"> + <h1 data-test-subj="apmMainTemplateHeaderServiceName"> + {serviceName} <TechnicalPreviewBadge icon="beaker" /> + </h1> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <ServiceIcons + serviceName={serviceName} + environment={environment} + start={start} + end={end} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ), + }} + > + <SearchBar {...searchBarOptions} /> + <ServiceAnomalyTimeseriesContextProvider>{children}</ServiceAnomalyTimeseriesContextProvider> + </ApmMainTemplate> + ); +} + +function useTabs({ selectedTabKey }: { selectedTabKey: Tab['key'] }) { + const router = useApmRouter(); + + const { + path: { serviceName }, + query: queryFromUrl, + } = useApmParams(`/logs-services/{serviceName}/${selectedTabKey}` as const); + + const query = omit(queryFromUrl, 'page', 'pageSize', 'sortField', 'sortDirection'); + + const tabs: Tab[] = [ + { + key: 'overview', + href: router.link('/logs-services/{serviceName}/overview', { + path: { serviceName }, + query, + }), + label: i18n.translate('xpack.apm.logsServiceDetails.overviewTabLabel', { + defaultMessage: 'Overview', + }), + }, + { + key: 'logs', + href: router.link('/logs-services/{serviceName}/logs', { + path: { serviceName }, + query, + }), + label: i18n.translate('xpack.apm.logsServiceDetails.logsTabLabel', { + defaultMessage: 'Logs', + }), + }, + { + key: 'dashboards', + href: router.link('/logs-services/{serviceName}/dashboards', { + path: { serviceName }, + query, + }), + append: <TechnicalPreviewBadge icon="beaker" />, + label: i18n.translate('xpack.apm.logsServiceDetails.dashboardsTabLabel', { + defaultMessage: 'Dashboards', + }), + }, + ]; + + return tabs + .filter((t) => !t.hidden) + .map(({ href, key, label, append }) => ({ + href, + label, + append, + isSelected: key === selectedTabKey, + 'data-test-subj': `${key}Tab`, + })); +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx index 6863d76908a0..135848bae13d 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx @@ -12,12 +12,13 @@ import { NotAvailableEnvironment } from '../not_available_environment'; interface Props { environments: string[]; + isMetricsSignalType?: boolean; } -export function EnvironmentBadge({ environments = [] }: Props) { - return environments && environments.length > 0 ? ( +export function EnvironmentBadge({ environments = [], isMetricsSignalType = true }: Props) { + return isMetricsSignalType || (environments && environments.length > 0) ? ( <ItemsBadge - items={environments} + items={environments ?? []} multipleItemsMessage={i18n.translate('xpack.apm.servicesTable.environmentCount', { values: { environmentCount: environments.length }, defaultMessage: '{environmentCount, plural, one {1 environment} other {# environments}}', diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/is_route_with_time_range.ts b/x-pack/plugins/observability_solution/apm/public/components/shared/is_route_with_time_range.ts index f5ec67374593..f27b00dfc0c0 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/is_route_with_time_range.ts +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/is_route_with_time_range.ts @@ -25,6 +25,7 @@ export function isRouteWithTimeRange({ route.path === '/dependencies/inventory' || route.path === '/services/{serviceName}' || route.path === '/mobile-services/{serviceName}' || + route.path === '/logs-services/{serviceName}' || route.path === '/service-groups' || route.path === '/storage-explorer' || location.pathname === '/' || diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx index e5403397f842..fdd993fab6c2 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx @@ -8,7 +8,7 @@ import { castArray } from 'lodash'; import React, { TableHTMLAttributes } from 'react'; import { EuiTable, EuiTableProps, EuiTableBody, EuiTableRow, EuiTableRowCell } from '@elastic/eui'; import { FormattedValue } from './formatted_value'; -import { KeyValuePair } from '../../../utils/flatten_object'; +import { KeyValuePair } from '../../../../common/utils/flatten_object'; export function KeyValueTable({ keyValuePairs, diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/links/apm/service_link/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/links/apm/service_link/index.tsx index f031448b5d71..452631266056 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/links/apm/service_link/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/links/apm/service_link/index.tsx @@ -12,9 +12,11 @@ import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { TypeOf } from '@kbn/typed-react-router-config'; import React from 'react'; import { isMobileAgentName } from '../../../../../../common/agent_name'; +import { SignalTypes } from '../../../../../../common/entities/types'; import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n'; import { AgentName } from '../../../../../../typings/es_schemas/ui/fields/agent'; import { useApmRouter } from '../../../../../hooks/use_apm_router'; +import { isLogsSignal } from '../../../../../utils/get_signal_type'; import { truncate, unit } from '../../../../../utils/style'; import { ApmRoutes } from '../../../../routing/apm_route_config'; import { PopoverTooltip } from '../../../popover_tooltip'; @@ -32,12 +34,20 @@ interface ServiceLinkProps { query: TypeOf<ApmRoutes, '/services/{serviceName}/overview'>['query']; serviceName: string; serviceOverflowCount?: number; + signalTypes?: SignalTypes[]; } -export function ServiceLink({ agentName, query, serviceName }: ServiceLinkProps) { - const { link } = useApmRouter(); +export function ServiceLink({ + agentName, + query, + serviceName, + signalTypes = [SignalTypes.METRICS], +}: ServiceLinkProps) { + const apmRouter = useApmRouter(); const serviceLink = isMobileAgentName(agentName) ? '/mobile-services/{serviceName}/overview' + : isLogsSignal(signalTypes) + ? '/logs-services/{serviceName}/overview' : '/services/{serviceName}/overview'; if (serviceName === OTHER_SERVICE_NAME) { @@ -74,7 +84,7 @@ export function ServiceLink({ agentName, query, serviceName }: ServiceLinkProps) content={ <StyledLink data-test-subj={`serviceLink_${agentName}`} - href={link(serviceLink, { + href={apmRouter.link(serviceLink, { path: { serviceName }, query, })} diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/managed_table/index.tsx index e1d22482a30f..dcc5a5ee9c67 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/managed_table/index.tsx @@ -66,6 +66,7 @@ export const shouldfetchServer = ({ function UnoptimizedManagedTable<T extends object>(props: { items: T[]; columns: Array<ITableColumn<T>>; + rowHeader?: string | false; noItemsMessage?: React.ReactNode; isLoading?: boolean; error?: boolean; @@ -96,6 +97,7 @@ function UnoptimizedManagedTable<T extends object>(props: { const { items, columns, + rowHeader, noItemsMessage, isLoading = false, error = false, @@ -285,6 +287,7 @@ function UnoptimizedManagedTable<T extends object>(props: { } items={renderedItems} columns={columns as unknown as Array<EuiBasicTableColumn<T>>} // EuiBasicTableColumn is stricter than ITableColumn + rowHeader={rowHeader === false ? undefined : rowHeader ?? columns[0]?.field} sorting={sorting} onChange={onTableChange} {...(paginationProps ? { pagination: paginationProps } : {})} diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx index 81a7c2f8e18f..5dc9a8a5073b 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { Stackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; import { KeyValueTable } from '../key_value_table'; -import { flattenObject } from '../../../utils/flatten_object'; +import { flattenObject } from '../../../../common/utils/flatten_object'; const VariablesContainer = euiStyled.div` background: ${({ theme }) => theme.eui.euiColorEmptyShade}; diff --git a/x-pack/plugins/observability_solution/apm/public/context/apm_service/apm_service_context.tsx b/x-pack/plugins/observability_solution/apm/public/context/apm_service/apm_service_context.tsx index 3c2dd7006570..3fe833e6d9c6 100644 --- a/x-pack/plugins/observability_solution/apm/public/context/apm_service/apm_service_context.tsx +++ b/x-pack/plugins/observability_solution/apm/public/context/apm_service/apm_service_context.tsx @@ -47,7 +47,11 @@ export function ApmServiceContextProvider({ children }: { children: ReactNode }) path: { serviceName }, query, query: { kuery, rangeFrom, rangeTo }, - } = useAnyOfApmParams('/services/{serviceName}', '/mobile-services/{serviceName}'); + } = useAnyOfApmParams( + '/services/{serviceName}', + '/mobile-services/{serviceName}', + '/logs-services/{serviceName}' + ); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/observability_solution/apm/public/context/service_anomaly_timeseries/service_anomaly_timeseries_context.tsx b/x-pack/plugins/observability_solution/apm/public/context/service_anomaly_timeseries/service_anomaly_timeseries_context.tsx index 986730c99691..30e6481b951b 100644 --- a/x-pack/plugins/observability_solution/apm/public/context/service_anomaly_timeseries/service_anomaly_timeseries_context.tsx +++ b/x-pack/plugins/observability_solution/apm/public/context/service_anomaly_timeseries/service_anomaly_timeseries_context.tsx @@ -41,7 +41,11 @@ export function ServiceAnomalyTimeseriesContextProvider({ const { query: { rangeFrom, rangeTo }, - } = useAnyOfApmParams('/services/{serviceName}', '/mobile-services/{serviceName}'); + } = useAnyOfApmParams( + '/services/{serviceName}', + '/mobile-services/{serviceName}', + '/logs-services/{serviceName}' + ); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const { preferredEnvironment } = useEnvironmentsContext(); diff --git a/x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts b/x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts index c700ae7bd288..e319d4ce03ca 100644 --- a/x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts +++ b/x-pack/plugins/observability_solution/apm/public/hooks/use_current_user.ts @@ -8,26 +8,26 @@ import { useState, useEffect } from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { ApmPluginStartDeps } from '../plugin'; +import { ApmServices } from '../plugin'; export function useCurrentUser() { const { - services: { security }, - } = useKibana<ApmPluginStartDeps>(); + services: { securityService }, + } = useKibana<ApmServices>(); const [user, setUser] = useState<AuthenticatedUser>(); useEffect(() => { const getCurrentUser = async () => { try { - const authenticatedUser = await security?.authc.getCurrentUser(); + const authenticatedUser = await securityService.authc.getCurrentUser(); setUser(authenticatedUser); } catch { setUser(undefined); } }; getCurrentUser(); - }, [security?.authc]); + }, [securityService.authc]); return user; } diff --git a/x-pack/plugins/observability_solution/apm/public/plugin.ts b/x-pack/plugins/observability_solution/apm/public/plugin.ts index 82c05479c052..087718436afa 100644 --- a/x-pack/plugins/observability_solution/apm/public/plugin.ts +++ b/x-pack/plugins/observability_solution/apm/public/plugin.ts @@ -17,6 +17,7 @@ import { DEFAULT_APP_CATEGORIES, Plugin, PluginInitializerContext, + SecurityServiceStart, } from '@kbn/core/public'; import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -107,6 +108,7 @@ export interface ApmPluginSetupDeps { } export interface ApmServices { + securityService: SecurityServiceStart; telemetry: ITelemetryClient; } @@ -390,6 +392,7 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> { pluginsStart: pluginsStart as ApmPluginStartDeps, observabilityRuleTypeRegistry, apmServices: { + securityService: coreStart.security, telemetry, }, }); diff --git a/x-pack/plugins/observability_solution/apm/public/utils/get_signal_type.ts b/x-pack/plugins/observability_solution/apm/public/utils/get_signal_type.ts new file mode 100644 index 000000000000..a72bb4d782e9 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/utils/get_signal_type.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 { SignalTypes } from '../../common/entities/types'; + +export function isApmSignal(signalTypes: SignalTypes[]) { + return signalTypes.includes(SignalTypes.METRICS) || signalTypes.includes(SignalTypes.TRACES); +} +export function isLogsSignal(signalTypes: SignalTypes[]) { + return signalTypes.includes(SignalTypes.LOGS) && !isApmSignal(signalTypes); +} diff --git a/x-pack/plugins/observability_solution/apm/server/index.ts b/x-pack/plugins/observability_solution/apm/server/index.ts index 44d447ce4c11..f3ebcec582a4 100644 --- a/x-pack/plugins/observability_solution/apm/server/index.ts +++ b/x-pack/plugins/observability_solution/apm/server/index.ts @@ -26,6 +26,7 @@ const configSchema = schema.object({ serviceMapFingerprintGlobalBucketSize: schema.number({ defaultValue: 1000, }), + serviceMapMaxAllowableBytes: schema.number({ defaultValue: 2_576_980_377 }), // 2.4GB serviceMapTraceIdBucketSize: schema.number({ defaultValue: 65 }), serviceMapTraceIdGlobalBucketSize: schema.number({ defaultValue: 6 }), serviceMapMaxTracesPerRequest: schema.number({ defaultValue: 50 }), diff --git a/x-pack/plugins/observability_solution/apm/server/lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients.ts b/x-pack/plugins/observability_solution/apm/server/lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients.ts index f7838cd36fbe..045d8bc25181 100644 --- a/x-pack/plugins/observability_solution/apm/server/lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients.ts +++ b/x-pack/plugins/observability_solution/apm/server/lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients.ts @@ -8,6 +8,10 @@ import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; import type { KibanaRequest } from '@kbn/core/server'; import { ElasticsearchClient } from '@kbn/core/server'; import { unwrapEsResponse } from '@kbn/observability-plugin/common/utils/unwrap_es_response'; +import { + MsearchMultisearchBody, + MsearchMultisearchHeader, +} from '@elastic/elasticsearch/lib/api/types'; import { withApmSpan } from '../../../../utils/with_apm_span'; const ENTITIES_INDEX_NAME = '.entities-observability.latest-*'; @@ -29,6 +33,9 @@ export interface EntitiesESClient { operationName: string, searchRequest: TSearchRequest ): Promise<InferSearchResponseOf<TDocument, TSearchRequest>>; + msearch<TDocument = unknown, TSearchRequest extends ESSearchRequest = ESSearchRequest>( + allSearches: TSearchRequest[] + ): Promise<{ responses: Array<InferSearchResponseOf<TDocument, TSearchRequest>> }>; } export async function createEntitiesESClient({ @@ -63,5 +70,36 @@ export async function createEntitiesESClient({ return unwrapEsResponse(promise); }, + + async msearch<TDocument = unknown, TSearchRequest extends ESSearchRequest = ESSearchRequest>( + allSearches: TSearchRequest[] + ): Promise<{ responses: Array<InferSearchResponseOf<TDocument, TSearchRequest>> }> { + const searches = allSearches + .map((params) => { + const searchParams: [MsearchMultisearchHeader, MsearchMultisearchBody] = [ + { + index: [ENTITIES_INDEX_NAME], + }, + { + ...params.body, + }, + ]; + + return searchParams; + }) + .flat(); + + const promise = esClient.msearch( + { searches }, + { + meta: true, + } + ) as unknown as Promise<{ + body: { responses: Array<InferSearchResponseOf<TDocument, TSearchRequest>> }; + }>; + + const { body } = await promise; + return { responses: body.responses }; + }, }; } diff --git a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts index 9685cb920c17..c55df1122a71 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts @@ -9,15 +9,9 @@ import datemath from '@elastic/datemath'; import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { CoreRequestHandlerContext } from '@kbn/core/server'; import { aiAssistantLogsIndexPattern } from '@kbn/observability-ai-assistant-plugin/server'; +import { flattenObject, KeyValuePair } from '../../../../common/utils/flatten_object'; import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; -import { - SERVICE_NAME, - CONTAINER_ID, - HOST_NAME, - KUBERNETES_POD_NAME, - PROCESSOR_EVENT, - TRACE_ID, -} from '../../../../common/es_fields/apm'; +import { PROCESSOR_EVENT, TRACE_ID } from '../../../../common/es_fields/apm'; import { getTypedSearch } from '../../../utils/create_typed_es_client'; import { getDownstreamServiceResource } from '../get_observability_alert_details_context/get_downstream_dependency_name'; @@ -40,24 +34,25 @@ export async function getLogCategories({ arguments: { start: string; end: string; - 'service.name'?: string; - 'host.name'?: string; - 'container.id'?: string; - 'kubernetes.pod.name'?: string; + entities: { + 'service.name'?: string; + 'host.name'?: string; + 'container.id'?: string; + 'kubernetes.pod.name'?: string; + }; }; -}): Promise<LogCategory[] | undefined> { +}): Promise<{ + logCategories: LogCategory[]; + entities: KeyValuePair[]; +}> { const start = datemath.parse(args.start)?.valueOf()!; const end = datemath.parse(args.end)?.valueOf()!; - const keyValueFilters = getShouldMatchOrNotExistFilter([ - { field: SERVICE_NAME, value: args[SERVICE_NAME] }, - { field: CONTAINER_ID, value: args[CONTAINER_ID] }, - { field: HOST_NAME, value: args[HOST_NAME] }, - { field: KUBERNETES_POD_NAME, value: args[KUBERNETES_POD_NAME] }, - ]); + const keyValueFilters = getShouldMatchOrNotExistFilter( + Object.entries(args.entities).map(([key, value]) => ({ field: key, value })) + ); const index = await coreContext.uiSettings.client.get<string>(aiAssistantLogsIndexPattern); - const search = getTypedSearch(esClient); const query = { @@ -93,7 +88,8 @@ export async function getLogCategories({ const categorizedLogsRes = await search({ index, - size: 0, + size: 1, + _source: Object.keys(args.entities), track_total_hits: 0, query, aggs: { @@ -144,7 +140,12 @@ export async function getLogCategories({ } ); - return Promise.all(promises ?? []); + const sampleDoc = categorizedLogsRes.hits.hits?.[0]?._source as Record<string, string>; + + return { + logCategories: await Promise.all(promises ?? []), + entities: flattenObject(sampleDoc), + }; } // field/value pairs should match, or the field should not exist diff --git a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts index c3a3eb550086..4e9ae1b546aa 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts @@ -6,9 +6,9 @@ */ import { Logger } from '@kbn/core/server'; -import { - AlertDetailsContextualInsightsHandlerQuery, - AlertDetailsContextualInsightsRequestContext, +import type { + AlertDetailsContextualInsight, + AlertDetailsContextualInsightsHandler, } from '@kbn/observability-plugin/server/services'; import moment from 'moment'; import { isEmpty } from 'lodash'; @@ -18,8 +18,11 @@ import { getApmEventClient } from '../../../lib/helpers/get_apm_event_client'; import { getMlClient } from '../../../lib/helpers/get_ml_client'; import { getRandomSampler } from '../../../lib/helpers/get_random_sampler'; import { getApmServiceSummary } from '../get_apm_service_summary'; -import { getAssistantDownstreamDependencies } from '../get_apm_downstream_dependencies'; -import { getLogCategories } from '../get_log_categories'; +import { + APMDownstreamDependency, + getAssistantDownstreamDependencies, +} from '../get_apm_downstream_dependencies'; +import { getLogCategories, LogCategory } from '../get_log_categories'; import { getAnomalies } from '../get_apm_service_summary/get_anomalies'; import { getServiceNameFromSignals } from './get_service_name_from_signals'; import { getContainerIdFromSignals } from './get_container_id_from_signals'; @@ -30,11 +33,8 @@ import { getApmErrors } from './get_apm_errors'; export const getAlertDetailsContextHandler = ( resourcePlugins: APMRouteHandlerResources['plugins'], logger: Logger -) => { - return async ( - requestContext: AlertDetailsContextualInsightsRequestContext, - query: AlertDetailsContextualInsightsHandlerQuery - ) => { +): AlertDetailsContextualInsightsHandler => { + return async (requestContext, query) => { const resources = { getApmIndices: async () => { const coreContext = await requestContext.core; @@ -91,6 +91,7 @@ export const getAlertDetailsContextHandler = ( const serviceEnvironment = query['service.environment']; const hostName = query['host.name']; const kubernetesPodName = query['kubernetes.pod.name']; + const [serviceName, containerId] = await Promise.all([ getServiceNameFromSignals({ query, @@ -106,169 +107,182 @@ export const getAlertDetailsContextHandler = ( }), ]); - async function handleError<T>(cb: () => Promise<T>): Promise<T | undefined> { - try { - return await cb(); - } catch (error) { - logger.error('Error while fetching observability alert details context'); - logger.error(error); - return; - } - } - - const serviceSummaryPromise = serviceName - ? handleError(() => - getApmServiceSummary({ - apmEventClient, - annotationsClient, - esClient, - apmAlertsClient, - mlClient, - logger, - arguments: { - 'service.name': serviceName, - 'service.environment': serviceEnvironment, - start: moment(alertStartedAt).subtract(5, 'minute').toISOString(), - end: alertStartedAt, - }, - }) - ) - : undefined; - const downstreamDependenciesPromise = serviceName - ? handleError(() => - getAssistantDownstreamDependencies({ - apmEventClient, - arguments: { - 'service.name': serviceName, - 'service.environment': serviceEnvironment, - start: moment(alertStartedAt).subtract(24, 'hours').toISOString(), - end: alertStartedAt, - }, - randomSampler, - }) - ) + ? getAssistantDownstreamDependencies({ + apmEventClient, + arguments: { + 'service.name': serviceName, + 'service.environment': serviceEnvironment, + start: moment(alertStartedAt).subtract(24, 'hours').toISOString(), + end: alertStartedAt, + }, + randomSampler, + }) : undefined; - const logCategoriesPromise = handleError(() => - getLogCategories({ + const dataFetchers: Array<() => Promise<AlertDetailsContextualInsight>> = []; + + // service summary + if (serviceName) { + dataFetchers.push(async () => { + const serviceSummary = await getApmServiceSummary({ + apmEventClient, + annotationsClient, + esClient, + apmAlertsClient, + mlClient, + logger, + arguments: { + 'service.name': serviceName, + 'service.environment': serviceEnvironment, + start: moment(alertStartedAt).subtract(5, 'minute').toISOString(), + end: alertStartedAt, + }, + }); + + return { + key: 'serviceSummary', + description: `Metadata for the service "${serviceName}" that produced the alert. The alert might be caused by an issue in the service itself or one of its dependencies.`, + data: serviceSummary, + }; + }); + } + + // downstream dependencies + if (serviceName) { + dataFetchers.push(async () => { + const downstreamDependencies = await downstreamDependenciesPromise; + return { + key: 'downstreamDependencies', + description: `Downstream dependencies from the service "${serviceName}". Problems in these services can negatively affect the performance of "${serviceName}"`, + data: downstreamDependencies, + }; + }); + } + + // log categories + dataFetchers.push(async () => { + const downstreamDependencies = await downstreamDependenciesPromise; + const { logCategories, entities } = await getLogCategories({ apmEventClient, esClient, coreContext, arguments: { start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), end: alertStartedAt, - 'service.name': serviceName, - 'host.name': hostName, - 'container.id': containerId, - 'kubernetes.pod.name': kubernetesPodName, + entities: { + 'service.name': serviceName, + 'host.name': hostName, + 'container.id': containerId, + 'kubernetes.pod.name': kubernetesPodName, + }, }, - }) - ); + }); - const apmErrorsPromise = serviceName - ? handleError(() => - getApmErrors({ - apmEventClient, - start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), - end: alertStartedAt, - serviceName, - serviceEnvironment, - }) - ) - : undefined; + const entitiesAsString = entities.map(({ key, value }) => `${key}:${value}`).join(', '); + + return { + key: 'logCategories', + description: `Log events occurring up to 15 minutes before the alert was triggered. Filtered by the entities: ${entitiesAsString}`, + data: logCategoriesWithDownstreamServiceName(logCategories, downstreamDependencies), + }; + }); + + // apm errors + if (serviceName) { + dataFetchers.push(async () => { + const apmErrors = await getApmErrors({ + apmEventClient, + start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), + end: alertStartedAt, + serviceName, + serviceEnvironment, + }); + + const downstreamDependencies = await downstreamDependenciesPromise; + const errorsWithDownstreamServiceName = getApmErrorsWithDownstreamServiceName( + apmErrors, + downstreamDependencies + ); - const serviceChangePointsPromise = handleError(() => - getServiceChangePoints({ + return { + key: 'apmErrors', + description: `Exceptions (errors) thrown by the service "${serviceName}". If an error contains a downstream service name this could be a possible root cause. If relevant please describe what the error means and what it could be caused by.`, + data: errorsWithDownstreamServiceName, + }; + }); + } + + // exit span change points + dataFetchers.push(async () => { + const exitSpanChangePoints = await getExitSpanChangePoints({ apmEventClient, start: moment(alertStartedAt).subtract(6, 'hours').toISOString(), end: alertStartedAt, serviceName, serviceEnvironment, - transactionType: query['transaction.type'], - transactionName: query['transaction.name'], - }) - ); + }); + + return { + key: 'exitSpanChangePoints', + description: `Significant change points for the dependencies of "${serviceName}". Use this to spot dips or spikes in throughput, latency and failure rate for downstream dependencies`, + data: exitSpanChangePoints, + }; + }); - const exitSpanChangePointsPromise = handleError(() => - getExitSpanChangePoints({ + // service change points + dataFetchers.push(async () => { + const serviceChangePoints = await getServiceChangePoints({ apmEventClient, start: moment(alertStartedAt).subtract(6, 'hours').toISOString(), end: alertStartedAt, serviceName, serviceEnvironment, - }) - ); + transactionType: query['transaction.type'], + transactionName: query['transaction.name'], + }); + + return { + key: 'serviceChangePoints', + description: `Significant change points for "${serviceName}". Use this to spot dips and spikes in throughput, latency and failure rate`, + data: serviceChangePoints, + }; + }); - const anomaliesPromise = handleError(() => - getAnomalies({ + // Anomalies + dataFetchers.push(async () => { + const anomalies = await getAnomalies({ start: moment(alertStartedAt).subtract(1, 'hour').valueOf(), end: moment(alertStartedAt).valueOf(), environment: serviceEnvironment, mlClient, logger, - }) - ); - - const [ - serviceSummary, - downstreamDependencies, - logCategories, - apmErrors, - serviceChangePoints, - exitSpanChangePoints, - anomalies, - ] = await Promise.all([ - serviceSummaryPromise, - downstreamDependenciesPromise, - logCategoriesPromise, - apmErrorsPromise, - serviceChangePointsPromise, - exitSpanChangePointsPromise, - anomaliesPromise, - ]); + }); - return [ - { - key: 'serviceSummary', - description: `Metadata for the service "${serviceName}" that produced the alert. The alert might be caused by an issue in the service itself or one of its dependencies.`, - data: serviceSummary, - }, - { - key: 'downstreamDependencies', - description: `Downstream dependencies from the service "${serviceName}". Problems in these services can negatively affect the performance of "${serviceName}"`, - data: downstreamDependencies, - }, - { - key: 'serviceChangePoints', - description: `Significant change points for "${serviceName}". Use this to spot dips and spikes in throughput, latency and failure rate`, - data: serviceChangePoints, - }, - { - key: 'exitSpanChangePoints', - description: `Significant change points for the dependencies of "${serviceName}". Use this to spot dips or spikes in throughput, latency and failure rate for downstream dependencies`, - data: exitSpanChangePoints, - }, - { - key: 'logCategories', - description: `Related log events occurring shortly before the alert was triggered.`, - data: logCategoriesWithDownstreamServiceName(logCategories, downstreamDependencies), - }, - { - key: 'apmErrors', - description: `Exceptions for the service "${serviceName}". If a downstream service name is included this could be a possible root cause. If relevant please describe what the error means and what it could be caused by.`, - data: apmErrorsWithDownstreamServiceName(apmErrors, downstreamDependencies), - }, - { + return { key: 'anomalies', description: `Anomalies for services running in the environment "${serviceEnvironment}". Anomalies are detected using machine learning and can help you spot unusual patterns in your data.`, data: anomalies, - }, - ].filter(({ data }) => !isEmpty(data)); + }; + }); + + const items = await Promise.all( + dataFetchers.map(async (dataFetcher) => { + try { + return await dataFetcher(); + } catch (error) { + logger.error('Error while fetching observability alert details context'); + logger.error(error); + return; + } + }) + ); + + return items.filter((item) => item && !isEmpty(item.data)) as AlertDetailsContextualInsight[]; }; }; -function apmErrorsWithDownstreamServiceName( +function getApmErrorsWithDownstreamServiceName( apmErrors?: Awaited<ReturnType<typeof getApmErrors>>, downstreamDependencies?: Awaited<ReturnType<typeof getAssistantDownstreamDependencies>> ) { @@ -290,8 +304,8 @@ function apmErrorsWithDownstreamServiceName( } function logCategoriesWithDownstreamServiceName( - logCategories?: Awaited<ReturnType<typeof getLogCategories>>, - downstreamDependencies?: Awaited<ReturnType<typeof getAssistantDownstreamDependencies>> + logCategories?: LogCategory[], + downstreamDependencies?: APMDownstreamDependency[] ) { return logCategories?.map( ({ errorCategory, docCount, sampleMessage, downstreamServiceResource }) => { diff --git a/x-pack/plugins/observability_solution/apm/server/routes/correlations/queries/fetch_duration_field_candidates.test.ts b/x-pack/plugins/observability_solution/apm/server/routes/correlations/queries/fetch_duration_field_candidates.test.ts new file mode 100644 index 000000000000..cd9f1f2312f4 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/server/routes/correlations/queries/fetch_duration_field_candidates.test.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 { ProcessorEvent } from '@kbn/observability-plugin/common'; +import type { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; +import { fetchDurationFieldCandidates } from './fetch_duration_field_candidates'; + +const mockResponse = { + indices: ['.ds-traces-apm-default-2024.06.17-000001'], + fields: { + 'keep.this.field': { + keyword: { type: 'keyword', metadata_field: false, searchable: true, aggregatable: true }, + }, + 'source.ip': { + ip: { type: 'ip', metadata_field: false, searchable: true, aggregatable: true }, + }, + // fields prefixed with 'observer.' should be ignored (via FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE) + 'observer.version': { + keyword: { type: 'keyword', metadata_field: false, searchable: true, aggregatable: true }, + }, + 'observer.hostname': { + keyword: { type: 'keyword', metadata_field: false, searchable: true, aggregatable: true }, + }, + // example fields to exclude (via FIELDS_TO_EXCLUDE_AS_CANDIDATE) + 'agent.name': { + keyword: { type: 'keyword', metadata_field: false, searchable: true, aggregatable: true }, + }, + 'parent.id': { + keyword: { type: 'keyword', metadata_field: false, searchable: true, aggregatable: true }, + }, + }, +}; + +const mockApmEventClient = { + fieldCaps: async () => { + return mockResponse; + }, +} as unknown as APMEventClient; + +describe('fetchDurationFieldCandidates', () => { + it('returns duration field candidates', async () => { + const response = await fetchDurationFieldCandidates({ + apmEventClient: mockApmEventClient, + eventType: ProcessorEvent.transaction, + start: 0, + end: 1, + environment: 'ENVIRONMENT_ALL', + query: { match_all: {} }, + kuery: '', + }); + + expect(response).toStrictEqual({ + fieldCandidates: ['keep.this.field', 'source.ip'], + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/apm/server/routes/correlations/queries/fetch_duration_field_candidates.ts b/x-pack/plugins/observability_solution/apm/server/routes/correlations/queries/fetch_duration_field_candidates.ts index 97d0ff54fc41..9f7e35ec56f1 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/correlations/queries/fetch_duration_field_candidates.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/correlations/queries/fetch_duration_field_candidates.ts @@ -8,15 +8,12 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ES_FIELD_TYPES } from '@kbn/field-types'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { rangeQuery } from '@kbn/observability-plugin/server'; import type { CommonCorrelationsQueryParams } from '../../../../common/correlations/types'; import { FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE, - FIELDS_TO_ADD_AS_CANDIDATE, FIELDS_TO_EXCLUDE_AS_CANDIDATE, - POPULATED_DOC_COUNT_SAMPLE_SIZE, } from '../../../../common/correlations/constants'; -import { hasPrefixToInclude } from '../../../../common/correlations/utils'; -import { getCommonCorrelationsQuery } from './get_common_correlations_query'; import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; const SUPPORTED_ES_FIELD_TYPES = [ @@ -25,13 +22,6 @@ const SUPPORTED_ES_FIELD_TYPES = [ ES_FIELD_TYPES.BOOLEAN, ]; -export const shouldBeExcluded = (fieldName: string) => { - return ( - FIELDS_TO_EXCLUDE_AS_CANDIDATE.has(fieldName) || - FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE.some((prefix) => fieldName.startsWith(prefix)) - ); -}; - export interface DurationFieldCandidatesResponse { fieldCandidates: string[]; } @@ -39,73 +29,34 @@ export interface DurationFieldCandidatesResponse { export async function fetchDurationFieldCandidates({ apmEventClient, eventType, - query, start, end, - environment, - kuery, }: CommonCorrelationsQueryParams & { query: estypes.QueryDslQueryContainer; apmEventClient: APMEventClient; eventType: ProcessorEvent.transaction | ProcessorEvent.span; }): Promise<DurationFieldCandidatesResponse> { // Get all supported fields - const [respMapping, respRandomDoc] = await Promise.all([ - apmEventClient.fieldCaps('get_field_caps', { - apm: { - events: [eventType], - }, - fields: '*', - }), - apmEventClient.search('get_random_doc_for_field_candidate', { - apm: { - events: [eventType], - }, - body: { - track_total_hits: false, - fields: ['*'], - _source: false, - query: getCommonCorrelationsQuery({ - start, - end, - environment, - kuery, - query, - }), - size: POPULATED_DOC_COUNT_SAMPLE_SIZE, - }, - }), - ]); - - const finalFieldCandidates = new Set(FIELDS_TO_ADD_AS_CANDIDATE); - const acceptableFields: Set<string> = new Set(); - - Object.entries(respMapping.fields).forEach(([key, value]) => { - const fieldTypes = Object.keys(value) as ES_FIELD_TYPES[]; - const isSupportedType = fieldTypes.some((type) => SUPPORTED_ES_FIELD_TYPES.includes(type)); - // Definitely include if field name matches any of the wild card - if (hasPrefixToInclude(key) && isSupportedType) { - finalFieldCandidates.add(key); - } - - // Check if fieldName is something we can aggregate on - if (isSupportedType) { - acceptableFields.add(key); - } - }); - - const sampledDocs = respRandomDoc.hits.hits.map((d) => d.fields ?? {}); - - // Get all field names for each returned doc and flatten it - // to a list of unique field names used across all docs - // and filter by list of acceptable fields and some APM specific unique fields. - [...new Set(sampledDocs.map(Object.keys).flat(1))].forEach((field) => { - if (acceptableFields.has(field) && !shouldBeExcluded(field)) { - finalFieldCandidates.add(field); - } + const respMapping = await apmEventClient.fieldCaps('get_field_caps', { + apm: { + events: [eventType], + }, + fields: '*', + // We exclude metadata and parent fields as they are not useful for correlations. + // There's an issue in ES (https://github.com/elastic/elasticsearch/issues/109797) + // that describes why we need to add -parent in addition to the types option. + filters: '-metadata,-parent', + include_empty_fields: false, + index_filter: rangeQuery(start, end)[0], + types: SUPPORTED_ES_FIELD_TYPES, }); return { - fieldCandidates: [...finalFieldCandidates], + fieldCandidates: Object.keys(respMapping.fields).filter((fieldName: string) => { + return ( + !FIELDS_TO_EXCLUDE_AS_CANDIDATE.has(fieldName) && + !FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE.some((prefix) => fieldName.startsWith(prefix)) + ); + }), }; } diff --git a/x-pack/plugins/observability_solution/apm/server/routes/custom_dashboards/get_entities_with_dashboards.ts b/x-pack/plugins/observability_solution/apm/server/routes/custom_dashboards/get_entities_with_dashboards.ts new file mode 100644 index 000000000000..0d5b443ae6d4 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/server/routes/custom_dashboards/get_entities_with_dashboards.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kqlQuery, termQuery } from '@kbn/observability-plugin/server'; +import { estypes } from '@elastic/elasticsearch'; +import { SERVICE_NAME } from '../../../common/es_fields/apm'; +import { SavedApmCustomDashboard } from '../../../common/custom_dashboards'; +import { EntitiesESClient } from '../../lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients'; + +function getSearchRequest(filters: estypes.QueryDslQueryContainer[]) { + return { + body: { + track_total_hits: false, + terminate_after: 1, + size: 1, + query: { + bool: { + filter: filters, + }, + }, + }, + }; +} +export async function getEntitiesWithDashboards({ + entitiesESClient, + allLinkedCustomDashboards, + serviceName, +}: { + entitiesESClient: EntitiesESClient; + allLinkedCustomDashboards: SavedApmCustomDashboard[]; + serviceName: string; +}): Promise<SavedApmCustomDashboard[]> { + const allKueryPerDashboard = allLinkedCustomDashboards.map(({ kuery }) => ({ + kuery, + })); + + const allSearches = allKueryPerDashboard.map((dashboard) => + getSearchRequest([...kqlQuery(dashboard.kuery), ...termQuery(SERVICE_NAME, serviceName)]) + ); + + const filteredDashboards = []; + + if (allSearches.length > 0) { + const allResponses = (await entitiesESClient.msearch(allSearches)).responses; + + for (let index = 0; index < allLinkedCustomDashboards.length; index++) { + const responsePerDashboard = allResponses[index]; + const dashboard = allLinkedCustomDashboards[index]; + + if (responsePerDashboard.hits.hits.length > 0) { + filteredDashboards.push(dashboard); + } + } + } + + return filteredDashboards; +} diff --git a/x-pack/plugins/observability_solution/apm/server/routes/custom_dashboards/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/custom_dashboards/route.ts index 75039a0adafd..4fcc1508715d 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/custom_dashboards/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/custom_dashboards/route.ts @@ -14,6 +14,8 @@ import { getCustomDashboards } from './get_custom_dashboards'; import { getServicesWithDashboards } from './get_services_with_dashboards'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; import { rangeRt } from '../default_api_types'; +import { createEntitiesESClient } from '../../lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients'; +import { getEntitiesWithDashboards } from './get_entities_with_dashboards'; const serviceDashboardSaveRoute = createApmServerRoute({ endpoint: 'POST /internal/apm/custom-dashboard', @@ -53,14 +55,20 @@ const serviceDashboardsRoute = createApmServerRoute({ path: t.type({ serviceName: t.string, }), - query: rangeRt, + query: t.intersection([ + rangeRt, + t.partial({ + checkFor: t.union([t.literal('entities'), t.literal('services')]), + }), + ]), }), options: { tags: ['access:apm'], }, handler: async (resources): Promise<{ serviceDashboards: SavedApmCustomDashboard[] }> => { - const { context, params } = resources; - const { start, end } = params.query; + const { context, params, request } = resources; + const coreContext = await context.core; + const { start, end, checkFor } = params.query; const { serviceName } = params.path; @@ -74,6 +82,21 @@ const serviceDashboardsRoute = createApmServerRoute({ savedObjectsClient, }); + if (checkFor === 'entities') { + const entitiesESClient = await createEntitiesESClient({ + request, + esClient: coreContext.elasticsearch.client.asCurrentUser, + }); + + const entitiesWithDashboards = await getEntitiesWithDashboards({ + entitiesESClient, + allLinkedCustomDashboards, + serviceName, + }); + + return { serviceDashboards: entitiesWithDashboards }; + } + const servicesWithDashboards = await getServicesWithDashboards({ apmEventClient, allLinkedCustomDashboards, diff --git a/x-pack/plugins/observability_solution/apm/server/routes/entities/services/routes.ts b/x-pack/plugins/observability_solution/apm/server/routes/entities/services/routes.ts index c4a68c8c8a21..895129c33bda 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/entities/services/routes.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/entities/services/routes.ts @@ -6,8 +6,8 @@ */ import * as t from 'io-ts'; import { EntityServiceListItem } from '../../../../common/entities/types'; +import { environmentQuery } from '../../../../common/utils/environment_query'; import { createEntitiesESClient } from '../../../lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients'; -import { getApmEventClient } from '../../../lib/helpers/get_apm_event_client'; import { createApmServerRoute } from '../../apm_routes/create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from '../../default_api_types'; import { getServiceEntities } from './get_service_entities'; @@ -23,12 +23,8 @@ const servicesEntitiesRoute = createApmServerRoute({ }), options: { tags: ['access:apm'] }, async handler(resources): Promise<EntityServicesResponse> { - const { context, params, request, plugins } = resources; - const [coreContext] = await Promise.all([ - context.core, - getApmEventClient(resources), - plugins.logsDataAccess.start(), - ]); + const { context, params, request } = resources; + const coreContext = await context.core; const entitiesESClient = await createEntitiesESClient({ request, @@ -50,6 +46,76 @@ const servicesEntitiesRoute = createApmServerRoute({ }, }); +const serviceLogRateTimeseriesRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([environmentRt, kueryRt, rangeRt]), + }), + options: { tags: ['access:apm'] }, + async handler(resources) { + const { context, params, plugins } = resources; + const [coreContext, logsDataAccessStart] = await Promise.all([ + context.core, + plugins.logsDataAccess.start(), + ]); + + const { serviceName } = params.path; + const { start, end, kuery, environment } = params.query; + + const curentPeriodlogsRateTimeseries = await logsDataAccessStart.services.getLogsRateTimeseries( + { + esClient: coreContext.elasticsearch.client.asCurrentUser, + identifyingMetadata: 'service.name', + timeFrom: start, + timeTo: end, + kuery, + serviceEnvironmentQuery: environmentQuery(environment), + serviceNames: [serviceName], + } + ); + + return { currentPeriod: curentPeriodlogsRateTimeseries }; + }, +}); + +const serviceLogErrorRateTimeseriesRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([environmentRt, kueryRt, rangeRt]), + }), + options: { tags: ['access:apm'] }, + async handler(resources) { + const { context, params, plugins } = resources; + const [coreContext, logsDataAccessStart] = await Promise.all([ + context.core, + plugins.logsDataAccess.start(), + ]); + + const { serviceName } = params.path; + const { start, end, kuery, environment } = params.query; + + const logsErrorRateTimeseries = await logsDataAccessStart.services.getLogsErrorRateTimeseries({ + esClient: coreContext.elasticsearch.client.asCurrentUser, + identifyingMetadata: 'service.name', + timeFrom: start, + timeTo: end, + kuery, + serviceEnvironmentQuery: environmentQuery(environment), + serviceNames: [serviceName], + }); + + return { currentPeriod: logsErrorRateTimeseries }; + }, +}); + export const servicesEntitiesRoutesRepository = { ...servicesEntitiesRoute, + ...serviceLogRateTimeseriesRoute, + ...serviceLogErrorRateTimeseriesRoute, }; diff --git a/x-pack/plugins/observability_solution/apm/server/routes/service_map/calculate_docs_per_shard.test.ts b/x-pack/plugins/observability_solution/apm/server/routes/service_map/calculate_docs_per_shard.test.ts new file mode 100644 index 000000000000..cf32db83f2da --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/server/routes/service_map/calculate_docs_per_shard.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { calculateDocsPerShard } from './calculate_docs_per_shard'; + +describe('calculateDocsPerShard', () => { + it('calculates correct docs per shard', () => { + expect( + calculateDocsPerShard({ + serviceMapMaxAllowableBytes: 2_576_980_377, + avgDocSizeInBytes: 495, + totalShards: 3, + numOfRequests: 10, + }) + ).toBe(173534); + }); + it('handles zeros', () => { + expect(() => + calculateDocsPerShard({ + serviceMapMaxAllowableBytes: 0, + avgDocSizeInBytes: 0, + totalShards: 0, + numOfRequests: 0, + }) + ).toThrow('all parameters must be > 0'); + }); +}); diff --git a/x-pack/plugins/observability_solution/apm/server/routes/service_map/calculate_docs_per_shard.ts b/x-pack/plugins/observability_solution/apm/server/routes/service_map/calculate_docs_per_shard.ts new file mode 100644 index 000000000000..de9145d7542f --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/server/routes/service_map/calculate_docs_per_shard.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +interface Params { + serviceMapMaxAllowableBytes: number; + avgDocSizeInBytes: number; + totalShards: number; + numOfRequests: number; +} + +export const calculateDocsPerShard = ({ + serviceMapMaxAllowableBytes, + avgDocSizeInBytes, + totalShards, + numOfRequests, +}: Params): number => { + if ( + serviceMapMaxAllowableBytes <= 0 || + avgDocSizeInBytes <= 0 || + totalShards <= 0 || + numOfRequests <= 0 + ) { + throw new Error('all parameters must be > 0'); + } + const bytesPerRequest = Math.floor(serviceMapMaxAllowableBytes / numOfRequests); + const totalNumDocsAllowed = Math.floor(bytesPerRequest / avgDocSizeInBytes); + const numDocsPerShardAllowed = Math.floor(totalNumDocsAllowed / totalShards); + + return numDocsPerShardAllowed; +}; diff --git a/x-pack/plugins/observability_solution/apm/server/routes/service_map/fetch_service_paths_from_trace_ids.ts b/x-pack/plugins/observability_solution/apm/server/routes/service_map/fetch_service_paths_from_trace_ids.ts index 546760695484..70d51a56c617 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/service_map/fetch_service_paths_from_trace_ids.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/service_map/fetch_service_paths_from_trace_ids.ts @@ -7,13 +7,38 @@ import { rangeQuery } from '@kbn/observability-plugin/server'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; -import { TRACE_ID } from '../../../common/es_fields/apm'; +import { + AGENT_NAME, + PARENT_ID, + PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_SUBTYPE, + SPAN_TYPE, + TRACE_ID, +} from '../../../common/es_fields/apm'; import { ConnectionNode, ExternalConnectionNode, ServiceConnectionNode, } from '../../../common/service_map'; import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { calculateDocsPerShard } from './calculate_docs_per_shard'; + +const SCRIPTED_METRICS_FIELDS_TO_COPY = [ + PARENT_ID, + SERVICE_NAME, + SERVICE_ENVIRONMENT, + SPAN_DESTINATION_SERVICE_RESOURCE, + TRACE_ID, + PROCESSOR_EVENT, + SPAN_TYPE, + SPAN_SUBTYPE, + AGENT_NAME, +]; + +const AVG_BYTES_PER_FIELD = 55; export async function fetchServicePathsFromTraceIds({ apmEventClient, @@ -21,12 +46,16 @@ export async function fetchServicePathsFromTraceIds({ start, end, terminateAfter, + serviceMapMaxAllowableBytes, + numOfRequests, }: { apmEventClient: APMEventClient; traceIds: string[]; start: number; end: number; terminateAfter: number; + serviceMapMaxAllowableBytes: number; + numOfRequests: number; }) { // make sure there's a range so ES can skip shards const dayInMs = 24 * 60 * 60 * 1000; @@ -37,8 +66,8 @@ export async function fetchServicePathsFromTraceIds({ apm: { events: [ProcessorEvent.span, ProcessorEvent.transaction], }, - terminate_after: terminateAfter, body: { + terminate_after: terminateAfter, track_total_hits: false, size: 0, query: { @@ -53,178 +82,252 @@ export async function fetchServicePathsFromTraceIds({ ], }, }, - aggs: { - service_map: { - scripted_metric: { - init_script: { - lang: 'painless', - source: `state.eventsById = new HashMap(); - - String[] fieldsToCopy = new String[] { - 'parent.id', - 'service.name', - 'service.environment', - 'span.destination.service.resource', - 'trace.id', - 'processor.event', - 'span.type', - 'span.subtype', - 'agent.name' - }; - state.fieldsToCopy = fieldsToCopy;`, - }, - map_script: { - lang: 'painless', - source: `def id; - id = $('span.id', null); - if (id == null) { - id = $('transaction.id', null); - } - - def copy = new HashMap(); - copy.id = id; - - for(key in state.fieldsToCopy) { - def value = $(key, null); - if (value != null) { - copy[key] = value; - } + }, + }; + // fetch without aggs to get shard count, first + const serviceMapQueryDataResponse = await apmEventClient.search( + 'get_trace_ids_shard_data', + serviceMapParams + ); + /* + * Calculate how many docs we can fetch per shard. + * Used in both terminate_after and tracking in the map script of the scripted_metric agg + * to ensure we don't fetch more than we can handle. + * + * 1. Use serviceMapMaxAllowableBytes setting, which represents our baseline request circuit breaker limit. + * 2. Divide by numOfRequests we fire off simultaneously to calculate bytesPerRequest. + * 3. Divide bytesPerRequest by the average doc size to get totalNumDocsAllowed. + * 4. Divide totalNumDocsAllowed by totalShards to get numDocsPerShardAllowed. + * 5. Use the lesser of numDocsPerShardAllowed or terminateAfter. + */ + + const avgDocSizeInBytes = SCRIPTED_METRICS_FIELDS_TO_COPY.length * AVG_BYTES_PER_FIELD; // estimated doc size in bytes + const totalShards = serviceMapQueryDataResponse._shards.total; + + const calculatedDocs = calculateDocsPerShard({ + serviceMapMaxAllowableBytes, + avgDocSizeInBytes, + totalShards, + numOfRequests, + }); + + const numDocsPerShardAllowed = calculatedDocs > terminateAfter ? terminateAfter : calculatedDocs; + + const serviceMapAggs = { + service_map: { + scripted_metric: { + params: { + limit: numDocsPerShardAllowed, + fieldsToCopy: SCRIPTED_METRICS_FIELDS_TO_COPY, + }, + init_script: { + lang: 'painless', + source: ` + state.docCount = 0; + state.limit = params.limit; + state.eventsById = new HashMap(); + state.fieldsToCopy = params.fieldsToCopy;`, + }, + map_script: { + lang: 'painless', + source: ` + if (state.docCount >= state.limit) { + // Stop processing if the document limit is reached + return; + } + + def id = $('span.id', null); + if (id == null) { + id = $('transaction.id', null); + } + + // Ensure same event isn't processed twice + if (id != null && !state.eventsById.containsKey(id)) { + def copy = new HashMap(); + copy.id = id; + + for(key in state.fieldsToCopy) { + def value = $(key, null); + if (value != null) { + copy[key] = value; } - - state.eventsById[id] = copy`, - }, - combine_script: { - lang: 'painless', - source: `return state.eventsById;`, - }, - reduce_script: { - lang: 'painless', - source: ` - def getDestination ( def event ) { - def destination = new HashMap(); - destination['span.destination.service.resource'] = event['span.destination.service.resource']; - destination['span.type'] = event['span.type']; - destination['span.subtype'] = event['span.subtype']; - return destination; } - def processAndReturnEvent(def context, def eventId) { - if (context.processedEvents[eventId] != null) { - return context.processedEvents[eventId]; - } - - def event = context.eventsById[eventId]; - - if (event == null) { - return null; + state.eventsById[id] = copy; + state.docCount++; + } + `, + }, + combine_script: { + lang: 'painless', + source: `return state;`, + }, + reduce_script: { + lang: 'painless', + source: ` + def getDestination(def event) { + def destination = new HashMap(); + destination['span.destination.service.resource'] = event['span.destination.service.resource']; + destination['span.type'] = event['span.type']; + destination['span.subtype'] = event['span.subtype']; + return destination; + } + + def processAndReturnEvent(def context, def eventId) { + def stack = new Stack(); + def reprocessQueue = new LinkedList(); + + // Avoid reprocessing the same event + def visited = new HashSet(); + + stack.push(eventId); + + while (!stack.isEmpty()) { + def currentEventId = stack.pop(); + def event = context.eventsById.get(currentEventId); + + if (event == null || context.processedEvents.get(currentEventId) != null) { + continue; } + visited.add(currentEventId); def service = new HashMap(); service['service.name'] = event['service.name']; service['service.environment'] = event['service.environment']; service['agent.name'] = event['agent.name']; - + def basePath = new ArrayList(); - def parentId = event['parent.id']; - def parent; - - if (parentId != null && parentId != event['id']) { - parent = processAndReturnEvent(context, parentId); - if (parent != null) { - /* copy the path from the parent */ - basePath.addAll(parent.path); - /* flag parent path for removal, as it has children */ - context.locationsToRemove.add(parent.path); - - /* if the parent has 'span.destination.service.resource' set, and the service is different, - we've discovered a service */ - - if (parent['span.destination.service.resource'] != null - && parent['span.destination.service.resource'] != "" - && (parent['service.name'] != event['service.name'] - || parent['service.environment'] != event['service.environment'] - ) - ) { - def parentDestination = getDestination(parent); - context.externalToServiceMap.put(parentDestination, service); + + if (parentId != null && !parentId.equals(currentEventId)) { + def parent = context.processedEvents.get(parentId); + + if (parent == null) { + + // Only adds the parentId to the stack if it hasn't been visited to prevent infinite loop scenarios + // if the parent is null, it means it hasn't been processed yet or it could also mean that the current event + // doesn't have a parent, in which case we should skip it + if (!visited.contains(parentId)) { + stack.push(parentId); + // Add currentEventId to be reprocessed once its parent is processed + reprocessQueue.add(currentEventId); } + + + continue; } - } + // copy the path from the parent + basePath.addAll(parent.path); + // flag parent path for removal, as it has children + context.locationsToRemove.add(parent.path); + + // if the parent has 'span.destination.service.resource' set, and the service is different, we've discovered a service + if (parent['span.destination.service.resource'] != null + && !parent['span.destination.service.resource'].equals("") + && (!parent['service.name'].equals(event['service.name']) + || !parent['service.environment'].equals(event['service.environment']) + ) + ) { + def parentDestination = getDestination(parent); + context.externalToServiceMap.put(parentDestination, service); + } + } + def lastLocation = basePath.size() > 0 ? basePath[basePath.size() - 1] : null; - def currentLocation = service; - - /* only add the current location to the path if it's different from the last one*/ + + // only add the current location to the path if it's different from the last one if (lastLocation == null || !lastLocation.equals(currentLocation)) { basePath.add(currentLocation); } - - /* if there is an outgoing span, create a new path */ + + // if there is an outgoing span, create a new path if (event['span.destination.service.resource'] != null - && event['span.destination.service.resource'] != '') { + && !event['span.destination.service.resource'].equals("")) { + def outgoingLocation = getDestination(event); def outgoingPath = new ArrayList(basePath); outgoingPath.add(outgoingLocation); context.paths.add(outgoingPath); } - + event.path = basePath; + context.processedEvents[currentEventId] = event; - context.processedEvents[eventId] = event; - return event; - } - - def context = new HashMap(); - - context.processedEvents = new HashMap(); - context.eventsById = new HashMap(); - - context.paths = new HashSet(); - context.externalToServiceMap = new HashMap(); - context.locationsToRemove = new HashSet(); - - for (state in states) { - context.eventsById.putAll(state); - } - - for (entry in context.eventsById.entrySet()) { - processAndReturnEvent(context, entry.getKey()); - } - - def paths = new HashSet(); - - for(foundPath in context.paths) { - if (!context.locationsToRemove.contains(foundPath)) { - paths.add(foundPath); + // reprocess events which were waiting for their parents to be processed + while (!reprocessQueue.isEmpty()) { + stack.push(reprocessQueue.remove()); } } - def response = new HashMap(); - response.paths = paths; - - def discoveredServices = new HashSet(); - - for(entry in context.externalToServiceMap.entrySet()) { - def map = new HashMap(); - map.from = entry.getKey(); - map.to = entry.getValue(); - discoveredServices.add(map); + return null; + } + + def context = new HashMap(); + + context.processedEvents = new HashMap(); + context.eventsById = new HashMap(); + context.paths = new HashSet(); + context.externalToServiceMap = new HashMap(); + context.locationsToRemove = new HashSet(); + + for (state in states) { + context.eventsById.putAll(state.eventsById); + state.eventsById.clear(); + } + + states.clear(); + + for (entry in context.eventsById.entrySet()) { + processAndReturnEvent(context, entry.getKey()); + } + + context.processedEvents.clear(); + context.eventsById.clear(); + + def response = new HashMap(); + response.paths = new HashSet(); + response.discoveredServices = new HashSet(); + + for (foundPath in context.paths) { + if (!context.locationsToRemove.contains(foundPath)) { + response.paths.add(foundPath); } - response.discoveredServices = discoveredServices; - - return response;`, - }, - }, - } as const, + } + + context.locationsToRemove.clear(); + context.paths.clear(); + + for (entry in context.externalToServiceMap.entrySet()) { + def map = new HashMap(); + map.from = entry.getKey(); + map.to = entry.getValue(); + response.discoveredServices.add(map); + } + + context.externalToServiceMap.clear(); + + return response; + `, + }, }, + } as const, + }; + + const serviceMapParamsWithAggs = { + ...serviceMapParams, + body: { + ...serviceMapParams.body, + size: 1, + terminate_after: numDocsPerShardAllowed, + aggs: serviceMapAggs, }, }; const serviceMapFromTraceIdsScriptResponse = await apmEventClient.search( 'get_service_paths_from_trace_ids', - serviceMapParams + serviceMapParamsWithAggs ); return serviceMapFromTraceIdsScriptResponse as { diff --git a/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map.ts b/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map.ts index 61fc849996a6..69a000f4c2a8 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map.ts @@ -82,6 +82,8 @@ async function getConnectionData({ start, end, terminateAfter: config.serviceMapTerminateAfter, + serviceMapMaxAllowableBytes: config.serviceMapMaxAllowableBytes, + numOfRequests: chunks.length, logger, }) ) diff --git a/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map_from_trace_ids.ts b/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map_from_trace_ids.ts index 1b2cc6070d87..d1bec0076d8f 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map_from_trace_ids.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/service_map/get_service_map_from_trace_ids.ts @@ -46,6 +46,8 @@ export async function getServiceMapFromTraceIds({ start, end, terminateAfter, + serviceMapMaxAllowableBytes, + numOfRequests, logger, }: { apmEventClient: APMEventClient; @@ -53,6 +55,8 @@ export async function getServiceMapFromTraceIds({ start: number; end: number; terminateAfter: number; + serviceMapMaxAllowableBytes: number; + numOfRequests: number; logger: Logger; }) { const serviceMapFromTraceIdsScriptResponse = await fetchServicePathsFromTraceIds({ @@ -61,6 +65,8 @@ export async function getServiceMapFromTraceIds({ start, end, terminateAfter, + serviceMapMaxAllowableBytes, + numOfRequests, }); logger.debug('Received scripted metric agg response'); diff --git a/x-pack/plugins/observability_solution/dataset_quality/README.md b/x-pack/plugins/observability_solution/dataset_quality/README.md index d000c5697b1d..25f01ceb6fa6 100755 --- a/x-pack/plugins/observability_solution/dataset_quality/README.md +++ b/x-pack/plugins/observability_solution/dataset_quality/README.md @@ -47,7 +47,7 @@ Once the tests finish, the instances will be terminated. node x-pack/plugins/observability_solution/dataset_quality/scripts/api --server # run tests -node x-pack/plugins/observability_solution/dataset_quality/scripts/api --runner --grep-files=error_group_list +node x-pack/plugins/observability_solution/dataset_quality/scripts/api --runner --grep-files=data_stream_settings.spec.ts ``` diff --git a/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts b/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts index ce84985a3940..a88b2d531b9b 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts @@ -11,8 +11,6 @@ const userPrivilegesRt = rt.type({ canMonitor: rt.boolean, }); -export type DataStreamUserPrivileges = rt.TypeOf<typeof userPrivilegesRt>; - const datasetUserPrivilegesRt = rt.intersection([ userPrivilegesRt, rt.type({ @@ -44,14 +42,13 @@ export const dashboardRT = rt.type({ title: rt.string, }); +export type Dashboard = rt.TypeOf<typeof dashboardRT>; + export const integrationDashboardsRT = rt.type({ dashboards: rt.array(dashboardRT), }); -export type IntegrationDashboards = rt.TypeOf<typeof integrationDashboardsRT>; -export type Dashboard = rt.TypeOf<typeof dashboardRT>; - -export const getIntegrationDashboardsResponseRt = rt.exact(integrationDashboardsRT); +export type IntegrationDashboardsResponse = rt.TypeOf<typeof integrationDashboardsRT>; export const integrationIconRt = rt.intersection([ rt.type({ @@ -74,11 +71,10 @@ export const integrationRt = rt.intersection([ version: rt.string, icons: rt.array(integrationIconRt), datasets: rt.record(rt.string, rt.string), - dashboards: rt.array(dashboardRT), }), ]); -export type Integration = rt.TypeOf<typeof integrationRt>; +export type IntegrationType = rt.TypeOf<typeof integrationRt>; export const getIntegrationsResponseRt = rt.exact( rt.type({ @@ -86,6 +82,8 @@ export const getIntegrationsResponseRt = rt.exact( }) ); +export type IntegrationResponse = rt.TypeOf<typeof getIntegrationsResponseRt>; + export const degradedDocsRt = rt.type({ dataset: rt.string, count: rt.number, @@ -117,6 +115,7 @@ export type DegradedFieldResponse = rt.TypeOf<typeof getDataStreamDegradedFields export const dataStreamSettingsRt = rt.partial({ createdOn: rt.union([rt.null, rt.number]), // rt.null is needed because `createdOn` is not available on Serverless + integration: rt.string, }); export type DataStreamSettings = rt.TypeOf<typeof dataStreamSettingsRt>; diff --git a/x-pack/plugins/ml/public/application/services/upgrade_service.ts b/x-pack/plugins/observability_solution/dataset_quality/common/data_stream_details/types.ts similarity index 55% rename from x-pack/plugins/ml/public/application/services/upgrade_service.ts rename to x-pack/plugins/observability_solution/dataset_quality/common/data_stream_details/types.ts index d72a9adddfca..74f065339644 100644 --- a/x-pack/plugins/ml/public/application/services/upgrade_service.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/common/data_stream_details/types.ts @@ -5,12 +5,9 @@ * 2.0. */ -let upgradeInProgress: boolean = false; +import { DataStreamType } from '../types'; -export function setUpgradeInProgress(show: boolean) { - upgradeInProgress = show; -} - -export function isUpgradeInProgress(): boolean { - return upgradeInProgress; +export interface GetDataStreamIntegrationParams { + type: DataStreamType; + integrationName: string; } diff --git a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts index ad8fecd91c7e..8a3a9d4b5e8b 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DashboardType, IntegrationType } from './types'; +import { IntegrationType } from '../api_types'; export class Integration { name: IntegrationType['name']; @@ -13,14 +13,12 @@ export class Integration { version: string; datasets: Record<string, string>; icons?: IntegrationType['icons']; - dashboards?: DashboardType[]; private constructor(integration: Integration) { this.name = integration.name; this.title = integration.title || integration.name; this.version = integration.version || '1.0.0'; this.icons = integration.icons; - this.dashboards = integration.dashboards || []; this.datasets = integration.datasets || {}; } @@ -29,7 +27,6 @@ export class Integration { ...integration, title: integration.title || integration.name, version: integration.version || '1.0.0', - dashboards: integration.dashboards || [], datasets: integration.datasets || {}, }; diff --git a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts index 42fbeeb2cff5..9fe23f6ceef6 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts @@ -17,9 +17,6 @@ export type DataStreamStatServiceResponse = GetDataStreamsStatsResponse; export type GetIntegrationsParams = APIClientRequestParamsOf<`GET /internal/dataset_quality/integrations`>['params']; -export type GetIntegrationsResponse = APIReturnType<`GET /internal/dataset_quality/integrations`>; -export type IntegrationType = GetIntegrationsResponse['integrations'][0]; -export type IntegrationsResponse = IntegrationType[]; export type GetDataStreamsDegradedDocsStatsParams = APIClientRequestParamsOf<`GET /internal/dataset_quality/data_streams/degraded_docs`>['params']; @@ -69,9 +66,6 @@ export type GetNonAggregatableDataStreamsResponse = export type GetIntegrationDashboardsParams = APIClientRequestParamsOf<`GET /internal/dataset_quality/integrations/{integration}/dashboards`>['params']['path']; -export type GetIntegrationDashboardsResponse = - APIReturnType<`GET /internal/dataset_quality/integrations/{integration}/dashboards`>; -export type DashboardType = GetIntegrationDashboardsResponse['dashboards'][0]; export type { DataStreamStat } from './data_stream_stat'; export type { diff --git a/x-pack/plugins/observability_solution/dataset_quality/common/types/common.ts b/x-pack/plugins/observability_solution/dataset_quality/common/types/common.ts index d7e5c4d78bbe..ca5c4632ec0d 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/common/types/common.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/common/types/common.ts @@ -5,6 +5,9 @@ * 2.0. */ +import { DataStreamStatType } from '../data_streams_stats'; +import { Integration } from '../data_streams_stats/integration'; + export type SortDirection = 'asc' | 'desc'; export type Maybe<T> = T | null | undefined; @@ -13,3 +16,12 @@ export interface Coordinate { x: number; y: Maybe<number>; } + +export interface BasicDataStream { + type: string; + name: DataStreamStatType['name']; + rawName: string; + namespace: string; + title: string; + integration?: Integration; +} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts index 9d89c8522d7d..460aad2f0247 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts @@ -6,9 +6,11 @@ */ import { createContext, useContext } from 'react'; import { DatasetQualityControllerStateService } from '../../state_machines/dataset_quality_controller'; +import { ITelemetryClient } from '../../services/telemetry'; export interface DatasetQualityContextValue { service: DatasetQualityControllerStateService; + telemetryClient: ITelemetryClient; } export const DatasetQualityContext = createContext({} as DatasetQualityContextValue); diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx index 44d72802bc86..8ae6f1f74bd2 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx @@ -8,11 +8,12 @@ import React, { useMemo } from 'react'; import { CoreStart } from '@kbn/core/public'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { dynamic } from '@kbn/shared-ux-utility'; +import { PerformanceContextProvider } from '@kbn/ebt-tools'; import { DatasetQualityContext, DatasetQualityContextValue } from './context'; import { useKibanaContextForPluginProvider } from '../../utils'; import { DatasetQualityStartDeps } from '../../types'; import { DatasetQualityController } from '../../controller'; -import { IDataStreamsStatsClient } from '../../services/data_streams_stats'; +import { ITelemetryClient } from '../../services/telemetry'; export interface DatasetQualityProps { controller: DatasetQualityController; @@ -21,13 +22,13 @@ export interface DatasetQualityProps { export interface CreateDatasetQualityArgs { core: CoreStart; plugins: DatasetQualityStartDeps; - dataStreamStatsClient: IDataStreamsStatsClient; + telemetryClient: ITelemetryClient; } export const createDatasetQuality = ({ core, plugins, - dataStreamStatsClient, + telemetryClient, }: CreateDatasetQualityArgs) => { return ({ controller }: DatasetQualityProps) => { const SummaryPanelProvider = dynamic(() => import('../../hooks/use_summary_panel')); @@ -36,18 +37,21 @@ export const createDatasetQuality = ({ const datasetQualityProviderValue: DatasetQualityContextValue = useMemo( () => ({ service: controller.service, + telemetryClient, }), [controller.service] ); return ( - <DatasetQualityContext.Provider value={datasetQualityProviderValue}> - <SummaryPanelProvider> - <KibanaContextProviderForPlugin> - <DatasetQuality /> - </KibanaContextProviderForPlugin> - </SummaryPanelProvider> - </DatasetQualityContext.Provider> + <PerformanceContextProvider> + <DatasetQualityContext.Provider value={datasetQualityProviderValue}> + <SummaryPanelProvider> + <KibanaContextProviderForPlugin> + <DatasetQuality /> + </KibanaContextProviderForPlugin> + </SummaryPanelProvider> + </DatasetQualityContext.Provider> + </PerformanceContextProvider> ); }; }; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/header.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/header.tsx index da0e2e0c7462..4ec200b1b98c 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/header.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/header.tsx @@ -11,16 +11,11 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { DEFAULT_LOGS_DATA_VIEW } from '../../../common/constants'; -import { useKibanaContextForPlugin } from '../../utils'; import { datasetQualityAppTitle } from '../../../common/translations'; // Allow for lazy loading // eslint-disable-next-line import/no-default-export export default function Header() { - const { - services: { docLinks }, - } = useKibanaContextForPlugin(); - return ( <EuiPageHeader bottomBorder @@ -38,19 +33,19 @@ export default function Header() { description={ <FormattedMessage id="xpack.datasetQuality.appDescription" - defaultMessage="Monitor the data set quality for {logsPattern} data streams that follow the {ecsNamingSchemeLink}." + defaultMessage="Monitor the data set quality for {logsPattern} data streams that follow the {dsNamingSchemeLink}." values={{ logsPattern: <EuiCode>{DEFAULT_LOGS_DATA_VIEW}</EuiCode>, - ecsNamingSchemeLink: ( + dsNamingSchemeLink: ( <EuiLink - data-test-subj="datasetQualityAppDescriptionEcsNamingSchemeLink" - href={docLinks.links.ecs.dataStreams} + data-test-subj="datasetQualityAppDescriptionDsNamingSchemeLink" + href="https://ela.st/data-stream-naming-scheme" target="_blank" rel="noopener" > <FormattedMessage - id="xpack.datasetQuality.appDescription.ecsNamingSchemeLinkText" - defaultMessage="ECS naming scheme" + id="xpack.datasetQuality.appDescription.dsNamingSchemeLinkText" + defaultMessage="Data stream naming scheme" /> </EuiLink> ), diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/summary_panel/datasets_quality_indicators.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/summary_panel/datasets_quality_indicators.tsx index 7bb4acc8d450..836580565b2d 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/summary_panel/datasets_quality_indicators.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/summary_panel/datasets_quality_indicators.tsx @@ -19,6 +19,7 @@ import { EuiIconTip, EuiSkeletonTitle, } from '@elastic/eui'; +import { usePerformanceContext } from '@kbn/ebt-tools'; import { InfoIndicators } from '../../../../common/types'; import { useSummaryPanelContext } from '../../../hooks'; import { @@ -31,11 +32,16 @@ import { import { mapPercentagesToQualityCounts } from '../../quality_indicator'; export function DatasetsQualityIndicators() { + const { onPageReady } = usePerformanceContext(); const { datasetsQuality, isDatasetsQualityLoading, datasetsActivity } = useSummaryPanelContext(); const qualityCounts = mapPercentagesToQualityCounts(datasetsQuality.percentages); const datasetsWithoutIgnoredField = datasetsActivity.total > 0 ? datasetsActivity.total - datasetsQuality.percentages.length : 0; + if (!isDatasetsQualityLoading) { + onPageReady(); + } + return ( <EuiPanel hasBorder> <EuiFlexGroup direction="column" gutterSize="s"> diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx index 60f959176866..515307df1a20 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx @@ -31,6 +31,7 @@ import { BYTE_NUMBER_FORMAT, } from '../../../../common/constants'; import { DataStreamStat } from '../../../../common/data_streams_stats/data_stream_stat'; +import { NavigationSource } from '../../../services/telemetry'; import { DatasetQualityIndicator, QualityIndicator } from '../../quality_indicator'; import { PrivilegesWarningIconWrapper, IntegrationIcon } from '../../common'; import { useRedirectLink } from '../../../hooks'; @@ -352,10 +353,13 @@ const RedirectLink = ({ dataStreamStat: DataStreamStat; title: string; }) => { - const redirectLinkProps = useRedirectLink({ dataStreamStat }); + const redirectLinkProps = useRedirectLink({ + dataStreamStat, + telemetry: { page: 'main', navigationSource: NavigationSource.Table }, + }); return ( - <EuiLink data-test-subj="datasetQualityLogsExplorerLinkLink" {...redirectLinkProps}> + <EuiLink data-test-subj="datasetQualityLogsExplorerLinkLink" {...redirectLinkProps.linkProps}> {title} </EuiLink> ); diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx index 66f61d6996bd..9e8fb79168fd 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx @@ -8,6 +8,7 @@ import { EuiSkeletonRectangle, EuiFlexGroup, EuiLink } from '@elastic/eui'; import React from 'react'; import { _IGNORED } from '../../../../common/es_fields'; +import { NavigationSource } from '../../../services/telemetry'; import { useRedirectLink } from '../../../hooks'; import { QualityPercentageIndicator } from '../../quality_indicator'; import { DataStreamStat } from '../../../../common/data_streams_stats/data_stream_stat'; @@ -26,13 +27,20 @@ export const DegradedDocsPercentageLink = ({ const redirectLinkProps = useRedirectLink({ dataStreamStat, query: { language: 'kuery', query: `${_IGNORED}: *` }, + telemetry: { + page: 'main', + navigationSource: NavigationSource.Table, + }, }); return ( <EuiSkeletonRectangle width="50px" height="20px" borderRadius="m" isLoading={isLoading}> <EuiFlexGroup alignItems="center" gutterSize="s"> {percentage ? ( - <EuiLink data-test-subj="datasetQualityDegradedDocsPercentageLink" {...redirectLinkProps}> + <EuiLink + data-test-subj="datasetQualityDegradedDocsPercentageLink" + {...redirectLinkProps.linkProps} + > <QualityPercentageIndicator percentage={percentage} degradedDocsCount={count} /> </EuiLink> ) : ( diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx index 7543322a5daf..7411067a7317 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React, { Fragment, useEffect } from 'react'; import { css } from '@emotion/react'; import { EuiButtonEmpty, @@ -20,12 +20,13 @@ import { EuiSkeletonRectangle, } from '@elastic/eui'; import { flyoutCancelText } from '../../../common/translations'; -import { useDatasetQualityFlyout } from '../../hooks'; +import { useDatasetQualityFlyout, useDatasetDetailsTelemetry } from '../../hooks'; import { DatasetSummary, DatasetSummaryLoading } from './dataset_summary'; import { Header } from './header'; import { IntegrationSummary } from './integration_summary'; import { FlyoutProps } from './types'; import { FlyoutSummary } from './flyout_summary/flyout_summary'; +import { BasicDataStream } from '../../../common/types'; // Allow for lazy loading // eslint-disable-next-line import/no-default-export @@ -39,8 +40,24 @@ export default function Flyout({ dataset, closeFlyout }: FlyoutProps) { timeRange, loadingState, flyoutLoading, + integration, } = useDatasetQualityFlyout(); + const titleAndLinkDetails: BasicDataStream = { + name: dataset.name, + rawName: dataset.rawName, + integration: integration?.integrationDetails, + type: dataset.type, + namespace: dataset.namespace, + title: integration?.integrationDetails?.datasets?.[dataset.name] ?? dataset.name, + }; + + const { startTracking } = useDatasetDetailsTelemetry(); + + useEffect(() => { + startTracking(); + }, [startTracking]); + return ( <EuiFlyout onClose={closeFlyout} @@ -52,7 +69,10 @@ export default function Flyout({ dataset, closeFlyout }: FlyoutProps) { <EuiSkeletonRectangle width="100%" height={80} /> ) : ( <> - <Header dataStreamStat={dataset} /> + <Header + titleAndLinkDetails={titleAndLinkDetails} + loading={!loadingState.datasetIntegrationDone} + /> <EuiFlyoutBody css={flyoutBodyStyles} data-test-subj="datasetQualityFlyoutBody"> <EuiPanel hasBorder={false} hasShadow={false} paddingSize="l"> <FlyoutSummary @@ -80,12 +100,13 @@ export default function Flyout({ dataset, closeFlyout }: FlyoutProps) { fieldFormats={fieldFormats} /> - {dataStreamStat.integration && ( + {integration?.integrationDetails && ( <> <EuiSpacer /> <IntegrationSummary - integration={dataStreamStat.integration} - dashboardsLoading={loadingState.datasetIntegrationsLoading} + integration={integration.integrationDetails} + dashboards={integration?.dashboards ?? []} + dashboardsLoading={loadingState.datasetIntegrationDashboardLoading} /> </> )} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpi_item.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpi_item.tsx index 3f27521cd54f..767cd8ec41f4 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpi_item.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpi_item.tsx @@ -20,6 +20,7 @@ import { import { PrivilegesWarningIconWrapper } from '../../common'; import { notAvailableLabel } from '../../../../common/translations'; +import type { getSummaryKpis } from './get_summary_kpis'; export function FlyoutSummaryKpiItem({ title, @@ -27,16 +28,7 @@ export function FlyoutSummaryKpiItem({ link, isLoading, userHasPrivilege, -}: { - title: string; - value: string; - link?: { - label: string; - href: string; - }; - isLoading: boolean; - userHasPrivilege: boolean; -}) { +}: ReturnType<typeof getSummaryKpis>[number] & { isLoading: boolean }) { const { euiTheme } = useEuiTheme(); return ( @@ -71,8 +63,7 @@ export function FlyoutSummaryKpiItem({ alignItems: 'center', width: 'fit-content', }} - href={link.href} - target="_blank" + {...link.props} > <EuiText css={{ diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpis.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpis.tsx index c95cd7cae0d1..9b0ca4dfd127 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpis.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/flyout_summary_kpis.tsx @@ -12,7 +12,8 @@ import { _IGNORED } from '../../../../common/es_fields'; import { DataStreamDetails } from '../../../../common/api_types'; import { useKibanaContextForPlugin } from '../../../utils'; -import { useRedirectLink } from '../../../hooks'; +import { NavigationSource } from '../../../services/telemetry'; +import { useDatasetDetailsTelemetry, useRedirectLink } from '../../../hooks'; import { FlyoutDataset, TimeRangeConfig } from '../../../state_machines/dataset_quality_controller'; import { FlyoutSummaryKpiItem, FlyoutSummaryKpiItemLoading } from './flyout_summary_kpi_item'; import { getSummaryKpis } from './get_summary_kpis'; @@ -31,12 +32,17 @@ export function FlyoutSummaryKpis({ const { services: { observabilityShared }, } = useKibanaContextForPlugin(); + const telemetry = useDatasetDetailsTelemetry(); const hostsLocator = observabilityShared.locators.infra.hostsLocator; - const redirectLinkProps = useRedirectLink({ + const degradedDocsLinkProps = useRedirectLink({ dataStreamStat, query: { language: 'kuery', query: `${_IGNORED}: *` }, timeRangeConfig: timeRange, + telemetry: { + page: 'details', + navigationSource: NavigationSource.Summary, + }, }); const kpis = useMemo( @@ -44,10 +50,11 @@ export function FlyoutSummaryKpis({ getSummaryKpis({ dataStreamDetails, timeRange, - degradedDocsHref: redirectLinkProps.href, + degradedDocsLinkProps, hostsLocator, + telemetry, }), - [dataStreamDetails, redirectLinkProps, hostsLocator, timeRange] + [dataStreamDetails, degradedDocsLinkProps, hostsLocator, telemetry, timeRange] ); return ( @@ -64,10 +71,12 @@ export function FlyoutSummaryKpis({ } export function FlyoutSummaryKpisLoading() { + const telemetry = useDatasetDetailsTelemetry(); + return ( <EuiFlexGroup direction="column"> <EuiFlexGroup wrap={true} gutterSize="m"> - {getSummaryKpis({}).map(({ title }) => ( + {getSummaryKpis({ telemetry }).map(({ title }) => ( <EuiFlexItem key={title}> <FlyoutSummaryKpiItemLoading title={title} /> </EuiFlexItem> diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts index 42ac8bc5e7b2..5807c21a6a25 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts @@ -7,6 +7,7 @@ import { formatNumber } from '@elastic/eui'; import type { useKibanaContextForPlugin } from '../../../utils'; +import type { useDatasetDetailsTelemetry } from '../../../hooks'; import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller'; import { @@ -46,7 +47,11 @@ const timeRange: TimeRangeConfig = { to: 'now', }; -const degradedDocsHref = 'http://exploratory-view/degraded-docs'; +const degradedDocsLinkProps = { + linkProps: { href: 'http://exploratory-view/degraded-docs', onClick: () => {} }, + navigate: () => {}, + isLogsExplorerAvailable: true, +}; const hostsRedirectUrl = 'http://hosts/metric/'; const hostsLocator = { @@ -55,13 +60,18 @@ const hostsLocator = { typeof useKibanaContextForPlugin >['services']['observabilityShared']['locators']['infra']['hostsLocator']; +const telemetry = { + trackDetailsNavigated: () => {}, +} as unknown as ReturnType<typeof useDatasetDetailsTelemetry>; + describe('getSummaryKpis', () => { it('should return the correct KPIs', () => { const result = getSummaryKpis({ dataStreamDetails, timeRange, - degradedDocsHref, + degradedDocsLinkProps, hostsLocator, + telemetry, }); expect(result).toEqual([ @@ -92,7 +102,7 @@ describe('getSummaryKpis', () => { value: '200', link: { label: flyoutShowAllText, - href: degradedDocsHref, + props: degradedDocsLinkProps.linkProps, }, userHasPrivilege: true, }, @@ -119,8 +129,9 @@ describe('getSummaryKpis', () => { const result = getSummaryKpis({ dataStreamDetails: detailsWithMaxPlusHosts, timeRange, - degradedDocsHref, + degradedDocsLinkProps, hostsLocator, + telemetry, }); expect(result).toEqual([ @@ -151,7 +162,7 @@ describe('getSummaryKpis', () => { value: '200', link: { label: flyoutShowAllText, - href: degradedDocsHref, + props: degradedDocsLinkProps.linkProps, }, userHasPrivilege: true, }, diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts index 6dcf61838b83..563c7d06cea4 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts @@ -6,6 +6,7 @@ */ import { formatNumber } from '@elastic/eui'; +import { getRouterLinkProps, RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props'; import { BYTE_NUMBER_FORMAT, DEFAULT_DATEPICKER_REFRESH, @@ -22,25 +23,29 @@ import { flyoutSizeText, } from '../../../../common/translations'; import { DataStreamDetails } from '../../../../common/api_types'; +import { NavigationTarget, NavigationSource } from '../../../services/telemetry'; import { useKibanaContextForPlugin } from '../../../utils'; +import type { useRedirectLink, useDatasetDetailsTelemetry } from '../../../hooks'; import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller'; export function getSummaryKpis({ dataStreamDetails, timeRange = { ...DEFAULT_TIME_RANGE, refresh: DEFAULT_DATEPICKER_REFRESH }, - degradedDocsHref, + degradedDocsLinkProps, hostsLocator, + telemetry, }: { dataStreamDetails?: DataStreamDetails; timeRange?: TimeRangeConfig; - degradedDocsHref?: string; + degradedDocsLinkProps?: ReturnType<typeof useRedirectLink>; hostsLocator?: ReturnType< typeof useKibanaContextForPlugin >['services']['observabilityShared']['locators']['infra']['hostsLocator']; + telemetry: ReturnType<typeof useDatasetDetailsTelemetry>; }): Array<{ title: string; value: string; - link?: { label: string; href: string }; + link?: { label: string; props: RouterLinkProps }; userHasPrivilege: boolean; }> { const services = dataStreamDetails?.services ?? {}; @@ -48,14 +53,17 @@ export function getSummaryKpis({ const countOfServices = serviceKeys .map((key: string) => services[key].length) .reduce((a, b) => a + b, 0); - const servicesLink = undefined; // TODO: Add link to APM services page when possible - const degradedDocsLink = degradedDocsHref - ? { - label: flyoutShowAllText, - href: degradedDocsHref, - } - : undefined; + // @ts-ignore // TODO: Add link to APM services page when possible - https://github.com/elastic/kibana/issues/179904 + const servicesLink = { + label: flyoutShowAllText, + props: getRouterLinkProps({ + href: undefined, + onClick: () => { + telemetry.trackDetailsNavigated(NavigationTarget.Services, NavigationSource.Summary); + }, + }), + }; return [ { @@ -76,14 +84,20 @@ export function getSummaryKpis({ { title: flyoutServicesText, value: formatMetricValueForMax(countOfServices, MAX_HOSTS_METRIC_VALUE, NUMBER_FORMAT), - link: servicesLink, + link: undefined, userHasPrivilege: true, }, - getHostsKpi(dataStreamDetails?.hosts, timeRange, hostsLocator), + getHostsKpi(dataStreamDetails?.hosts, timeRange, telemetry, hostsLocator), { title: flyoutDegradedDocsText, value: formatNumber(dataStreamDetails?.degradedDocsCount ?? 0, NUMBER_FORMAT), - link: degradedDocsLink, + link: + degradedDocsLinkProps && degradedDocsLinkProps.linkProps.href + ? { + label: flyoutShowAllText, + props: degradedDocsLinkProps.linkProps, + } + : undefined, userHasPrivilege: true, }, ]; @@ -92,6 +106,7 @@ export function getSummaryKpis({ function getHostsKpi( dataStreamHosts: DataStreamDetails['hosts'], timeRange: TimeRangeConfig, + telemetry: ReturnType<typeof useDatasetDetailsTelemetry>, hostsLocator?: ReturnType< typeof useKibanaContextForPlugin >['services']['observabilityShared']['locators']['infra']['hostsLocator'] @@ -120,12 +135,15 @@ function getHostsKpi( }); // @ts-ignore // TODO: Add link to Infra Hosts page when possible - const hostsLink = hostsUrl - ? { - label: flyoutShowAllText, - href: hostsUrl, - } - : undefined; + const hostsLink = { + label: flyoutShowAllText, + props: getRouterLinkProps({ + href: hostsUrl, + onClick: () => { + telemetry.trackDetailsNavigated(NavigationTarget.Hosts, NavigationSource.Summary); + }, + }), + }; return { title: flyoutHostsText, diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx index 63fb761ce87f..5fc66f79b79b 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx @@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFlyoutHeader, + EuiSkeletonTitle, EuiTitle, useEuiShadow, useEuiTheme, @@ -20,59 +21,80 @@ import { flyoutOpenInDiscoverText, flyoutOpenInLogsExplorerText, } from '../../../common/translations'; +import { NavigationSource } from '../../services/telemetry'; import { useRedirectLink } from '../../hooks'; -import { FlyoutDataset } from '../../state_machines/dataset_quality_controller'; import { IntegrationIcon } from '../common'; +import { BasicDataStream } from '../../../common/types'; -export function Header({ dataStreamStat }: { dataStreamStat: FlyoutDataset }) { - const { integration, title } = dataStreamStat; +export function Header({ + titleAndLinkDetails, + loading, +}: { + titleAndLinkDetails: BasicDataStream; + loading: boolean; +}) { + const { integration, title } = titleAndLinkDetails; const euiShadow = useEuiShadow('s'); const { euiTheme } = useEuiTheme(); - const redirectLinkProps = useRedirectLink({ dataStreamStat }); + const redirectLinkProps = useRedirectLink({ + dataStreamStat: titleAndLinkDetails, + telemetry: { + page: 'details', + navigationSource: NavigationSource.Header, + }, + }); return ( <EuiFlyoutHeader hasBorder> - <EuiFlexGroup justifyContent="flexStart"> - <EuiFlexItem grow> - <EuiFlexGroup gutterSize="m" justifyContent="flexStart" alignItems="center"> - <EuiTitle data-test-subj="datasetQualityFlyoutTitle"> - <h3>{title}</h3> - </EuiTitle> - <div + {loading ? ( + <EuiSkeletonTitle + size="s" + data-test-subj="datasetQualityFlyoutIntegrationLoading" + className="datasetQualityFlyoutIntegrationLoading" + /> + ) : ( + <EuiFlexGroup justifyContent="flexStart"> + <EuiFlexItem grow> + <EuiFlexGroup gutterSize="m" justifyContent="flexStart" alignItems="center"> + <EuiTitle data-test-subj="datasetQualityFlyoutTitle"> + <h3>{title}</h3> + </EuiTitle> + <div + css={css` + ${euiShadow}; + padding: ${euiTheme.size.xs}; + border-radius: ${euiTheme.size.xxs}; + `} + > + <IntegrationIcon integration={integration} /> + </div> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup css={css` - ${euiShadow}; - padding: ${euiTheme.size.xs}; - border-radius: ${euiTheme.size.xxs}; + margin-right: ${euiTheme.size.l}; `} + gutterSize="s" + justifyContent="flexEnd" + alignItems="center" > - <IntegrationIcon integration={integration} /> - </div> - </EuiFlexGroup> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiFlexGroup - css={css` - margin-right: ${euiTheme.size.l}; - `} - gutterSize="s" - justifyContent="flexEnd" - alignItems="center" - > - <EuiButton - data-test-subj="datasetQualityHeaderButton" - size="s" - {...redirectLinkProps} - iconType={ - redirectLinkProps.isLogsExplorerAvailable ? 'logoObservability' : 'discoverApp' - } - > - {redirectLinkProps.isLogsExplorerAvailable - ? flyoutOpenInLogsExplorerText - : flyoutOpenInDiscoverText} - </EuiButton> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> + <EuiButton + data-test-subj="datasetQualityHeaderButton" + size="s" + {...redirectLinkProps.linkProps} + iconType={ + redirectLinkProps.isLogsExplorerAvailable ? 'logoObservability' : 'discoverApp' + } + > + {redirectLinkProps.isLogsExplorerAvailable + ? flyoutOpenInLogsExplorerText + : flyoutOpenInDiscoverText} + </EuiButton> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + )} </EuiFlyoutHeader> ); } diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx index 9b0e45ce27e9..99afb7f269df 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx @@ -18,9 +18,10 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; import { RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props'; -import { Integration } from '../../../common/data_streams_stats/integration'; import { useDatasetQualityFlyout } from '../../hooks'; import { useFlyoutIntegrationActions } from '../../hooks/use_flyout_integration_actions'; +import { Integration } from '../../../common/data_streams_stats/integration'; +import { Dashboard } from '../../../common/api_types'; const integrationActionsText = i18n.translate('xpack.datasetQuality.flyoutIntegrationActionsText', { defaultMessage: 'Integration actions', @@ -40,15 +41,17 @@ const viewDashboardsText = i18n.translate('xpack.datasetQuality.flyoutViewDashbo export function IntegrationActionsMenu({ integration, + dashboards, dashboardsLoading, }: { integration: Integration; + dashboards: Dashboard[]; dashboardsLoading: boolean; }) { const { dataStreamStat, canUserAccessDashboards, canUserViewIntegrations } = useDatasetQualityFlyout(); + const { version, name: integrationName } = integration; const { type, name } = dataStreamStat!; - const { dashboards = [], version, name: integrationName } = integration; const { isOpen, handleCloseMenu, diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx index 1450e830eac1..6ac94411098a 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx @@ -13,22 +13,29 @@ import { flyoutIntegrationNameText, flyoutIntegrationVersionText, } from '../../../common/translations'; -import { Integration } from '../../../common/data_streams_stats/integration'; import { IntegrationIcon } from '../common'; import { FieldsList } from './fields_list'; import { IntegrationActionsMenu } from './integration_actions_menu'; +import { Integration } from '../../../common/data_streams_stats/integration'; +import { Dashboard } from '../../../common/api_types'; export function IntegrationSummary({ integration, + dashboards, dashboardsLoading, }: { integration: Integration; + dashboards: Dashboard[]; dashboardsLoading: boolean; }) { const { name, version } = integration; const integrationActionsMenu = ( - <IntegrationActionsMenu integration={integration} dashboardsLoading={dashboardsLoading} /> + <IntegrationActionsMenu + integration={integration} + dashboards={dashboards} + dashboardsLoading={dashboardsLoading} + /> ); return ( <FieldsList diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/index.ts b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/index.ts index aa9c59ed77b3..0fc332e68bf8 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/index.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/index.ts @@ -12,3 +12,4 @@ export * from './use_redirect_link'; export * from './use_summary_panel'; export * from './use_create_dataview'; export * from './use_dataset_quality_degraded_field'; +export * from './use_telemetry'; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx index 4cc164b6b92c..88bf869a5a2a 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx @@ -23,6 +23,7 @@ export const useDatasetQualityFlyout = () => { insightsTimeRange, breakdownField, isNonAggregatable, + integration, } = useSelector(service, (state) => state.context.flyout) ?? {}; const { timeRange } = useSelector(service, (state) => state.context.filters); @@ -30,12 +31,20 @@ export const useDatasetQualityFlyout = () => { const loadingState = useSelector(service, (state) => ({ dataStreamDetailsLoading: state.matches('flyout.initializing.dataStreamDetails.fetching'), dataStreamSettingsLoading: state.matches('flyout.initializing.dataStreamSettings.fetching'), - datasetIntegrationsLoading: state.matches('flyout.initializing.integrationDashboards.fetching'), + datasetIntegrationDashboardLoading: state.matches( + 'flyout.initializing.dataStreamSettings.initializeIntegrations.integrationDashboards.fetching' + ), + datasetIntegrationDone: state.matches( + 'flyout.initializing.dataStreamSettings.initializeIntegrations.integrationDetails.done' + ), })); const canUserAccessDashboards = useSelector( service, - (state) => !state.matches('flyout.initializing.integrationDashboards.unauthorized') + (state) => + !state.matches( + 'flyout.initializing.dataStreamSettings.initializeIntegrations.integrationDashboards.unauthorized' + ) ); const canUserViewIntegrations = useSelector( @@ -48,6 +57,7 @@ export const useDatasetQualityFlyout = () => { dataStreamSettings, dataStreamDetails, isNonAggregatable, + integration, fieldFormats, timeRange: insightsTimeRange ?? timeRange, breakdownField, diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_degraded_docs_chart.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_degraded_docs_chart.tsx index c1bbca58d645..03da29c7e04e 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_degraded_docs_chart.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_degraded_docs_chart.tsx @@ -20,6 +20,7 @@ import { useCreateDataView } from './use_create_dataview'; import { useRedirectLink } from './use_redirect_link'; import { useDatasetQualityFlyout } from './use_dataset_quality_flyout'; import { useKibanaContextForPlugin } from '../utils'; +import { useDatasetDetailsTelemetry } from './use_telemetry'; const exploreDataInLogsExplorerText = i18n.translate( 'xpack.datasetQuality.flyoutChartExploreDataInLogsExplorerText', @@ -53,6 +54,8 @@ export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => { services: { lens }, } = useKibanaContextForPlugin(); const { service } = useDatasetQualityContext(); + const { trackDetailsNavigated, navigationTargets, navigationSources } = + useDatasetDetailsTelemetry(); const { dataStreamStat, timeRange, breakdownField } = useDatasetQualityFlyout(); @@ -104,13 +107,14 @@ export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => { const openInLensCallback = useCallback(() => { if (attributes) { + trackDetailsNavigated(navigationTargets.Lens, navigationSources.Chart); lens.navigateToPrefilledEditor({ id: '', timeRange, attributes, }); } - }, [lens, attributes, timeRange]); + }, [attributes, trackDetailsNavigated, navigationTargets, navigationSources, lens, timeRange]); const getOpenInLensAction = useMemo(() => { return { @@ -137,6 +141,10 @@ export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => { query: { language: 'kuery', query: '_ignored:*' }, timeRangeConfig: timeRange, breakdownField: breakdownDataViewField?.name, + telemetry: { + page: 'details', + navigationSource: navigationSources.Chart, + }, }); const getOpenInLogsExplorerAction = useMemo(() => { @@ -149,10 +157,10 @@ export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => { : exploreDataInDiscoverText; }, getHref: async () => { - return redirectLinkProps.href; + return redirectLinkProps.linkProps.href; }, getIconType(): string | undefined { - return 'popout'; + return 'visTable'; }, async isCompatible(): Promise<boolean> { return true; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx index 29faaec2788e..b0f47716100b 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx @@ -10,8 +10,9 @@ import { useMemo, useCallback } from 'react'; import useToggle from 'react-use/lib/useToggle'; import { MANAGEMENT_APP_LOCATOR } from '@kbn/deeplinks-management/constants'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; -import { DashboardType } from '../../common/data_streams_stats'; import { useKibanaContextForPlugin } from '../utils'; +import { Dashboard } from '../../common/api_types'; +import { useDatasetDetailsTelemetry } from './use_telemetry'; export const useFlyoutIntegrationActions = () => { const { @@ -21,6 +22,8 @@ export const useFlyoutIntegrationActions = () => { share, }, } = useKibanaContextForPlugin(); + const { wrapLinkPropsForTelemetry, navigationSources, navigationTargets } = + useDatasetDetailsTelemetry(); const [isOpen, toggleIsOpen] = useToggle(false); @@ -43,28 +46,51 @@ export const useFlyoutIntegrationActions = () => { const getIntegrationOverviewLinkProps = useCallback( (name: string, version: string) => { const href = basePath.prepend(`/app/integrations/detail/${name}-${version}/overview`); - return getRouterLinkProps({ - href, - onClick: () => navigateToUrl(href), - }); + return wrapLinkPropsForTelemetry( + getRouterLinkProps({ + href, + onClick: () => { + return navigateToUrl(href); + }, + }), + navigationTargets.Integration, + navigationSources.ActionMenu + ); }, - [basePath, navigateToUrl] + [basePath, navigateToUrl, navigationSources, navigationTargets, wrapLinkPropsForTelemetry] ); const getIndexManagementLinkProps = useCallback( (params: { sectionId: string; appId: string }) => - getRouterLinkProps({ - href: indexManagementLocator?.getRedirectUrl(params), - onClick: () => indexManagementLocator?.navigate(params), - }), - [indexManagementLocator] + wrapLinkPropsForTelemetry( + getRouterLinkProps({ + href: indexManagementLocator?.getRedirectUrl(params), + onClick: () => { + return indexManagementLocator?.navigate(params); + }, + }), + navigationTargets.IndexTemplate, + navigationSources.ActionMenu + ), + [ + indexManagementLocator, + navigationSources.ActionMenu, + navigationTargets.IndexTemplate, + wrapLinkPropsForTelemetry, + ] ); const getDashboardLinkProps = useCallback( - (dashboard: DashboardType) => - getRouterLinkProps({ - href: dashboardLocator?.getRedirectUrl({ dashboardId: dashboard?.id } || ''), - onClick: () => dashboardLocator?.navigate({ dashboardId: dashboard?.id } || ''), - }), - [dashboardLocator] + (dashboard: Dashboard) => + wrapLinkPropsForTelemetry( + getRouterLinkProps({ + href: dashboardLocator?.getRedirectUrl({ dashboardId: dashboard?.id } || ''), + onClick: () => { + return dashboardLocator?.navigate({ dashboardId: dashboard?.id } || ''); + }, + }), + navigationTargets.Dashboard, + navigationSources.ActionMenu + ), + [dashboardLocator, navigationSources, navigationTargets, wrapLinkPropsForTelemetry] ); return { diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts index 751b7e14ebb7..4a4c6772c412 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { useMemo } from 'react'; import { SINGLE_DATASET_LOCATOR_ID, SingleDatasetLocatorParams, @@ -16,21 +17,24 @@ import { RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props'; import { LocatorPublic } from '@kbn/share-plugin/common'; import { LocatorClient } from '@kbn/shared-ux-prompt-no-data-views-types'; import { useSelector } from '@xstate/react'; -import { DataStreamStat } from '../../common/data_streams_stats/data_stream_stat'; import { useDatasetQualityContext } from '../components/dataset_quality/context'; -import { FlyoutDataset, TimeRangeConfig } from '../state_machines/dataset_quality_controller'; +import { TimeRangeConfig } from '../state_machines/dataset_quality_controller'; import { useKibanaContextForPlugin } from '../utils'; +import { BasicDataStream } from '../../common/types'; +import { useRedirectLinkTelemetry } from './use_telemetry'; -export const useRedirectLink = ({ +export const useRedirectLink = <T extends BasicDataStream>({ dataStreamStat, query, timeRangeConfig, breakdownField, + telemetry, }: { - dataStreamStat: DataStreamStat | FlyoutDataset; + dataStreamStat: T; query?: Query | AggregateQuery; timeRangeConfig?: TimeRangeConfig; breakdownField?: string; + telemetry?: Parameters<typeof useRedirectLinkTelemetry>[0]['telemetry']; }) => { const { services: { share }, @@ -43,32 +47,69 @@ export const useRedirectLink = ({ const logsExplorerLocator = share.url.locators.get<SingleDatasetLocatorParams>(SINGLE_DATASET_LOCATOR_ID); - const config = logsExplorerLocator - ? buildLogsExplorerConfig({ - locator: logsExplorerLocator, - dataStreamStat, - query, - from, - to, - breakdownField, - }) - : buildDiscoverConfig({ - locatorClient: share.url.locators, - dataStreamStat, - query, - from, - to, - breakdownField, - }); - - return { - ...config.routerLinkProps, - navigate: config.navigate, - isLogsExplorerAvailable: !!logsExplorerLocator, - }; + const { sendTelemetry } = useRedirectLinkTelemetry({ + rawName: dataStreamStat.rawName, + isLogsExplorer: !!logsExplorerLocator, + telemetry, + query, + }); + + return useMemo<{ + linkProps: RouterLinkProps; + navigate: () => void; + isLogsExplorerAvailable: boolean; + }>(() => { + const config = logsExplorerLocator + ? buildLogsExplorerConfig({ + locator: logsExplorerLocator, + dataStreamStat, + query, + from, + to, + breakdownField, + }) + : buildDiscoverConfig({ + locatorClient: share.url.locators, + dataStreamStat, + query, + from, + to, + breakdownField, + }); + + const onClickWithTelemetry = (event: Parameters<RouterLinkProps['onClick']>[0]) => { + sendTelemetry(); + if (config.routerLinkProps.onClick) { + config.routerLinkProps.onClick(event); + } + }; + + const navigateWithTelemetry = () => { + sendTelemetry(); + config.navigate(); + }; + + return { + linkProps: { + ...config.routerLinkProps, + onClick: onClickWithTelemetry, + }, + navigate: navigateWithTelemetry, + isLogsExplorerAvailable: !!logsExplorerLocator, + }; + }, [ + breakdownField, + dataStreamStat, + from, + to, + logsExplorerLocator, + query, + sendTelemetry, + share.url.locators, + ]); }; -const buildLogsExplorerConfig = ({ +const buildLogsExplorerConfig = <T extends BasicDataStream>({ locator, dataStreamStat, query, @@ -77,7 +118,7 @@ const buildLogsExplorerConfig = ({ breakdownField, }: { locator: LocatorPublic<SingleDatasetLocatorParams>; - dataStreamStat: DataStreamStat | FlyoutDataset; + dataStreamStat: T; query?: Query | AggregateQuery; from: string; to: string; @@ -117,7 +158,7 @@ const buildLogsExplorerConfig = ({ return { routerLinkProps: logsExplorerLinkProps, navigate: navigateToLogsExplorer }; }; -const buildDiscoverConfig = ({ +const buildDiscoverConfig = <T extends BasicDataStream>({ locatorClient, dataStreamStat, query, @@ -126,7 +167,7 @@ const buildDiscoverConfig = ({ breakdownField, }: { locatorClient: LocatorClient; - dataStreamStat: DataStreamStat | FlyoutDataset; + dataStreamStat: T; query?: Query | AggregateQuery; from: string; to: string; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_telemetry.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_telemetry.tsx new file mode 100644 index 000000000000..e5fc1088e746 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_telemetry.tsx @@ -0,0 +1,352 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useSelector } from '@xstate/react'; +import { getDateISORange } from '@kbn/timerange'; +import { AggregateQuery, Query } from '@kbn/es-query'; + +import { DataStreamStat } from '../../common/data_streams_stats'; +import { DataStreamDetails } from '../../common/api_types'; +import { mapPercentageToQuality } from '../../common/utils'; +import { + NavigationTarget, + NavigationSource, + DatasetDetailsEbtProps, + DatasetNavigatedEbtProps, + DatasetEbtProps, +} from '../services/telemetry'; +import { FlyoutDataset, TimeRangeConfig } from '../state_machines/dataset_quality_controller'; +import { useDatasetQualityContext } from '../components/dataset_quality/context'; +import { useDatasetQualityFilters } from './use_dataset_quality_filters'; + +export const useRedirectLinkTelemetry = ({ + rawName, + isLogsExplorer, + telemetry, + query, +}: { + rawName: string; + isLogsExplorer: boolean; + telemetry?: { + page: 'main' | 'details'; + navigationSource: NavigationSource; + }; + query?: Query | AggregateQuery; +}) => { + const { trackDatasetNavigated } = useDatasetTelemetry(); + const { trackDetailsNavigated, navigationTargets } = useDatasetDetailsTelemetry(); + + const sendTelemetry = useCallback(() => { + if (telemetry) { + const isIgnoredFilter = query ? JSON.stringify(query).includes('_ignored') : false; + if (telemetry.page === 'main') { + trackDatasetNavigated(rawName, isIgnoredFilter); + } else { + trackDetailsNavigated( + isLogsExplorer ? navigationTargets.LogsExplorer : navigationTargets.Discover, + telemetry.navigationSource, + isIgnoredFilter + ); + } + } + }, [ + isLogsExplorer, + trackDetailsNavigated, + navigationTargets, + query, + rawName, + telemetry, + trackDatasetNavigated, + ]); + + const wrapLinkPropsForTelemetry = useCallback( + (props: RouterLinkProps) => { + return { + ...props, + onClick: (event: Parameters<RouterLinkProps['onClick']>[0]) => { + sendTelemetry(); + if (props.onClick) { + props.onClick(event); + } + }, + }; + }, + [sendTelemetry] + ); + + return { + wrapLinkPropsForTelemetry, + sendTelemetry, + }; +}; + +export const useDatasetTelemetry = () => { + const { service, telemetryClient } = useDatasetQualityContext(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const datasets = useSelector(service, (state) => state.context.datasets) ?? {}; + const nonAggregatableDatasets = useSelector( + service, + (state) => state.context.nonAggregatableDatasets + ); + const canUserViewIntegrations = useSelector( + service, + (state) => state.context.datasetUserPrivileges.canViewIntegrations + ); + const sort = useSelector(service, (state) => state.context.table.sort); + const appliedFilters = useDatasetQualityFilters(); + + const trackDatasetNavigated = useCallback<(rawName: string, isIgnoredFilter: boolean) => void>( + (rawName: string, isIgnoredFilter: boolean) => { + const foundDataset = datasets.find((dataset) => dataset.rawName === rawName); + if (foundDataset) { + const ebtProps = getDatasetEbtProps( + foundDataset, + sort, + appliedFilters, + nonAggregatableDatasets, + isIgnoredFilter, + canUserViewIntegrations + ); + telemetryClient.trackDatasetNavigated(ebtProps); + } else { + throw new Error( + `Cannot report dataset navigation telemetry for unknown dataset ${rawName}` + ); + } + }, + [ + sort, + appliedFilters, + canUserViewIntegrations, + datasets, + nonAggregatableDatasets, + telemetryClient, + ] + ); + + return { trackDatasetNavigated }; +}; + +export const useDatasetDetailsTelemetry = () => { + const { service, telemetryClient } = useDatasetQualityContext(); + + const { + dataset: dataStreamStat, + datasetDetails: dataStreamDetails, + insightsTimeRange, + breakdownField, + isNonAggregatable, + } = useSelector(service, (state) => state.context.flyout) ?? {}; + + const loadingState = useSelector(service, (state) => ({ + dataStreamDetailsLoading: state.matches('flyout.initializing.dataStreamDetails.fetching'), + })); + + const canUserAccessDashboards = useSelector( + service, + (state) => !state.matches('flyout.initializing.integrationDashboards.unauthorized') + ); + + const canUserViewIntegrations = useSelector( + service, + (state) => state.context.datasetUserPrivileges.canViewIntegrations + ); + + const ebtProps = useMemo<DatasetDetailsEbtProps | undefined>(() => { + if ( + dataStreamDetails && + insightsTimeRange && + dataStreamStat && + !loadingState.dataStreamDetailsLoading + ) { + return getDatasetDetailsEbtProps( + insightsTimeRange, + dataStreamStat, + dataStreamDetails, + isNonAggregatable ?? false, + canUserViewIntegrations, + canUserAccessDashboards, + breakdownField + ); + } + + return undefined; + }, [ + insightsTimeRange, + dataStreamStat, + dataStreamDetails, + loadingState.dataStreamDetailsLoading, + isNonAggregatable, + canUserViewIntegrations, + canUserAccessDashboards, + breakdownField, + ]); + + const startTracking = useCallback(() => { + telemetryClient.startDatasetDetailsTracking(); + }, [telemetryClient]); + + // Report opening dataset details + useEffect(() => { + const datasetDetailsTrackingState = telemetryClient.getDatasetDetailsTrackingState(); + if (datasetDetailsTrackingState === 'started' && ebtProps) { + telemetryClient.trackDatasetDetailsOpened(ebtProps); + } + }, [ebtProps, telemetryClient]); + + const trackDetailsNavigated = useCallback( + (target: NavigationTarget, source: NavigationSource, isDegraded = false) => { + const datasetDetailsTrackingState = telemetryClient.getDatasetDetailsTrackingState(); + if ( + (datasetDetailsTrackingState === 'opened' || datasetDetailsTrackingState === 'navigated') && + ebtProps + ) { + telemetryClient.trackDatasetDetailsNavigated({ + ...ebtProps, + filters: { + is_degraded: isDegraded, + }, + target, + source, + }); + } else { + throw new Error( + 'Cannot report dataset details navigation telemetry without required data and state' + ); + } + }, + [ebtProps, telemetryClient] + ); + + const wrapLinkPropsForTelemetry = useCallback( + ( + props: RouterLinkProps, + target: NavigationTarget, + source: NavigationSource, + isDegraded = false + ) => { + return { + ...props, + onClick: (event: Parameters<RouterLinkProps['onClick']>[0]) => { + trackDetailsNavigated(target, source, isDegraded); + if (props.onClick) { + props.onClick(event); + } + }, + }; + }, + [trackDetailsNavigated] + ); + + return { + startTracking, + trackDetailsNavigated, + wrapLinkPropsForTelemetry, + navigationTargets: NavigationTarget, + navigationSources: NavigationSource, + }; +}; + +function getDatasetEbtProps( + dataset: DataStreamStat, + sort: { field: string; direction: 'asc' | 'desc' }, + filters: ReturnType<typeof useDatasetQualityFilters>, + nonAggregatableDatasets: string[], + isIgnoredFilter: boolean, + canUserViewIntegrations: boolean +): DatasetNavigatedEbtProps { + const { startDate: from, endDate: to } = getDateISORange(filters.timeRange); + const datasetEbtProps: DatasetEbtProps = { + index_name: dataset.rawName, + data_stream: { + dataset: dataset.name, + namespace: dataset.namespace, + type: dataset.type, + }, + data_stream_health: dataset.degradedDocs.quality, + data_stream_aggregatable: nonAggregatableDatasets.some( + (indexName) => indexName === dataset.rawName + ), + from, + to, + degraded_percentage: dataset.degradedDocs.percentage, + integration: dataset.integration?.name, + privileges: { + can_monitor_data_stream: dataset.userPrivileges?.canMonitor ?? true, + can_view_integrations: canUserViewIntegrations, + }, + }; + + const ebtFilters: DatasetNavigatedEbtProps['filters'] = { + is_degraded: isIgnoredFilter, + query_length: filters.selectedQuery?.length ?? 0, + integrations: { + total: filters.integrations.filter((item) => item.name !== 'none').length, + included: filters.integrations.filter((item) => item?.checked === 'on').length, + excluded: filters.integrations.filter((item) => item?.checked === 'off').length, + }, + namespaces: { + total: filters.namespaces.length, + included: filters.namespaces.filter((item) => item?.checked === 'on').length, + excluded: filters.namespaces.filter((item) => item?.checked === 'off').length, + }, + qualities: { + total: filters.qualities.length, + included: filters.qualities.filter((item) => item?.checked === 'on').length, + excluded: filters.qualities.filter((item) => item?.checked === 'off').length, + }, + }; + + return { + ...datasetEbtProps, + sort, + filters: ebtFilters, + }; +} + +function getDatasetDetailsEbtProps( + insightsTimeRange: TimeRangeConfig, + flyoutDataset: FlyoutDataset, + details: DataStreamDetails, + isNonAggregatable: boolean, + canUserViewIntegrations: boolean, + canUserAccessDashboards: boolean, + breakdownField?: string +): DatasetDetailsEbtProps { + const indexName = flyoutDataset.rawName; + const dataStream = { + dataset: flyoutDataset.name, + namespace: flyoutDataset.namespace, + type: flyoutDataset.type, + }; + const degradedDocs = details?.degradedDocsCount ?? 0; + const totalDocs = details?.docsCount ?? 0; + const degradedPercentage = + totalDocs > 0 ? Number(((degradedDocs / totalDocs) * 100).toFixed(2)) : 0; + const health = mapPercentageToQuality(degradedPercentage); + const { startDate: from, endDate: to } = getDateISORange(insightsTimeRange); + + return { + index_name: indexName, + data_stream: dataStream, + privileges: { + can_monitor_data_stream: true, + can_view_integrations: canUserViewIntegrations, + can_view_dashboards: canUserAccessDashboards, + }, + data_stream_aggregatable: !isNonAggregatable, + data_stream_health: health, + from, + to, + degraded_percentage: degradedPercentage, + integration: flyoutDataset.integration?.name, + breakdown_field: breakdownField, + }; +} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx index 6ea265545060..3e90347875ba 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx @@ -6,6 +6,7 @@ */ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import { TelemetryService } from './services/telemetry'; import { createDatasetQuality } from './components/dataset_quality'; import { createDatasetQualityControllerLazyFactory } from './controller/lazy_create_controller'; import { DataStreamsStatsService } from './services/data_streams_stats'; @@ -20,13 +21,18 @@ import { export class DatasetQualityPlugin implements Plugin<DatasetQualityPluginSetup, DatasetQualityPluginStart> { + private telemetry = new TelemetryService(); constructor(context: PluginInitializerContext) {} public setup(core: CoreSetup, plugins: DatasetQualitySetupDeps) { + this.telemetry.setup({ analytics: core.analytics }); + return {}; } public start(core: CoreStart, plugins: DatasetQualityStartDeps): DatasetQualityPluginStart { + const telemetryClient = this.telemetry.start(); + const dataStreamStatsClient = new DataStreamsStatsService().start({ http: core.http, }).client; @@ -38,7 +44,7 @@ export class DatasetQualityPlugin const DatasetQuality = createDatasetQuality({ core, plugins, - dataStreamStatsClient, + telemetryClient, }); const createDatasetQualityController = createDatasetQualityControllerLazyFactory({ diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts index 52bdf3c53ea5..a5813b319035 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts @@ -11,7 +11,10 @@ import { getDataStreamDegradedFieldsResponseRt, getDataStreamsDetailsResponseRt, getDataStreamsSettingsResponseRt, + getIntegrationsResponseRt, + IntegrationDashboardsResponse, integrationDashboardsRT, + IntegrationResponse, } from '../../../common/api_types'; import { DataStreamDetails, @@ -24,10 +27,11 @@ import { GetDataStreamSettingsResponse, GetDataStreamsStatsError, GetIntegrationDashboardsParams, - GetIntegrationDashboardsResponse, } from '../../../common/data_streams_stats'; import { IDataStreamDetailsClient } from './types'; import { GetDataStreamsDetailsError } from '../../../common/data_stream_details'; +import { Integration } from '../../../common/data_streams_stats/integration'; +import { GetDataStreamIntegrationParams } from '../../../common/data_stream_details/types'; export class DataStreamDetailsClient implements IDataStreamDetailsClient { constructor(private readonly http: HttpStart) {} @@ -107,7 +111,7 @@ export class DataStreamDetailsClient implements IDataStreamDetailsClient { public async getIntegrationDashboards({ integration }: GetIntegrationDashboardsParams) { const response = await this.http - .get<GetIntegrationDashboardsResponse>( + .get<IntegrationDashboardsResponse>( `/internal/dataset_quality/integrations/${integration}/dashboards` ) .catch((error) => { @@ -117,7 +121,7 @@ export class DataStreamDetailsClient implements IDataStreamDetailsClient { ); }); - const integrationDashboards = decodeOrThrow( + const { dashboards } = decodeOrThrow( integrationDashboardsRT, (message: string) => new GetDataStreamsStatsError( @@ -125,6 +129,32 @@ export class DataStreamDetailsClient implements IDataStreamDetailsClient { ) )(response); - return integrationDashboards; + return dashboards; + } + + public async getDataStreamIntegration( + params: GetDataStreamIntegrationParams + ): Promise<Integration | undefined> { + const { type, integrationName } = params; + const response = await this.http + .get<IntegrationResponse>('/internal/dataset_quality/integrations', { + query: { type }, + }) + .catch((error) => { + throw new GetDataStreamsStatsError( + `Failed to fetch integrations: ${error}`, + error.body.statusCode + ); + }); + + const { integrations } = decodeOrThrow( + getIntegrationsResponseRt, + (message: string) => + new GetDataStreamsStatsError(`Failed to decode integrations response: ${message}`) + )(response); + + const integration = integrations.find((i) => i.name === integrationName); + + if (integration) return Integration.create(integration); } } diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts index 708c1ba8a6c4..40eaf499a303 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts @@ -6,16 +6,18 @@ */ import { HttpStart } from '@kbn/core/public'; +import { Integration } from '../../../common/data_streams_stats/integration'; import { GetDataStreamSettingsParams, DataStreamSettings, GetDataStreamDetailsParams, DataStreamDetails, GetIntegrationDashboardsParams, - GetIntegrationDashboardsResponse, GetDataStreamDegradedFieldsParams, DegradedFieldResponse, } from '../../../common/data_streams_stats'; +import { GetDataStreamIntegrationParams } from '../../../common/data_stream_details/types'; +import { Dashboard } from '../../../common/api_types'; export type DataStreamDetailsServiceSetup = void; @@ -33,7 +35,8 @@ export interface IDataStreamDetailsClient { getDataStreamDegradedFields( params: GetDataStreamDegradedFieldsParams ): Promise<DegradedFieldResponse>; - getIntegrationDashboards( - params: GetIntegrationDashboardsParams - ): Promise<GetIntegrationDashboardsResponse>; + getIntegrationDashboards(params: GetIntegrationDashboardsParams): Promise<Dashboard[]>; + getDataStreamIntegration( + params: GetDataStreamIntegrationParams + ): Promise<Integration | undefined>; } diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts index 6ffa91e7151d..bbce86404e1d 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts @@ -12,6 +12,7 @@ import { getDataStreamsStatsResponseRt, getIntegrationsResponseRt, getNonAggregatableDatasetsRt, + IntegrationResponse, } from '../../../common/api_types'; import { DEFAULT_DATASET_TYPE } from '../../../common/constants'; import { @@ -24,7 +25,6 @@ import { GetIntegrationsParams, GetNonAggregatableDataStreamsParams, GetNonAggregatableDataStreamsResponse, - IntegrationsResponse, } from '../../../common/data_streams_stats'; import { Integration } from '../../../common/data_streams_stats/integration'; import { IDataStreamsStatsClient } from './types'; @@ -113,9 +113,9 @@ export class DataStreamsStatsClient implements IDataStreamsStatsClient { public async getIntegrations( params: GetIntegrationsParams['query'] = { type: DEFAULT_DATASET_TYPE } - ): Promise<IntegrationsResponse> { + ): Promise<Integration[]> { const response = await this.http - .get<GetDataStreamsStatsResponse>('/internal/dataset_quality/integrations', { + .get<IntegrationResponse>('/internal/dataset_quality/integrations', { query: params, }) .catch((error) => { diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts index 7e3ee958b407..ba1f9077f45b 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts @@ -14,8 +14,8 @@ import { GetIntegrationsParams, GetNonAggregatableDataStreamsParams, GetNonAggregatableDataStreamsResponse, - IntegrationsResponse, } from '../../../common/data_streams_stats'; +import { Integration } from '../../../common/data_streams_stats/integration'; export type DataStreamsStatsServiceSetup = void; @@ -32,7 +32,7 @@ export interface IDataStreamsStatsClient { getDataStreamsDegradedStats( params?: GetDataStreamsDegradedDocsStatsQuery ): Promise<DataStreamDegradedDocsStatServiceResponse>; - getIntegrations(params: GetIntegrationsParams['query']): Promise<IntegrationsResponse>; + getIntegrations(params: GetIntegrationsParams['query']): Promise<Integration[]>; getNonAggregatableDatasets( params: GetNonAggregatableDataStreamsParams ): Promise<GetNonAggregatableDataStreamsResponse>; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/index.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/index.ts new file mode 100644 index 000000000000..c7cc9eb577e3 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/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 './telemetry_client'; +export * from './telemetry_service'; +export * from './types'; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts new file mode 100644 index 000000000000..c0e93f13cd1b --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import { + ITelemetryClient, + DatasetDetailsEbtProps, + DatasetQualityTelemetryEventTypes, + DatasetDetailsNavigatedEbtProps, + DatasetDetailsTrackingState, + DatasetNavigatedEbtProps, +} from './types'; + +export class TelemetryClient implements ITelemetryClient { + private datasetDetailsTrackingId = ''; + private startTime = 0; + private datasetDetailsState: DatasetDetailsTrackingState = 'initial'; + + constructor(private analytics: AnalyticsServiceSetup) {} + + public trackDatasetNavigated = (eventProps: DatasetNavigatedEbtProps) => { + this.analytics.reportEvent(DatasetQualityTelemetryEventTypes.NAVIGATED, eventProps); + }; + + public startDatasetDetailsTracking() { + this.datasetDetailsTrackingId = uuidv4(); + this.startTime = Date.now(); + this.datasetDetailsState = 'started'; + } + + public getDatasetDetailsTrackingState() { + return this.datasetDetailsState; + } + + public trackDatasetDetailsOpened = (eventProps: DatasetDetailsEbtProps) => { + const datasetDetailsLoadDuration = Date.now() - this.startTime; + + this.datasetDetailsState = 'opened'; + this.analytics.reportEvent(DatasetQualityTelemetryEventTypes.DETAILS_OPENED, { + ...eventProps, + tracking_id: this.datasetDetailsTrackingId, + duration: datasetDetailsLoadDuration, + }); + }; + + public trackDatasetDetailsNavigated = (eventProps: DatasetDetailsNavigatedEbtProps) => { + this.datasetDetailsState = 'navigated'; + this.analytics.reportEvent(DatasetQualityTelemetryEventTypes.DETAILS_NAVIGATED, { + ...eventProps, + tracking_id: this.datasetDetailsTrackingId, + }); + }; +} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_events.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_events.ts new file mode 100644 index 000000000000..a8244a6830ae --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_events.ts @@ -0,0 +1,261 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { SchemaObject, SchemaValue } from '@kbn/ebt'; +import { + DatasetEbtFilter, + DatasetEbtProps, + DatasetNavigatedEbtProps, + DatasetQualityTelemetryEvent, + DatasetQualityTelemetryEventTypes, +} from './types'; + +const dataStreamSchema: SchemaObject<DatasetEbtProps['data_stream']> = { + properties: { + dataset: { + type: 'keyword', + _meta: { + description: 'Data stream dataset name', + }, + }, + namespace: { + type: 'keyword', + _meta: { + description: 'Data stream namespace', + }, + }, + type: { + type: 'keyword', + _meta: { + description: 'Data stream type e.g. "logs", "metrics"', + }, + }, + }, +}; + +const privilegesSchema: SchemaObject<DatasetEbtProps['privileges']> = { + properties: { + can_monitor_data_stream: { + type: 'boolean', + _meta: { + description: 'Whether user can monitor the data stream', + }, + }, + can_view_integrations: { + type: 'boolean', + _meta: { + description: 'Whether user can view integrations', + }, + }, + can_view_dashboards: { + type: 'boolean', + _meta: { + description: 'Whether user can view dashboards', + optional: true, + }, + }, + }, +}; + +const ebtFilterObjectSchema: SchemaObject<DatasetEbtFilter> = { + properties: { + total: { + type: 'short', + _meta: { + description: 'Total number of values available to filter', + optional: false, + }, + }, + included: { + type: 'short', + _meta: { + description: 'Number of values selected to filter for', + optional: false, + }, + }, + excluded: { + type: 'short', + _meta: { + description: 'Number of values selected to filter out', + optional: false, + }, + }, + }, + _meta: { + description: 'Represents the multi select filters', + optional: false, + }, +}; + +const sortSchema: SchemaObject<DatasetNavigatedEbtProps['sort']> = { + properties: { + field: { + type: 'keyword', + _meta: { + description: 'Field used for sorting on the main table', + optional: false, + }, + }, + direction: { + type: 'keyword', + _meta: { + description: 'Sort direction', + optional: false, + }, + }, + }, + _meta: { + description: 'Represents the state of applied sorting on the dataset quality home page', + optional: false, + }, +}; + +const filtersSchema: SchemaObject<DatasetNavigatedEbtProps['filters']> = { + properties: { + is_degraded: { + type: 'boolean', + _meta: { + description: 'Whether _ignored filter is applied', + optional: false, + }, + }, + query_length: { + type: 'short', + _meta: { + description: 'Length of the query string', + optional: false, + }, + }, + integrations: ebtFilterObjectSchema, + namespaces: ebtFilterObjectSchema, + qualities: ebtFilterObjectSchema, + }, + _meta: { + description: 'Represents the state of applied filters on the dataset quality home page', + optional: false, + }, +}; + +const datasetCommonSchema = { + index_name: { + type: 'keyword', + _meta: { + description: 'Index name', + }, + } as SchemaValue<string>, + data_stream: dataStreamSchema, + privileges: privilegesSchema, + data_stream_health: { + type: 'keyword', + _meta: { + description: 'Quality of the data stream e.g. "good", "degraded", "poor"', + }, + } as SchemaValue<string>, + data_stream_aggregatable: { + type: 'boolean', + _meta: { + description: 'Whether data stream is aggregatable against _ignored field', + }, + } as SchemaValue<boolean>, + degraded_percentage: { + type: 'float', + _meta: { + description: 'Percentage of degraded documents in the data stream', + }, + } as SchemaValue<number>, + from: { + type: 'date', + _meta: { + description: 'Start of the time range ISO8601 formatted string', + }, + } as SchemaValue<string>, + to: { + type: 'date', + _meta: { + description: 'End of the time range ISO8601 formatted string', + }, + } as SchemaValue<string>, + integration: { + type: 'keyword', + _meta: { + description: 'Integration name, if any', + optional: true, + }, + } as SchemaValue<string | undefined>, +}; + +const datasetNavigatedEventType: DatasetQualityTelemetryEvent = { + eventType: DatasetQualityTelemetryEventTypes.NAVIGATED, + schema: { + ...datasetCommonSchema, + sort: sortSchema, + filters: filtersSchema, + }, +}; + +const datasetDetailsOpenedEventType: DatasetQualityTelemetryEvent = { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_OPENED, + schema: { + ...datasetCommonSchema, + tracking_id: { + type: 'keyword', + _meta: { + description: `Locally generated session tracking ID for funnel analysis`, + }, + }, + duration: { + type: 'long', + _meta: { + description: 'Duration in milliseconds to load the dataset details page', + }, + }, + breakdown_field: { + type: 'keyword', + _meta: { + description: 'Field used for chart breakdown, if any', + optional: true, + }, + }, + }, +}; + +const datasetDetailsNavigatedEventType: DatasetQualityTelemetryEvent = { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_NAVIGATED, + schema: { + ...omit(datasetDetailsOpenedEventType.schema, 'duration'), + filters: { + properties: { + is_degraded: { + type: 'boolean', + _meta: { + description: 'Whether _ignored filter is applied to the link', + optional: false, + }, + }, + }, + }, + target: { + type: 'keyword', + _meta: { + description: 'Action that user took to navigate away from the dataset details page', + }, + }, + source: { + type: 'keyword', + _meta: { + description: + 'Section of dataset details page the action is originated from e.g. header, summary, chart or table etc.', + }, + }, + }, +}; + +export const datasetQualityEbtEvents = { + datasetNavigatedEventType, + datasetDetailsOpenedEventType, + datasetDetailsNavigatedEventType, +}; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts new file mode 100644 index 000000000000..dfd1bd4fb2b5 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { coreMock } from '@kbn/core/server/mocks'; +import { datasetQualityEbtEvents } from './telemetry_events'; +import { TelemetryService } from './telemetry_service'; +import { + NavigationTarget, + NavigationSource, + DatasetDetailsNavigatedEbtProps, + DatasetDetailsEbtProps, + WithTrackingId, + WithDuration, + DatasetEbtProps, + DatasetNavigatedEbtProps, +} from './types'; + +// Mock uuidv4 +jest.mock('uuid', () => { + return { + v4: jest.fn(() => `mock-uuid-${Math.random()}`), + }; +}); + +describe('TelemetryService', () => { + const service = new TelemetryService(); + + const mockCoreStart = coreMock.createSetup(); + service.setup({ analytics: mockCoreStart.analytics }); + + const defaultEbtProps: DatasetEbtProps = { + index_name: 'logs-example-dataset-default', + data_stream: { + dataset: 'example-dataset', + namespace: 'default', + type: 'logs', + }, + privileges: { + can_monitor_data_stream: true, + can_view_integrations: true, + can_view_dashboards: true, + }, + data_stream_health: 'poor', + data_stream_aggregatable: true, + degraded_percentage: 0.5, + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-02T00:00:00.000Z', + }; + + const defaultSort: DatasetNavigatedEbtProps['sort'] = { field: 'name', direction: 'asc' }; + + const defaultFilters: DatasetNavigatedEbtProps['filters'] = { + is_degraded: false, + query_length: 0, + integrations: { + total: 0, + included: 0, + excluded: 0, + }, + namespaces: { + total: 0, + included: 0, + excluded: 0, + }, + qualities: { + total: 0, + included: 0, + excluded: 0, + }, + }; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should register all events', () => { + expect(mockCoreStart.analytics.registerEventType).toHaveBeenCalledTimes( + Object.keys(datasetQualityEbtEvents).length + ); + }); + + it('should report dataset navigated event', async () => { + const telemetry = service.start(); + const exampleEventData: DatasetNavigatedEbtProps = { + ...defaultEbtProps, + sort: defaultSort, + filters: defaultFilters, + }; + + telemetry.trackDatasetNavigated(exampleEventData); + + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledWith( + datasetQualityEbtEvents.datasetNavigatedEventType.eventType, + expect.objectContaining(exampleEventData) + ); + }); + + it('should report opening dataset details with a tracking_id', async () => { + const telemetry = service.start(); + const exampleEventData: DatasetDetailsEbtProps = { + ...defaultEbtProps, + }; + + telemetry.startDatasetDetailsTracking(); + + // Increment jest's internal timer to simulate user interaction delay + jest.advanceTimersByTime(500); + + telemetry.trackDatasetDetailsOpened(exampleEventData); + + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledWith( + datasetQualityEbtEvents.datasetDetailsOpenedEventType.eventType, + expect.objectContaining({ + ...exampleEventData, + tracking_id: expect.stringMatching(/\S/), + duration: expect.any(Number), + }) + ); + + // Expect the duration to be greater than the time mark + const args = mockCoreStart.analytics.reportEvent.mock.calls[0][1] as WithTrackingId & + WithDuration; + expect(args.duration).toBeGreaterThanOrEqual(500); + }); + + it('should report closing dataset details with the same tracking_id', async () => { + const telemetry = service.start(); + const exampleOpenEventData: DatasetDetailsEbtProps = { + ...defaultEbtProps, + }; + + const exampleNavigatedEventData: DatasetDetailsNavigatedEbtProps = { + ...exampleOpenEventData, + breakdown_field: 'example_field', + filters: { + is_degraded: false, + }, + target: NavigationTarget.Exit, + source: NavigationSource.Chart, + }; + + telemetry.startDatasetDetailsTracking(); + telemetry.trackDatasetDetailsOpened(exampleOpenEventData); + telemetry.trackDatasetDetailsNavigated(exampleNavigatedEventData); + + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledTimes(2); + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledWith( + datasetQualityEbtEvents.datasetDetailsNavigatedEventType.eventType, + expect.objectContaining({ + ...exampleNavigatedEventData, + tracking_id: expect.stringMatching(/\S/), + }) + ); + + // Make sure the tracking_id is the same for both events + const [firstCall, secondCall] = mockCoreStart.analytics.reportEvent.mock.calls; + expect((firstCall[1] as WithTrackingId).tracking_id).toEqual( + (secondCall[1] as WithTrackingId).tracking_id + ); + expect((secondCall[1] as DatasetDetailsNavigatedEbtProps).breakdown_field).toEqual( + 'example_field' + ); + }); +}); diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.ts new file mode 100644 index 000000000000..f8f8c5322f55 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.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 { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import { TelemetryServiceSetupParams, ITelemetryClient } from './types'; +import { datasetQualityEbtEvents } from './telemetry_events'; +import { TelemetryClient } from './telemetry_client'; + +/** + * Service that interacts with the Core's analytics module + */ +export class TelemetryService { + constructor(private analytics?: AnalyticsServiceSetup) {} + + public setup({ analytics }: TelemetryServiceSetupParams) { + this.analytics = analytics; + + analytics.registerEventType(datasetQualityEbtEvents.datasetNavigatedEventType); + analytics.registerEventType(datasetQualityEbtEvents.datasetDetailsOpenedEventType); + analytics.registerEventType(datasetQualityEbtEvents.datasetDetailsNavigatedEventType); + } + + public start(): ITelemetryClient { + if (!this.analytics) { + throw new Error( + 'The TelemetryService.setup() method has not been invoked, be sure to call it during the plugin setup.' + ); + } + + return new TelemetryClient(this.analytics); + } +} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/types.ts new file mode 100644 index 000000000000..2784f02187db --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/types.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AnalyticsServiceSetup, RootSchema } from '@kbn/core/public'; +import { QualityIndicators } from '../../../common/types'; + +export interface TelemetryServiceSetupParams { + analytics: AnalyticsServiceSetup; +} + +export type DatasetDetailsTrackingState = 'initial' | 'started' | 'opened' | 'navigated'; + +export enum NavigationTarget { + Exit = 'exit', + LogsExplorer = 'logs_explorer', + Discover = 'discover', + Lens = 'lens', + Integration = 'integration', + IndexTemplate = 'index_template', + Dashboard = 'dashboard', + Hosts = 'hosts', + Services = 'services', +} + +/** + * Source UI component that triggered the navigation + */ +export enum NavigationSource { + Header = 'header', + Footer = 'footer', + Summary = 'summary', + Chart = 'chart', + Table = 'table', + ActionMenu = 'action_menu', +} + +export interface WithTrackingId { + tracking_id: string; // For funnel analysis and session tracking +} + +export interface WithDuration { + duration: number; // The time (in milliseconds) it took to reach the meaningful state +} + +export interface DatasetEbtProps { + index_name: string; + data_stream: { + dataset: string; + namespace: string; + type: string; + }; + data_stream_health: QualityIndicators; + data_stream_aggregatable: boolean; + from: string; + to: string; + degraded_percentage: number; + integration?: string; + privileges: { + can_monitor_data_stream: boolean; + can_view_integrations: boolean; + can_view_dashboards?: boolean; + }; +} + +export interface DatasetEbtFilter { + total: number; + included: number; + excluded: number; +} + +export interface DatasetNavigatedEbtProps extends DatasetEbtProps { + sort: { field: string; direction: 'asc' | 'desc' }; + filters: { + is_degraded: boolean; + query_length: number; + integrations: DatasetEbtFilter; + namespaces: DatasetEbtFilter; + qualities: DatasetEbtFilter; + }; +} + +export interface DatasetDetailsEbtProps extends DatasetEbtProps { + breakdown_field?: string; +} + +export interface DatasetDetailsNavigatedEbtProps extends DatasetDetailsEbtProps { + filters: { + is_degraded: boolean; + }; + target: NavigationTarget; + source: NavigationSource; +} + +export interface ITelemetryClient { + trackDatasetNavigated: (eventProps: DatasetNavigatedEbtProps) => void; + startDatasetDetailsTracking: () => void; + getDatasetDetailsTrackingState: () => DatasetDetailsTrackingState; + trackDatasetDetailsOpened: (eventProps: DatasetDetailsEbtProps) => void; + trackDatasetDetailsNavigated: (eventProps: DatasetDetailsNavigatedEbtProps) => void; +} + +export enum DatasetQualityTelemetryEventTypes { + NAVIGATED = 'Dataset Quality Navigated', + DETAILS_OPENED = 'Dataset Quality Dataset Details Opened', + DETAILS_NAVIGATED = 'Dataset Quality Dataset Details Navigated', +} + +export type DatasetQualityTelemetryEvent = + | { + eventType: DatasetQualityTelemetryEventTypes.NAVIGATED; + schema: RootSchema<DatasetNavigatedEbtProps>; + } + | { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_OPENED; + schema: RootSchema<DatasetDetailsEbtProps & WithTrackingId & WithDuration>; + } + | { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_NAVIGATED; + schema: RootSchema<DatasetDetailsNavigatedEbtProps & WithTrackingId>; + }; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts index d2077f20ad9d..f92db44eb516 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts @@ -71,6 +71,22 @@ export const fetchIntegrationsFailedNotifier = (toasts: IToasts, error: Error) = }); }; +export const fetchDataStreamIntegrationFailedNotifier = ( + toasts: IToasts, + error: Error, + integrationName?: string +) => { + toasts.addDanger({ + title: i18n.translate('xpack.datasetQuality.flyout.fetchIntegrationsFailed', { + defaultMessage: "We couldn't get {integrationName} integration info.", + values: { + integrationName, + }, + }), + text: error.message, + }); +}; + export const noDatasetSelected = i18n.translate( 'xpack.datasetQuality.fetchDatasetDetailsFailed.noDatasetSelected', { diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts index 4c2b516fc466..6cad5ae252c0 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts @@ -8,11 +8,10 @@ import { IToasts } from '@kbn/core/public'; import { getDateISORange } from '@kbn/timerange'; import { assign, createMachine, DoneInvokeEvent, InterpreterFrom } from 'xstate'; -import { DataStreamStat, DegradedFieldResponse } from '../../../../common/api_types'; +import { Dashboard, DataStreamStat, DegradedFieldResponse } from '../../../../common/api_types'; import { Integration } from '../../../../common/data_streams_stats/integration'; import { IDataStreamDetailsClient } from '../../../services/data_stream_details'; import { - DashboardType, DataStreamSettings, DataStreamDetails, GetDataStreamsStatsQuery, @@ -35,6 +34,7 @@ import { fetchIntegrationsFailedNotifier, noDatasetSelected, fetchNonAggregatableDatasetsFailedNotifier, + fetchDataStreamIntegrationFailedNotifier, } from './notifications'; import { DatasetQualityControllerContext, @@ -261,7 +261,7 @@ export const createPureDatasetQualityControllerStateMachine = ( invoke: { src: 'loadDataStreamSettings', onDone: { - target: 'done', + target: 'initializeIntegrations', actions: ['storeDataStreamSettings'], }, onError: { @@ -270,6 +270,62 @@ export const createPureDatasetQualityControllerStateMachine = ( }, }, }, + initializeIntegrations: { + type: 'parallel', + states: { + integrationDetails: { + initial: 'fetching', + states: { + fetching: { + invoke: { + src: 'loadDataStreamIntegration', + onDone: { + target: 'done', + actions: ['storeDataStreamIntegration'], + }, + onError: { + target: 'done', + actions: ['notifyFetchDatasetIntegrationsFailed'], + }, + }, + }, + done: { + type: 'final', + }, + }, + }, + integrationDashboards: { + initial: 'fetching', + states: { + fetching: { + invoke: { + src: 'loadIntegrationDashboards', + onDone: { + target: 'done', + actions: ['storeIntegrationDashboards'], + }, + onError: [ + { + target: 'unauthorized', + cond: 'checkIfActionForbidden', + }, + { + target: 'done', + actions: ['notifyFetchIntegrationDashboardsFailed'], + }, + ], + }, + }, + done: { + type: 'final', + }, + unauthorized: { + type: 'final', + }, + }, + }, + }, + }, done: { type: 'final', }, @@ -304,36 +360,6 @@ export const createPureDatasetQualityControllerStateMachine = ( }, }, }, - integrationDashboards: { - initial: 'fetching', - states: { - fetching: { - invoke: { - src: 'loadIntegrationDashboards', - onDone: { - target: 'done', - actions: ['storeIntegrationDashboards'], - }, - onError: [ - { - target: 'unauthorized', - cond: 'checkIfActionForbidden', - }, - { - target: 'done', - actions: ['notifyFetchIntegrationDashboardsFailed'], - }, - ], - }, - }, - done: { - type: 'final', - }, - unauthorized: { - type: 'final', - }, - }, - }, dataStreamDegradedFields: { initial: 'fetching', states: { @@ -623,18 +649,28 @@ export const createPureDatasetQualityControllerStateMachine = ( integrations: [], }; }), - storeIntegrationDashboards: assign((context, event) => { - return 'data' in event && 'dashboards' in event.data + storeDataStreamIntegration: assign((context, event: DoneInvokeEvent<Integration>) => { + return 'data' in event ? { flyout: { ...context.flyout, - dataset: { - ...context.flyout.dataset, - integration: { - ...context.flyout.dataset?.integration, - dashboards: event.data.dashboards as DashboardType[], - }, - } as FlyoutDataset, + integration: { + ...context.flyout.integration, + integrationDetails: event.data, + }, + }, + } + : {}; + }), + storeIntegrationDashboards: assign((context, event: DoneInvokeEvent<Dashboard[]>) => { + return 'data' in event + ? { + flyout: { + ...context.flyout, + integration: { + ...context.flyout.integration, + dashboards: event.data, + }, }, } : {}; @@ -688,6 +724,10 @@ export const createDatasetQualityControllerStateMachine = ({ fetchIntegrationDashboardsFailedNotifier(toasts, event.data), notifyFetchIntegrationsFailed: (_context, event: DoneInvokeEvent<Error>) => fetchIntegrationsFailedNotifier(toasts, event.data), + notifyFetchDatasetIntegrationsFailed: (context, event: DoneInvokeEvent<Error>) => { + const integrationName = context.flyout.datasetSettings?.integration; + return fetchDataStreamIntegrationFailedNotifier(toasts, event.data, integrationName); + }, }, services: { loadDataStreamStats: (context) => @@ -757,6 +797,16 @@ export const createDatasetQualityControllerStateMachine = ({ }), }); }, + loadDataStreamIntegration: (context) => { + if (context.flyout.datasetSettings?.integration && context.flyout.dataset) { + const { type } = context.flyout.dataset; + return dataStreamDetailsClient.getDataStreamIntegration({ + type: type as DataStreamType, + integrationName: context.flyout.datasetSettings.integration, + }); + } + return Promise.resolve(); + }, loadDataStreamDetails: (context) => { if (!context.flyout.dataset || !context.flyout.insightsTimeRange) { fetchDatasetDetailsFailedNotifier(toasts, new Error(noDatasetSelected)); @@ -780,17 +830,13 @@ export const createDatasetQualityControllerStateMachine = ({ }); }, loadIntegrationDashboards: (context) => { - if (!context.flyout.dataset) { - fetchDatasetDetailsFailedNotifier(toasts, new Error(noDatasetSelected)); - - return Promise.resolve({}); + if (context.flyout.datasetSettings?.integration) { + return dataStreamDetailsClient.getIntegrationDashboards({ + integration: context.flyout.datasetSettings.integration, + }); } - const { integration } = context.flyout.dataset; - - return integration - ? dataStreamDetailsClient.getIntegrationDashboards({ integration: integration.name }) - : Promise.resolve({}); + return Promise.resolve(); }, loadDatasetIsNonAggregatable: async (context) => { if (!context.flyout.dataset || !context.flyout.insightsTimeRange) { diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts index c75c648020d6..1454c098f978 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts @@ -8,17 +8,15 @@ import { DoneInvokeEvent } from 'xstate'; import { RefreshInterval, TimeRange } from '@kbn/data-plugin/common'; import { QualityIndicators, SortDirection } from '../../../../common/types'; -import { DatasetUserPrivileges } from '../../../../common/api_types'; +import { Dashboard, DatasetUserPrivileges } from '../../../../common/api_types'; import { Integration } from '../../../../common/data_streams_stats/integration'; import { DatasetTableSortField, DegradedFieldSortField } from '../../../hooks'; import { DegradedDocsStat } from '../../../../common/data_streams_stats/malformed_docs_stat'; import { - DashboardType, DataStreamDegradedDocsStatServiceResponse, DataStreamSettings, DataStreamDetails, DataStreamStatServiceResponse, - IntegrationsResponse, DataStreamStat, DataStreamStatType, GetNonAggregatableDataStreamsResponse, @@ -59,6 +57,11 @@ interface FiltersCriteria { query?: string; } +export interface DataStreamIntegrations { + integrationDetails?: Integration; + dashboards?: Dashboard[]; +} + export interface WithTableOptions { table: TableCriteria<DatasetTableSortField>; } @@ -72,6 +75,7 @@ export interface WithFlyoutOptions { breakdownField?: string; degradedFields: DegradedFields; isNonAggregatable?: boolean; + integration?: DataStreamIntegrations; }; } @@ -146,6 +150,18 @@ export type DatasetQualityControllerTypeState = value: 'flyout.initializing.dataStreamSettings.fetching'; context: DefaultDatasetQualityStateContext; } + | { + value: 'flyout.initializing.dataStreamSettings.initializeIntegrations.integrationDashboards.fetching'; + context: DefaultDatasetQualityStateContext; + } + | { + value: 'flyout.initializing.dataStreamSettings.initializeIntegrations.integrationDashboards.unauthorized'; + context: DefaultDatasetQualityStateContext; + } + | { + value: 'flyout.initializing.dataStreamSettings.initializeIntegrations.integrationDetails.done'; + context: DefaultDatasetQualityStateContext; + } | { value: 'flyout.initializing.dataStreamDetails.fetching'; context: DefaultDatasetQualityStateContext; @@ -222,10 +238,10 @@ export type DatasetQualityControllerEvent = } | DoneInvokeEvent<DataStreamDegradedDocsStatServiceResponse> | DoneInvokeEvent<GetNonAggregatableDataStreamsResponse> - | DoneInvokeEvent<DashboardType> + | DoneInvokeEvent<Dashboard[]> | DoneInvokeEvent<DataStreamDetails> | DoneInvokeEvent<DegradedFieldResponse> | DoneInvokeEvent<DataStreamSettings> | DoneInvokeEvent<DataStreamStatServiceResponse> - | DoneInvokeEvent<IntegrationsResponse> + | DoneInvokeEvent<Integration> | DoneInvokeEvent<Error>; diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/get_data_stream_settings.test.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/get_data_stream_settings.test.ts index dd2548d033c7..1a4ce17fbce3 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/get_data_stream_settings.test.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/get_data_stream_settings.test.ts @@ -8,6 +8,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { getDataStreamSettings } from '.'; +import { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/types'; const accessLogsDataStream = 'logs-nginx.access-default'; const errorLogsDataStream = 'logs-nginx.error-default'; const dateStr1 = '1702998651925'; // .ds-logs-nginx.access-default-2023.12.19-000001 @@ -38,12 +39,13 @@ describe('getDataStreamSettings', () => { esClientMock.indices.getSettings.mockReturnValue( Promise.resolve(MOCK_NGINX_ERROR_INDEX_SETTINGS) ); + esClientMock.indices.getDataStream.mockReturnValue(Promise.resolve(MOCK_NGINX_DATA_STREAM)); const dataStreamSettings = await getDataStreamSettings({ esClient: esClientMock, dataStream: errorLogsDataStream, }); - expect(dataStreamSettings).toEqual({ createdOn: Number(dateStr3) }); + expect(dataStreamSettings).toEqual({ createdOn: Number(dateStr3), integration: 'apache' }); }); it('returns the earliest creation date of a data stream with multiple backing indices', async () => { @@ -51,12 +53,13 @@ describe('getDataStreamSettings', () => { esClientMock.indices.getSettings.mockReturnValue( Promise.resolve(MOCK_NGINX_ACCESS_INDEX_SETTINGS) ); + esClientMock.indices.getDataStream.mockReturnValue(Promise.resolve(MOCK_NGINX_DATA_STREAM)); const dataStreamSettings = await getDataStreamSettings({ esClient: esClientMock, dataStream: accessLogsDataStream, }); - expect(dataStreamSettings).toEqual({ createdOn: Number(dateStr1) }); + expect(dataStreamSettings).toEqual({ createdOn: Number(dateStr1), integration: 'apache' }); }); }); @@ -225,3 +228,40 @@ const MOCK_INDEX_ERROR = { }, status: 404, }; + +const MOCK_NGINX_DATA_STREAM: IndicesGetDataStreamResponse = { + data_streams: [ + { + name: 'logs-apache.access-production', + timestamp_field: { + name: '@timestamp', + }, + indices: [ + { + index_name: '.ds-logs-apache.access-production-2024.07.03-000001', + index_uuid: 'xrGjQ-GFSeqAlUbyS0Fx-A', + prefer_ilm: true, + ilm_policy: 'logs', + managed_by: 'Index Lifecycle Management', + }, + ], + generation: 1, + _meta: { + package: { + name: 'apache', + }, + managed: true, + managed_by: 'fleet', + }, + status: 'YELLOW', + template: 'logs-apache.access', + ilm_policy: 'logs', + next_generation_managed_by: 'Index Lifecycle Management', + prefer_ilm: true, + hidden: false, + system: false, + allow_custom_routing: false, + replicated: false, + }, + ], +}; diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/index.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/index.ts index 52804c5d9369..d89bb83867d1 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/index.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_stream_details/index.ts @@ -32,8 +32,13 @@ export async function getDataStreamSettings({ const createdOn = await getDataStreamCreatedOn(esClient, dataStream); + // Getting the 1st item from the data streams endpoint as we will be passing the exact DS name + const [dataStreamInfo] = await dataStreamService.getMatchingDataStreams(esClient, dataStream); + const integration = dataStreamInfo?._meta?.package?.name; + return { createdOn, + integration, }; } diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_streams_stats/index.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_streams_stats/index.ts index 41f89fa7ac15..8c7878f24486 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_streams_stats/index.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_data_streams_stats/index.ts @@ -24,7 +24,7 @@ export async function getDataStreamsStats({ }; } - const matchingDataStreamsStats = await dataStreamService.getStreamsStats(esClient, dataStreams); + const matchingDataStreamsStats = dataStreamService.getStreamsStats(esClient, dataStreams); const indicesDocsCount = sizeStatsAvailable ? indexStatsService.getIndicesDocCounts(esClient, dataStreams) diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/get_integrations.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/get_integrations.ts index 70aa86f08efc..208ed7e70ab3 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/get_integrations.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/get_integrations.ts @@ -11,13 +11,13 @@ import { PackageNotFoundError } from '@kbn/fleet-plugin/server/errors'; import { PackageListItem, RegistryDataStream } from '@kbn/fleet-plugin/common'; import { DEFAULT_DATASET_TYPE } from '../../../common/constants'; import { DataStreamType } from '../../../common/types'; -import { Integration } from '../../../common/api_types'; +import { IntegrationType } from '../../../common/api_types'; export async function getIntegrations(options: { packageClient: PackageClient; logger: Logger; type?: DataStreamType; -}): Promise<Integration[]> { +}): Promise<IntegrationType[]> { const { packageClient, logger, type = DEFAULT_DATASET_TYPE } = options; const packages = await packageClient.getPackages(); diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/routes.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/routes.ts index dfc0b8a2824e..1a21d7e1a14c 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/routes.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/integrations/routes.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { Integration, IntegrationDashboards } from '../../../common/api_types'; +import { IntegrationType, IntegrationDashboardsResponse } from '../../../common/api_types'; import { typeRt } from '../../types/default_api_types'; import { createDatasetQualityServerRoute } from '../create_datasets_quality_server_route'; import { getIntegrations } from './get_integrations'; @@ -21,7 +21,7 @@ const integrationsRoute = createDatasetQualityServerRoute({ tags: [], }, async handler(resources): Promise<{ - integrations: Integration[]; + integrations: IntegrationType[]; }> { const { params, plugins, logger } = resources; @@ -44,7 +44,7 @@ const integrationDashboardsRoute = createDatasetQualityServerRoute({ options: { tags: [], }, - async handler(resources): Promise<IntegrationDashboards> { + async handler(resources): Promise<IntegrationDashboardsResponse> { const { context, params, plugins } = resources; const { integration } = params.path; const { savedObjects } = await context.core; diff --git a/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json b/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json index 564a1e421684..13e698502a7a 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json +++ b/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json @@ -49,7 +49,10 @@ "@kbn/metrics-data-access-plugin", "@kbn/calculate-auto", "@kbn/discover-plugin", - "@kbn/shared-ux-prompt-no-data-views-types" + "@kbn/shared-ux-prompt-no-data-views-types", + "@kbn/core-analytics-server", + "@kbn/ebt", + "@kbn/ebt-tools" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/observability_solution/entity_manager/common/errors.ts b/x-pack/plugins/observability_solution/entity_manager/common/errors.ts index ebf5670db2aa..27e9406771da 100644 --- a/x-pack/plugins/observability_solution/entity_manager/common/errors.ts +++ b/x-pack/plugins/observability_solution/entity_manager/common/errors.ts @@ -9,3 +9,5 @@ export const ERROR_API_KEY_NOT_FOUND = 'api_key_not_found'; export const ERROR_API_KEY_NOT_VALID = 'api_key_not_valid'; export const ERROR_USER_NOT_AUTHORIZED = 'user_not_authorized'; export const ERROR_API_KEY_SERVICE_DISABLED = 'api_key_service_disabled'; +export const ERROR_PARTIAL_BUILTIN_INSTALLATION = 'partial_builtin_installation'; +export const ERROR_DEFINITION_STOPPED = 'error_definition_stopped'; diff --git a/x-pack/plugins/observability_solution/entity_manager/kibana.jsonc b/x-pack/plugins/observability_solution/entity_manager/kibana.jsonc index b13c51630469..1b9a56cd77da 100644 --- a/x-pack/plugins/observability_solution/entity_manager/kibana.jsonc +++ b/x-pack/plugins/observability_solution/entity_manager/kibana.jsonc @@ -5,20 +5,10 @@ "description": "Entity manager plugin for entity assets (inventory, topology, etc)", "plugin": { "id": "entityManager", - "configPath": [ - "xpack", - "entityManager" - ], - "optionalPlugins": [ - "spaces" - ], - "requiredPlugins": [ - "security", - "encryptedSavedObjects", - ], + "configPath": ["xpack", "entityManager"], + "requiredPlugins": ["security", "encryptedSavedObjects"], "browser": true, "server": true, - "requiredBundles": [ - ] + "requiredBundles": [] } } diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts index c6fbc127230a..141452f78635 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts @@ -16,11 +16,10 @@ import { generateHistoryIngestPipelineId } from './ingest_pipeline/generate_hist export async function createAndInstallHistoryIngestPipeline( esClient: ElasticsearchClient, definition: EntityDefinition, - logger: Logger, - spaceId: string + logger: Logger ) { try { - const historyProcessors = generateHistoryProcessors(definition, spaceId); + const historyProcessors = generateHistoryProcessors(definition); const historyId = generateHistoryIngestPipelineId(definition); await retryTransientEsErrors( () => @@ -40,11 +39,10 @@ export async function createAndInstallHistoryIngestPipeline( export async function createAndInstallLatestIngestPipeline( esClient: ElasticsearchClient, definition: EntityDefinition, - logger: Logger, - spaceId: string + logger: Logger ) { try { - const latestProcessors = generateLatestProcessors(definition, spaceId); + const latestProcessors = generateLatestProcessors(definition); const latestId = generateLatestIngestPipelineId(definition); await retryTransientEsErrors( () => diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/errors/entity_definition_id_too_long_error.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/errors/entity_definition_id_too_long_error.ts new file mode 100644 index 000000000000..fb78f237bee3 --- /dev/null +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/errors/entity_definition_id_too_long_error.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 class EntityDefinitionIdTooLong extends Error { + constructor(message: string) { + super(message); + this.name = 'EntityDefinitionIdTooLong'; + } +} diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/find_entity_definition.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/find_entity_definition.ts index 8351142333f9..fbca1362491a 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/find_entity_definition.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/find_entity_definition.ts @@ -14,6 +14,7 @@ import { generateLatestIngestPipelineId } from './ingest_pipeline/generate_lates import { generateHistoryTransformId } from './transform/generate_history_transform_id'; import { generateLatestTransformId } from './transform/generate_latest_transform_id'; import { BUILT_IN_ID_PREFIX } from './built_in'; +import { EntityDefinitionWithState } from './types'; export async function findEntityDefinitions({ soClient, @@ -29,7 +30,7 @@ export async function findEntityDefinitions({ id?: string; page?: number; perPage?: number; -}): Promise<Array<EntityDefinition & { state: { installed: boolean; running: boolean } }>> { +}): Promise<EntityDefinitionWithState[]> { const filter = compact([ typeof builtIn === 'boolean' ? `${SO_ENTITY_DEFINITION_TYPE}.attributes.id:(${BUILT_IN_ID_PREFIX}*)` diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_history_processors.test.ts.snap b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_history_processors.test.ts.snap index f5d7c7e3683b..b712456ed8fc 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_history_processors.test.ts.snap +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_history_processors.test.ts.snap @@ -10,8 +10,8 @@ Array [ }, Object { "set": Object { - "field": "entity.spaceId", - "value": "default", + "field": "entity.type", + "value": "service", }, }, Object { @@ -109,7 +109,7 @@ if (ctx.entity?.metadata?.host?.os?.name != null) { ], "date_rounding": "M", "field": "@timestamp", - "index_name_prefix": ".entities-observability.history-v1.admin-console-services.default.", + "index_name_prefix": ".entities-observability.history-v1.admin-console-services.", }, }, ] diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap index bd31c8563be4..6867a10b9b99 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap @@ -10,8 +10,8 @@ Array [ }, Object { "set": Object { - "field": "entity.spaceId", - "value": "default", + "field": "entity.type", + "value": "service", }, }, Object { @@ -46,7 +46,7 @@ if (ctx.entity?.metadata?.host?.os?.name.data != null) { Object { "set": Object { "field": "_index", - "value": ".entities-observability.latest-v1.admin-console-services.default", + "value": ".entities-observability.latest-v1.admin-console-services", }, }, ] diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.test.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.test.ts index 8203d06c1f8e..697b3a5223f9 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.test.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.test.ts @@ -10,7 +10,7 @@ import { generateHistoryProcessors } from './generate_history_processors'; describe('generateHistoryProcessors(definition)', () => { it('should genearte a valid pipeline', () => { - const processors = generateHistoryProcessors(entityDefinition, 'default'); + const processors = generateHistoryProcessors(entityDefinition); expect(processors).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.ts index dcfa23d39881..733ec63b6d51 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.ts @@ -43,7 +43,7 @@ function createMetadataPainlessScript(definition: EntityDefinition) { }, ''); } -export function generateHistoryProcessors(definition: EntityDefinition, spaceId: string) { +export function generateHistoryProcessors(definition: EntityDefinition) { return [ { set: { @@ -53,8 +53,8 @@ export function generateHistoryProcessors(definition: EntityDefinition, spaceId: }, { set: { - field: 'entity.spaceId', - value: spaceId, + field: 'entity.type', + value: definition.type, }, }, { @@ -135,7 +135,7 @@ export function generateHistoryProcessors(definition: EntityDefinition, spaceId: { date_index_name: { field: '@timestamp', - index_name_prefix: `${generateHistoryIndexName(definition)}.${spaceId}.`, + index_name_prefix: `${generateHistoryIndexName(definition)}.`, date_rounding: 'M', date_formats: ['UNIX_MS', 'ISO8601', "yyyy-MM-dd'T'HH:mm:ss.SSSXX"], }, diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.test.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.test.ts index 63cab821b472..b6a10ce3db34 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.test.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.test.ts @@ -10,7 +10,7 @@ import { generateLatestProcessors } from './generate_latest_processors'; describe('generateLatestProcessors(definition)', () => { it('should genearte a valid pipeline', () => { - const processors = generateLatestProcessors(entityDefinition, 'default'); + const processors = generateLatestProcessors(entityDefinition); expect(processors).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts index 992f4e14c8d1..0676e8926bc1 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts @@ -37,7 +37,7 @@ function createMetadataPainlessScript(definition: EntityDefinition) { }, ''); } -export function generateLatestProcessors(definition: EntityDefinition, spaceId: string) { +export function generateLatestProcessors(definition: EntityDefinition) { return [ { set: { @@ -47,8 +47,8 @@ export function generateLatestProcessors(definition: EntityDefinition, spaceId: }, { set: { - field: 'entity.spaceId', - value: spaceId, + field: 'entity.type', + value: definition.type, }, }, { @@ -74,7 +74,7 @@ export function generateLatestProcessors(definition: EntityDefinition, spaceId: { set: { field: '_index', - value: `${generateLatestIndexName(definition)}.${spaceId}`, + value: `${generateLatestIndexName(definition)}`, }, }, ]; diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.test.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.test.ts index 02de8754e6af..c85cdc7907da 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.test.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.test.ts @@ -116,7 +116,6 @@ describe('install_entity_definition', () => { soClient, builtInDefinitions, logger: loggerMock.create(), - spaceId: 'default', }); assertHasCreatedDefinition(builtInServicesEntityDefinition, soClient, esClient); @@ -159,7 +158,6 @@ describe('install_entity_definition', () => { soClient, builtInDefinitions, logger: loggerMock.create(), - spaceId: 'default', }); assertHasUninstalledDefinition(builtInServicesEntityDefinition, soClient, esClient); @@ -200,7 +198,6 @@ describe('install_entity_definition', () => { soClient, builtInDefinitions, logger: loggerMock.create(), - spaceId: 'default', }); expect(soClient.create).toHaveBeenCalledTimes(0); diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.ts index 630833ef16d5..2b3ab240b2ef 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/install_entity_definition.ts @@ -17,6 +17,7 @@ import { createAndInstallHistoryTransform, createAndInstallLatestTransform, } 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 { findEntityDefinitions } from './find_entity_definition'; @@ -33,7 +34,6 @@ export interface InstallDefinitionParams { soClient: SavedObjectsClientContract; definition: EntityDefinition; logger: Logger; - spaceId: string; } export async function installEntityDefinition({ @@ -41,7 +41,6 @@ export async function installEntityDefinition({ soClient, definition, logger, - spaceId, }: InstallDefinitionParams): Promise<EntityDefinition> { const installState = { ingestPipelines: { @@ -57,14 +56,17 @@ export async function installEntityDefinition({ try { logger.debug(`Installing definition ${JSON.stringify(definition)}`); + + validateDefinitionCanCreateValidTransformIds(definition); + const entityDefinition = await saveEntityDefinition(soClient, definition); installState.definition = true; // install ingest pipelines logger.debug(`Installing ingest pipelines for definition ${definition.id}`); - await createAndInstallHistoryIngestPipeline(esClient, entityDefinition, logger, spaceId); + await createAndInstallHistoryIngestPipeline(esClient, entityDefinition, logger); installState.ingestPipelines.history = true; - await createAndInstallLatestIngestPipeline(esClient, entityDefinition, logger, spaceId); + await createAndInstallLatestIngestPipeline(esClient, entityDefinition, logger); installState.ingestPipelines.latest = true; // install transforms @@ -106,7 +108,6 @@ export async function installBuiltInEntityDefinitions({ soClient, logger, builtInDefinitions, - spaceId, }: Omit<InstallDefinitionParams, 'definition'> & { builtInDefinitions: EntityDefinition[]; }): Promise<EntityDefinition[]> { @@ -126,7 +127,6 @@ export async function installBuiltInEntityDefinitions({ esClient, soClient, logger, - spaceId, }); } @@ -140,7 +140,6 @@ export async function installBuiltInEntityDefinitions({ esClient, soClient, logger, - spaceId, }); } diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/transform/validate_transform_ids.test.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/transform/validate_transform_ids.test.ts new file mode 100644 index 000000000000..9a8d23b16c97 --- /dev/null +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/transform/validate_transform_ids.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EntityDefinitionIdTooLong } from '../errors/entity_definition_id_too_long_error'; +import { entityDefinition } from '../helpers/fixtures/entity_definition'; +import { validateDefinitionCanCreateValidTransformIds } from './validate_transform_ids'; + +describe('validateDefinitionCanCreateValidTransformIds(definition)', () => { + it('should not throw an error for a definition ID which is not too long', () => { + validateDefinitionCanCreateValidTransformIds(entityDefinition); + }); + + it('should throw an error for a definition ID which is too long', () => { + const entityDefinitionWithLongID = entityDefinition; + entityDefinitionWithLongID.id = + 'a-really-really-really-really-really-really-really-really-really-really-long-id'; + + expect(() => { + validateDefinitionCanCreateValidTransformIds(entityDefinition); + }).toThrow(EntityDefinitionIdTooLong); + }); +}); diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/transform/validate_transform_ids.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/transform/validate_transform_ids.ts new file mode 100644 index 000000000000..320505134dd9 --- /dev/null +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/transform/validate_transform_ids.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. + */ + +const TRANSFORM_ID_MAX_LENGTH = 64; + +import { EntityDefinition } from '@kbn/entities-schema'; +import { EntityDefinitionIdTooLong } from '../errors/entity_definition_id_too_long_error'; +import { generateHistoryTransformId } from './generate_history_transform_id'; +import { generateLatestTransformId } from './generate_latest_transform_id'; + +export function validateDefinitionCanCreateValidTransformIds(definition: EntityDefinition) { + const historyTransformId = generateHistoryTransformId(definition); + const latestTransformId = generateLatestTransformId(definition); + + const spareChars = + TRANSFORM_ID_MAX_LENGTH - Math.max(historyTransformId.length, latestTransformId.length); + + if (spareChars < 0) { + throw new EntityDefinitionIdTooLong( + `Entity definition ID is too long (max = ${ + definition.id.length + spareChars + }); the resulting transform ID will be invalid` + ); + } +} diff --git a/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/types.ts b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/types.ts new file mode 100644 index 000000000000..1f3498a9354a --- /dev/null +++ b/x-pack/plugins/observability_solution/entity_manager/server/lib/entities/types.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. + */ + +import { EntityDefinition } from '@kbn/entities-schema'; + +export type EntityDefinitionWithState = EntityDefinition & { + state: { installed: boolean; running: boolean }; +}; diff --git a/x-pack/plugins/observability_solution/entity_manager/server/plugin.ts b/x-pack/plugins/observability_solution/entity_manager/server/plugin.ts index e71f5b36bb46..010a9ffaa515 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/plugin.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/plugin.ts @@ -73,7 +73,6 @@ export class EntityManagerServerPlugin setupRoutes<RequestHandlerContext>({ router, logger: this.logger, - spaces: plugins.spaces, server: this.server, }); diff --git a/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/check.ts b/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/check.ts index 97f621daf750..251c3ccfefad 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/check.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/check.ts @@ -11,13 +11,19 @@ import { SetupRouteOptions } from '../types'; import { ENTITY_INTERNAL_API_PREFIX } from '../../../common/constants_entities'; import { ManagedEntityEnabledResponse } from '../../../common/types_api'; import { checkIfEntityDiscoveryAPIKeyIsValid, readEntityDiscoveryAPIKey } from '../../lib/auth'; -import { ERROR_API_KEY_NOT_FOUND, ERROR_API_KEY_NOT_VALID } from '../../../common/errors'; +import { + ERROR_API_KEY_NOT_FOUND, + ERROR_API_KEY_NOT_VALID, + ERROR_DEFINITION_STOPPED, + ERROR_PARTIAL_BUILTIN_INSTALLATION, +} from '../../../common/errors'; import { findEntityDefinitions } from '../../lib/entities/find_entity_definition'; import { builtInDefinitions } from '../../lib/entities/built_in'; export function checkEntityDiscoveryEnabledRoute<T extends RequestHandlerContext>({ router, server, + logger, }: SetupRouteOptions<T>) { router.get<unknown, unknown, ManagedEntityEnabledResponse>( { @@ -25,37 +31,60 @@ export function checkEntityDiscoveryEnabledRoute<T extends RequestHandlerContext validate: false, }, async (context, req, res) => { - server.logger.debug('reading entity discovery API key from saved object'); - const apiKey = await readEntityDiscoveryAPIKey(server); + try { + logger.debug('reading entity discovery API key from saved object'); + const apiKey = await readEntityDiscoveryAPIKey(server); - if (apiKey === undefined) { - return res.ok({ body: { enabled: false, reason: ERROR_API_KEY_NOT_FOUND } }); - } + if (apiKey === undefined) { + return res.ok({ body: { enabled: false, reason: ERROR_API_KEY_NOT_FOUND } }); + } - server.logger.debug('validating existing entity discovery API key'); - const isValid = await checkIfEntityDiscoveryAPIKeyIsValid(server, apiKey); + logger.debug('validating existing entity discovery API key'); + const isValid = await checkIfEntityDiscoveryAPIKeyIsValid(server, apiKey); - if (!isValid) { - return res.ok({ body: { enabled: false, reason: ERROR_API_KEY_NOT_VALID } }); - } + if (!isValid) { + return res.ok({ body: { enabled: false, reason: ERROR_API_KEY_NOT_VALID } }); + } + + const fakeRequest = getFakeKibanaRequest({ id: apiKey.id, api_key: apiKey.apiKey }); + const soClient = server.core.savedObjects.getScopedClient(fakeRequest); + const esClient = server.core.elasticsearch.client.asScoped(fakeRequest).asCurrentUser; - const fakeRequest = getFakeKibanaRequest({ id: apiKey.id, api_key: apiKey.apiKey }); - const soClient = server.core.savedObjects.getScopedClient(fakeRequest); - const esClient = server.core.elasticsearch.client.asScoped(fakeRequest).asCurrentUser; + const entityDiscoveryState = await Promise.all( + builtInDefinitions.map(async (builtInDefinition) => { + const definitions = await findEntityDefinitions({ + esClient, + soClient, + id: builtInDefinition.id, + }); - const entityDiscoveryEnabled = await Promise.all( - builtInDefinitions.map(async (builtInDefinition) => { - const [definition] = await findEntityDefinitions({ - esClient, - soClient, - id: builtInDefinition.id, - }); + return definitions[0]; + }) + ).then((results) => + results.reduce( + (state, definition) => { + return { + installed: Boolean(state.installed && definition?.state.installed), + running: Boolean(state.running && definition?.state.running), + }; + }, + { installed: true, running: true } + ) + ); - return definition && definition.state.installed && definition.state.running; - }) - ).then((results) => results.every(Boolean)); + if (!entityDiscoveryState.installed) { + return res.ok({ body: { enabled: false, reason: ERROR_PARTIAL_BUILTIN_INSTALLATION } }); + } - return res.ok({ body: { enabled: entityDiscoveryEnabled } }); + if (!entityDiscoveryState.running) { + return res.ok({ body: { enabled: false, reason: ERROR_DEFINITION_STOPPED } }); + } + + return res.ok({ body: { enabled: true } }); + } catch (err) { + logger.error(err); + return res.customError({ statusCode: 500, body: err }); + } } ); } diff --git a/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/disable.ts b/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/disable.ts index e1312d0dff08..33a1b60dab29 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/disable.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/disable.ts @@ -29,32 +29,37 @@ export function disableEntityDiscoveryRoute<T extends RequestHandlerContext>({ validate: false, }, async (context, req, res) => { - server.logger.debug('reading entity discovery API key from saved object'); - const apiKey = await readEntityDiscoveryAPIKey(server); + try { + server.logger.debug('reading entity discovery API key from saved object'); + const apiKey = await readEntityDiscoveryAPIKey(server); - if (apiKey === undefined) { - return res.ok({ body: { success: false, reason: ERROR_API_KEY_NOT_FOUND } }); - } + if (apiKey === undefined) { + return res.ok({ body: { success: false, reason: ERROR_API_KEY_NOT_FOUND } }); + } - server.logger.debug('validating existing entity discovery API key'); - const isValid = await checkIfEntityDiscoveryAPIKeyIsValid(server, apiKey); + server.logger.debug('validating existing entity discovery API key'); + const isValid = await checkIfEntityDiscoveryAPIKeyIsValid(server, apiKey); - if (!isValid) { - return res.ok({ body: { success: false, reason: ERROR_API_KEY_NOT_VALID } }); - } + if (!isValid) { + return res.ok({ body: { success: false, reason: ERROR_API_KEY_NOT_VALID } }); + } - const fakeRequest = getFakeKibanaRequest({ id: apiKey.id, api_key: apiKey.apiKey }); - const soClient = server.core.savedObjects.getScopedClient(fakeRequest); - const esClient = server.core.elasticsearch.client.asScoped(fakeRequest).asCurrentUser; + const fakeRequest = getFakeKibanaRequest({ id: apiKey.id, api_key: apiKey.apiKey }); + const soClient = server.core.savedObjects.getScopedClient(fakeRequest); + const esClient = server.core.elasticsearch.client.asScoped(fakeRequest).asCurrentUser; - await uninstallBuiltInEntityDefinitions({ soClient, esClient, logger }); + await uninstallBuiltInEntityDefinitions({ soClient, esClient, logger }); - await deleteEntityDiscoveryAPIKey((await context.core).savedObjects.client); - await server.security.authc.apiKeys.invalidateAsInternalUser({ - ids: [apiKey.id], - }); + await deleteEntityDiscoveryAPIKey((await context.core).savedObjects.client); + await server.security.authc.apiKeys.invalidateAsInternalUser({ + ids: [apiKey.id], + }); - return res.ok(); + return res.ok({ body: { success: true } }); + } catch (err) { + logger.error(err); + return res.customError({ statusCode: 500, body: err }); + } } ); } diff --git a/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/enable.ts b/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/enable.ts index 82f682fc82a6..16c6bae80520 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/enable.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/routes/enablement/enable.ts @@ -35,69 +35,73 @@ export function enableEntityDiscoveryRoute<T extends RequestHandlerContext>({ validate: false, }, async (context, req, res) => { - const apiKeysEnabled = await checkIfAPIKeysAreEnabled(server); - if (!apiKeysEnabled) { - return res.ok({ - body: { - success: false, - reason: ERROR_API_KEY_SERVICE_DISABLED, - message: - 'API key service is not enabled; try configuring `xpack.security.authc.api_key.enabled` in your elasticsearch config', - }, - }); - } + try { + const apiKeysEnabled = await checkIfAPIKeysAreEnabled(server); + if (!apiKeysEnabled) { + return res.ok({ + body: { + success: false, + reason: ERROR_API_KEY_SERVICE_DISABLED, + message: + 'API key service is not enabled; try configuring `xpack.security.authc.api_key.enabled` in your elasticsearch config', + }, + }); + } - const esClient = (await context.core).elasticsearch.client.asCurrentUser; - const canEnable = await canEnableEntityDiscovery(esClient); - if (!canEnable) { - return res.ok({ - body: { - success: false, - reason: ERROR_USER_NOT_AUTHORIZED, - message: - 'Current Kibana user does not have the required permissions to enable entity discovery', - }, - }); - } + const esClient = (await context.core).elasticsearch.client.asCurrentUser; + const canEnable = await canEnableEntityDiscovery(esClient); + if (!canEnable) { + return res.ok({ + body: { + success: false, + reason: ERROR_USER_NOT_AUTHORIZED, + message: + 'Current Kibana user does not have the required permissions to enable entity discovery', + }, + }); + } - const soClient = (await context.core).savedObjects.getClient({ - includedHiddenTypes: [EntityDiscoveryApiKeyType.name], - }); + const soClient = (await context.core).savedObjects.getClient({ + includedHiddenTypes: [EntityDiscoveryApiKeyType.name], + }); - const existingApiKey = await readEntityDiscoveryAPIKey(server); + const existingApiKey = await readEntityDiscoveryAPIKey(server); - if (existingApiKey !== undefined) { - const isValid = await checkIfEntityDiscoveryAPIKeyIsValid(server, existingApiKey); + if (existingApiKey !== undefined) { + const isValid = await checkIfEntityDiscoveryAPIKeyIsValid(server, existingApiKey); - if (!isValid) { - await deleteEntityDiscoveryAPIKey(soClient); - await server.security.authc.apiKeys.invalidateAsInternalUser({ - ids: [existingApiKey.id], - }); + if (!isValid) { + await deleteEntityDiscoveryAPIKey(soClient); + await server.security.authc.apiKeys.invalidateAsInternalUser({ + ids: [existingApiKey.id], + }); + } } - } - const apiKey = await generateEntityDiscoveryAPIKey(server, req); + const apiKey = await generateEntityDiscoveryAPIKey(server, req); - if (apiKey === undefined) { - throw new Error('could not generate entity discovery API key'); - } + if (apiKey === undefined) { + throw new Error('could not generate entity discovery API key'); + } - await saveEntityDiscoveryAPIKey(soClient, apiKey); + await saveEntityDiscoveryAPIKey(soClient, apiKey); - const fakeRequest = getFakeKibanaRequest({ id: apiKey.id, api_key: apiKey.apiKey }); - const scopedSoClient = server.core.savedObjects.getScopedClient(fakeRequest); - const scopedEsClient = server.core.elasticsearch.client.asScoped(fakeRequest).asCurrentUser; + const fakeRequest = getFakeKibanaRequest({ id: apiKey.id, api_key: apiKey.apiKey }); + const scopedSoClient = server.core.savedObjects.getScopedClient(fakeRequest); + const scopedEsClient = server.core.elasticsearch.client.asScoped(fakeRequest).asCurrentUser; - await installBuiltInEntityDefinitions({ - logger, - builtInDefinitions, - spaceId: 'default', - esClient: scopedEsClient, - soClient: scopedSoClient, - }); + await installBuiltInEntityDefinitions({ + logger, + builtInDefinitions, + esClient: scopedEsClient, + soClient: scopedSoClient, + }); - return res.ok({ body: { success: true } }); + return res.ok({ body: { success: true } }); + } catch (err) { + logger.error(err); + return res.customError({ statusCode: 500, body: err }); + } } ); } diff --git a/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/create.ts b/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/create.ts index 40004aa85c41..4b0f0c083986 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/create.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/create.ts @@ -19,7 +19,6 @@ import { installEntityDefinition } from '../../lib/entities/install_entity_defin export function createEntityDefinitionRoute<T extends RequestHandlerContext>({ router, server, - spaces, }: SetupRouteOptions<T>) { router.post<unknown, unknown, EntityDefinition>( { @@ -39,12 +38,10 @@ export function createEntityDefinitionRoute<T extends RequestHandlerContext>({ const core = await context.core; const soClient = core.savedObjects.client; const esClient = core.elasticsearch.client.asCurrentUser; - const spaceId = spaces?.spacesService.getSpaceId(req) ?? 'default'; try { const definition = await installEntityDefinition({ soClient, esClient, - spaceId, logger, definition: req.body, }); diff --git a/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/reset.ts b/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/reset.ts index 2a267c6c4e33..68d4b021244a 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/reset.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/routes/entities/reset.ts @@ -35,7 +35,6 @@ import { ENTITY_INTERNAL_API_PREFIX } from '../../../common/constants_entities'; export function resetEntityDefinitionRoute<T extends RequestHandlerContext>({ router, logger, - spaces, }: SetupRouteOptions<T>) { router.post<{ id: string }, unknown, unknown>( { @@ -50,7 +49,6 @@ export function resetEntityDefinitionRoute<T extends RequestHandlerContext>({ try { const soClient = (await context.core).savedObjects.client; const esClient = (await context.core).elasticsearch.client.asCurrentUser; - const spaceId = spaces?.spacesService.getSpaceId(req) ?? 'default'; const definition = await readEntityDefinition(soClient, req.params.id, logger); @@ -62,8 +60,8 @@ export function resetEntityDefinitionRoute<T extends RequestHandlerContext>({ await deleteIndices(esClient, definition, logger); // Recreate everything - await createAndInstallHistoryIngestPipeline(esClient, definition, logger, spaceId); - await createAndInstallLatestIngestPipeline(esClient, definition, logger, spaceId); + await createAndInstallHistoryIngestPipeline(esClient, definition, logger); + await createAndInstallLatestIngestPipeline(esClient, definition, logger); await createAndInstallHistoryTransform(esClient, definition, logger); await createAndInstallLatestTransform(esClient, definition, logger); await startTransform(esClient, definition, logger); diff --git a/x-pack/plugins/observability_solution/entity_manager/server/routes/types.ts b/x-pack/plugins/observability_solution/entity_manager/server/routes/types.ts index e5c11777a57d..8e3dc7111298 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/routes/types.ts @@ -7,12 +7,10 @@ import { IRouter, RequestHandlerContextBase } from '@kbn/core-http-server'; import { Logger } from '@kbn/core/server'; -import { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { EntityManagerServerSetup } from '../types'; export interface SetupRouteOptions<T extends RequestHandlerContextBase> { router: IRouter<T>; server: EntityManagerServerSetup; logger: Logger; - spaces?: SpacesPluginSetup; } diff --git a/x-pack/plugins/observability_solution/entity_manager/server/templates/components/entity.ts b/x-pack/plugins/observability_solution/entity_manager/server/templates/components/entity.ts index 56a0ea333330..fd8781dc64e9 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/templates/components/entity.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/templates/components/entity.ts @@ -21,6 +21,10 @@ export const entitiesEntityComponentTemplateConfig: ClusterPutComponentTemplateR ignore_above: 1024, type: 'keyword', }, + type: { + ignore_above: 1024, + type: 'keyword', + }, displayName: { type: 'text', fields: { diff --git a/x-pack/plugins/observability_solution/entity_manager/server/types.ts b/x-pack/plugins/observability_solution/entity_manager/server/types.ts index 505f44eccf3f..2215b50285c8 100644 --- a/x-pack/plugins/observability_solution/entity_manager/server/types.ts +++ b/x-pack/plugins/observability_solution/entity_manager/server/types.ts @@ -11,7 +11,6 @@ import { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, } from '@kbn/encrypted-saved-objects-plugin/server'; -import { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { EntityManagerConfig } from '../common/config'; export interface EntityManagerServerSetup { @@ -29,7 +28,6 @@ export interface ElasticsearchAccessorOptions { export interface EntityManagerPluginSetupDependencies { encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; - spaces?: SpacesPluginSetup; } export interface EntityManagerPluginStartDependencies { diff --git a/x-pack/plugins/observability_solution/entity_manager/tsconfig.json b/x-pack/plugins/observability_solution/entity_manager/tsconfig.json index 176494e26e60..fac5d6774280 100644 --- a/x-pack/plugins/observability_solution/entity_manager/tsconfig.json +++ b/x-pack/plugins/observability_solution/entity_manager/tsconfig.json @@ -27,7 +27,6 @@ "@kbn/zod-helpers", "@kbn/security-plugin", "@kbn/encrypted-saved-objects-plugin", - "@kbn/spaces-plugin", - "@kbn/logging-mocks", + "@kbn/logging-mocks" ] } diff --git a/x-pack/plugins/observability_solution/infra/common/http_api/host_details/process_list.ts b/x-pack/plugins/observability_solution/infra/common/http_api/host_details/process_list.ts index 4203742cc2fb..51982726a0b3 100644 --- a/x-pack/plugins/observability_solution/infra/common/http_api/host_details/process_list.ts +++ b/x-pack/plugins/observability_solution/infra/common/http_api/host_details/process_list.ts @@ -14,7 +14,7 @@ const AggValueRT = rt.type({ export const ProcessListAPIRequestRT = rt.type({ hostTerm: rt.record(rt.string, rt.string), - indexPattern: rt.string, + sourceId: rt.string, to: rt.number, sortBy: rt.type({ name: rt.string, diff --git a/x-pack/plugins/observability_solution/infra/common/http_api/metadata_api.ts b/x-pack/plugins/observability_solution/infra/common/http_api/metadata_api.ts index f9db4df10ed1..f2f5bc2c07db 100644 --- a/x-pack/plugins/observability_solution/infra/common/http_api/metadata_api.ts +++ b/x-pack/plugins/observability_solution/infra/common/http_api/metadata_api.ts @@ -48,7 +48,6 @@ export const InfraMetadataContainerRT = rt.partial({ name: rt.string, id: rt.string, runtime: rt.string, - imageName: rt.string, image: rt.partial({ name: rt.string }), }); diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_process_list.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_process_list.ts index 6ddff690c381..3dfbbb0068a0 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_process_list.ts +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_process_list.ts @@ -26,10 +26,9 @@ export function useProcessList( to: number, sortBy: SortBy, searchFilter: object, + sourceId: string, request$?: BehaviorSubject<(() => Promise<unknown>) | undefined> ) { - const { metricsView } = useMetricsDataViewContext(); - const decodeResponse = (response: any) => { return pipe( ProcessListAPIResponseRT.decode(response), @@ -50,7 +49,7 @@ export function useProcessList( 'POST', JSON.stringify({ hostTerm, - indexPattern: metricsView?.indices, + sourceId, to, sortBy: parsedSortBy, searchFilter, diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/metadata_summary/metadata_summary_list.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/metadata_summary/metadata_summary_list.tsx index fdd5b0b8aeee..0b369c90f139 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/metadata_summary/metadata_summary_list.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/metadata_summary/metadata_summary_list.tsx @@ -104,7 +104,7 @@ const containerMetadataData = (metadataInfo: InfraMetadata['info']): MetadataDat }, { field: 'containerImageName', - value: metadataInfo?.container?.imageName, + value: metadataInfo?.container?.image?.name, tooltipFieldLabel: 'container.image.name', }, { diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx index be3aa0b5d6d0..bc97d5e8afd2 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx @@ -20,6 +20,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { EuiLoadingSpinner } from '@elastic/eui'; import { getFieldByType } from '@kbn/metrics-data-access-plugin/common'; +import { useSourceContext } from '../../../../containers/metrics_source'; import { parseSearchString } from './parse_search_string'; import { ProcessesTable } from './processes_table'; import { STATE_NAMES } from './states'; @@ -44,6 +45,8 @@ export const Processes = () => { const { getDateRangeInTimestamp } = useDatePickerContext(); const [urlState, setUrlState] = useAssetDetailsUrlState(); const { asset } = useAssetDetailsRenderPropsContext(); + const { sourceId } = useSourceContext(); + const [searchText, setSearchText] = useState(urlState?.processSearch ?? ''); const [searchQueryError, setSearchQueryError] = useState<Error | null>(null); const [searchBarState, setSearchBarState] = useState<Query>(() => @@ -75,6 +78,7 @@ export const Processes = () => { state.currentTimestamp, sortBy, parseSearchString(searchText), + sourceId, request$ ); diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx index 4791875958c6..f90176edddaf 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx @@ -162,6 +162,12 @@ export const TableView = (props: Props) => { ); return ( - <EuiInMemoryTable pagination={true} sorting={initialSorting} items={items} columns={columns} /> + <EuiInMemoryTable + rowHeader="name" + pagination={true} + sorting={initialSorting} + items={items} + columns={columns} + /> ); }; diff --git a/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts b/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts index 55f2cf3f612f..fa9cb52ee13d 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/host_details/process_list.ts @@ -8,12 +8,14 @@ import { TIMESTAMP_FIELD, CMDLINE_FIELD } from '../../../common/constants'; import { ProcessListAPIRequest, ProcessListAPIQueryAggregation } from '../../../common/http_api'; import { ESSearchClient } from '../metrics/types'; +import type { InfraSourceConfiguration } from '../sources'; const TOP_N = 10; export const getProcessList = async ( search: ESSearchClient, - { hostTerm, indexPattern, to, sortBy, searchFilter }: ProcessListAPIRequest + sourceConfiguration: InfraSourceConfiguration, + { hostTerm, to, sortBy, searchFilter }: ProcessListAPIRequest ) => { const body = { size: 0, @@ -111,7 +113,7 @@ export const getProcessList = async ( try { const result = await search<{}, ProcessListAPIQueryAggregation>({ body, - index: indexPattern, + index: sourceConfiguration.metricAlias, }); const { buckets: processListBuckets } = result.aggregations!.processes.filteredProcs; const processList = processListBuckets.map((bucket) => { diff --git a/x-pack/plugins/observability_solution/infra/server/routes/process_list/index.ts b/x-pack/plugins/observability_solution/infra/server/routes/process_list/index.ts index f1ba7a7be036..28fc192c2759 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/process_list/index.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/process_list/index.ts @@ -41,7 +41,14 @@ export const initProcessListRoute = (libs: InfraBackendLibs) => { ); const client = createSearchClient(requestContext, framework); - const processListResponse = await getProcessList(client, options); + const soClient = (await requestContext.core).savedObjects.client; + + const { configuration } = await libs.sources.getSourceConfiguration( + soClient, + options.sourceId + ); + + const processListResponse = await getProcessList(client, configuration, options); return response.ok({ body: ProcessListAPIResponseRT.encode(processListResponse), diff --git a/x-pack/plugins/observability_solution/logs_data_access/common/constants.ts b/x-pack/plugins/observability_solution/logs_data_access/common/constants.ts new file mode 100644 index 000000000000..83acb8bcfff1 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/common/constants.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 const DEFAULT_LOG_SOURCES = ['logs-*-*']; diff --git a/x-pack/plugins/observability_solution/logs_data_access/common/types.ts b/x-pack/plugins/observability_solution/logs_data_access/common/types.ts new file mode 100644 index 000000000000..d021617f294a --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/common/types.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 interface LogSource { + indexPattern: string; +} diff --git a/x-pack/plugins/observability_solution/logs_data_access/common/ui_settings.ts b/x-pack/plugins/observability_solution/logs_data_access/common/ui_settings.ts new file mode 100644 index 000000000000..500011231ee3 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/common/ui_settings.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { UiSettingsParams } from '@kbn/core-ui-settings-common'; +import { i18n } from '@kbn/i18n'; +import { OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID } from '@kbn/management-settings-ids'; +import { DEFAULT_LOG_SOURCES } from './constants'; + +/** + * uiSettings definitions for the logs_data_access plugin. + */ +export const uiSettings: Record<string, UiSettingsParams> = { + [OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID]: { + category: ['observability'], + name: i18n.translate('xpack.logsDataAccess.logSources', { + defaultMessage: 'Log sources', + }), + value: DEFAULT_LOG_SOURCES, + description: i18n.translate('xpack.logsDataAccess.logSourcesDescription', { + defaultMessage: + 'Sources to be used for logs data. If the data contained in these indices is not logs data, you may experience degraded functionality.', + }), + type: 'array', + schema: schema.arrayOf(schema.string()), + requiresPageReload: true, + }, +}; diff --git a/x-pack/plugins/observability_solution/logs_data_access/kibana.jsonc b/x-pack/plugins/observability_solution/logs_data_access/kibana.jsonc index 0636aac3e5c9..56d8e556affc 100644 --- a/x-pack/plugins/observability_solution/logs_data_access/kibana.jsonc +++ b/x-pack/plugins/observability_solution/logs_data_access/kibana.jsonc @@ -5,7 +5,7 @@ "plugin": { "id": "logsDataAccess", "server": true, - "browser": false, + "browser": true, "requiredPlugins": [ "data", "dataViews" diff --git a/x-pack/plugins/observability_solution/logs_data_access/public/index.ts b/x-pack/plugins/observability_solution/logs_data_access/public/index.ts new file mode 100644 index 000000000000..ed4a2be8a1b0 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/public/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializer } from '@kbn/core/public'; +import { + LogsDataAccessPlugin, + LogsDataAccessPluginSetup, + LogsDataAccessPluginStart, +} from './plugin'; +import { LogsDataAccessPluginSetupDeps, LogsDataAccessPluginStartDeps } from './types'; + +export const plugin: PluginInitializer< + LogsDataAccessPluginSetup, + LogsDataAccessPluginStart, + LogsDataAccessPluginSetupDeps, + LogsDataAccessPluginStartDeps +> = () => { + return new LogsDataAccessPlugin(); +}; diff --git a/x-pack/plugins/observability_solution/logs_data_access/public/plugin.ts b/x-pack/plugins/observability_solution/logs_data_access/public/plugin.ts new file mode 100644 index 000000000000..b68d3734ee69 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from '@kbn/core/public'; +import { Plugin } from '@kbn/core/public'; +import { registerServices } from './services/register_services'; +import { LogsDataAccessPluginSetupDeps, LogsDataAccessPluginStartDeps } from './types'; +export type LogsDataAccessPluginSetup = ReturnType<LogsDataAccessPlugin['setup']>; +export type LogsDataAccessPluginStart = ReturnType<LogsDataAccessPlugin['start']>; + +export class LogsDataAccessPlugin + implements + Plugin< + LogsDataAccessPluginSetup, + LogsDataAccessPluginStart, + LogsDataAccessPluginSetupDeps, + LogsDataAccessPluginStartDeps + > +{ + public setup() {} + + public start(core: CoreStart, plugins: LogsDataAccessPluginStartDeps) { + const services = registerServices({ + deps: { + uiSettings: core.uiSettings, + }, + }); + + return { + services, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/observability_solution/logs_data_access/public/services/log_sources_service/index.ts b/x-pack/plugins/observability_solution/logs_data_access/public/services/log_sources_service/index.ts new file mode 100644 index 000000000000..3fd4674ea550 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/public/services/log_sources_service/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID } from '@kbn/management-settings-ids'; +import { LogSource } from '../../../common/types'; +import { RegisterServicesParams } from '../register_services'; + +export function createLogSourcesService(params: RegisterServicesParams) { + const { uiSettings } = params.deps; + return { + getLogSources: (): LogSource[] => { + const logSources = uiSettings.get<string[]>(OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID); + return logSources.map((logSource) => ({ + indexPattern: logSource, + })); + }, + setLogSources: async (sources: LogSource[]) => { + return await uiSettings.set( + OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID, + sources.map((source) => source.indexPattern) + ); + }, + }; +} diff --git a/x-pack/plugins/observability_solution/logs_data_access/public/services/register_services.ts b/x-pack/plugins/observability_solution/logs_data_access/public/services/register_services.ts new file mode 100644 index 000000000000..73ce18910628 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/public/services/register_services.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 { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { createLogSourcesService } from './log_sources_service'; + +export interface RegisterServicesParams { + deps: { + uiSettings: IUiSettingsClient; + }; +} + +export function registerServices(params: RegisterServicesParams) { + return { + logSourcesService: createLogSourcesService(params), + }; +} diff --git a/x-pack/plugins/observability_solution/logs_data_access/public/types.ts b/x-pack/plugins/observability_solution/logs_data_access/public/types.ts new file mode 100644 index 000000000000..a330a295c17c --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/public/types.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. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface LogsDataAccessPluginSetupDeps {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface LogsDataAccessPluginStartDeps {} diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/es_fields.ts b/x-pack/plugins/observability_solution/logs_data_access/server/es_fields.ts new file mode 100644 index 000000000000..6393700df9b1 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/server/es_fields.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 const LOG_LEVEL = 'log.level'; diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/plugin.ts b/x-pack/plugins/observability_solution/logs_data_access/server/plugin.ts index 13977e869b23..74d56a794b3f 100644 --- a/x-pack/plugins/observability_solution/logs_data_access/server/plugin.ts +++ b/x-pack/plugins/observability_solution/logs_data_access/server/plugin.ts @@ -12,6 +12,7 @@ import type { Plugin, PluginInitializerContext, } from '@kbn/core/server'; +import { uiSettings } from '../common/ui_settings'; import { registerServices } from './services/register_services'; import { LogsDataAccessPluginStartDeps, LogsDataAccessPluginSetupDeps } from './types'; @@ -32,12 +33,17 @@ export class LogsDataAccessPlugin constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } - public setup(core: CoreSetup, plugins: LogsDataAccessPluginSetupDeps) {} + public setup(core: CoreSetup, plugins: LogsDataAccessPluginSetupDeps) { + core.uiSettings.register(uiSettings); + } public start(core: CoreStart, plugins: LogsDataAccessPluginStartDeps) { const services = registerServices({ logger: this.logger, - deps: {}, + deps: { + savedObjects: core.savedObjects, + uiSettings: core.uiSettings, + }, }); return { diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_error_rate_timeseries/get_logs_error_rate_timeseries.ts b/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_error_rate_timeseries/get_logs_error_rate_timeseries.ts new file mode 100644 index 000000000000..c5abd6a564ed --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_error_rate_timeseries/get_logs_error_rate_timeseries.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { AggregationOptionsByType, AggregationResultOf } from '@kbn/es-types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import { existsQuery, kqlQuery } from '@kbn/observability-plugin/server'; +import { estypes } from '@elastic/elasticsearch'; +import { getBucketSizeFromTimeRangeAndBucketCount, getLogErrorRate } from '../../utils'; +import { LOG_LEVEL } from '../../es_fields'; + +export interface LogsErrorRateTimeseries { + esClient: ElasticsearchClient; + serviceEnvironmentQuery?: QueryDslQueryContainer[]; + serviceNames: string[]; + identifyingMetadata: string; + timeFrom: number; + timeTo: number; + kuery?: string; +} + +export const getLogErrorsAggegation = () => ({ + terms: { + field: LOG_LEVEL, + include: ['error', 'ERROR'], + }, +}); + +type LogErrorsAggregation = ReturnType<typeof getLogErrorsAggegation>; +interface LogsErrorRateTimeseriesHistogram { + timeseries: AggregationResultOf< + { + date_histogram: AggregationOptionsByType['date_histogram']; + aggs: { logErrors: LogErrorsAggregation }; + }, + {} + >; + doc_count: number; + key: string; +} + +interface LogRateQueryAggregation { + services: estypes.AggregationsTermsAggregateBase<LogsErrorRateTimeseriesHistogram>; +} +export interface LogsErrorRateTimeseriesReturnType { + [serviceName: string]: Array<{ x: number; y: number | null }>; +} +export function createGetLogErrorRateTimeseries() { + return async ({ + esClient, + identifyingMetadata, + serviceNames, + timeFrom, + timeTo, + kuery, + serviceEnvironmentQuery = [], + }: LogsErrorRateTimeseries): Promise<LogsErrorRateTimeseriesReturnType> => { + const intervalString = getBucketSizeFromTimeRangeAndBucketCount(timeFrom, timeTo, 50); + + const esResponse = await esClient.search({ + index: 'logs-*-*', + size: 0, + query: { + bool: { + filter: [ + ...existsQuery(LOG_LEVEL), + ...kqlQuery(kuery), + { + terms: { + [identifyingMetadata]: serviceNames, + }, + }, + ...serviceEnvironmentQuery, + { + range: { + ['@timestamp']: { + gte: timeFrom, + lte: timeTo, + format: 'epoch_millis', + }, + }, + }, + ], + }, + }, + aggs: { + services: { + terms: { + field: identifyingMetadata, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: `${intervalString}s`, + min_doc_count: 0, + extended_bounds: { + min: timeFrom, + max: timeTo, + }, + }, + aggs: { + logErrors: getLogErrorsAggegation(), + }, + }, + }, + }, + }, + }); + + const aggregations = esResponse.aggregations as LogRateQueryAggregation | undefined; + const buckets = aggregations?.services.buckets as LogsErrorRateTimeseriesHistogram[]; + + return buckets + ? buckets.reduce<LogsErrorRateTimeseriesReturnType>((acc, bucket) => { + const timeseries = bucket.timeseries.buckets.map((timeseriesBucket) => { + const totalCount = timeseriesBucket.doc_count; + const logErrorCount = timeseriesBucket.logErrors.buckets[0]?.doc_count; + + return { + x: timeseriesBucket.key, + y: logErrorCount ? getLogErrorRate({ logCount: totalCount, logErrorCount }) : null, + }; + }); + + return { + ...acc, + [bucket.key]: timeseries, + }; + }, {}) + : {}; + }; +} diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rate_timeseries/get_logs_rate_timeseries.ts b/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rate_timeseries/get_logs_rate_timeseries.ts new file mode 100644 index 000000000000..c0e970493157 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rate_timeseries/get_logs_rate_timeseries.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { AggregationOptionsByType, AggregationResultOf } from '@kbn/es-types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import { existsQuery, kqlQuery } from '@kbn/observability-plugin/server'; +import { estypes } from '@elastic/elasticsearch'; +import { getBucketSizeFromTimeRangeAndBucketCount } from '../../utils'; +import { LOG_LEVEL } from '../../es_fields'; + +export interface LogsRateTimeseries { + esClient: ElasticsearchClient; + serviceEnvironmentQuery?: QueryDslQueryContainer[]; + serviceNames: string[]; + identifyingMetadata: string; + timeFrom: number; + timeTo: number; + kuery?: string; +} + +interface LogsRateTimeseriesHistogram { + timeseries: AggregationResultOf< + { + date_histogram: AggregationOptionsByType['date_histogram']; + }, + {} + >; + doc_count: number; + key: string; +} + +interface LogRateQueryAggregation { + service: estypes.AggregationsTermsAggregateBase<LogsRateTimeseriesHistogram>; +} +export interface LogsRateTimeseriesReturnType { + [serviceName: string]: Array<{ x: number; y: number | null }>; +} +export function createGetLogsRateTimeseries() { + return async ({ + esClient, + identifyingMetadata, + serviceNames, + timeFrom, + timeTo, + kuery, + serviceEnvironmentQuery = [], + }: LogsRateTimeseries): Promise<LogsRateTimeseriesReturnType> => { + const intervalString = getBucketSizeFromTimeRangeAndBucketCount(timeFrom, timeTo, 50); + + const esResponse = await esClient.search({ + index: 'logs-*-*', + size: 0, + query: { + bool: { + filter: [ + ...existsQuery(LOG_LEVEL), + ...kqlQuery(kuery), + { + terms: { + [identifyingMetadata]: serviceNames, + }, + }, + ...serviceEnvironmentQuery, + { + range: { + ['@timestamp']: { + gte: timeFrom, + lte: timeTo, + format: 'epoch_millis', + }, + }, + }, + ], + }, + }, + aggs: { + service: { + terms: { + field: identifyingMetadata, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: `${intervalString}s`, + min_doc_count: 0, + extended_bounds: { + min: timeFrom, + max: timeTo, + }, + }, + }, + }, + }, + }, + }); + + const aggregations = esResponse.aggregations as LogRateQueryAggregation | undefined; + const buckets = aggregations?.service.buckets as LogsRateTimeseriesHistogram[]; + + return buckets + ? buckets.reduce<LogsRateTimeseriesReturnType>((acc, bucket) => { + const totalCount = bucket.doc_count; + + const timeseries = bucket.timeseries.buckets.map((timeseriesBucket) => { + return { + x: timeseriesBucket.key, + y: timeseriesBucket.doc_count / totalCount, + }; + }); + + return { + ...acc, + [bucket.key]: timeseries, + }; + }, {}) + : {}; + }; +} diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/index.ts b/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/index.ts index 0ef186d16b18..1eeeb82ed0b9 100644 --- a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/index.ts +++ b/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/index.ts @@ -7,8 +7,8 @@ import { ElasticsearchClient } from '@kbn/core/server'; import { estypes } from '@elastic/elasticsearch'; -import { RegisterServicesParams } from '../register_services'; -import { getLogErrorRate, getLogRatePerMinute } from './utils'; +import { getLogErrorRate, getLogRatePerMinute } from '../../utils'; +import { LOG_LEVEL } from '../../es_fields'; export interface LogsRatesServiceParams { esClient: ElasticsearchClient; @@ -35,7 +35,7 @@ export interface LogsRatesServiceReturnType { [serviceName: string]: LogsRatesMetrics; } -export function createGetLogsRatesService(params: RegisterServicesParams) { +export function createGetLogsRatesService() { return async ({ esClient, identifyingMetadata, @@ -52,7 +52,7 @@ export function createGetLogsRatesService(params: RegisterServicesParams) { { exists: { // For now, we don't want to count APM server logs or any other logs that don't have the log.level field. - field: 'log.level', + field: LOG_LEVEL, }, }, { @@ -80,7 +80,7 @@ export function createGetLogsRatesService(params: RegisterServicesParams) { aggs: { logErrors: { terms: { - field: 'log.level', + field: LOG_LEVEL, include: ['error', 'ERROR'], }, }, diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/services/log_sources_service/index.ts b/x-pack/plugins/observability_solution/logs_data_access/server/services/log_sources_service/index.ts new file mode 100644 index 000000000000..c6075d1d2083 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_data_access/server/services/log_sources_service/index.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaRequest } from '@kbn/core-http-server'; +import { OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID } from '@kbn/management-settings-ids'; +import { LogSource } from '../../../common/types'; +import { RegisterServicesParams } from '../register_services'; + +export function createGetLogSourcesService(params: RegisterServicesParams) { + return async (request: KibanaRequest) => { + const { savedObjects, uiSettings } = params.deps; + const soClient = savedObjects.getScopedClient(request); + const uiSettingsClient = uiSettings.asScopedToClient(soClient); + return { + getLogSources: async (): Promise<LogSource[]> => { + const logSources = await uiSettingsClient.get<string[]>( + OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID + ); + return logSources.map((logSource) => ({ + indexPattern: logSource, + })); + }, + setLogSources: async (sources: LogSource[]) => { + return await uiSettingsClient.set( + OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID, + sources.map((source) => source.indexPattern) + ); + }, + }; + }; +} diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/services/register_services.ts b/x-pack/plugins/observability_solution/logs_data_access/server/services/register_services.ts index c35b30783b5f..4756bb17f25b 100644 --- a/x-pack/plugins/observability_solution/logs_data_access/server/services/register_services.ts +++ b/x-pack/plugins/observability_solution/logs_data_access/server/services/register_services.ts @@ -5,16 +5,27 @@ * 2.0. */ +import { SavedObjectsServiceStart } from '@kbn/core-saved-objects-server'; +import { UiSettingsServiceStart } from '@kbn/core-ui-settings-server'; import { Logger } from '@kbn/logging'; +import { createGetLogsRateTimeseries } from './get_logs_rate_timeseries/get_logs_rate_timeseries'; +import { createGetLogErrorRateTimeseries } from './get_logs_error_rate_timeseries/get_logs_error_rate_timeseries'; import { createGetLogsRatesService } from './get_logs_rates_service'; +import { createGetLogSourcesService } from './log_sources_service'; export interface RegisterServicesParams { logger: Logger; - deps: {}; + deps: { + savedObjects: SavedObjectsServiceStart; + uiSettings: UiSettingsServiceStart; + }; } export function registerServices(params: RegisterServicesParams) { return { - getLogsRatesService: createGetLogsRatesService(params), + getLogsRatesService: createGetLogsRatesService(), + getLogsRateTimeseries: createGetLogsRateTimeseries(), + getLogsErrorRateTimeseries: createGetLogErrorRateTimeseries(), + getLogSourcesService: createGetLogSourcesService(params), }; } diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/utils.ts b/x-pack/plugins/observability_solution/logs_data_access/server/utils/index.ts similarity index 64% rename from x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/utils.ts rename to x-pack/plugins/observability_solution/logs_data_access/server/utils/index.ts index c56f7114999a..9d6e42365140 100644 --- a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/utils.ts +++ b/x-pack/plugins/observability_solution/logs_data_access/server/utils/index.ts @@ -5,6 +5,19 @@ * 2.0. */ +import moment from 'moment/moment'; +import { calculateAuto } from '@kbn/calculate-auto'; + +export function getBucketSizeFromTimeRangeAndBucketCount( + timeFrom: number, + timeTo: number, + numBuckets: number +): number { + const duration = moment.duration(timeTo - timeFrom, 'ms'); + + return Math.max(calculateAuto.near(numBuckets, duration)?.asSeconds() ?? 0, 60); +} + export function getLogRatePerMinute({ logCount, timeFrom, diff --git a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/utils.test.ts b/x-pack/plugins/observability_solution/logs_data_access/server/utils/utils.test.ts similarity index 95% rename from x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/utils.test.ts rename to x-pack/plugins/observability_solution/logs_data_access/server/utils/utils.test.ts index e1fbd84e7177..3a1d83b9693a 100644 --- a/x-pack/plugins/observability_solution/logs_data_access/server/services/get_logs_rates_service/utils.test.ts +++ b/x-pack/plugins/observability_solution/logs_data_access/server/utils/utils.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { getLogRatePerMinute, getLogErrorRate } from './utils'; +import { getLogRatePerMinute, getLogErrorRate } from '.'; describe('getLogRatePerMinute', () => { it('should log rate per minute for one minute period', () => { diff --git a/x-pack/plugins/observability_solution/logs_data_access/tsconfig.json b/x-pack/plugins/observability_solution/logs_data_access/tsconfig.json index 9bd4031c7a39..09a57dea36e4 100644 --- a/x-pack/plugins/observability_solution/logs_data_access/tsconfig.json +++ b/x-pack/plugins/observability_solution/logs_data_access/tsconfig.json @@ -3,12 +3,23 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["common/**/*", "server/**/*", "jest.config.js"], + "include": ["common/**/*", "server/**/*", "public/**/*", "jest.config.js"], "exclude": ["target/**/*"], "kbn_references": [ "@kbn/core", - "@kbn/logging", "@kbn/data-plugin", "@kbn/data-views-plugin", + "@kbn/observability-plugin", + "@kbn/calculate-auto", + "@kbn/es-types", + "@kbn/core-http-server", + "@kbn/management-settings-ids", + "@kbn/config-schema", + "@kbn/core-ui-settings-common", + "@kbn/i18n", + "@kbn/core-saved-objects-server", + "@kbn/core-ui-settings-server", + "@kbn/core-ui-settings-browser", + "@kbn/logging" ] } diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_esql.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_esql.tsx index e26474b6165e..65140b4c1e4f 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_esql.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_esql.tsx @@ -35,7 +35,7 @@ export const useEsql = ({ dataSourceSelection }: EsqlContextDeps): UseEsqlResult const discoverLinkParams = { query: { - esql: `from ${esqlPattern} | limit 10`, + esql: `FROM ${esqlPattern} | LIMIT 10`, }, }; diff --git a/x-pack/plugins/observability_solution/logs_shared/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/observability_solution/logs_shared/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index a6ce92ad2512..f8102e8f0dff 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -28,6 +28,21 @@ import { TIMESTAMP_FIELD, TIEBREAKER_FIELD } from '../../../../common/constants' const TIMESTAMP_FORMAT = 'epoch_millis'; +const MAX_BUCKETS = 1000; + +function getBucketIntervalStarts( + startTimestamp: number, + endTimestamp: number, + bucketSize: number +): Date[] { + // estimated number of buckets + const bucketCount = Math.ceil((endTimestamp - startTimestamp) / bucketSize); + if (bucketCount > MAX_BUCKETS) { + throw new Error(`Requested too many buckets: ${bucketCount} > ${MAX_BUCKETS}`); + } + return timeMilliseconds(new Date(startTimestamp), new Date(endTimestamp), bucketSize); +} + export class LogsSharedKibanaLogEntriesAdapter implements LogEntriesAdapter { constructor(private readonly framework: KibanaFramework) {} @@ -134,11 +149,7 @@ export class LogsSharedKibanaLogEntriesAdapter implements LogEntriesAdapter { bucketSize: number, filterQuery?: LogEntryQuery ): Promise<LogSummaryBucket[]> { - const bucketIntervalStarts = timeMilliseconds( - new Date(startTimestamp), - new Date(endTimestamp), - bucketSize - ); + const bucketIntervalStarts = getBucketIntervalStarts(startTimestamp, endTimestamp, bucketSize); const query = { allow_no_indices: true, diff --git a/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entries_search_strategy.ts b/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entries_search_strategy.ts index 587e0cd753f6..f886060964b5 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entries_search_strategy.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entries_search_strategy.ts @@ -118,7 +118,16 @@ export const logEntriesSearchStrategyProvider = ({ const searchResponse$ = concat(recoveredRequest$, initialRequest$).pipe( take(1), - concatMap((esRequest) => esSearchStrategy.search(esRequest, options, dependencies)) + concatMap((esRequest) => + esSearchStrategy.search( + esRequest, + { + ...options, + retrieveResults: true, // the subsequent processing requires the actual search results + }, + dependencies + ) + ) ); return combineLatest([searchResponse$, resolvedLogView$, messageFormattingRules$]).pipe( diff --git a/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.test.ts b/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.test.ts index 9a82a743e8e5..7c46fe37d649 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.test.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.test.ts @@ -5,17 +5,21 @@ * 2.0. */ -import { errors } from '@elastic/elasticsearch'; -import { lastValueFrom, of, throwError } from 'rxjs'; +import { errors, TransportResult } from '@elastic/elasticsearch'; +import { AsyncSearchSubmitResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { elasticsearchServiceMock, httpServerMock, savedObjectsClientMock, uiSettingsServiceMock, } from '@kbn/core/server/mocks'; -import { IEsSearchRequest, IEsSearchResponse } from '@kbn/search-types'; -import { ISearchStrategy, SearchStrategyDependencies } from '@kbn/data-plugin/server'; +import { getMockSearchConfig } from '@kbn/data-plugin/config.mock'; +import { ISearchStrategy } from '@kbn/data-plugin/server'; +import { enhancedEsSearchStrategyProvider } from '@kbn/data-plugin/server/search'; import { createSearchSessionsClientMock } from '@kbn/data-plugin/server/search/mocks'; +import { KbnSearchError } from '@kbn/data-plugin/server/search/report_search_error'; +import { loggerMock } from '@kbn/logging-mocks'; +import { EMPTY, lastValueFrom } from 'rxjs'; import { createResolvedLogViewMock } from '../../../common/log_views/resolved_log_view.mock'; import { createLogViewsClientMock } from '../log_views/log_views_client.mock'; import { createLogViewsServiceStartMock } from '../log_views/log_views_service.mock'; @@ -26,23 +30,34 @@ import { describe('LogEntry search strategy', () => { it('handles initial search requests', async () => { - const esSearchStrategyMock = createEsSearchStrategyMock({ - id: 'ASYNC_REQUEST_ID', - isRunning: true, - rawResponse: { - took: 0, - _shards: { total: 1, failed: 0, skipped: 0, successful: 0 }, - timed_out: false, - hits: { total: 0, max_score: 0, hits: [] }, + const esSearchStrategy = createEsSearchStrategy(); + const mockDependencies = createSearchStrategyDependenciesMock(); + const esClient = mockDependencies.esClient.asCurrentUser; + esClient.asyncSearch.submit.mockResolvedValueOnce({ + body: { + id: 'ASYNC_REQUEST_ID', + response: { + took: 0, + _shards: { total: 1, failed: 0, skipped: 0, successful: 0 }, + timed_out: false, + hits: { total: 0, max_score: 0, hits: [] }, + }, + is_partial: false, + is_running: false, + expiration_time_in_millis: 0, + start_time_in_millis: 0, }, - }); + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + } as TransportResult<AsyncSearchSubmitResponse> as any); // type inference for the mock fails - const dataMock = createDataPluginMock(esSearchStrategyMock); + const dataMock = createDataPluginMock(esSearchStrategy); const logViewsClientMock = createLogViewsClientMock(); logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock()); const logViewsMock = createLogViewsServiceStartMock(); logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock); - const mockDependencies = createSearchStrategyDependenciesMock(); const logEntrySearchStrategy = logEntrySearchStrategyProvider({ data: dataMock, @@ -62,72 +77,88 @@ describe('LogEntry search strategy', () => { ) ); + // ensure log view was resolved expect(logViewsMock.getScopedClient).toHaveBeenCalled(); expect(logViewsClientMock.getResolvedLogView).toHaveBeenCalled(); - expect(esSearchStrategyMock.search).toHaveBeenCalledWith( - { - params: expect.objectContaining({ - index: 'log-indices-*', - body: expect.objectContaining({ - track_total_hits: false, - terminate_after: 1, - query: { - ids: { - values: ['LOG_ENTRY_ID'], - }, + + // ensure search request was made + expect(esClient.asyncSearch.submit).toHaveBeenCalledWith( + expect.objectContaining({ + index: 'log-indices-*', + body: expect.objectContaining({ + track_total_hits: false, + terminate_after: 1, + query: { + ids: { + values: ['LOG_ENTRY_ID'], }, - runtime_mappings: { - runtime_field: { - type: 'keyword', - script: { - source: 'emit("runtime value")', - }, + }, + runtime_mappings: { + runtime_field: { + type: 'keyword', + script: { + source: 'emit("runtime value")', }, }, - }), + }, }), - }, - expect.anything(), + }), expect.anything() ); + + // ensure response content is as expected expect(response.id).toEqual(expect.any(String)); - expect(response.isRunning).toBe(true); + expect(response.isRunning).toBe(false); }); it('handles subsequent polling requests', async () => { const date = new Date(1605116827143).toISOString(); - const esSearchStrategyMock = createEsSearchStrategyMock({ - id: 'ASYNC_REQUEST_ID', - isRunning: false, - rawResponse: { - took: 1, - _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, - timed_out: false, - hits: { - total: 0, - max_score: 0, - hits: [ - { - _id: 'HIT_ID', - _index: 'HIT_INDEX', - _score: 0, - _source: null, - fields: { - '@timestamp': [date], - message: ['HIT_MESSAGE'], + const esSearchStrategy = createEsSearchStrategy(); + const mockDependencies = createSearchStrategyDependenciesMock(); + const esClient = mockDependencies.esClient.asCurrentUser; + + // set up response to polling request + esClient.asyncSearch.get.mockResolvedValueOnce({ + body: { + id: 'ASYNC_REQUEST_ID', + response: { + took: 0, + _shards: { total: 1, failed: 0, skipped: 0, successful: 0 }, + timed_out: false, + hits: { + total: 1, + max_score: 0, + hits: [ + { + _id: 'HIT_ID', + _index: 'HIT_INDEX', + _score: 0, + _source: null, + fields: { + '@timestamp': [date], + message: ['HIT_MESSAGE'], + }, + sort: [date as any, 1 as any], // incorrectly typed as string upstream }, - sort: [date as any, 1 as any], // incorrectly typed as string upstream - }, - ], + ], + }, }, + is_partial: false, + is_running: false, + expiration_time_in_millis: 0, + start_time_in_millis: 0, }, - }); - const dataMock = createDataPluginMock(esSearchStrategyMock); + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + } as TransportResult<AsyncSearchSubmitResponse> as any); + + const dataMock = createDataPluginMock(esSearchStrategy); const logViewsClientMock = createLogViewsClientMock(); logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock()); const logViewsMock = createLogViewsServiceStartMock(); logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock); - const mockDependencies = createSearchStrategyDependenciesMock(); const logEntrySearchStrategy = logEntrySearchStrategyProvider({ data: dataMock, @@ -151,9 +182,18 @@ describe('LogEntry search strategy', () => { ) ); + // ensure search was polled using the get API + expect(esClient.asyncSearch.get).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ASYNC_REQUEST_ID' }), + expect.anything() + ); + expect(esClient.asyncSearch.status).not.toHaveBeenCalled(); + + // ensure log view was not resolved again expect(logViewsMock.getScopedClient).not.toHaveBeenCalled(); expect(logViewsClientMock.getResolvedLogView).not.toHaveBeenCalled(); - expect(esSearchStrategyMock.search).toHaveBeenCalled(); + + // ensure response content is as expected expect(response.id).toEqual(requestId); expect(response.isRunning).toBe(false); expect(response.rawResponse.data).toEqual({ @@ -171,22 +211,30 @@ describe('LogEntry search strategy', () => { }); it('forwards errors from the underlying search strategy', async () => { - const esSearchStrategyMock = createEsSearchStrategyMock({ - id: 'ASYNC_REQUEST_ID', - isRunning: false, - rawResponse: { - took: 1, - _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, - timed_out: false, - hits: { total: 0, max_score: 0, hits: [] }, - }, - }); - const dataMock = createDataPluginMock(esSearchStrategyMock); + const esSearchStrategy = createEsSearchStrategy(); + const mockDependencies = createSearchStrategyDependenciesMock(); + const esClient = mockDependencies.esClient.asCurrentUser; + + // set up failing response + esClient.asyncSearch.get.mockRejectedValueOnce( + new errors.ResponseError({ + body: { + error: { + type: 'mock_error', + }, + }, + headers: {}, + meta: {} as any, + statusCode: 404, + warnings: [], + }) + ); + + const dataMock = createDataPluginMock(esSearchStrategy); const logViewsClientMock = createLogViewsClientMock(); logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock()); const logViewsMock = createLogViewsServiceStartMock(); logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock); - const mockDependencies = createSearchStrategyDependenciesMock(); const logEntrySearchStrategy = logEntrySearchStrategyProvider({ data: dataMock, @@ -205,26 +253,24 @@ describe('LogEntry search strategy', () => { mockDependencies ); - await expect(response.toPromise()).rejects.toThrowError(errors.ResponseError); + await expect(lastValueFrom(response)).rejects.toThrowError(KbnSearchError); }); it('forwards cancellation to the underlying search strategy', async () => { - const esSearchStrategyMock = createEsSearchStrategyMock({ - id: 'ASYNC_REQUEST_ID', - isRunning: false, - rawResponse: { - took: 1, - _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, - timed_out: false, - hits: { total: 0, max_score: 0, hits: [] }, - }, + const esSearchStrategy = createEsSearchStrategy(); + const mockDependencies = createSearchStrategyDependenciesMock(); + const esClient = mockDependencies.esClient.asCurrentUser; + + // set up response to cancellation request + esClient.asyncSearch.delete.mockResolvedValueOnce({ + acknowledged: true, }); - const dataMock = createDataPluginMock(esSearchStrategyMock); + + const dataMock = createDataPluginMock(esSearchStrategy); const logViewsClientMock = createLogViewsClientMock(); logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock()); const logViewsMock = createLogViewsServiceStartMock(); logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock); - const mockDependencies = createSearchStrategyDependenciesMock(); const logEntrySearchStrategy = logEntrySearchStrategyProvider({ data: dataMock, @@ -236,34 +282,23 @@ describe('LogEntry search strategy', () => { await logEntrySearchStrategy.cancel?.(requestId, {}, mockDependencies); - expect(esSearchStrategyMock.cancel).toHaveBeenCalled(); + // ensure cancellation request is forwarded + expect(esClient.asyncSearch.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ASYNC_REQUEST_ID', + }) + ); }); }); -const createEsSearchStrategyMock = (esSearchResponse: IEsSearchResponse) => ({ - search: jest.fn((esSearchRequest: IEsSearchRequest) => { - if (typeof esSearchRequest.id === 'string') { - if (esSearchRequest.id === esSearchResponse.id) { - return of(esSearchResponse); - } else { - return throwError( - new errors.ResponseError({ - body: {}, - headers: {}, - meta: {} as any, - statusCode: 404, - warnings: [], - }) - ); - } - } else { - return of(esSearchResponse); - } - }), - cancel: jest.fn().mockResolvedValue(undefined), -}); +const createEsSearchStrategy = () => { + const legacyConfig$ = EMPTY; + const searchConfig = getMockSearchConfig({}); + const logger = loggerMock.create(); + return enhancedEsSearchStrategyProvider(legacyConfig$, searchConfig, logger); +}; -const createSearchStrategyDependenciesMock = (): SearchStrategyDependencies => ({ +const createSearchStrategyDependenciesMock = () => ({ uiSettingsClient: uiSettingsServiceMock.createClient(), esClient: elasticsearchServiceMock.createScopedClusterClient(), savedObjectsClient: savedObjectsClientMock.create(), diff --git a/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.ts b/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.ts index ee0a112fc3a6..635322383ffd 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/services/log_entries/log_entry_search_strategy.ts @@ -82,7 +82,16 @@ export const logEntrySearchStrategyProvider = ({ return concat(recoveredRequest$, initialRequest$).pipe( take(1), - concatMap((esRequest) => esSearchStrategy.search(esRequest, options, dependencies)), + concatMap((esRequest) => + esSearchStrategy.search( + esRequest, + { + ...options, + retrieveResults: true, // without it response will not contain progress information + }, + dependencies + ) + ), map((esResponse) => ({ ...esResponse, rawResponse: decodeOrThrow(getLogEntryResponseRT)(esResponse.rawResponse), diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.test.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.test.tsx index da814c916ea1..e505a45fe7f6 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.test.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.test.tsx @@ -244,13 +244,13 @@ describe('ObservabilityActions component', () => { const wrapper = await setup(RULE_DETAILS_PAGE_ID); expect( - wrapper.find('[data-test-subj="o11yAlertActionsButton"]').first().getElement().props - ).toEqual(expect.objectContaining({ href: 'http://localhost:5620/app/o11y/log-explorer' })); + wrapper.find('[data-test-subj="o11yAlertActionsButton"]').first().getElement().props.onClick + ).toBeDefined(); prependMock.mockClear(); await waitFor(() => { - wrapper.find('[data-test-subj="o11yAlertActionsButton"]').first().simulate('mouseOver'); + wrapper.find('[data-test-subj="o11yAlertActionsButton"]').first().simulate('mouseover'); expect(prependMock).toBeCalledTimes(1); }); }); diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx index 07383d9781c7..f591347b1723 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx @@ -197,7 +197,7 @@ export function AlertActions({ return ( <> - {viewInAppUrl && !isInApp ? ( + {viewInAppUrl !== '' && !isInApp ? ( <EuiFlexItem> <EuiToolTip content={i18n.translate('xpack.observability.alertsTable.viewInAppTextLabel', { @@ -211,7 +211,7 @@ export function AlertActions({ })} color="text" onMouseOver={handleViewInAppUrl} - href={viewInAppUrl} + onClick={() => window.open(viewInAppUrl)} iconType="eye" size="s" /> diff --git a/x-pack/plugins/observability_solution/observability/server/services/index.ts b/x-pack/plugins/observability_solution/observability/server/services/index.ts index 840bac95ee48..3325a9d1dbfe 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/index.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/index.ts @@ -57,7 +57,7 @@ export interface AlertDetailsContextualInsightsRequestContext { }>; licensing: Promise<LicensingApiRequestHandlerContext>; } -type AlertDetailsContextualInsightsHandler = ( +export type AlertDetailsContextualInsightsHandler = ( context: AlertDetailsContextualInsightsRequestContext, query: AlertDetailsContextualInsightsHandlerQuery ) => Promise<AlertDetailsContextualInsight[]>; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts index c7df07dc18d2..fd5796861718 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts @@ -98,7 +98,21 @@ export function registerContextFunction({ subscriber.complete(); }) .catch((error) => { - subscriber.error(error); + resources.logger.error('Error in context function'); + resources.logger.error(error); + + subscriber.next( + createFunctionResponseMessage({ + name: CONTEXT_FUNCTION_NAME, + content: `Error in context function: ${error.message}`, + data: { + error: { + message: error.message, + }, + }, + }) + ); + subscriber.complete(); }); }); } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/elasticsearch.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/elasticsearch.ts index 81a9cffc6d03..8c255b885fd4 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/elasticsearch.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/elasticsearch.ts @@ -39,7 +39,7 @@ export function registerElasticsearchFunction({ }, async ({ arguments: { method, path, body } }) => { const esClient = (await resources.context.core).elasticsearch.client; - const response = esClient.asCurrentUser.transport.request({ + const response = await esClient.asCurrentUser.transport.request({ method, path, body, diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx index 835233b424c6..2134edf1170d 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx @@ -17,12 +17,11 @@ import { Router } from '@kbn/shared-ux-router'; import React from 'react'; import ReactDOM from 'react-dom'; import { OBSERVABILITY_ONBOARDING_TELEMETRY_EVENT } from '../../common/telemetry_events'; -import { ConfigSchema } from '..'; +import { AppContext, ConfigSchema, ObservabilityOnboardingAppServices } from '..'; import { ObservabilityOnboardingHeaderActionMenu } from './shared/header_action_menu'; import { ObservabilityOnboardingPluginSetupDeps, ObservabilityOnboardingPluginStartDeps, - ObservabilityOnboardingContextValue, } from '../plugin'; import { ObservabilityOnboardingFlow } from './observability_onboarding_flow'; @@ -43,11 +42,17 @@ export function ObservabilityOnboardingAppRoot({ core, corePlugins, config, + context, }: { appMountParameters: AppMountParameters; } & RenderAppProps) { const { history, setHeaderActionMenu, theme$ } = appMountParameters; - const services: ObservabilityOnboardingContextValue = { ...core, ...corePlugins, config }; + const services: ObservabilityOnboardingAppServices = { + ...core, + ...corePlugins, + config, + context, + }; const renderFeedbackLinkAsPortal = !config.serverless.enabled; @@ -101,6 +106,7 @@ interface RenderAppProps { appMountParameters: AppMountParameters; corePlugins: ObservabilityOnboardingPluginStartDeps; config: ConfigSchema; + context: AppContext; } export const renderApp = (props: RenderAppProps) => { diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/observability_onboarding_flow.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/observability_onboarding_flow.tsx index 4177e682d6e7..33e472d841bb 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/observability_onboarding_flow.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/observability_onboarding_flow.tsx @@ -17,6 +17,7 @@ import { OnboardingFlowForm } from './onboarding_flow_form/onboarding_flow_form' import { Header } from './header/header'; import { SystemLogsPanel } from './quickstart_flows/system_logs'; import { CustomLogsPanel } from './quickstart_flows/custom_logs'; +import { OtelLogsPanel } from './quickstart_flows/otel_logs'; import { AutoDetectPanel } from './quickstart_flows/auto_detect'; import { BackButton } from './shared/back_button'; @@ -65,6 +66,10 @@ export function ObservabilityOnboardingFlow() { <BackButton /> <CustomLogsPanel /> </Route> + <Route path="/otel-logs"> + <BackButton /> + <OtelLogsPanel /> + </Route> <Route> <OnboardingFlowForm /> </Route> diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts index 021a3035131b..5bc63403365c 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts @@ -8,7 +8,8 @@ import { reactRouterNavigate, useKibana } from '@kbn/kibana-react-plugin/public'; import { useHistory } from 'react-router-dom'; import { useLocation } from 'react-router-dom-v5-compat'; -import { CustomCard, FeaturedCard } from '../packages_list/types'; +import { ObservabilityOnboardingAppServices } from '../..'; +import { CustomCard, FeaturedCard, VirtualCard } from '../packages_list/types'; import { Category } from './types'; function toFeaturedCard(name: string): FeaturedCard { @@ -22,12 +23,37 @@ export function useCustomCardsForCategory( const history = useHistory(); const location = useLocation(); const { - services: { application, http }, - } = useKibana(); + services: { + application, + http, + context: { isServerless }, + }, + } = useKibana<ObservabilityOnboardingAppServices>(); const getUrlForApp = application?.getUrlForApp; const { href: systemLogsUrl } = reactRouterNavigate(history, `/systemLogs/${location.search}`); const { href: customLogsUrl } = reactRouterNavigate(history, `/customLogs/${location.search}`); + const { href: otelLogsUrl } = reactRouterNavigate(history, `/otel-logs/${location.search}`); + + const otelCard: VirtualCard = { + id: 'otel-logs', + type: 'virtual', + release: 'preview', + title: 'OpenTelemetry', + description: + 'Collect Logs and host metrics using the Elastic distribution of the OpenTelemetry Collector', + name: 'custom-logs-virtual', + categories: ['observability'], + icons: [ + { + type: 'svg', + src: http?.staticAssets.getPluginAssetHref('opentelemetry.svg') ?? '', + }, + ], + url: otelLogsUrl, + version: '', + integration: '', + }; switch (category) { case 'apm': @@ -87,8 +113,8 @@ export function useCustomCardsForCategory( case 'infra': return [ toFeaturedCard('kubernetes'), - toFeaturedCard('prometheus'), toFeaturedCard('docker'), + isServerless ? toFeaturedCard('prometheus') : otelCard, { id: 'azure-virtual', type: 'virtual', @@ -168,7 +194,7 @@ export function useCustomCardsForCategory( version: '', integration: '', }, - toFeaturedCard('nginx'), + isServerless ? toFeaturedCard('nginx') : otelCard, { id: 'azure-logs-virtual', type: 'virtual', diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/custom_logs/api_key_banner.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/custom_logs/api_key_banner.tsx index b5448debbcbf..7c5cf36a46b3 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/custom_logs/api_key_banner.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/custom_logs/api_key_banner.tsx @@ -32,7 +32,7 @@ export function ApiKeyBanner({ }: { hasPrivileges?: boolean; status: FETCH_STATUS; - payload?: ApiKeyPayload; + payload?: Partial<ApiKeyPayload>; error?: IHttpFetchError<ResponseErrorBody>; }) { const loadingCallout = ( diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_logs/index.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_logs/index.tsx new file mode 100644 index 000000000000..cac3cd41e06b --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_logs/index.tsx @@ -0,0 +1,901 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { + EuiBetaBadge, + EuiButton, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiPanel, + EuiSpacer, + EuiSteps, + EuiText, + EuiIcon, + EuiButtonGroup, + EuiCopy, + EuiLink, + EuiImage, + EuiCallOut, +} from '@elastic/eui'; +import { + AllDatasetsLocatorParams, + ALL_DATASETS_LOCATOR_ID, +} from '@kbn/deeplinks-observability/locators'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ObservabilityOnboardingAppServices } from '../../..'; +import { ApiKeyBanner } from '../custom_logs/api_key_banner'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { MultiIntegrationInstallBanner } from './multi_integration_install_banner'; + +const HOST_COMMAND = i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.p.runTheCommandOnYourHostLabel', + { + defaultMessage: + 'Run the following command on your host to download and configure the collector.', + } +); + +export const OtelLogsPanel: React.FC = () => { + const { + data: apiKeyData, + status: apiKeyStatus, + error, + } = useFetcher((callApi) => { + return callApi('POST /internal/observability_onboarding/otel/api_key', {}); + }, []); + + const { data: setup } = useFetcher((callApi) => { + return callApi('GET /internal/observability_onboarding/logs/setup/environment'); + }, []); + + const { + services: { + share, + http, + // context: { isServerless, stackVersion }, + }, + } = useKibana<ObservabilityOnboardingAppServices>(); + + const AGENT_CDN_BASE_URL = 'snapshots.elastic.co/8.15.0-2088c97b/downloads/beats/elastic-agent'; + const agentVersion = '8.15.0-SNAPSHOT'; + // TODO uncomment before merge + // const AGENT_CDN_BASE_URL = 'artifacts.elastic.co/downloads/beats/elastic-agent'; + // const agentVersion = isServerless ? setup?.elasticAgentVersion : stackVersion; + + const allDatasetsLocator = + share.url.locators.get<AllDatasetsLocatorParams>(ALL_DATASETS_LOCATOR_ID); + + const hostsLocator = share.url.locators.get('HOSTS_LOCATOR'); + + const [{ value: deeplinks }, getDeeplinks] = useAsyncFn(async () => { + return { + logs: allDatasetsLocator?.getRedirectUrl({ + type: 'logs', + }), + metrics: hostsLocator?.getRedirectUrl({}), + }; + }, [allDatasetsLocator]); + + useEffect(() => { + getDeeplinks(); + }, [getDeeplinks]); + + const installTabContents = [ + { + id: 'kubernetes', + name: 'Kubernetes', + prompt: ( + <> + <EuiText> + <p> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.kubernetesApplyCommandPromptLabel', + { + defaultMessage: + 'From the directory where the manifest is downloaded, run the following command to install the collector on every node of your cluster:', + } + )} + </p> + </EuiText> + <CopyableCodeBlock + content={`kubectl create secret generic elastic-secret-otel --from-literal=es_endpoint='${setup?.elasticsearchUrl}' --from-literal=es_api_key='${apiKeyData?.apiKeyEncoded}' + +kubectl apply -f otel-collector-k8s.yml`} + /> + </> + ), + firstStepTitle: i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.steps.downloadManifest', + { defaultMessage: 'Download the manifest:' } + ), + content: `apiVersion: v1 +kind: ServiceAccount +metadata: + name: elastic-otel-collector-agent + namespace: default + labels: + app.kubernetes.io/name: elastic-opentelemetry-collector + app.kubernetes.io/version: "${agentVersion}" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: elastic-otel-collector-agent + labels: + app.kubernetes.io/name: elastic-opentelemetry-collector + app.kubernetes.io/version: "${agentVersion}" +rules: + - apiGroups: [""] + resources: ["pods", "namespaces", "nodes"] + verbs: ["get", "watch", "list"] + - apiGroups: ["apps"] + resources: ["daemonsets", "deployments", "replicasets", "statefulsets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["extensions"] + resources: ["daemonsets", "deployments", "replicasets"] + verbs: ["get", "list", "watch"] + - apiGroups: [ "" ] + resources: [ "nodes/stats" ] + verbs: [ "get", "watch", "list" ] + - apiGroups: [ "" ] + resources: [ "nodes/proxy" ] + verbs: [ "get" ] + - apiGroups: [ "" ] + resources: ["configmaps"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: elastic-otel-collector-agent + labels: + app.kubernetes.io/name: elastic-opentelemetry-collector + app.kubernetes.io/version: "${agentVersion}" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: elastic-otel-collector-agent +subjects: + - kind: ServiceAccount + name: elastic-otel-collector-agent + namespace: default +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: elastic-otel-collector-agent + namespace: default + labels: + app.kubernetes.io/name: elastic-opentelemetry-collector + app.kubernetes.io/version: "${agentVersion}" +data: + otel.yaml: | + exporters: + debug: + verbosity: normal + elasticsearch: + endpoints: + - \${env:ES_ENDPOINT} + api_key: \${env:ES_API_KEY} + #logs_index: logs-otel.generic-default + # Metrics are not supported yet + #metrics_index: metrics-otel.generic-default + mapping: + mode: ecs + processors: + elasticinframetrics: + add_system_metrics: true + add_k8s_metrics: true + resourcedetection/eks: + detectors: [env, eks] + timeout: 15s + override: true + eks: + resource_attributes: + k8s.cluster.name: + enabled: true + resourcedetection/gcp: + detectors: [env, gcp] + timeout: 2s + override: false + resource/k8s: + attributes: + - key: service.name + from_attribute: app.label.component + action: insert + attributes/dataset: + actions: + - key: event.dataset + from_attribute: data_stream.dataset + action: upsert + resource/cloud: + attributes: + - key: cloud.instance.id + from_attribute: host.id + action: insert + resource/process: + attributes: + - key: process.executable.name + action: delete + - key: process.executable.path + action: delete + resourcedetection/system: + detectors: ["system", "ec2"] + system: + hostname_sources: [ "os" ] + resource_attributes: + host.name: + enabled: true + host.id: + enabled: false + host.arch: + enabled: true + host.ip: + enabled: true + host.mac: + enabled: true + host.cpu.vendor.id: + enabled: true + host.cpu.family: + enabled: true + host.cpu.model.id: + enabled: true + host.cpu.model.name: + enabled: true + host.cpu.stepping: + enabled: true + host.cpu.cache.l2.size: + enabled: true + os.description: + enabled: true + os.type: + enabled: true + ec2: + resource_attributes: + host.name: + enabled: false + host.id: + enabled: true + k8sattributes: + filter: + node_from_env_var: K8S_NODE_NAME + passthrough: false + pod_association: + - sources: + - from: resource_attribute + name: k8s.pod.ip + - sources: + - from: resource_attribute + name: k8s.pod.uid + - sources: + - from: connection + extract: + metadata: + - "k8s.namespace.name" + - "k8s.deployment.name" + - "k8s.statefulset.name" + - "k8s.daemonset.name" + - "k8s.cronjob.name" + - "k8s.job.name" + - "k8s.node.name" + - "k8s.pod.name" + - "k8s.pod.uid" + - "k8s.pod.start_time" + labels: + - tag_name: app.label.component + key: app.kubernetes.io/component + from: pod + extensions: + file_storage: + directory: /var/lib/otelcol + receivers: + filelog: + retry_on_failure: + enabled: true + start_at: end + exclude: + - /var/log/pods/default_elastic-otel-collector-agent*_*/elastic-opentelemetry-collector/*.log + include: + - /var/log/pods/*/*/*.log + include_file_name: false + include_file_path: true + storage: file_storage + operators: + - id: container-parser + type: container + hostmetrics: + collection_interval: 10s + root_path: /hostfs + scrapers: + cpu: + metrics: + system.cpu.utilization: + enabled: true + system.cpu.logical.count: + enabled: true + memory: + metrics: + system.memory.utilization: + enabled: true + process: + mute_process_exe_error: true + mute_process_io_error: true + mute_process_user_error: true + metrics: + process.threads: + enabled: true + process.open_file_descriptors: + enabled: true + process.memory.utilization: + enabled: true + process.disk.operations: + enabled: true + network: + processes: + load: + disk: + filesystem: + exclude_mount_points: + mount_points: + - /dev/* + - /proc/* + - /sys/* + - /run/k3s/containerd/* + - /var/lib/docker/* + - /var/lib/kubelet/* + - /snap/* + match_type: regexp + exclude_fs_types: + fs_types: + - autofs + - binfmt_misc + - bpf + - cgroup2 + - configfs + - debugfs + - devpts + - devtmpfs + - fusectl + - hugetlbfs + - iso9660 + - mqueue + - nsfs + - overlay + - proc + - procfs + - pstore + - rpc_pipefs + - securityfs + - selinuxfs + - squashfs + - sysfs + - tracefs + match_type: strict + kubeletstats: + auth_type: serviceAccount + collection_interval: 20s + endpoint: \${env:K8S_NODE_NAME}:10250 + node: '\${env:K8S_NODE_NAME}' + # Verify if this can be removed for all CSPs + #insecure_skip_verify: true + k8s_api_config: + auth_type: serviceAccount + metric_groups: + - node + - pod + - node + - volume + metrics: + k8s.pod.cpu.node.utilization: + enabled: true + k8s.container.cpu_limit_utilization: + enabled: true + k8s.pod.cpu_limit_utilization: + enabled: true + k8s.container.cpu_request_utilization: + enabled: true + k8s.container.memory_limit_utilization: + enabled: true + k8s.pod.memory_limit_utilization: + enabled: true + k8s.container.memory_request_utilization: + enabled: true + k8s.node.uptime: + enabled: true + k8s.node.cpu.usage: + enabled: true + k8s.pod.cpu.usage: + enabled: true + extra_metadata_labels: + - container.id + + service: + extensions: [file_storage] + pipelines: + logs: + exporters: + - elasticsearch + - debug + processors: + - k8sattributes + - resourcedetection/system + - resourcedetection/eks + - resourcedetection/gcp + - resource/k8s + - resource/cloud + receivers: + - filelog + metrics: + exporters: + - debug + - elasticsearch + processors: + - k8sattributes + - elasticinframetrics + - resourcedetection/system + - resourcedetection/eks + - resourcedetection/gcp + - resource/k8s + - resource/cloud + - attributes/dataset + - resource/process + receivers: + - kubeletstats + - hostmetrics +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: elastic-otel-collector-agent + namespace: default + labels: + app.kubernetes.io/name: elastic-opentelemetry-collector + app.kubernetes.io/version: "${agentVersion}" +spec: + selector: + matchLabels: + app.kubernetes.io/name: elastic-opentelemetry-collector + app.kubernetes.io/version: "${agentVersion}" + template: + metadata: + labels: + app.kubernetes.io/name: elastic-opentelemetry-collector + app.kubernetes.io/version: "${agentVersion}" + spec: + serviceAccountName: elastic-otel-collector-agent + securityContext: + runAsUser: 0 + runAsGroup: 0 + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + containers: + - name: elastic-opentelemetry-collector + command: [/usr/share/elastic-agent/elastic-agent] + args: ["otel", "-c", "/etc/elastic-agent/otel.yaml"] + image: docker.elastic.co/beats/elastic-agent:${agentVersion} + imagePullPolicy: IfNotPresent + env: + - name: MY_POD_IP + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + - name: K8S_NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + - name: ES_ENDPOINT + valueFrom: + secretKeyRef: + key: es_endpoint + name: elastic-secret-otel + - name: ES_API_KEY + valueFrom: + secretKeyRef: + key: es_api_key + name: elastic-secret-otel + volumeMounts: + - mountPath: /etc/elastic-agent/otel.yaml + name: opentelemetry-collector-configmap + readOnly: true + subPath: otel.yaml + - name: varlogpods + mountPath: /var/log/pods + readOnly: true + - name: varlibdockercontainers + mountPath: /var/lib/docker/containers + readOnly: true + - name: varlibotelcol + mountPath: /var/lib/otelcol + - name: hostfs + mountPath: /hostfs + readOnly: true + mountPropagation: HostToContainer + + volumes: + - name: opentelemetry-collector-configmap + configMap: + name: elastic-otel-collector-agent + defaultMode: 0640 + - name: varlogpods + hostPath: + path: /var/log/pods + - name: varlibdockercontainers + hostPath: + path: /var/lib/docker/containers + - name: varlibotelcol + hostPath: + path: /var/lib/otelcol + type: DirectoryOrCreate + - name: hostfs + hostPath: + path: /`, + type: 'download', + fileName: 'otel-collector-k8s.yml', + }, + { + id: 'linux', + name: 'Linux', + firstStepTitle: HOST_COMMAND, + content: `arch=$(if ([[ $(arch) == "arm" || $(arch) == "aarch64" ]]); then echo "arm64"; else echo $(arch); fi) + +curl --output elastic-distro-${agentVersion}-linux-$arch.tar.gz --url https://${AGENT_CDN_BASE_URL}/elastic-agent-${agentVersion}-linux-$arch.tar.gz --proto '=https' --tlsv1.2 -fOL && mkdir elastic-distro-${agentVersion}-linux-$arch && tar -xvf elastic-distro-${agentVersion}-linux-$arch.tar.gz -C "elastic-distro-${agentVersion}-linux-$arch" --strip-components=1 && cd elastic-distro-${agentVersion}-linux-$arch + +rm ./otel.yml && cp ./otel_samples/platformlogs_hostmetrics.yml ./otel.yml && sed -i 's#\\\${env:ELASTIC_ENDPOINT}#${setup?.elasticsearchUrl}#g' ./otel.yml && sed -i 's/\\\${env:ELASTIC_API_KEY}/${apiKeyData?.apiKeyEncoded}/g' ./otel.yml`, + start: './otelcol --config otel.yml', + type: 'copy', + }, + { + id: 'mac', + name: 'Mac', + firstStepTitle: HOST_COMMAND, + content: `arch=$(if [[ $(arch) == "arm64" ]]; then echo "aarch64"; else echo $(arch); fi) + +curl --output elastic-distro-${agentVersion}-darwin-$arch.tar.gz --url https://${AGENT_CDN_BASE_URL}/elastic-agent-${agentVersion}-darwin-$arch.tar.gz --proto '=https' --tlsv1.2 -fOL && mkdir "elastic-distro-${agentVersion}-darwin-$arch" && tar -xvf elastic-distro-${agentVersion}-darwin-$arch.tar.gz -C "elastic-distro-${agentVersion}-darwin-$arch" --strip-components=1 && cd elastic-distro-${agentVersion}-darwin-$arch + +rm ./otel.yml && cp ./otel_samples/platformlogs_hostmetrics.yml ./otel.yml && sed -i '' 's#\\\${env:ELASTIC_ENDPOINT}#${setup?.elasticsearchUrl}#g' ./otel.yml && sed -i '' 's/\\\${env:ELASTIC_API_KEY}/${apiKeyData?.apiKeyEncoded}/g' ./otel.yml`, + start: './otelcol --config otel.yml', + type: 'copy', + }, + ]; + + const [selectedTab, setSelectedTab] = React.useState(installTabContents[0].id); + + const selectedContent = installTabContents.find((tab) => tab.id === selectedTab)!; + + return ( + <EuiPanel hasBorder> + <EuiModalHeader> + <EuiModalHeaderTitle> + <EuiFlexGroup gutterSize="l" alignItems="flexStart"> + {http && ( + <EuiFlexItem grow={false}> + <EuiPanel paddingSize="s"> + <EuiIcon + type={http?.staticAssets.getPluginAssetHref('opentelemetry.svg')} + size="xxl" + /> + </EuiPanel> + </EuiFlexItem> + )} + <EuiFlexItem grow> + <EuiFlexGroup gutterSize="m" direction="column"> + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.otelLogsModalHeaderTitleLabel', + { defaultMessage: 'OpenTelemetry' } + )} + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiBetaBadge + label={i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.techPreviewBadge.label', + { + defaultMessage: 'Technical preview', + } + )} + size="m" + color="hollow" + tooltipContent={i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.techPreviewBadge.tooltip', + { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', + } + )} + tooltipPosition={'right'} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiText size="s" color="subdued"> + <p> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.p.collectLogsWithOpenTelemetryLabel', + { + defaultMessage: + 'Collect logs and host metrics using the Elastic distribution of the OTel collector.', + } + )} + </p> + </EuiText> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiModalHeaderTitle> + </EuiModalHeader> + <EuiModalBody> + <EuiFlexGroup direction="column"> + <MultiIntegrationInstallBanner /> + {error && ( + <EuiFlexItem> + <ApiKeyBanner status={apiKeyStatus} payload={apiKeyData} error={error} /> + </EuiFlexItem> + )} + <EuiSteps + steps={[ + { + title: i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.steps.platform', + { + defaultMessage: 'Select your platform', + } + ), + + children: ( + <EuiFlexGroup direction="column"> + <EuiButtonGroup + legend={i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.choosePlatform', + { defaultMessage: 'Choose platform' } + )} + options={installTabContents.map(({ id, name }) => ({ + id, + label: name, + }))} + type="single" + idSelected={selectedTab} + onChange={(id: string) => { + setSelectedTab(id); + }} + /> + <EuiText> + <p>{selectedContent.firstStepTitle}</p> + </EuiText> + <EuiFlexItem> + <EuiCodeBlock language="sh" isCopyable overflowHeight={300}> + {selectedContent.content} + </EuiCodeBlock> + </EuiFlexItem> + <EuiFlexItem align="left"> + <EuiFlexGroup> + {selectedContent.type === 'download' ? ( + <EuiButton + iconType="download" + color="primary" + href={`data:application/yaml;base64,${Buffer.from( + selectedContent.content, + 'utf8' + ).toString('base64')}`} + download={selectedContent.fileName} + target="_blank" + data-test-subj="obltOnboardingOtelDownloadConfig" + > + {i18n.translate( + 'xpack.observability_onboarding.installOtelCollector.configStep.downloadConfigButton', + { defaultMessage: 'Download manifest' } + )} + </EuiButton> + ) : ( + <EuiCopy textToCopy={selectedContent.content}> + {(copy) => ( + <EuiButton + data-test-subj="observabilityOnboardingOtelLogsPanelButton" + iconType="copyClipboard" + onClick={copy} + > + {i18n.translate( + 'xpack.observability_onboarding.installOtelCollector.configStep.copyCommand', + { defaultMessage: 'Copy to clipboard' } + )} + </EuiButton> + )} + </EuiCopy> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ), + }, + { + title: i18n.translate('xpack.observability_onboarding.otelLogsPanel.steps.start', { + defaultMessage: 'Start the collector', + }), + children: ( + <EuiFlexGroup direction="column"> + <EuiCallOut + title={i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.historicalDataTitle', + { defaultMessage: 'Historical logs will not be collected' } + )} + color="warning" + iconType="iInCircle" + > + <p> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.historicalDataDescription', + { + defaultMessage: + 'We only collect new log messages from the setup onward.', + } + )} + </p> + </EuiCallOut> + + {selectedContent.prompt} + {selectedContent.start && ( + <> + <EuiText> + <p> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.p.startTheCollectorLabel', + { + defaultMessage: 'Run the following command to start the collector', + } + )} + </p> + </EuiText> + <CopyableCodeBlock content={selectedContent.start} /> + </> + )} + </EuiFlexGroup> + ), + }, + { + title: 'Visualize your data', + children: ( + <> + <EuiText> + <p> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.waitForTheDataLabel', + { + defaultMessage: + 'After running the previous command, come back and view your data.', + } + )} + </p> + </EuiText> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiImage + src={http?.staticAssets.getPluginAssetHref('waterfall_screen.svg')} + width={160} + alt="Illustration" + hasShadow + /> + </EuiFlexItem> + <EuiFlexItem grow> + <EuiFlexGroup direction="column" gutterSize="xs"> + {deeplinks?.logs && ( + <> + <EuiFlexItem grow={false}> + <EuiText size="s"> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.viewAndAnalyzeYourTextLabel', + { defaultMessage: 'View and analyze your logs' } + )} + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiLink + data-test-subj="obltOnboardingExploreLogs" + href={deeplinks.logs} + > + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.exploreLogs', + { + defaultMessage: 'Open Logs Explorer', + } + )} + </EuiLink> + </EuiFlexItem> + </> + )} + <EuiSpacer size="s" /> + {deeplinks?.metrics && ( + <> + <EuiFlexItem grow={false}> + <EuiText size="s"> + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.viewAndAnalyzeYourMetricsTextLabel', + { defaultMessage: 'View and analyze your metrics' } + )} + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiLink + data-test-subj="obltOnboardingExploreMetrics" + href={deeplinks.metrics} + > + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.exploreMetrics', + { + defaultMessage: 'Open Hosts', + } + )} + </EuiLink> + </EuiFlexItem> + </> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer /> + <EuiText size="xs" color="subdued"> + <FormattedMessage + id="xpack.observability_onboarding.otelLogsPanel.troubleshooting" + defaultMessage="Find more details and troubleshooting solution in our documentation. {link}" + values={{ + link: ( + <EuiLink + data-test-subj="observabilityOnboardingOtelLogsPanelDocumentationLink" + href="https://www.elastic.co/guide/en/observability/current/get-started-opentelemetry.html" + target="_blank" + external + > + {i18n.translate( + 'xpack.observability_onboarding.otelLogsPanel.documentationLink', + { defaultMessage: 'Open documentation' } + )} + </EuiLink> + ), + }} + /> + </EuiText> + </> + ), + }, + ]} + /> + </EuiFlexGroup> + </EuiModalBody> + </EuiPanel> + ); +}; + +function CopyableCodeBlock({ content }: { content: string }) { + return ( + <> + <EuiCodeBlock language="yaml">{content}</EuiCodeBlock> + <EuiCopy textToCopy={content}> + {(copy) => ( + <EuiButton + data-test-subj="observabilityOnboardingCopyableCodeBlockCopyToClipboardButton" + iconType="copyClipboard" + onClick={copy} + > + {i18n.translate( + 'xpack.observability_onboarding.installOtelCollector.configStep.copyCommand', + { defaultMessage: 'Copy to clipboard' } + )} + </EuiButton> + )} + </EuiCopy> + </> + ); +} diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_logs/multi_integration_install_banner.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_logs/multi_integration_install_banner.tsx new file mode 100644 index 000000000000..4696e3af43a8 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_logs/multi_integration_install_banner.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 { EuiCallOut, EuiCodeBlock, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + IntegrationInstallationError, + useInstallIntegrations, +} from '../../../hooks/use_install_integrations'; + +export function MultiIntegrationInstallBanner() { + const [error, setError] = useState<IntegrationInstallationError>(); + + const onIntegrationCreationFailure = useCallback((e: IntegrationInstallationError) => { + setError(e); + }, []); + + const { performRequest, requestState } = useInstallIntegrations({ + onIntegrationCreationFailure, + packages: ['system', 'kubernetes'], + }); + + useEffect(() => { + performRequest(); + }, [performRequest]); + + const hasFailedInstallingIntegration = requestState.state === 'rejected'; + + if (hasFailedInstallingIntegration) { + return ( + <EuiFlexItem> + <EuiCallOut + title={i18n.translate('xpack.observability_onboarding.otelLogs.status.failed', { + defaultMessage: 'Integration installation failed', + })} + color="warning" + iconType="warning" + data-test-subj="obltOnboardingOtelLogsIntegrationInstallationFailed" + > + <EuiFlexGroup direction="column"> + <EuiFlexItem> + {i18n.translate('xpack.observability_onboarding.otelLogs.status.failedDetails', { + defaultMessage: 'Incoming data might not be indexed correctly. Details:', + })} + </EuiFlexItem> + <EuiCodeBlock>{error?.message}</EuiCodeBlock> + </EuiFlexGroup> + </EuiCallOut> + </EuiFlexItem> + ); + } + return null; +} diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/system_logs/system_integration_banner.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/system_logs/system_integration_banner.tsx index 3700a8205212..305c921dddfb 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/system_logs/system_integration_banner.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/system_logs/system_integration_banner.tsx @@ -11,9 +11,9 @@ import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback, useEffect, useState } from 'react'; import type { MouseEvent } from 'react'; import { - SystemIntegrationError, - useInstallSystemIntegration, -} from '../../../hooks/use_install_system_integration'; + IntegrationInstallationError, + useInstallIntegrations, +} from '../../../hooks/use_install_integrations'; import { useKibanaNavigation } from '../../../hooks/use_kibana_navigation'; import { PopoverTooltip } from '../shared/popover_tooltip'; @@ -22,29 +22,29 @@ export type SystemIntegrationBannerState = 'pending' | 'resolved' | 'rejected'; export function SystemIntegrationBanner({ onStatusChange, }: { - onStatusChange: (status: SystemIntegrationBannerState) => void; + onStatusChange?: (status: SystemIntegrationBannerState) => void; }) { const { navigateToAppUrl } = useKibanaNavigation(); const [integrationVersion, setIntegrationVersion] = useState<string>(); - const [error, setError] = useState<SystemIntegrationError>(); + const [error, setError] = useState<IntegrationInstallationError>(); const onIntegrationCreationSuccess = useCallback( - ({ version }: { version?: string }) => { - setIntegrationVersion(version); - onStatusChange('resolved'); + ({ versions }: { versions?: string[] }) => { + setIntegrationVersion(versions?.[0]); + onStatusChange?.('resolved'); }, [onStatusChange] ); const onIntegrationCreationFailure = useCallback( - (e: SystemIntegrationError) => { + (e: IntegrationInstallationError) => { setError(e); - onStatusChange('rejected'); + onStatusChange?.('rejected'); }, [onStatusChange] ); - const { performRequest, requestState } = useInstallSystemIntegration({ + const { performRequest, requestState } = useInstallIntegrations({ onIntegrationCreationSuccess, onIntegrationCreationFailure, }); diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/hooks/use_install_system_integration.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/hooks/use_install_integrations.ts similarity index 66% rename from x-pack/plugins/observability_solution/observability_onboarding/public/hooks/use_install_system_integration.ts rename to x-pack/plugins/observability_solution/observability_onboarding/public/hooks/use_install_integrations.ts index d1e65b0c2fad..018831fac948 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/hooks/use_install_system_integration.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/hooks/use_install_integrations.ts @@ -12,7 +12,7 @@ import { useKibana } from './use_kibana'; // Errors const UNAUTHORIZED_ERROR = i18n.translate( - 'xpack.observability_onboarding.installSystemIntegration.error.unauthorized', + 'xpack.observability_onboarding.installIntegration.error.unauthorized', { defaultMessage: 'Required kibana privilege {requiredKibanaPrivileges} is missing, please add the required privilege to the role of the authenticated user.', @@ -23,19 +23,21 @@ const UNAUTHORIZED_ERROR = i18n.translate( ); type ErrorType = 'AuthorizationError' | 'UnknownError'; -export interface SystemIntegrationError { +export interface IntegrationInstallationError { type: ErrorType; message: string; } type IntegrationInstallStatus = 'installed' | 'installing' | 'install_failed' | 'not_installed'; -export const useInstallSystemIntegration = ({ +export const useInstallIntegrations = ({ onIntegrationCreationSuccess, onIntegrationCreationFailure, + packages = ['system'], }: { - onIntegrationCreationSuccess: ({ version }: { version?: string }) => void; - onIntegrationCreationFailure: (error: SystemIntegrationError) => void; + onIntegrationCreationSuccess?: ({ versions }: { versions?: string[] }) => void; + onIntegrationCreationFailure: (error: IntegrationInstallationError) => void; + packages?: string[]; }) => { const { services: { http }, @@ -48,20 +50,24 @@ export const useInstallSystemIntegration = ({ headers: { 'Elastic-Api-Version': '2023-10-31' }, }; - const { item: systemIntegration } = await http.get<{ - item: { version: string; status: IntegrationInstallStatus }; - }>('/api/fleet/epm/packages/system', options); + const integrations = []; + for (const packageName of packages) { + const { item: integration } = await http.get<{ + item: { version: string; status: IntegrationInstallStatus }; + }>(`/api/fleet/epm/packages/${packageName}`, options); - if (systemIntegration.status !== 'installed') { - await http.post('/api/fleet/epm/packages/system', options); + if (integration.status !== 'installed') { + await http.post(`/api/fleet/epm/packages/${packageName}`, options); + } + integrations.push(integration); } return { - version: systemIntegration.version, + versions: integrations.map((integration) => integration.version), }; }, - onResolve: ({ version }: { version?: string }) => { - onIntegrationCreationSuccess({ version }); + onResolve: ({ versions }: { versions?: string[] }) => { + onIntegrationCreationSuccess?.({ versions }); }, onReject: (requestError: any) => { if (requestError?.body?.statusCode === 403) { diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/index.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/index.ts index 98174497d6c3..b84ae734d385 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/index.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/index.ts @@ -13,6 +13,7 @@ import { PluginInitializer, PluginInitializerContext, } from '@kbn/core/public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; import { ObservabilityOnboardingPlugin, ObservabilityOnboardingPluginSetup, @@ -28,9 +29,16 @@ export interface ConfigSchema { }; } +export interface AppContext { + isServerless: boolean; + stackVersion: string; +} + export interface ObservabilityOnboardingAppServices { application: ApplicationStart; http: HttpStart; + share: SharePluginStart; + context: AppContext; config: ConfigSchema; docLinks: DocLinksStart; chrome: ChromeStart; diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts index be73b77bd336..2e3dfb201b35 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts @@ -75,6 +75,7 @@ export class ObservabilityOnboardingPlugin constructor(private readonly ctx: PluginInitializerContext) {} public setup(core: CoreSetup, plugins: ObservabilityOnboardingPluginSetupDeps) { + const stackVersion = this.ctx.env.packageInfo.version; const config = this.ctx.config.get<ObservabilityOnboardingConfig>(); const { ui: { enabled: isObservabilityOnboardingUiEnabled }, @@ -109,6 +110,10 @@ export class ObservabilityOnboardingPlugin appMountParameters, corePlugins: corePlugins as ObservabilityOnboardingPluginStartDeps, config, + context: { + isServerless: Boolean(pluginSetupDeps.cloud?.isServerlessEnabled), + stackVersion, + }, }); }, visibleIn: [], diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/logs/route.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/routes/logs/route.ts index 4f7c1360dc08..524037c1c422 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/logs/route.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/server/routes/logs/route.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import { createObservabilityOnboardingServerRoute } from '../create_observability_onboarding_server_route'; +import { getFallbackESUrl } from '../../lib/get_fallback_urls'; import { getKibanaUrl } from '../../lib/get_fallback_urls'; import { getAgentVersion } from '../../lib/get_agent_version'; import { hasLogMonitoringPrivileges } from './api_key/has_log_monitoring_privileges'; @@ -38,8 +39,14 @@ const installShipperSetupRoute = createObservabilityOnboardingServerRoute({ apiEndpoint: string; scriptDownloadUrl: string; elasticAgentVersion: string; + elasticsearchUrl: string[]; }> { - const { core, plugins, kibanaVersion } = resources; + const { + core, + plugins, + kibanaVersion, + services: { esLegacyConfigService }, + } = resources; const fleetPluginStart = await plugins.fleet.start(); const elasticAgentVersion = await getAgentVersion(fleetPluginStart, kibanaVersion); @@ -51,14 +58,34 @@ const installShipperSetupRoute = createObservabilityOnboardingServerRoute({ const apiEndpoint = new URL(`${kibanaUrl}/internal/observability_onboarding`).toString(); + const elasticsearchUrl = plugins.cloud?.setup?.elasticsearchUrl + ? [plugins.cloud?.setup?.elasticsearchUrl] + : await getFallbackESUrl(esLegacyConfigService); + return { apiEndpoint, + elasticsearchUrl, scriptDownloadUrl, elasticAgentVersion, }; }, }); +const createAPIKeyRoute = createObservabilityOnboardingServerRoute({ + endpoint: 'POST /internal/observability_onboarding/otel/api_key', + options: { tags: [] }, + params: t.type({}), + async handler(resources): Promise<{ apiKeyEncoded: string }> { + const { context } = resources; + const { + elasticsearch: { client }, + } = await context.core; + const { encoded: apiKeyEncoded } = await createShipperApiKey(client.asCurrentUser, 'otel logs'); + + return { apiKeyEncoded }; + }, +}); + const createFlowRoute = createObservabilityOnboardingServerRoute({ endpoint: 'POST /internal/observability_onboarding/logs/flow', options: { tags: [] }, @@ -110,4 +137,5 @@ export const logsOnboardingRouteRepository = { ...logMonitoringPrivilegesRoute, ...installShipperSetupRoute, ...createFlowRoute, + ...createAPIKeyRoute, }; diff --git a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/long_window_duration.tsx b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/long_window_duration.tsx index 707712798add..aaad1d6ae6d0 100644 --- a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/long_window_duration.tsx +++ b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/long_window_duration.tsx @@ -8,23 +8,15 @@ import { EuiFieldNumber, EuiFormRow, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ChangeEvent, useState } from 'react'; - import { Duration } from '../../typings'; -import { toMinutes } from '../../utils/slo/duration'; interface Props { - shortWindowDuration: Duration; initialDuration?: Duration; errors?: string[]; onChange: (duration: Duration) => void; } -export function LongWindowDuration({ - shortWindowDuration, - initialDuration, - onChange, - errors, -}: Props) { +export function LongWindowDuration({ initialDuration, onChange, errors }: Props) { const [durationValue, setDurationValue] = useState<number>(initialDuration?.value ?? 1); const hasError = errors !== undefined && errors.length > 0; @@ -35,7 +27,7 @@ export function LongWindowDuration({ }; return ( - <EuiFormRow label={getRowLabel(shortWindowDuration)} fullWidth isInvalid={hasError}> + <EuiFormRow label={getRowLabel()} fullWidth isInvalid={hasError}> <EuiFieldNumber isInvalid={hasError} min={1} @@ -44,7 +36,7 @@ export function LongWindowDuration({ value={String(durationValue)} onChange={onDurationValueChange} aria-label={i18n.translate('xpack.slo.rules.longWindow.valueLabel', { - defaultMessage: 'Lookback period in hours', + defaultMessage: 'Long lookback period in hours', })} data-test-subj="durationValueInput" /> @@ -52,18 +44,16 @@ export function LongWindowDuration({ ); } -const getRowLabel = (shortWindowDuration: Duration) => ( +const getRowLabel = () => ( <> {i18n.translate('xpack.slo.rules.longWindow.rowLabel', { - defaultMessage: 'Lookback (hours)', + defaultMessage: 'Long lookback (hours)', })}{' '} - <EuiIconTip position="top" content={getTooltipText(shortWindowDuration)} /> + <EuiIconTip + position="top" + content={i18n.translate('xpack.slo.rules.longWindowDuration.tooltip', { + defaultMessage: 'Long lookback period over which the burn rate is computed.', + })} + /> </> ); - -const getTooltipText = (shortWindowDuration: Duration) => - i18n.translate('xpack.slo.rules.longWindowDuration.tooltip', { - defaultMessage: - 'Lookback period over which the burn rate is computed. A shorter lookback period of {shortWindowDuration} minutes (1/12 the lookback period) will be used for faster recovery', - values: { shortWindowDuration: toMinutes(shortWindowDuration) }, - }); diff --git a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/short_window_duration.tsx b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/short_window_duration.tsx new file mode 100644 index 000000000000..53ab30de8ca6 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/short_window_duration.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFieldNumber, EuiFormRow, EuiIconTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { ChangeEvent, useState } from 'react'; + +import { Duration } from '../../typings'; +import { toMinutes } from '../../utils/slo/duration'; + +interface Props { + longWindowDuration: Duration; + initialDuration?: Duration; + errors?: string[]; + onChange: (duration: Duration) => void; +} + +export function ShortWindowDuration({ + longWindowDuration, + initialDuration, + onChange, + errors, +}: Props) { + const [durationValue, setDurationValue] = useState<number>(initialDuration?.value ?? 1); + const hasError = errors !== undefined && errors.length > 0; + const maxShortWindowDuration = toMinutes(longWindowDuration); + + const onDurationValueChange = (e: ChangeEvent<HTMLInputElement>) => { + const value = Number(e.target.value); + setDurationValue(value); + onChange({ value, unit: 'm' }); + }; + + return ( + <EuiFormRow label={getRowLabel()} fullWidth isInvalid={hasError}> + <EuiFieldNumber + isInvalid={hasError} + min={1} + max={maxShortWindowDuration} + step={1} + value={String(durationValue)} + onChange={onDurationValueChange} + aria-label={i18n.translate('xpack.slo.rules.shortWindow.valueLabel', { + defaultMessage: 'short lookback period in minutes', + })} + data-test-subj="durationValueInput" + /> + </EuiFormRow> + ); +} + +const getRowLabel = () => ( + <> + {i18n.translate('xpack.slo.rules.shortWindow.rowLabel', { + defaultMessage: 'Short lookback (min)', + })}{' '} + <EuiIconTip + position="top" + content={i18n.translate('xpack.slo.rules.shortWindowDuration.tooltip', { + defaultMessage: + 'Short lookback period over which the burn rate is computed. Used for faster recovery, a good default value is 1/12th of the long lookback period.', + })} + /> + </> +); diff --git a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.test.ts b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.test.ts index 4e4d7fbc6655..245c0c0e59c8 100644 --- a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.test.ts +++ b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.test.ts @@ -102,4 +102,33 @@ describe('ValidateBurnRateRule', () => { ).errors.windows[0].longWindow.length ).toBe(0); }); + + it('validates shortWindow is less than longWindow', () => { + expect( + validateBurnRateRule( + createTestParams({ + shortWindow: { value: 61, unit: 'm' }, + longWindow: { value: 1, unit: 'h' }, + }) + ).errors.windows[0].shortWindow.length + ).toBe(1); + + expect( + validateBurnRateRule( + createTestParams({ + shortWindow: { value: 60, unit: 'm' }, + longWindow: { value: 1, unit: 'h' }, + }) + ).errors.windows[0].shortWindow.length + ).toBe(0); + + expect( + validateBurnRateRule( + createTestParams({ + shortWindow: { value: 15, unit: 'm' }, + longWindow: { value: 1, unit: 'h' }, + }) + ).errors.windows[0].shortWindow.length + ).toBe(0); + }); }); diff --git a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.ts b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.ts index 6880cf942643..72362e98aa10 100644 --- a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.ts +++ b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/validation.ts @@ -8,9 +8,11 @@ import { i18n } from '@kbn/i18n'; import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public'; import { BurnRateRuleParams, Duration } from '../../typings'; +import { toMinutes } from '../../utils/slo/duration'; export interface WindowResult { longWindow: string[]; + shortWindow: string[]; burnRateThreshold: string[]; } @@ -42,8 +44,12 @@ export function validateBurnRateRule( } if (windows) { - windows.forEach(({ burnRateThreshold, longWindow, maxBurnRateThreshold }) => { - const result = { longWindow: new Array<string>(), burnRateThreshold: new Array<string>() }; + windows.forEach(({ burnRateThreshold, longWindow, shortWindow, maxBurnRateThreshold }) => { + const result = { + longWindow: new Array<string>(), + shortWindow: new Array<string>(), + burnRateThreshold: new Array<string>(), + }; if (burnRateThreshold === undefined || maxBurnRateThreshold === undefined) { result.burnRateThreshold.push(BURN_RATE_THRESHOLD_REQUIRED); } else if (sloId && (burnRateThreshold < 0.01 || burnRateThreshold > maxBurnRateThreshold)) { @@ -54,6 +60,13 @@ export function validateBurnRateRule( } else if (!isValidLongWindowDuration(longWindow)) { result.longWindow.push(LONG_WINDOW_DURATION_INVALID); } + + if (shortWindow === undefined) { + result.shortWindow.push(SHORT_WINDOW_DURATION_REQUIRED); + } else if (!isValidShortWindowDuration(shortWindow, longWindow)) { + result.shortWindow.push(SHORT_WINDOW_DURATION_INVALID); + } + validationResult.errors.windows.push(result); }); } @@ -72,13 +85,29 @@ const SLO_REQUIRED = i18n.translate('xpack.slo.rules.burnRate.errors.sloRequired const LONG_WINDOW_DURATION_REQUIRED = i18n.translate( 'xpack.slo.rules.burnRate.errors.windowDurationRequired', - { defaultMessage: 'The lookback period is required.' } + { defaultMessage: 'The long lookback period is required.' } ); const LONG_WINDOW_DURATION_INVALID = i18n.translate('xpack.slo.rules.longWindow.errorText', { defaultMessage: 'The lookback period must be between 1 and 72 hours.', }); +const isValidShortWindowDuration = (shortWindow: Duration, longWindow: Duration): boolean => { + const longWindowInMinutes = toMinutes(longWindow); + const shortWindowInMinutes = toMinutes(shortWindow); + const { unit } = shortWindow; + return shortWindowInMinutes >= 1 && shortWindowInMinutes <= longWindowInMinutes && unit === 'm'; +}; + +const SHORT_WINDOW_DURATION_REQUIRED = i18n.translate( + 'xpack.slo.rules.burnRate.errors.shortWindowDurationRequired', + { defaultMessage: 'The short lookback period is required.' } +); + +const SHORT_WINDOW_DURATION_INVALID = i18n.translate('xpack.slo.rules.shortWindow.errorText', { + defaultMessage: 'The short lookback period must be lower than the long lookback period.', +}); + const BURN_RATE_THRESHOLD_REQUIRED = i18n.translate( 'xpack.slo.rules.burnRate.errors.burnRateThresholdRequired', { defaultMessage: 'Burn rate threshold is required.' } diff --git a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/windows.tsx b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/windows.tsx index e0b960a7303d..240ad192ca0e 100644 --- a/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/windows.tsx +++ b/x-pack/plugins/observability_solution/slo/public/components/burn_rate_rule_editor/windows.tsx @@ -34,6 +34,7 @@ import { } from '../../../common/constants'; import { WindowResult } from './validation'; import { BudgetConsumed } from './budget_consumed'; +import { ShortWindowDuration } from './short_window_duration'; interface WindowProps extends WindowSchema { slo?: SLODefinitionResponse; @@ -75,14 +76,23 @@ function Window({ budgetMode = true, }: WindowProps) { const onLongWindowDurationChange = (duration: Duration) => { - const longWindowDurationInMinutes = toMinutes(duration); - const shortWindowDurationValue = Math.floor(longWindowDurationInMinutes / 12); onChange({ id, burnRateThreshold, maxBurnRateThreshold, longWindow: duration, - shortWindow: { value: shortWindowDurationValue, unit: 'm' }, + shortWindow, + actionGroup, + }); + }; + + const onShortWindowDurationChange = (duration: Duration) => { + onChange({ + id, + burnRateThreshold, + maxBurnRateThreshold, + longWindow, + shortWindow: duration, actionGroup, }); }; @@ -135,15 +145,22 @@ function Window({ return ( <> - <EuiFlexGroup direction="row" alignItems="center"> + <EuiFlexGroup direction="row" alignItems="flexEnd"> <EuiFlexItem> <LongWindowDuration initialDuration={longWindow} - shortWindowDuration={shortWindow} onChange={onLongWindowDurationChange} errors={errors.longWindow} /> </EuiFlexItem> + <EuiFlexItem> + <ShortWindowDuration + longWindowDuration={longWindow} + initialDuration={shortWindow} + onChange={onShortWindowDurationChange} + errors={errors.shortWindow} + /> + </EuiFlexItem> {!budgetMode && ( <EuiFlexItem> <BurnRate @@ -300,6 +317,7 @@ export function Windows({ slo, windows, errors, onChange, totalNumberOfWindows } {windows.map((windowDef, index) => { const windowErrors = errors[index] || { longWindow: new Array<string>(), + shortWindow: new Array<string>(), burnRateThreshold: new Array<string>(), }; return ( diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/alert_config.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/alert_config.ts index 596292879bfb..a2beff50149d 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/alert_config.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/alert_config.ts @@ -6,7 +6,6 @@ */ import * as t from 'io-ts'; -import { schema } from '@kbn/config-schema'; export const AlertConfigCodec = t.intersection([ t.interface({ @@ -22,17 +21,6 @@ export const AlertConfigsCodec = t.partial({ status: AlertConfigCodec, }); -export const AlertConfigSchema = schema.object({ - tls: schema.maybe( - schema.object({ - enabled: schema.boolean(), - }) - ), - status: schema.object({ - enabled: schema.boolean(), - }), -}); - export type AlertConfig = t.TypeOf<typeof AlertConfigCodec>; export type AlertConfigs = t.TypeOf<typeof AlertConfigsCodec>; diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/alert_config_schema.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/alert_config_schema.ts new file mode 100644 index 000000000000..87d2d9ced2f2 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/alert_config_schema.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const AlertConfigSchema = schema.object({ + tls: schema.maybe( + schema.object({ + enabled: schema.boolean(), + }) + ), + status: schema.object({ + enabled: schema.boolean(), + }), +}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/stderr_logs.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/stderr_logs.tsx index 2b332ec78671..ac9a313aaf1b 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/stderr_logs.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/components/stderr_logs.tsx @@ -129,6 +129,7 @@ export const StdErrorLogs = ({ <EuiInMemoryTable items={items} + rowHeader="@timestamp" columns={columns} tableLayout="auto" loading={loading} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.test.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.test.tsx index cedf240b3611..d98f8e2dd2ed 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.test.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.test.tsx @@ -62,7 +62,7 @@ describe('JourneyScreenshotPreview', () => { const { getByAltText, getByText, queryByAltText } = render( <JourneyScreenshotPreview {...defaultProps} /> ); - const img = getByAltText('First step'); + const img = getByAltText('"First step", 1 of 2'); fireEvent.click(img); expect(dialogProps.checkGroup).toEqual(defaultProps.checkGroup); expect(getByAltText('img-in-dialog')).not.toBeNull(); @@ -75,7 +75,7 @@ describe('JourneyScreenshotPreview', () => { <JourneyScreenshotPreview {...defaultProps} /> ); - const img = getByAltText('First step'); + const img = getByAltText('"First step", 1 of 2'); const euiPopoverMessage = 'You are in a dialog. Press Escape, or tap/click outside the dialog to close.'; // Helps to detect if popover is open expect(queryByText(euiPopoverMessage)).toBeNull(); @@ -88,7 +88,7 @@ describe('JourneyScreenshotPreview', () => { it('renders the correct image', () => { const { getByAltText } = render(<JourneyScreenshotPreview {...defaultProps} />); - const img = getByAltText('First step'); + const img = getByAltText('"First step", 1 of 2'); expect(img).toHaveAttribute('src', testImgUrl1); }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.tsx index f16586141971..62bb880a0f6b 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_screenshot_preview.tsx @@ -8,6 +8,7 @@ import React, { useCallback, useState, MouseEvent } from 'react'; import { EuiPopover, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { POPOVER_SCREENSHOT_SIZE, ScreenshotImageSize } from '../screenshot/screenshot_size'; import { JourneyScreenshotDialog } from '../screenshot/journey_screenshot_dialog'; import { ScreenshotImage } from '../screenshot/screenshot_image'; @@ -77,7 +78,14 @@ export const JourneyScreenshotPreview: React.FC<StepImagePopoverProps> = ({ const renderScreenshotImage = (screenshotSize: ScreenshotImageSize) => ( <ScreenshotImage - label={stepName} + label={i18n.translate('xpack.synthetics.monitorTestResult.screenshotImageLabel', { + defaultMessage: '"{stepName}", {stepNumber} of {totalSteps}', + values: { + stepName, + stepNumber, + totalSteps: maxSteps ?? stepNumber, + }, + })} imgSrc={imgSrc} isLoading={isLoading} size={screenshotSize} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_screenshot_dialog.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_screenshot_dialog.tsx index 9f261e738a96..8b08153acec1 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_screenshot_dialog.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_screenshot_dialog.tsx @@ -69,8 +69,6 @@ export const JourneyScreenshotDialog = ({ const { url, loading, stepName, maxSteps } = imageResult?.[imgPath] ?? {}; const imgSrc = stepNumber === initialStepNumber ? initialImgSrc ?? url : url; - const stepCountLabel = formatScreenshotStepsCount(stepNumber, maxSteps ?? stepNumber); - useEffect(() => { if (isOpen) { setStepNumber(initialStepNumber); @@ -122,7 +120,14 @@ export const JourneyScreenshotDialog = ({ > <ModalBodyStyled css={{ display: 'flex' }}> <ScreenshotImage - label={stepCountLabel} + label={i18n.translate('xpack.synthetics.monitor.screenshotImageLabel', { + defaultMessage: '"{stepName}", {stepNumber} of {totalSteps}', + values: { + stepName, + stepNumber, + totalSteps: maxSteps ?? stepNumber, + }, + })} imgSrc={imgSrc} isLoading={!!loading} animateLoading={false} @@ -172,7 +177,15 @@ export const JourneyScreenshotDialog = ({ </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false} css={{ flexBasis: 'fit-content' }}> - <EuiText color={euiTheme.colors.text}>{stepCountLabel}</EuiText> + <EuiText color={euiTheme.colors.text}> + {i18n.translate('xpack.synthetics.monitor.stepOfSteps', { + defaultMessage: 'Step: {stepNumber} of {totalSteps}', + values: { + stepNumber, + totalSteps: maxSteps ?? stepNumber, + }, + })} + </EuiText> </EuiFlexItem> <EuiFlexItem grow={true}> <EuiButtonEmpty @@ -241,15 +254,6 @@ export const getScreenshotUrl = ({ ).replace('{stepIndex}', stepNumber.toString())}`; }; -export const formatScreenshotStepsCount = (stepNumber: number, totalSteps: number) => - i18n.translate('xpack.synthetics.monitor.stepOfSteps', { - defaultMessage: 'Step: {stepNumber} of {totalSteps}', - values: { - stepNumber, - totalSteps, - }, - }); - const prevAriaLabel = i18n.translate('xpack.synthetics.monitor.step.previousStep', { defaultMessage: 'Previous step', }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_step_screenshot_container.test.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_step_screenshot_container.test.tsx index c2886934a848..454747ae2dd1 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_step_screenshot_container.test.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/screenshot/journey_step_screenshot_container.test.tsx @@ -69,7 +69,7 @@ describe('JourneyStepScreenshotContainer', () => { <JourneyStepScreenshotContainer checkGroup={checkGroup} /> ); - const img = getByAltText('First step'); + const img = getByAltText('"First step", 1 of 2'); const euiPopoverMessage = 'You are in a dialog. Press Escape, or tap/click outside the dialog to close.'; expect(queryByText(euiPopoverMessage)).toBeNull(); @@ -88,7 +88,7 @@ describe('JourneyStepScreenshotContainer', () => { <JourneyStepScreenshotContainer checkGroup={checkGroup} /> ); - const img = getByAltText('First step'); + const img = getByAltText('"First step", 1 of 2'); await waitFor(() => img); fireEvent.click(img); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx index 180b6299bfa4..7ae4196b5e31 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx @@ -15,7 +15,7 @@ import { useIsWithinMinBreakpoint, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { MonitorListSortField } from '../../../../../../../common/runtime_types/monitor_management/sort_field'; +import type { MonitorListSortField } from '../../../../../../../common/runtime_types/monitor_management/sort_field'; import { DeleteMonitor } from './delete_monitor'; import { IHttpSerializedFetchError } from '../../../../state/utils/http_error'; import { MonitorListPageState } from '../../../../state'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/sort_fields.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/sort_fields.tsx index 7305e5fcbd2c..4d1341a80543 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/sort_fields.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/sort_fields.tsx @@ -9,7 +9,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { useDispatch, useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import { MonitorListSortField } from '../../../../../../../common/runtime_types/monitor_management/sort_field'; +import type { MonitorListSortField } from '../../../../../../../common/runtime_types/monitor_management/sort_field'; import { ConfigKey } from '../../../../../../../common/runtime_types'; import { selectOverviewState, setOverviewPageStateAction } from '../../../../state/overview'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/use_infinite_scroll.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/use_infinite_scroll.ts index e30ab8aa952c..e3e1f48d4e52 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/use_infinite_scroll.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/use_infinite_scroll.ts @@ -9,7 +9,7 @@ import useThrottle from 'react-use/lib/useThrottle'; import { useEffect, useState, MutableRefObject } from 'react'; import useIntersection from 'react-use/lib/useIntersection'; import { useSelector } from 'react-redux'; -import { MonitorListSortField } from '../../../../../../../common/runtime_types/monitor_management/sort_field'; +import type { MonitorListSortField } from '../../../../../../../common/runtime_types/monitor_management/sort_field'; import { useGetUrlParams } from '../../../../hooks'; import { selectOverviewState } from '../../../../state'; import { MonitorOverviewItem } from '../../../../../../../common/runtime_types'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/step_details_page/step_page_nav.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/step_details_page/step_page_nav.tsx index 9bc91a52df4a..9dc4475778dc 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/step_details_page/step_page_nav.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/step_details_page/step_page_nav.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { ReactElement, useState } from 'react'; +import React, { useState } from 'react'; import { EuiButtonEmpty, EuiDescriptionList, @@ -43,8 +43,7 @@ export const StepPageNavigation = ({ testRunPage }: { testRunPage?: boolean }) = const formatter = useDateFormat(); const { basePath } = useSyntheticsSettingsContext(); const selectedLocation = useSelectedLocation(); - - let startedAt: string | ReactElement = formatter(data?.details?.timestamp); + const startedAt = formatter(data?.details?.timestamp); const { stepIndex, monitorId } = useParams<{ stepIndex: string; monitorId: string }>(); @@ -77,9 +76,7 @@ export const StepPageNavigation = ({ testRunPage }: { testRunPage?: boolean }) = } } - if (!startedAt) { - startedAt = <EuiSkeletonText lines={1} />; - } + const startedAtWrapped = startedAt || <EuiSkeletonText lines={1} />; return ( <EuiPopover @@ -94,7 +91,7 @@ export const StepPageNavigation = ({ testRunPage }: { testRunPage?: boolean }) = iconSide="right" flush="left" > - {startedAt} + {startedAtWrapped} </EuiButtonEmpty> } > @@ -111,8 +108,12 @@ export const StepPageNavigation = ({ testRunPage }: { testRunPage?: boolean }) = </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiText size="s" className="eui-textNoWrap"> - {startedAt} + <EuiText + size="s" + className="eui-textNoWrap" + aria-label={CURRENT_CHECK_ARIA_LABEL(startedAt)} + > + {startedAtWrapped} </EuiText> </EuiFlexItem> <EuiFlexItem grow={false}> @@ -145,3 +146,11 @@ export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( defaultMessage: 'Next check', } ); + +export const CURRENT_CHECK_ARIA_LABEL = (timestamp: string) => + i18n.translate('xpack.synthetics.synthetics.stepDetail.currentCheckAriaLabel', { + defaultMessage: 'Current check: {timestamp}', + values: { + timestamp, + }, + }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/monitor_status.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/monitor_status.tsx index c3220ede642f..7c33dc7aba96 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/monitor_status.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/monitor_status.tsx @@ -16,7 +16,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { kibanaService } from '../../../../../utils/kibana_service'; import { ClientPluginsStart } from '../../../../../plugin'; import { store } from '../../../state'; -import { StatusRuleParams } from '../../../../../../common/rules/status_rule'; +import type { StatusRuleParams } from '../../../../../../common/rules/status_rule'; interface Props { core: CoreStart; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx index df0f160aee3c..8ee01e185e8c 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/lib/alert_types/monitor_status.tsx @@ -9,14 +9,14 @@ import React from 'react'; import { ALERT_REASON } from '@kbn/rule-data-utils'; -import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public'; -import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import type { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public'; +import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; import { getSyntheticsErrorRouteFromMonitorId } from '../../../../../common/utils/get_synthetics_monitor_url'; import { STATE_ID } from '../../../../../common/field_names'; import { SyntheticsMonitorStatusTranslations } from '../../../../../common/rules/synthetics/translations'; -import { StatusRuleParams } from '../../../../../common/rules/status_rule'; +import type { StatusRuleParams } from '../../../../../common/rules/status_rule'; import { SYNTHETICS_ALERT_RULE_TYPES } from '../../../../../common/constants/synthetics_alerts'; -import { AlertTypeInitializer } from '.'; +import type { AlertTypeInitializer } from '.'; const { defaultActionMessage, defaultRecoveryMessage, description } = SyntheticsMonitorStatusTranslations; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/models.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/models.ts index ecd6f40a318c..4c9a105a4c1f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/models.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/models.ts @@ -7,7 +7,7 @@ import { ErrorToastOptions } from '@kbn/core-notifications-browser'; -import { MonitorListSortField } from '../../../../../common/runtime_types/monitor_management/sort_field'; +import type { MonitorListSortField } from '../../../../../common/runtime_types/monitor_management/sort_field'; import { EncryptedSyntheticsMonitor, FetchMonitorManagementListQueryArgs, diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts index 585e928c6f74..8706ca519d49 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { MonitorListSortField } from '../../../../../common/runtime_types/monitor_management/sort_field'; +import type { MonitorListSortField } from '../../../../../common/runtime_types/monitor_management/sort_field'; import { ConfigKey, MonitorOverviewResult } from '../../../../../common/runtime_types'; import { IHttpSerializedFetchError } from '../utils/http_error'; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts index 85714411f92b..ab5c2c089adb 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts @@ -11,7 +11,7 @@ import { formatErrors } from '@kbn/securitysolution-io-ts-utils'; import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; -import { AlertConfigSchema } from '../../../common/runtime_types/monitor_management/alert_config'; +import { AlertConfigSchema } from '../../../common/runtime_types/monitor_management/alert_config_schema'; import { CreateMonitorPayLoad } from './add_monitor/add_monitor_api'; import { flattenAndFormatObject } from '../../synthetics_service/project_monitor/normalizers/common_fields'; import { PrivateLocationAttributes } from '../../runtime_types/private_locations'; diff --git a/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/components/monitor/synthetics/step_detail/step_page_nav.tsx b/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/components/monitor/synthetics/step_detail/step_page_nav.tsx index 694347ac621f..5f7541abbc8b 100644 --- a/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/components/monitor/synthetics/step_detail/step_page_nav.tsx +++ b/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/components/monitor/synthetics/step_detail/step_page_nav.tsx @@ -24,6 +24,14 @@ export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( } ); +export const CURRENT_CHECK_ARIA_LABEL = (timestamp: string) => + i18n.translate('xpack.uptime.synthetics.stepDetail.currentCheckAriaLabel', { + defaultMessage: 'Current check: {timestamp}', + values: { + timestamp, + }, + }); + interface Props { previousCheckGroup?: string; dateFormat: string; @@ -40,6 +48,8 @@ export const StepPageNavigation = ({ checkTimestamp, nextCheckGroup, }: Props) => { + const formattedTimestamp = moment(checkTimestamp).format(dateFormat); + return ( <EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false}> <EuiFlexItem grow={false}> @@ -54,7 +64,9 @@ export const StepPageNavigation = ({ </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiText size="s">{moment(checkTimestamp).format(dateFormat)}</EuiText> + <EuiText size="s" aria-label={CURRENT_CHECK_ARIA_LABEL(formattedTimestamp)}> + {formattedTimestamp} + </EuiText> </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButtonEmpty diff --git a/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/pages/synthetics/checks_navigation.tsx b/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/pages/synthetics/checks_navigation.tsx index fce9055b9f82..92cb498a0d2a 100644 --- a/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/pages/synthetics/checks_navigation.tsx +++ b/x-pack/plugins/observability_solution/uptime/public/legacy_uptime/pages/synthetics/checks_navigation.tsx @@ -16,6 +16,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { useHistory } from 'react-router-dom'; import moment from 'moment'; +import { i18n } from '@kbn/i18n'; import { SyntheticsJourneyApiResponse } from '../../../../common/runtime_types/ping'; import { getShortTimeStamp } from '../../components/overview/monitor_list/columns/monitor_status_column'; @@ -27,6 +28,7 @@ interface Props { export const ChecksNavigation = ({ timestamp, details }: Props) => { const history = useHistory(); const isMobile = useIsWithinMaxBreakpoint('s'); + const shortTimestamp = getShortTimeStamp(moment(timestamp)); return ( <EuiFlexGroup alignItems="center" responsive={false} gutterSize="none"> @@ -47,8 +49,17 @@ export const ChecksNavigation = ({ timestamp, details }: Props) => { </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiText size={isMobile ? 'xs' : 'm'} className="eui-textNoWrap"> - {getShortTimeStamp(moment(timestamp))} + <EuiText + size={isMobile ? 'xs' : 'm'} + className="eui-textNoWrap" + aria-label={i18n.translate('xpack.uptime.synthetics.stepList.currentCheckAriaLabel', { + defaultMessage: 'Current check: {shortTimestamp}', + values: { + shortTimestamp, + }, + })} + > + {shortTimestamp} </EuiText> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts index 0c423dc9cfe8..6b847fb39696 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts @@ -103,8 +103,7 @@ describe('ALL - Add Integration', { tags: ['@ess', '@serverless'] }, () => { cy.contains(`version: ${oldVersion}`).should('not.exist'); }); }); - // FLAKY: https://github.com/elastic/kibana/issues/170593 - describe.skip('Add integration to policy', () => { + describe('Add integration to policy', () => { const [integrationName, policyName] = generateRandomStringName(2); let policyId: string; beforeEach(() => { diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_cases.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_cases.cy.ts index 6d41d0a50e25..d9cbe5c69ed6 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_cases.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_cases.cy.ts @@ -23,14 +23,9 @@ import { submitQuery, viewRecentCaseAndCheckResults, } from '../../tasks/live_query'; -import { - closeAlertsStepTourIfVisible, - generateRandomStringName, - interceptCaseId, -} from '../../tasks/integrations'; +import { generateRandomStringName, interceptCaseId } from '../../tasks/integrations'; -// Failing: See https://github.com/elastic/kibana/issues/187182 -describe.skip('Alert Event Details - Cases', { tags: ['@ess', '@serverless'] }, () => { +describe('Alert Event Details - Cases', { tags: ['@ess', '@serverless'] }, () => { let ruleId: string; let packId: string; let packName: string; @@ -71,7 +66,6 @@ describe.skip('Alert Event Details - Cases', { tags: ['@ess', '@serverless'] }, it('runs osquery against alert and creates a new case', () => { const [caseName, caseDescription] = generateRandomStringName(2); cy.getBySel('expand-event').first().click(); - closeAlertsStepTourIfVisible(); cy.getBySel('take-action-dropdown-btn').click(); cy.getBySel('osquery-action-item').click(); cy.contains(/^\d+ agen(t|ts) selected/); @@ -107,7 +101,6 @@ describe.skip('Alert Event Details - Cases', { tags: ['@ess', '@serverless'] }, it('sees osquery results from last action and add to a case', () => { cy.getBySel('expand-event').first().click(); - closeAlertsStepTourIfVisible(); cy.getBySel('securitySolutionFlyoutResponseSectionHeader').click(); cy.getBySel('securitySolutionFlyoutResponseButton').click(); cy.getBySel('responseActionsViewWrapper').should('exist'); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts index 45921be4cbc2..f1284bf8b528 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts @@ -15,11 +15,7 @@ import { selectAllAgents, submitQuery, } from '../../tasks/live_query'; -import { - closeAlertsStepTourIfVisible, - closeModalIfVisible, - closeToastIfVisible, -} from '../../tasks/integrations'; +import { closeModalIfVisible, closeToastIfVisible } from '../../tasks/integrations'; import { RESULTS_TABLE, RESULTS_TABLE_BUTTON } from '../../screens/live_query'; describe( @@ -73,7 +69,6 @@ describe( it('should be able to run live query and add to timeline', () => { const TIMELINE_NAME = 'Untitled timeline'; cy.getBySel('expand-event').first().click(); - closeAlertsStepTourIfVisible(); cy.getBySel('take-action-dropdown-btn').click(); cy.getBySel('osquery-action-item').click(); cy.contains('1 agent selected.'); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_multiple_agents.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_multiple_agents.cy.ts index f8241c52bda2..ca10dc80fe6b 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_multiple_agents.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_multiple_agents.cy.ts @@ -14,7 +14,6 @@ import { takeOsqueryActionWithParams, } from '../../tasks/live_query'; import { OSQUERY_FLYOUT_BODY_EDITOR } from '../../screens/live_query'; -import { closeAlertsStepTourIfVisible } from '../../tasks/integrations'; describe( 'Alert Event Details - dynamic params', @@ -43,7 +42,6 @@ describe( it('should substitute parameters in investigation guide', () => { cy.getBySel('expand-event').first().click(); - closeAlertsStepTourIfVisible(); cy.getBySel('securitySolutionFlyoutInvestigationGuideButton').click(); // Flakes at times if the button is only clicked once cy.contains('Get processes').should('be.visible').dblclick({ force: true }); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/packs_integration.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/packs_integration.cy.ts index 07ed4b815361..ccbd119aab3a 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/packs_integration.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/packs_integration.cy.ts @@ -40,8 +40,7 @@ import { cleanupPack, cleanupAgentPolicy } from '../../tasks/api_fixtures'; import { request } from '../../tasks/common'; import { ServerlessRoleName } from '../../support/roles'; -// Failing: See https://github.com/elastic/kibana/issues/176543 -describe.skip('ALL - Packs', { tags: ['@ess', '@serverless'] }, () => { +describe('ALL - Packs', { tags: ['@ess', '@serverless'] }, () => { const integration = 'Osquery Manager'; describe( @@ -162,6 +161,7 @@ describe.skip('ALL - Packs', { tags: ['@ess', '@serverless'] }, () => { it('should be able to run live prebuilt pack', () => { navigateTo('/app/osquery/live_queries'); cy.contains('New live query').click(); + cy.getBySel('globalLoadingIndicator').should('not.exist'); cy.contains('Run a set of queries in a pack.').click(); cy.getBySel(LIVE_QUERY_EDITOR).should('not.exist'); cy.getBySel('globalLoadingIndicator').should('not.exist'); @@ -189,8 +189,7 @@ describe.skip('ALL - Packs', { tags: ['@ess', '@serverless'] }, () => { navigateTo('/app/osquery/packs'); }); - // FLAKY: https://github.com/elastic/kibana/issues/171279 - describe.skip('add proper shard to policies packs config', () => { + describe('add proper shard to policies packs config', () => { const globalPack = 'globalPack' + generateRandomStringName(1)[0]; const agentPolicy = 'testGlobal' + generateRandomStringName(1)[0]; let globalPackId: string; diff --git a/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts index 27fe443eed06..f85b97950078 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { LIVE_QUERY_EDITOR } from '../../screens/live_query'; +import { closeToastIfVisible, generateRandomStringName } from '../../tasks/integrations'; +import { + LIVE_QUERY_EDITOR, + RESULTS_TABLE_BUTTON, + RESULTS_TABLE_COLUMNS_BUTTON, +} from '../../screens/live_query'; import { ADD_QUERY_BUTTON, customActionEditSavedQuerySelector, @@ -16,15 +21,17 @@ import { import { preparePack } from '../../tasks/packs'; import { addToCase, + BIG_QUERY, checkResults, deleteAndConfirm, + fillInQueryTimeout, inputQuery, selectAllAgents, submitQuery, + verifyQueryTimeout, viewRecentCaseAndCheckResults, } from '../../tasks/live_query'; import { navigateTo } from '../../tasks/navigation'; -import { getSavedQueriesComplexTest } from '../../tasks/saved_queries'; import { loadCase, cleanupCase, @@ -34,6 +41,7 @@ import { cleanupSavedQuery, } from '../../tasks/api_fixtures'; import { ServerlessRoleName } from '../../support/roles'; +import { getAdvancedButton } from '../../screens/integrations'; describe('ALL - Saved queries', { tags: ['@ess', '@serverless'] }, () => { let caseId: string; @@ -53,12 +61,131 @@ describe('ALL - Saved queries', { tags: ['@ess', '@serverless'] }, () => { cleanupCase(caseId); }); - getSavedQueriesComplexTest(); + it( + 'should create a new query and verify: \n ' + + '- hidden columns, full screen and sorting \n' + + '- pagination \n' + + '- query can viewed (status), edited and deleted ', + () => { + const timeout = '601'; + const suffix = generateRandomStringName(1)[0]; + const savedQueryId = `Saved-Query-Id-${suffix}`; + const savedQueryDescription = `Test saved query description ${suffix}`; + cy.contains('New live query').click(); + selectAllAgents(); + inputQuery(BIG_QUERY); + getAdvancedButton().click(); + fillInQueryTimeout(timeout); + submitQuery(); + checkResults(); + // enter fullscreen + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter fullscreen$/).should('exist'); + cy.contains('Exit fullscreen').should('not.exist'); + cy.getBySel(RESULTS_TABLE_BUTTON).click(); + + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter Fullscreen$/).should('not.exist'); + cy.contains('Exit fullscreen').should('exist'); + + // hidden columns + cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns35'); + cy.getBySel('dataGridColumnSelectorButton').click(); + cy.get('[data-popover-open="true"]').should('be.visible'); + cy.getBySel('dataGridColumnSelectorToggleColumnVisibility-osquery.cmdline').click(); + cy.getBySel('dataGridColumnSelectorToggleColumnVisibility-osquery.cwd').click(); + cy.getBySel( + 'dataGridColumnSelectorToggleColumnVisibility-osquery.disk_bytes_written.number' + ).click(); + cy.getBySel('dataGridColumnSelectorButton').click(); + cy.get('[data-popover-open="true"]').should('not.exist'); + cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); + + // change pagination + cy.getBySel('pagination-button-next').click(); + cy.getBySel('globalLoadingIndicator').should('not.exist'); + cy.getBySel('pagination-button-next').click(); + cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); + // enter fullscreen + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter fullscreen$/).should('not.exist'); + cy.contains('Exit fullscreen').should('exist'); + cy.getBySel(RESULTS_TABLE_BUTTON).click(); + + // sorting + cy.getBySel('dataGridHeaderCellActionButton-osquery.egid').click({ force: true }); + cy.contains(/Sort A-Z$/).click(); + cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter fullscreen$/).should('exist'); + + // visit Status results + cy.getBySel('osquery-status-tab').click(); + cy.get('tbody > tr.euiTableRow').should('have.lengthOf', 2); + + // save new query + cy.contains('Exit full screen').should('not.exist'); + cy.contains('Save for later').click(); + cy.contains('Save query'); + cy.get('input[name="id"]').type(`${savedQueryId}{downArrow}{enter}`); + cy.get('input[name="description"]').type(`${savedQueryDescription}{downArrow}{enter}`); + cy.getBySel('savedQueryFlyoutSaveButton').click(); + cy.contains('Successfully saved'); + closeToastIfVisible(); + + // play saved query + navigateTo('/app/osquery/saved_queries'); + cy.contains(savedQueryId); + cy.get(`[aria-label="Run ${savedQueryId}"]`).click(); + selectAllAgents(); + verifyQueryTimeout(timeout); + submitQuery(); + + // edit saved query + cy.contains('Saved queries').click(); + cy.contains(savedQueryId); + + cy.get(`[aria-label="Edit ${savedQueryId}"]`).click(); + cy.get('input[name="description"]').type(` Edited{downArrow}{enter}`); + + // Run in test configuration + cy.contains('Test configuration').click(); + selectAllAgents(); + verifyQueryTimeout(timeout); + submitQuery(); + checkResults(); + + // Disabled submit button in test configuration + cy.contains('Submit').should('not.be.disabled'); + cy.getBySel('osquery-save-query-flyout').within(() => { + cy.contains('Query is a required field').should('not.exist'); + // this clears the input + inputQuery('{selectall}{backspace}{selectall}{backspace}'); + cy.contains('Query is a required field'); + inputQuery(BIG_QUERY); + cy.contains('Query is a required field').should('not.exist'); + }); + + // Save edited + cy.getBySel('euiFlyoutCloseButton').click(); + cy.getBySel('update-query-button').click(); + cy.contains(`${savedQueryDescription} Edited`); + + // delete saved query + cy.contains(savedQueryId); + cy.get(`[aria-label="Edit ${savedQueryId}"]`).click(); + + deleteAndConfirm('query'); + cy.contains(savedQueryId).should('exist'); + cy.contains(savedQueryId).should('not.exist'); + } + ); + + // Failing: See https://github.com/elastic/kibana/issues/187388 it.skip('checks that user cant add a saved query with an ID that already exists', () => { cy.contains('Saved queries').click(); cy.contains('Add saved query').click(); - cy.get('input[name="id"]').type(`users_elastic{downArrow}{enter}`); cy.contains('ID must be unique').should('not.exist'); @@ -76,8 +203,7 @@ describe('ALL - Saved queries', { tags: ['@ess', '@serverless'] }, () => { }); }); - // FAILING ES SERVERLESS PROMOTION: https://github.com/elastic/kibana/issues/169787 - describe.skip('prebuilt', () => { + describe('prebuilt', () => { let packName: string; let packId: string; let savedQueryId: string; diff --git a/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts index 1c6ff3b2fd66..f77452a8e1e5 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts @@ -9,7 +9,7 @@ import { initializeDataViews } from '../../tasks/login'; import { takeOsqueryActionWithParams } from '../../tasks/live_query'; import { ServerlessRoleName } from '../../support/roles'; -describe.skip('ALL - Timelines', { tags: ['@ess'] }, () => { +describe('ALL - Timelines', { tags: ['@ess'] }, () => { before(() => { initializeDataViews(); }); @@ -22,7 +22,9 @@ describe.skip('ALL - Timelines', { tags: ['@ess'] }, () => { cy.getBySel('timeline-bottom-bar').within(() => { cy.getBySel('timeline-bottom-bar-title-button').click(); }); - cy.getBySel('timelineQueryInput').type('NOT host.name: "dev-fleet-server.8220"{enter}'); + cy.getBySel('timelineQueryInput').type( + 'NOT host.name: "dev-fleet-server*" and component.type: "osquery"{enter}' + ); // Filter out alerts cy.getBySel('timeline-sourcerer-trigger').click(); cy.getBySel('sourcerer-advanced-options-toggle').click(); diff --git a/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts b/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts index 5ca5775247ca..718c2f32fd58 100644 --- a/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts @@ -9,7 +9,6 @@ import { initializeDataViews } from '../../tasks/login'; import { checkResults, clickRuleName, submitQuery } from '../../tasks/live_query'; import { loadRule, cleanupRule } from '../../tasks/api_fixtures'; import { ServerlessRoleName } from '../../support/roles'; -import { closeAlertsStepTourIfVisible } from '../../tasks/integrations'; describe('Alert Test', { tags: ['@ess'] }, () => { let ruleName: string; @@ -30,7 +29,6 @@ describe('Alert Test', { tags: ['@ess'] }, () => { cy.visit('/app/security/rules'); clickRuleName(ruleName); cy.getBySel('expand-event').first().click({ force: true }); - closeAlertsStepTourIfVisible(); cy.wait(500); cy.getBySel('securitySolutionFlyoutInvestigationGuideButton').click(); diff --git a/x-pack/plugins/osquery/cypress/tasks/integrations.ts b/x-pack/plugins/osquery/cypress/tasks/integrations.ts index e87b9228ad7e..e4c38854a5fa 100644 --- a/x-pack/plugins/osquery/cypress/tasks/integrations.ts +++ b/x-pack/plugins/osquery/cypress/tasks/integrations.ts @@ -16,7 +16,6 @@ import { DATA_COLLECTION_SETUP_STEP, DATE_PICKER_ABSOLUTE_TAB, DATE_PICKER_ABSOLUTE_TAB_SEL, - SECURITY_SOLUTION_FLYOUT_TOUR_SEL, TOAST_CLOSE_BTN, TOAST_CLOSE_BTN_SEL, } from '../screens/integrations'; @@ -136,16 +135,6 @@ export function closeToastIfVisible() { }); } -export function closeAlertsStepTourIfVisible() { - cy.get(SECURITY_SOLUTION_FLYOUT_TOUR_SEL).then(($el) => { - if ($el.length > 0) { - cy.wrap($el).within(() => { - cy.contains('Exit').click(); - }); - } - }); -} - export const deleteIntegrations = async (integrationName: string) => { const ids: string[] = []; cy.contains(integrationName) diff --git a/x-pack/plugins/osquery/cypress/tasks/live_query.ts b/x-pack/plugins/osquery/cypress/tasks/live_query.ts index cf6bf6020f90..4c05e2bbc98f 100644 --- a/x-pack/plugins/osquery/cypress/tasks/live_query.ts +++ b/x-pack/plugins/osquery/cypress/tasks/live_query.ts @@ -14,6 +14,7 @@ export const DEFAULT_QUERY = 'select * from processes;'; export const BIG_QUERY = 'select * from processes, users limit 110;'; export const selectAllAgents = () => { + cy.getBySel('globalLoadingIndicator').should('not.exist'); cy.getBySel('agentSelection').find('input').should('not.be.disabled'); cy.getBySel('agentSelection').within(() => { cy.getBySel('comboBoxInput').click(); diff --git a/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts deleted file mode 100644 index bc006fae23dd..000000000000 --- a/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts +++ /dev/null @@ -1,145 +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 { getAdvancedButton } from '../screens/integrations'; -import { RESULTS_TABLE_BUTTON, RESULTS_TABLE_COLUMNS_BUTTON } from '../screens/live_query'; -import { closeToastIfVisible, generateRandomStringName } from './integrations'; -import { - checkResults, - BIG_QUERY, - deleteAndConfirm, - inputQuery, - selectAllAgents, - submitQuery, - fillInQueryTimeout, - verifyQueryTimeout, -} from './live_query'; -import { navigateTo } from './navigation'; - -export const getSavedQueriesComplexTest = () => - // FLAKY: https://github.com/elastic/kibana/issues/169786 - describe.skip('Saved queries Complex Test', () => { - const timeout = '601'; - const suffix = generateRandomStringName(1)[0]; - const savedQueryId = `Saved-Query-Id-${suffix}`; - const savedQueryDescription = `Test saved query description ${suffix}`; - - it( - 'should create a new query and verify: \n ' + - '- hidden columns, full screen and sorting \n' + - '- pagination \n' + - '- query can viewed (status), edited and deleted ', - () => { - cy.contains('New live query').click(); - selectAllAgents(); - inputQuery(BIG_QUERY); - getAdvancedButton().click(); - fillInQueryTimeout(timeout); - submitQuery(); - checkResults(); - // enter fullscreen - cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); - cy.contains(/Enter fullscreen$/).should('exist'); - cy.contains('Exit fullscreen').should('not.exist'); - cy.getBySel(RESULTS_TABLE_BUTTON).click(); - - cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); - cy.contains(/Enter Fullscreen$/).should('not.exist'); - cy.contains('Exit fullscreen').should('exist'); - - // hidden columns - cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns35'); - cy.getBySel('dataGridColumnSelectorButton').click(); - cy.get('[data-popover-open="true"]').should('be.visible'); - cy.getBySel('dataGridColumnSelectorToggleColumnVisibility-osquery.cmdline').click(); - cy.getBySel('dataGridColumnSelectorToggleColumnVisibility-osquery.cwd').click(); - cy.getBySel( - 'dataGridColumnSelectorToggleColumnVisibility-osquery.disk_bytes_written.number' - ).click(); - cy.getBySel('dataGridColumnSelectorButton').click(); - cy.get('[data-popover-open="true"]').should('not.exist'); - cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); - - // change pagination - cy.getBySel('pagination-button-next').click().wait(500).click(); - cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); - - // enter fullscreen - cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); - cy.contains(/Enter fullscreen$/).should('not.exist'); - cy.contains('Exit fullscreen').should('exist'); - cy.getBySel(RESULTS_TABLE_BUTTON).click(); - - // sorting - cy.getBySel('dataGridHeaderCellActionButton-osquery.egid').click({ force: true }); - cy.contains(/Sort A-Z$/).click(); - cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); - cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); - cy.contains(/Enter fullscreen$/).should('exist'); - - // visit Status results - cy.getBySel('osquery-status-tab').click(); - cy.get('tbody > tr.euiTableRow').should('have.lengthOf', 2); - - // save new query - cy.contains('Exit full screen').should('not.exist'); - cy.contains('Save for later').click(); - cy.contains('Save query'); - cy.get('input[name="id"]').type(`${savedQueryId}{downArrow}{enter}`); - cy.get('input[name="description"]').type(`${savedQueryDescription}{downArrow}{enter}`); - cy.getBySel('savedQueryFlyoutSaveButton').click(); - cy.contains('Successfully saved'); - closeToastIfVisible(); - - // play saved query - navigateTo('/app/osquery/saved_queries'); - cy.contains(savedQueryId); - cy.get(`[aria-label="Run ${savedQueryId}"]`).click(); - selectAllAgents(); - verifyQueryTimeout(timeout); - submitQuery(); - - // edit saved query - cy.contains('Saved queries').click(); - cy.contains(savedQueryId); - - cy.get(`[aria-label="Edit ${savedQueryId}"]`).click(); - cy.get('input[name="description"]').type(` Edited{downArrow}{enter}`); - - // Run in test configuration - cy.contains('Test configuration').click(); - selectAllAgents(); - verifyQueryTimeout(timeout); - submitQuery(); - checkResults(); - - // Disabled submit button in test configuration - cy.contains('Submit').should('not.be.disabled'); - cy.getBySel('osquery-save-query-flyout').within(() => { - cy.contains('Query is a required field').should('not.exist'); - // this clears the input - inputQuery('{selectall}{backspace}{selectall}{backspace}'); - cy.contains('Query is a required field'); - inputQuery(BIG_QUERY); - cy.contains('Query is a required field').should('not.exist'); - }); - - // Save edited - cy.getBySel('euiFlyoutCloseButton').click(); - cy.getBySel('update-query-button').click(); - cy.contains(`${savedQueryDescription} Edited`); - - // delete saved query - cy.contains(savedQueryId); - cy.get(`[aria-label="Edit ${savedQueryId}"]`).click(); - - deleteAndConfirm('query'); - cy.contains(savedQueryId).should('exist'); - cy.contains(savedQueryId).should('not.exist'); - } - ); - }); diff --git a/x-pack/plugins/search_notebooks/common/types.ts b/x-pack/plugins/search_notebooks/common/types.ts index 58922ed4abc6..8b46a7e4361c 100644 --- a/x-pack/plugins/search_notebooks/common/types.ts +++ b/x-pack/plugins/search_notebooks/common/types.ts @@ -17,8 +17,12 @@ export interface NotebookInformation { url: string; }; } + +export type NotebookCatalogResponse = Pick<NotebookCatalog, 'notebooks'>; + export interface NotebookCatalog { notebooks: NotebookInformation[]; + lists?: Record<string, string[] | undefined>; } export interface Notebook extends NotebookInformation { @@ -47,6 +51,9 @@ export const NotebookCatalogSchema = schema.object( ), { minSize: 1 } ), + lists: schema.maybe( + schema.recordOf(schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })) + ), }, { unknowns: 'allow', diff --git a/x-pack/plugins/search_notebooks/public/components/notebooks_button.tsx b/x-pack/plugins/search_notebooks/public/components/notebooks_button.tsx index 2e01f27beed4..66fc50a03ddd 100644 --- a/x-pack/plugins/search_notebooks/public/components/notebooks_button.tsx +++ b/x-pack/plugins/search_notebooks/public/components/notebooks_button.tsx @@ -10,7 +10,23 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { EmbeddedConsoleViewButtonProps } from '@kbn/console-plugin/public'; -export const SearchNotebooksButton = ({ activeView, onClick }: EmbeddedConsoleViewButtonProps) => { +export interface SearchNotebooksButtonProps extends EmbeddedConsoleViewButtonProps { + clearNotebookList: () => void; +} + +export const SearchNotebooksButton = ({ + activeView, + onClick, + clearNotebookList, +}: SearchNotebooksButtonProps) => { + React.useEffect(() => { + return () => { + // When the Notebooks button is unmounted we want to clear + // any page specific contextual notebook list that was set. + clearNotebookList(); + }; + }, [clearNotebookList]); + if (activeView) { return ( <EuiButton diff --git a/x-pack/plugins/search_notebooks/public/components/notebooks_view.tsx b/x-pack/plugins/search_notebooks/public/components/notebooks_view.tsx index bdf272a32012..a20c3b9ddaf2 100644 --- a/x-pack/plugins/search_notebooks/public/components/notebooks_view.tsx +++ b/x-pack/plugins/search_notebooks/public/components/notebooks_view.tsx @@ -11,15 +11,21 @@ import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SearchNotebooks } from './search_notebooks'; +import { NotebookListValue } from '../types'; export interface SearchNotebooksViewProps { core: CoreStart; queryClient: QueryClient; + getNotebookList: () => NotebookListValue; } -export const SearchNotebooksView = ({ core, queryClient }: SearchNotebooksViewProps) => ( +export const SearchNotebooksView = ({ + core, + queryClient, + getNotebookList, +}: SearchNotebooksViewProps) => ( <KibanaThemeProvider theme={core.theme}> - <KibanaContextProvider services={{ ...core }}> + <KibanaContextProvider services={{ ...core, notebooks: { getNotebookList } }}> <QueryClientProvider client={queryClient}> <SearchNotebooks /> </QueryClientProvider> diff --git a/x-pack/plugins/search_notebooks/public/console_view.tsx b/x-pack/plugins/search_notebooks/public/console_view.tsx index 4d2dba74cf68..99575c8e9394 100644 --- a/x-pack/plugins/search_notebooks/public/console_view.tsx +++ b/x-pack/plugins/search_notebooks/public/console_view.tsx @@ -7,22 +7,38 @@ import React from 'react'; import { CoreStart } from '@kbn/core/public'; -import { EmbeddedConsoleView } from '@kbn/console-plugin/public'; +import type { + EmbeddedConsoleView, + EmbeddedConsoleViewButtonProps, +} from '@kbn/console-plugin/public'; import { dynamic } from '@kbn/shared-ux-utility'; import { QueryClient } from '@tanstack/react-query'; -import { SearchNotebooksButton } from './components/notebooks_button'; +import { NotebookListValue } from './types'; +const SearchNotebooksButton = dynamic(async () => ({ + default: (await import('./components/notebooks_button')).SearchNotebooksButton, +})); const SearchNotebooksView = dynamic(async () => ({ default: (await import('./components/notebooks_view')).SearchNotebooksView, })); export const notebooksConsoleView = ( core: CoreStart, - queryClient: QueryClient + queryClient: QueryClient, + clearNotebookList: () => void, + getNotebookListValue: () => NotebookListValue ): EmbeddedConsoleView => { return { - ActivationButton: SearchNotebooksButton, - ViewContent: () => <SearchNotebooksView core={core} queryClient={queryClient} />, + ActivationButton: (props: EmbeddedConsoleViewButtonProps) => ( + <SearchNotebooksButton {...props} clearNotebookList={clearNotebookList} /> + ), + ViewContent: () => ( + <SearchNotebooksView + core={core} + queryClient={queryClient} + getNotebookList={getNotebookListValue} + /> + ), }; }; diff --git a/x-pack/plugins/search_notebooks/public/hooks/use_kibana.ts b/x-pack/plugins/search_notebooks/public/hooks/use_kibana.ts index c584dcb0013c..b490e61f4149 100644 --- a/x-pack/plugins/search_notebooks/public/hooks/use_kibana.ts +++ b/x-pack/plugins/search_notebooks/public/hooks/use_kibana.ts @@ -9,8 +9,13 @@ import type { ConsolePluginStart } from '@kbn/console-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import { useKibana as useKibanaBase } from '@kbn/kibana-react-plugin/public'; +import { NotebookListValue } from '../types'; + export interface SearchNotebooksContext { console: ConsolePluginStart; + notebooks: { + getNotebookList: () => NotebookListValue; + }; } type ServerlessSearchKibanaContext = CoreStart & SearchNotebooksContext; diff --git a/x-pack/plugins/search_notebooks/public/hooks/use_notebook_catalog.ts b/x-pack/plugins/search_notebooks/public/hooks/use_notebook_catalog.ts index fbcd298f8fad..0724181844f6 100644 --- a/x-pack/plugins/search_notebooks/public/hooks/use_notebook_catalog.ts +++ b/x-pack/plugins/search_notebooks/public/hooks/use_notebook_catalog.ts @@ -6,13 +6,20 @@ */ import { useQuery } from '@tanstack/react-query'; -import { NotebookCatalog } from '../../common/types'; +import { NotebookCatalogResponse } from '../../common/types'; import { useKibanaServices } from './use_kibana'; +import { useNotebookList } from './use_notebooks_list'; export const useNotebooksCatalog = () => { const { http } = useKibanaServices(); + const list = useNotebookList(); return useQuery({ - queryKey: ['fetchNotebooksCatalog'], - queryFn: () => http.get<NotebookCatalog>('/internal/search_notebooks/notebooks'), + queryKey: [`fetchNotebooksCatalog-${list ?? 'default'}`], + queryFn: () => + http.get<NotebookCatalogResponse>('/internal/search_notebooks/notebooks', { + query: { + list, + }, + }), }); }; diff --git a/x-pack/plugins/search_notebooks/public/hooks/use_notebooks_list.ts b/x-pack/plugins/search_notebooks/public/hooks/use_notebooks_list.ts new file mode 100644 index 000000000000..df903f2a00a5 --- /dev/null +++ b/x-pack/plugins/search_notebooks/public/hooks/use_notebooks_list.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { parse } from 'query-string'; +import { useKibanaServices } from './use_kibana'; + +export const readNotebookListFromParam = () => { + const [, queryString] = (window.location.search || window.location.hash || '').split('?'); + + const queryParams = parse(queryString || '', { sort: false }); + if (queryParams && queryParams.nblist && typeof queryParams.nblist === 'string') { + return queryParams.nblist; + } + return undefined; +}; + +export const useNotebookList = () => { + const { + notebooks: { getNotebookList }, + } = useKibanaServices(); + const nbList = useMemo(() => getNotebookList(), [getNotebookList]); + const nbListQueryParam = useMemo(() => readNotebookListFromParam(), []); + + if (nbListQueryParam) return nbListQueryParam; + if (nbList) return nbList; + return undefined; +}; diff --git a/x-pack/plugins/search_notebooks/public/plugin.ts b/x-pack/plugins/search_notebooks/public/plugin.ts index 8874cd936c2c..3b71193ed3ad 100644 --- a/x-pack/plugins/search_notebooks/public/plugin.ts +++ b/x-pack/plugins/search_notebooks/public/plugin.ts @@ -13,13 +13,16 @@ import { SearchNotebooksPluginSetup, SearchNotebooksPluginStart, SearchNotebooksPluginStartDependencies, + NotebookListValue, } from './types'; import { getErrorCode, getErrorMessage, isKibanaServerError } from './utils/get_error_message'; export class SearchNotebooksPlugin implements Plugin<SearchNotebooksPluginSetup, SearchNotebooksPluginStart> { + private notebooksList: NotebookListValue = null; private queryClient: QueryClient | undefined; + public setup(core: CoreSetup): SearchNotebooksPluginSetup { this.queryClient = new QueryClient({ mutationCache: new MutationCache({ @@ -55,10 +58,31 @@ export class SearchNotebooksPlugin ): SearchNotebooksPluginStart { if (deps.console?.registerEmbeddedConsoleAlternateView) { deps.console.registerEmbeddedConsoleAlternateView( - notebooksConsoleView(core, this.queryClient!) + notebooksConsoleView( + core, + this.queryClient!, + this.clearNotebookList.bind(this), + this.getNotebookList.bind(this) + ) ); } - return {}; + return { + setNotebookList: (value: NotebookListValue) => { + this.setNotebookList(value); + }, + }; } public stop() {} + + private clearNotebookList() { + this.setNotebookList(null); + } + + private setNotebookList(value: NotebookListValue) { + this.notebooksList = value; + } + + private getNotebookList(): NotebookListValue { + return this.notebooksList; + } } diff --git a/x-pack/plugins/search_notebooks/public/types.ts b/x-pack/plugins/search_notebooks/public/types.ts index 74dbc5250d44..150d08a5d463 100644 --- a/x-pack/plugins/search_notebooks/public/types.ts +++ b/x-pack/plugins/search_notebooks/public/types.ts @@ -9,9 +9,13 @@ import type { ConsolePluginStart } from '@kbn/console-plugin/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SearchNotebooksPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SearchNotebooksPluginStart {} + +export interface SearchNotebooksPluginStart { + setNotebookList: (value: NotebookListValue) => void; +} export interface SearchNotebooksPluginStartDependencies { console: ConsolePluginStart; } + +export type NotebookListValue = string | null; diff --git a/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.test.ts b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.test.ts index 7b0e345ece02..5f5bffe3b25f 100644 --- a/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.test.ts +++ b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.test.ts @@ -23,6 +23,7 @@ import { getNotebook, getNotebookCatalog, DEFAULT_NOTEBOOKS, + NOTEBOOKS_MAP, NotebookCatalogFetchOptions, getNotebookMetadata, } from './notebook_catalog'; @@ -65,7 +66,32 @@ describe('Notebook Catalog', () => { describe('getNotebookCatalog', () => { describe('static notebooks', () => { it('returns default notebooks when theres an empty catalog config', async () => { - await expect(getNotebookCatalog(staticOptions)).resolves.toBe(DEFAULT_NOTEBOOKS); + await expect(getNotebookCatalog(staticOptions)).resolves.toMatchObject(DEFAULT_NOTEBOOKS); + }); + it.skip('returns requested list of notebooks when it exists', async () => { + // Re-enable this with actual list when we implement them + await expect( + getNotebookCatalog({ ...staticOptions, notebookList: 'ml' }) + ).resolves.toMatchObject({ + notebooks: [ + NOTEBOOKS_MAP['03_elser'], + NOTEBOOKS_MAP['02_hybrid_search'], + NOTEBOOKS_MAP['04_multilingual'], + ], + }); + }); + it('returns default list if requested list doesnt exist', async () => { + await expect( + getNotebookCatalog({ ...staticOptions, notebookList: 'foo' }) + ).resolves.toMatchObject({ + notebooks: [ + NOTEBOOKS_MAP['00_quick_start'], + NOTEBOOKS_MAP['01_keyword_querying_filtering'], + NOTEBOOKS_MAP['02_hybrid_search'], + NOTEBOOKS_MAP['03_elser'], + NOTEBOOKS_MAP['04_multilingual'], + ], + }); }); }); @@ -310,6 +336,97 @@ describe('Notebook Catalog', () => { timestamp: fakeNow, }); }); + describe('supports notebook lists', () => { + beforeEach(() => { + const mockCatalog: RemoteNotebookCatalog = { + notebooks: [ + { + id: 'unit-test', + title: 'Test', + description: 'Test notebook', + url: 'http://localhost:3000/my_notebook.ipynb', + }, + { + id: 'unit-test-002', + title: 'Test', + description: 'Test notebook 2', + url: 'http://localhost:3000/my_other_notebook.ipynb', + }, + ], + lists: { + default: ['unit-test', 'unit-test-002'], + test: ['unit-test-002'], + vector: ['unit-test'], + }, + }; + const mockResp = { + status: 200, + statusText: 'OK', + ok: true, + json: jest.fn().mockResolvedValue(mockCatalog), + }; + fetchMock.mockResolvedValue(mockResp); + }); + + it('can return a custom notebook list', async () => { + await expect( + getNotebookCatalog({ ...dynamicOptions, notebookList: 'test' }) + ).resolves.toEqual({ + notebooks: [ + { + id: 'unit-test-002', + title: 'Test', + description: 'Test notebook 2', + }, + ], + }); + await expect( + getNotebookCatalog({ ...dynamicOptions, notebookList: 'vector' }) + ).resolves.toEqual({ + notebooks: [ + { + id: 'unit-test', + title: 'Test', + description: 'Test notebook', + }, + ], + }); + }); + it('returns default list when requested list not defined', async () => { + await expect( + getNotebookCatalog({ ...dynamicOptions, notebookList: 'foo' }) + ).resolves.toEqual({ + notebooks: [ + { + id: 'unit-test', + title: 'Test', + description: 'Test notebook', + }, + { + id: 'unit-test-002', + title: 'Test', + description: 'Test notebook 2', + }, + ], + }); + }); + it('returns default list when list is not specified', async () => { + await expect(getNotebookCatalog(dynamicOptions)).resolves.toEqual({ + notebooks: [ + { + id: 'unit-test', + title: 'Test', + description: 'Test notebook', + }, + { + id: 'unit-test-002', + title: 'Test', + description: 'Test notebook 2', + }, + ], + }); + }); + }); }); }); diff --git a/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.ts b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.ts index 6ee2a628f04d..54d2a1acd055 100644 --- a/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.ts +++ b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.ts @@ -14,8 +14,8 @@ import { NotebookDefinition } from '@kbn/ipynb'; import { NotebookCatalog, + NotebookCatalogResponse, NotebookInformation, - NotebookCatalogSchema, NotebookSchema, } from '../../common/types'; @@ -23,9 +23,10 @@ import type { SearchNotebooksConfig } from '../config'; import type { NotebooksCache, RemoteNotebookCatalog } from '../types'; import { cleanCachedNotebook, - cleanCachedNotebookCatalog, cleanNotebookMetadata, dateWithinTTL, + notebookCatalogResponse, + validateRemoteNotebookCatalog, } from '../utils'; const NOTEBOOKS_DATA_DIR = '../data'; @@ -38,8 +39,10 @@ export interface NotebookCatalogFetchOptions { cache: NotebooksCache; config: SearchNotebooksConfig; logger: Logger; + notebookList?: string; } +// Notebook catalog v1, leaving to ensure backward-compatibility export const DEFAULT_NOTEBOOKS: NotebookCatalog = { notebooks: [ { @@ -102,26 +105,40 @@ export const DEFAULT_NOTEBOOKS: NotebookCatalog = { }, ], }; +// Notebook catalog v1.1 with lists for contextual notebooks +export const DEFAULT_NOTEBOOK_CATALOG: NotebookCatalog = { + notebooks: [...DEFAULT_NOTEBOOKS.notebooks], + lists: { + default: [ + '00_quick_start', + '01_keyword_querying_filtering', + '02_hybrid_search', + '03_elser', + '04_multilingual', + ], + }, +}; export const NOTEBOOKS_MAP: Record<string, NotebookInformation> = - DEFAULT_NOTEBOOKS.notebooks.reduce((nbMap, nb) => { + DEFAULT_NOTEBOOK_CATALOG.notebooks.reduce((nbMap, nb) => { nbMap[nb.id] = nb; return nbMap; }, {} as Record<string, NotebookInformation>); -const NOTEBOOK_IDS = DEFAULT_NOTEBOOKS.notebooks.map(({ id }) => id); +const NOTEBOOK_IDS = DEFAULT_NOTEBOOK_CATALOG.notebooks.map(({ id }) => id); export const getNotebookCatalog = async ({ config, cache, logger, -}: NotebookCatalogFetchOptions) => { + notebookList, +}: NotebookCatalogFetchOptions): Promise<NotebookCatalogResponse> => { if (config.catalog && config.catalog.url) { - const catalog = await fetchNotebookCatalog(config.catalog, cache, logger); + const catalog = await fetchNotebookCatalog(config.catalog, cache, logger, notebookList); if (catalog) { return catalog; } } - return DEFAULT_NOTEBOOKS; + return notebookCatalogResponse(DEFAULT_NOTEBOOK_CATALOG, notebookList); }; export const getNotebook = async ( @@ -179,19 +196,20 @@ type CatalogConfig = Readonly<{ export const fetchNotebookCatalog = async ( catalogConfig: CatalogConfig, cache: NotebooksCache, - logger: Logger -): Promise<NotebookCatalog | null> => { + logger: Logger, + notebookList?: string +): Promise<NotebookCatalogResponse | null> => { if (cache.catalog && dateWithinTTL(cache.catalog.timestamp, catalogConfig.ttl)) { - return cleanCachedNotebookCatalog(cache.catalog); + return notebookCatalogResponse(cache.catalog, notebookList); } try { const resp = await fetch(catalogConfig.url, FETCH_OPTIONS); if (resp.ok) { const respJson = await resp.json(); - const catalog: RemoteNotebookCatalog = NotebookCatalogSchema.validate(respJson); + const catalog: RemoteNotebookCatalog = validateRemoteNotebookCatalog(respJson); cache.catalog = { ...catalog, timestamp: new Date() }; - return cleanCachedNotebookCatalog(cache.catalog); + return notebookCatalogResponse(cache.catalog, notebookList); } else { throw new Error(`Failed to fetch notebook ${resp.status} ${resp.statusText}`); } @@ -203,7 +221,7 @@ export const fetchNotebookCatalog = async ( if (cache.catalog && dateWithinTTL(cache.catalog.timestamp, catalogConfig.errorTTL)) { // If we can't fetch the catalog but we have it cached and it's within the error TTL, // returned the cached value. - return cleanCachedNotebookCatalog(cache.catalog); + return notebookCatalogResponse(cache.catalog, notebookList); } } diff --git a/x-pack/plugins/search_notebooks/server/routes/index.ts b/x-pack/plugins/search_notebooks/server/routes/index.ts index 0e5294b71e2d..b6d1f8a28ddf 100644 --- a/x-pack/plugins/search_notebooks/server/routes/index.ts +++ b/x-pack/plugins/search_notebooks/server/routes/index.ts @@ -16,16 +16,26 @@ export function defineRoutes({ config, notebooksCache, logger, router }: RouteDe router.get( { path: '/internal/search_notebooks/notebooks', - validate: {}, + validate: { + query: schema.object({ + list: schema.maybe(schema.string()), + }), + }, options: { access: 'internal', }, }, - async (_context, _request, response) => { - const notebooks = await getNotebookCatalog({ cache: notebooksCache, config, logger }); + async (_context, request, response) => { + const { list } = request.query; + const resp = await getNotebookCatalog({ + cache: notebooksCache, + config, + logger, + notebookList: list, + }); return response.ok({ - body: notebooks, + body: resp, headers: { 'content-type': 'application/json' }, }); } diff --git a/x-pack/plugins/search_notebooks/server/utils.ts b/x-pack/plugins/search_notebooks/server/utils.ts index 4028d6776dd0..c777e07c7b27 100644 --- a/x-pack/plugins/search_notebooks/server/utils.ts +++ b/x-pack/plugins/search_notebooks/server/utils.ts @@ -6,11 +6,17 @@ */ import { NotebookDefinition } from '@kbn/ipynb'; -import { NotebookCatalog, NotebookInformation } from '../common/types'; +import { + NotebookCatalog, + NotebookCatalogResponse, + NotebookCatalogSchema, + NotebookInformation, +} from '../common/types'; import type { CachedNotebook, CachedNotebookCatalog, NotebooksCache, + RemoteNotebookCatalog, RemoteNotebookInformation, } from './types'; @@ -24,10 +30,12 @@ export function dateWithinTTL(value: Date, ttl: number) { return (Date.now() - value.getTime()) / 1000 <= ttl; } -export function cleanCachedNotebookCatalog(catalog: CachedNotebookCatalog): NotebookCatalog { - const notebooks = catalog.notebooks.map(cleanNotebookMetadata); +export function cleanCachedNotebookCatalog( + notebooks: Array<RemoteNotebookInformation | NotebookInformation> +): NotebookCatalogResponse { + const cleanedNotebooks = notebooks.map(cleanNotebookMetadata); return { - notebooks, + notebooks: cleanedNotebooks, }; } export function cleanCachedNotebook(notebook: CachedNotebook): NotebookDefinition { @@ -35,7 +43,9 @@ export function cleanCachedNotebook(notebook: CachedNotebook): NotebookDefinitio return result; } -export function cleanNotebookMetadata(nb: RemoteNotebookInformation): NotebookInformation { +export function cleanNotebookMetadata( + nb: RemoteNotebookInformation | NotebookInformation +): NotebookInformation { const { id, title, description, link } = nb; return { description, @@ -44,3 +54,64 @@ export function cleanNotebookMetadata(nb: RemoteNotebookInformation): NotebookIn title, }; } + +export function isCachedNotebookCatalog( + catalog: CachedNotebookCatalog | NotebookCatalog +): catalog is CachedNotebookCatalog { + return 'timestamp' in catalog; +} + +const DEFAULT_NOTEBOOK_LIST_KEY = 'default'; +export function notebookCatalogResponse( + catalog: CachedNotebookCatalog | NotebookCatalog, + list: string = DEFAULT_NOTEBOOK_LIST_KEY +): NotebookCatalogResponse { + if (!catalog.lists) { + return isCachedNotebookCatalog(catalog) + ? cleanCachedNotebookCatalog(catalog.notebooks) + : catalog; + } + + const listOfNotebookIds = getListOfNotebookIds(catalog.lists, list); + const notebookIndexMap = (catalog.notebooks as NotebookInformation[]).reduce( + (indexMap, nb, i) => { + indexMap[nb.id] = i; + return indexMap; + }, + {} as Record<string, number | undefined> + ); + const notebooks = listOfNotebookIds + .map((id) => { + const nbIndex = notebookIndexMap[id]; + if (nbIndex === undefined) return undefined; + return catalog.notebooks[nbIndex] ?? undefined; + }) + .filter( + (nbInfo): nbInfo is RemoteNotebookInformation | NotebookInformation => nbInfo !== undefined + ); + return cleanCachedNotebookCatalog(notebooks); +} + +function getListOfNotebookIds( + catalogLists: NonNullable<NotebookCatalog['lists']>, + list: string +): string[] { + if (list in catalogLists && catalogLists[list]) return catalogLists[list]!; + if (DEFAULT_NOTEBOOK_LIST_KEY in catalogLists && catalogLists[DEFAULT_NOTEBOOK_LIST_KEY]) + return catalogLists[DEFAULT_NOTEBOOK_LIST_KEY]; + + // This should not happen as we should not load a catalog with lists thats missing the default list as valid, + // but handling this case for code completeness. + throw new Error('Notebook catalog has lists, but is missing default list'); // TODO: ?translate +} + +export function validateRemoteNotebookCatalog(respJson: any): RemoteNotebookCatalog { + const catalog: RemoteNotebookCatalog = NotebookCatalogSchema.validate(respJson); + if (catalog.lists && !(DEFAULT_NOTEBOOK_LIST_KEY in catalog.lists)) { + // TODO: translate error message + throw new Error( + 'Invalid remote notebook catalog. Catalog defines lists, but is missing the default list.' + ); + } + return catalog; +} diff --git a/x-pack/plugins/search_playground/public/components/question_input.test.tsx b/x-pack/plugins/search_playground/public/components/question_input.test.tsx new file mode 100644 index 000000000000..bfd156c9a922 --- /dev/null +++ b/x-pack/plugins/search_playground/public/components/question_input.test.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 { EuiButton, EuiForm } from '@elastic/eui'; +import React, { FormEventHandler } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { QuestionInput } from './question_input'; + +const mockButton = ( + <EuiButton data-test="btn" className="btn" onClick={() => {}}> + Send + </EuiButton> +); + +const handleOnSubmitMock = jest.fn(); + +const MockChatForm = ({ + children, + handleSubmit, +}: { + children: React.ReactElement; + handleSubmit: FormEventHandler; +}) => ( + <EuiForm + component="form" + css={{ display: 'flex', flexGrow: 1 }} + onSubmit={handleSubmit} + data-test-subj="chatPage" + > + {children} + </EuiForm> +); +describe('Question Input', () => { + describe('renders', () => { + it('correctly', () => { + render( + <IntlProvider locale="en"> + <MockChatForm handleSubmit={handleOnSubmitMock}> + <QuestionInput value="" onChange={() => {}} button={mockButton} isDisabled={false} /> + </MockChatForm> + </IntlProvider> + ); + + expect(screen.getByTestId('questionInput')).toBeInTheDocument(); + }); + + it('disabled', () => { + render( + <IntlProvider locale="en"> + <MockChatForm handleSubmit={handleOnSubmitMock}> + <QuestionInput + value="my question" + onChange={() => {}} + button={mockButton} + isDisabled={true} + /> + </MockChatForm> + </IntlProvider> + ); + + expect(screen.getByTestId('questionInput')).toBeDisabled(); + }); + + it('with value', () => { + render( + <IntlProvider locale="en"> + <MockChatForm handleSubmit={handleOnSubmitMock}> + <QuestionInput + value="my question" + onChange={() => {}} + button={mockButton} + isDisabled={false} + /> + </MockChatForm> + </IntlProvider> + ); + + expect(screen.getByTestId('questionInput')).toHaveDisplayValue('my question'); + }); + }); + it('submits form', () => { + render( + <IntlProvider locale="en"> + <MockChatForm handleSubmit={handleOnSubmitMock}> + <QuestionInput value="" onChange={() => {}} button={mockButton} isDisabled={false} /> + </MockChatForm> + </IntlProvider> + ); + + const textArea = screen.getByTestId('questionInput'); + fireEvent.compositionStart(textArea); + fireEvent.keyDown(textArea, { + key: 'Enter', + shiftKey: false, + }); + expect(handleOnSubmitMock).not.toHaveBeenCalled(); + + fireEvent.compositionEnd(textArea); + fireEvent.keyDown(textArea, { + key: 'Enter', + shiftKey: false, + }); + expect(handleOnSubmitMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/search_playground/public/components/question_input.tsx b/x-pack/plugins/search_playground/public/components/question_input.tsx index cdc9d347e4ff..1da424225f2d 100644 --- a/x-pack/plugins/search_playground/public/components/question_input.tsx +++ b/x-pack/plugins/search_playground/public/components/question_input.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTextArea, keys, useEuiTheme } from '@elastic/eui'; @@ -25,6 +25,7 @@ export const QuestionInput: React.FC<QuestionInputProps> = ({ button, isDisabled, }) => { + const [isComposing, setIsComposing] = useState(false); const { euiTheme } = useEuiTheme(); const handleChange = useCallback( (e: React.ChangeEvent<HTMLTextAreaElement>) => { @@ -35,13 +36,20 @@ export const QuestionInput: React.FC<QuestionInputProps> = ({ }, [onChange] ); - const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => { - if (event.key === keys.ENTER && !event.shiftKey) { - event.preventDefault(); + const handleCompositionStart = () => setIsComposing(true); + const handleCompositionEnd = () => { + setIsComposing(false); + }; + const handleKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (event.key === keys.ENTER && !event.shiftKey && !isComposing) { + event.preventDefault(); - event.currentTarget.form?.requestSubmit(); - } - }, []); + event.currentTarget.form?.requestSubmit(); + } + }, + [isComposing] + ); return ( <div css={{ position: 'relative' }}> @@ -67,6 +75,8 @@ export const QuestionInput: React.FC<QuestionInputProps> = ({ disabled={isDisabled} resize="none" data-test-subj="questionInput" + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} /> <div diff --git a/x-pack/plugins/search_playground/public/components/view_code/examples/__snapshots__/py_lang_client.test.tsx.snap b/x-pack/plugins/search_playground/public/components/view_code/examples/__snapshots__/py_lang_client.test.tsx.snap index 1b930bbc80a7..792a7bf37bb6 100644 --- a/x-pack/plugins/search_playground/public/components/view_code/examples/__snapshots__/py_lang_client.test.tsx.snap +++ b/x-pack/plugins/search_playground/public/components/view_code/examples/__snapshots__/py_lang_client.test.tsx.snap @@ -40,9 +40,15 @@ def get_elasticsearch_results(query): def create_openai_prompt(question, results): context = \\"\\" for hit in results: - source_field = index_source_fields.get(hit[\\"_index\\"])[0] - hit_context = hit[\\"_source\\"][source_field] - context += f\\"{hit_context}\\\\n\\" + inner_hit_path = f\\"{hit['_index']}.{index_source_fields.get(hit['_index'])[0]}\\" + + ## For semantic_text matches, we need to extract the text from the inner_hits + if 'inner_hits' in hit and inner_hit_path in hit['inner_hits']: + context += '\\\\n --- \\\\n'.join(inner_hit['_source']['text'] for inner_hit in hit['inner_hits'][inner_hit_path]['hits']['hits']) + else: + source_field = index_source_fields.get(hit[\\"_index\\"])[0] + hit_context = hit[\\"_source\\"][source_field] + context += f\\"{hit_context}\\\\n\\" prompt = f\\"\\"\\" Instructions: diff --git a/x-pack/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx b/x-pack/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx index e7532706627d..b920d2f4a6d2 100644 --- a/x-pack/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx +++ b/x-pack/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx @@ -40,9 +40,15 @@ def get_elasticsearch_results(query): def create_openai_prompt(question, results): context = "" for hit in results: - source_field = index_source_fields.get(hit["_index"])[0] - hit_context = hit["_source"][source_field] - context += f"{hit_context}\\n" + inner_hit_path = f"{hit['_index']}.{index_source_fields.get(hit['_index'])[0]}" + + ## For semantic_text matches, we need to extract the text from the inner_hits + if 'inner_hits' in hit and inner_hit_path in hit['inner_hits']: + context += '\\n --- \\n'.join(inner_hit['_source']['text'] for inner_hit in hit['inner_hits'][inner_hit_path]['hits']['hits']) + else: + source_field = index_source_fields.get(hit["_index"])[0] + hit_context = hit["_source"][source_field] + context += f"{hit_context}\\n" prompt = f"""${Prompt(formValues.prompt, { context: true, 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 8eb46a2cdb7e..b05ac4a75c0a 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 @@ -39,6 +39,7 @@ describe('getChatParams', () => { const actions = { getActionsClientWithRequest: jest.fn(() => Promise.resolve(mockActionsClient)), } as unknown as ActionsPluginStartContract; + const logger = jest.fn() as unknown as Logger; const request = jest.fn() as unknown as KibanaRequest; @@ -89,11 +90,10 @@ describe('getChatParams', () => { temperature: 0, llmType: 'bedrock', traceId: 'test-uuid', - request: expect.anything(), logger: expect.anything(), model: 'custom-model', connectorId: '2', - actions: expect.anything(), + actionsClient: expect.anything(), }); 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 a481309b9277..9740988c3e1e 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 @@ -46,9 +46,8 @@ export const getChatParams = async ( switch (connector.actionTypeId) { case OPENAI_CONNECTOR_ID: chatModel = new ActionsClientChatOpenAI({ - actions, + actionsClient, logger, - request, connectorId, model, traceId: uuidv4(), @@ -67,9 +66,8 @@ export const getChatParams = async ( case BEDROCK_CONNECTOR_ID: const llmType = 'bedrock'; chatModel = new ActionsClientLlm({ - actions, + actionsClient, logger, - request, connectorId, model, traceId: uuidv4(), diff --git a/x-pack/plugins/security/common/licensing/index.mock.ts b/x-pack/plugins/security/common/licensing/index.mock.ts index 9d2fef049de8..6ee9910b768b 100644 --- a/x-pack/plugins/security/common/licensing/index.mock.ts +++ b/x-pack/plugins/security/common/licensing/index.mock.ts @@ -14,12 +14,33 @@ import type { SecurityLicense, SecurityLicenseFeatures } from '@kbn/security-plu export const licenseMock = { create: ( features: Partial<SecurityLicenseFeatures> | Observable<Partial<SecurityLicenseFeatures>> = {}, - licenseType: LicenseType = 'basic' // default to basic if this is not specified + licenseType: LicenseType = 'basic', // default to basic if this is not specified, + isAvailable: Observable<boolean> = of(true) ): jest.Mocked<SecurityLicense> => ({ - isLicenseAvailable: jest.fn().mockReturnValue(true), + isLicenseAvailable: jest.fn().mockImplementation(() => { + let result = true; + + isAvailable.subscribe((next) => { + result = next; + }); + + return result; + }), + getLicenseType: jest.fn().mockReturnValue(licenseType), getUnavailableReason: jest.fn(), isEnabled: jest.fn().mockReturnValue(true), - getFeatures: jest.fn().mockReturnValue(features), + getFeatures: + features instanceof Observable + ? jest.fn().mockImplementation(() => { + let subbedFeatures: Partial<SecurityLicenseFeatures> = {}; + + features.subscribe((next) => { + subbedFeatures = next; + }); + + return subbedFeatures; + }) + : jest.fn().mockReturnValue(features), hasAtLeast: jest .fn() .mockImplementation( diff --git a/x-pack/plugins/security/common/licensing/license_service.test.ts b/x-pack/plugins/security/common/licensing/license_service.test.ts index f1b80db5cba2..ab8b5c803dea 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -32,6 +32,7 @@ describe('license features', function () { allowSubFeaturePrivileges: false, allowAuditLogging: false, allowUserProfileCollaboration: false, + allowFips: false, }); }); @@ -57,6 +58,7 @@ describe('license features', function () { allowSubFeaturePrivileges: false, allowAuditLogging: false, allowUserProfileCollaboration: false, + allowFips: false, }); }); @@ -78,6 +80,7 @@ describe('license features', function () { Object { "allowAccessAgreement": false, "allowAuditLogging": false, + "allowFips": false, "allowLogin": false, "allowRbac": false, "allowRemoteClusterPrivileges": false, @@ -102,6 +105,7 @@ describe('license features', function () { Object { "allowAccessAgreement": true, "allowAuditLogging": true, + "allowFips": true, "allowLogin": true, "allowRbac": true, "allowRemoteClusterPrivileges": true, @@ -146,6 +150,7 @@ describe('license features', function () { allowSubFeaturePrivileges: false, allowAuditLogging: false, allowUserProfileCollaboration: false, + allowFips: false, }); expect(getFeatureSpy).toHaveBeenCalledTimes(1); expect(getFeatureSpy).toHaveBeenCalledWith('security'); @@ -174,6 +179,7 @@ describe('license features', function () { allowSubFeaturePrivileges: false, allowAuditLogging: false, allowUserProfileCollaboration: false, + allowFips: false, }); }); @@ -201,6 +207,7 @@ describe('license features', function () { allowSubFeaturePrivileges: false, allowAuditLogging: false, allowUserProfileCollaboration: true, + allowFips: false, }); }); @@ -228,6 +235,7 @@ describe('license features', function () { allowSubFeaturePrivileges: true, allowAuditLogging: true, allowUserProfileCollaboration: true, + allowFips: false, }); }); @@ -255,6 +263,7 @@ describe('license features', function () { allowSubFeaturePrivileges: true, allowAuditLogging: true, allowUserProfileCollaboration: true, + allowFips: true, }); }); }); diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 3066d32a7269..817b3f207aa1 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -28,6 +28,8 @@ export class SecurityLicenseService { license: Object.freeze({ isLicenseAvailable: () => rawLicense?.isAvailable ?? false, + getLicenseType: () => rawLicense?.type ?? undefined, + getUnavailableReason: () => rawLicense?.getUnavailableReason(), isEnabled: () => this.isSecurityEnabledFromRawLicense(rawLicense), @@ -81,6 +83,7 @@ export class SecurityLicenseService { allowRbac: false, allowSubFeaturePrivileges: false, allowUserProfileCollaboration: false, + allowFips: false, layout: rawLicense !== undefined && !rawLicense?.isAvailable ? 'error-xpack-unavailable' @@ -103,6 +106,7 @@ export class SecurityLicenseService { allowRbac: false, allowSubFeaturePrivileges: false, allowUserProfileCollaboration: false, + allowFips: false, }; } @@ -124,6 +128,7 @@ export class SecurityLicenseService { allowRemoteClusterPrivileges: isLicensePlatinumOrBetter, allowRbac: true, allowUserProfileCollaboration: isLicenseStandardOrBetter, + allowFips: isLicensePlatinumOrBetter, }; } } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap index e64e867a71a5..ccb8decacd81 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap @@ -124,6 +124,7 @@ exports[`it renders correctly in serverless mode 1`] = ` "_subscribe": [Function], }, "getFeatures": [MockFunction], + "getLicenseType": [MockFunction], "getUnavailableReason": [MockFunction], "hasAtLeast": [MockFunction], "isEnabled": [MockFunction], @@ -322,6 +323,7 @@ exports[`it renders without crashing 1`] = ` "_subscribe": [Function], }, "getFeatures": [MockFunction], + "getLicenseType": [MockFunction], "getUnavailableReason": [MockFunction], "hasAtLeast": [MockFunction], "isEnabled": [MockFunction], diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privileges.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privileges.test.tsx.snap index c3df729a7e3e..705af534bc71 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privileges.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/index_privileges.test.tsx.snap @@ -24,6 +24,7 @@ exports[`it renders without crashing 1`] = ` "_subscribe": [Function], }, "getFeatures": [MockFunction], + "getLicenseType": [MockFunction], "getUnavailableReason": [MockFunction], "hasAtLeast": [MockFunction], "isEnabled": [MockFunction], diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/remote_cluster_privileges.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/remote_cluster_privileges.test.tsx.snap index e0939f7f55e0..7cc4e67ece4f 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/remote_cluster_privileges.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/remote_cluster_privileges.test.tsx.snap @@ -14,6 +14,7 @@ exports[`it renders without crashing 1`] = ` "_subscribe": [Function], }, "getFeatures": [MockFunction], + "getLicenseType": [MockFunction], "getUnavailableReason": [MockFunction], "hasAtLeast": [MockFunction], "isEnabled": [MockFunction], diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index edfc42aae585..d2700afc1c12 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -7,12 +7,12 @@ import { of } from 'rxjs'; +import { securityServiceMock } from '@kbn/core-security-server-mocks'; + import { authenticationMock, authorizationMock } from './authentication/index.mock'; import { navControlServiceMock } from './nav_control/index.mock'; import { getUiApiMock } from './ui_api/index.mock'; import { licenseMock } from '../common/licensing/index.mock'; -import type { MockAuthenticatedUserProps } from '../common/model/authenticated_user.mock'; -import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock'; function createSetupMock() { return { @@ -43,6 +43,5 @@ function createStartMock() { export const securityMock = { createSetup: createSetupMock, createStart: createStartMock, - createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => - mockAuthenticatedUser(props), + createMockAuthenticatedUser: securityServiceMock.createMockAuthenticatedUser, }; diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 874196f3e4c0..433e1981e9ce 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -43,6 +43,7 @@ describe('Security Plugin', () => { authz: { isRoleManagementEnabled: expect.any(Function), roles: expect.any(Object) }, license: { isLicenseAvailable: expect.any(Function), + getLicenseType: expect.any(Function), isEnabled: expect.any(Function), getUnavailableReason: expect.any(Function), getFeatures: expect.any(Function), @@ -71,6 +72,7 @@ describe('Security Plugin', () => { authc: { getCurrentUser: expect.any(Function), areAPIKeysEnabled: expect.any(Function) }, license: { isLicenseAvailable: expect.any(Function), + getLicenseType: expect.any(Function), isEnabled: expect.any(Function), getUnavailableReason: expect.any(Function), getFeatures: expect.any(Function), diff --git a/x-pack/plugins/security/server/analytics/analytics_service.ts b/x-pack/plugins/security/server/analytics/analytics_service.ts index b5bbbf0079cf..9dc501b354fe 100644 --- a/x-pack/plugins/security/server/analytics/analytics_service.ts +++ b/x-pack/plugins/security/server/analytics/analytics_service.ts @@ -235,7 +235,14 @@ const permissionsPolicyViolation: EventTypeOpts<PermissionsPolicyViolationEvent> type: 'text', _meta: { description: '"featureId" field of Reporting API permissions policy violation report.', - optional: false, + optional: true, + }, + }, + policyId: { + type: 'text', + _meta: { + description: '"policyId" field of Reporting API permissions policy violation report.', + optional: true, }, }, sourceFile: { diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 3a6ccc619fdb..5e6c59aee466 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -61,6 +61,11 @@ describe('config schema', () => { }, "cookieName": "sid", "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "experimental": Object { + "fipsMode": Object { + "enabled": false, + }, + }, "loginAssistanceMessage": "", "public": Object {}, "secureCookies": false, @@ -115,6 +120,11 @@ describe('config schema', () => { }, "cookieName": "sid", "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "experimental": Object { + "fipsMode": Object { + "enabled": false, + }, + }, "loginAssistanceMessage": "", "public": Object {}, "secureCookies": false, @@ -168,6 +178,11 @@ describe('config schema', () => { "selector": Object {}, }, "cookieName": "sid", + "experimental": Object { + "fipsMode": Object { + "enabled": false, + }, + }, "loginAssistanceMessage": "", "public": Object {}, "secureCookies": false, @@ -224,6 +239,11 @@ describe('config schema', () => { "selector": Object {}, }, "cookieName": "sid", + "experimental": Object { + "fipsMode": Object { + "enabled": false, + }, + }, "loginAssistanceMessage": "", "public": Object {}, "roleManagementEnabled": false, diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 1ea1c87d31d5..e12f1462b39b 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -314,6 +314,11 @@ export const ConfigSchema = schema.object({ roleMappingManagementEnabled: schema.boolean({ defaultValue: true }), }), }), + experimental: schema.object({ + fipsMode: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), + }), }); export function createConfig( diff --git a/x-pack/plugins/security/server/fips/fips_service.test.ts b/x-pack/plugins/security/server/fips/fips_service.test.ts new file mode 100644 index 000000000000..aba86633c281 --- /dev/null +++ b/x-pack/plugins/security/server/fips/fips_service.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const mockGetFipsFn = jest.fn(); +jest.mock('crypto', () => ({ + randomBytes: jest.fn(), + constants: jest.requireActual('crypto').constants, + get getFips() { + return mockGetFipsFn; + }, +})); + +import type { Observable } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import type { LicenseType } from '@kbn/licensing-plugin/common/types'; +import type { SecurityLicenseFeatures } from '@kbn/security-plugin-types-common'; + +import type { FipsServiceSetupInternal, FipsServiceSetupParams } from './fips_service'; +import { FipsService } from './fips_service'; +import { licenseMock } from '../../common/licensing/index.mock'; +import { ConfigSchema, createConfig } from '../config'; + +const logger = loggingSystemMock.createLogger(); + +function buildMockFipsServiceSetupParams( + licenseType: LicenseType, + isFipsConfigured: boolean, + features$: Observable<Partial<SecurityLicenseFeatures>>, + isAvailable: Observable<boolean> = of(true) +): FipsServiceSetupParams { + mockGetFipsFn.mockImplementationOnce(() => { + return isFipsConfigured ? 1 : 0; + }); + + const license = licenseMock.create(features$, licenseType, isAvailable); + + let mockConfig = {}; + if (isFipsConfigured) { + mockConfig = { experimental: { fipsMode: { enabled: true } } }; + } + + return { + license, + config: createConfig(ConfigSchema.validate(mockConfig), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + }; +} + +describe('FipsService', () => { + let fipsService: FipsService; + let fipsServiceSetup: FipsServiceSetupInternal; + + beforeEach(() => { + fipsService = new FipsService(logger); + logger.error.mockClear(); + }); + + afterEach(() => { + logger.error.mockClear(); + }); + + describe('setup()', () => { + it('should expose correct setup contract', () => { + fipsService = new FipsService(logger); + fipsServiceSetup = fipsService.setup( + buildMockFipsServiceSetupParams('platinum', true, of({ allowFips: true })) + ); + + expect(fipsServiceSetup).toMatchInlineSnapshot(` + Object { + "validateLicenseForFips": [Function], + } + `); + }); + }); + + describe('#validateLicenseForFips', () => { + describe('start-up check', () => { + it('should not throw Error/log.error if license features allowFips and `experimental.fipsMode.enabled` is `false`', () => { + fipsServiceSetup = fipsService.setup( + buildMockFipsServiceSetupParams('platinum', false, of({ allowFips: true })) + ); + fipsServiceSetup.validateLicenseForFips(); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should not throw Error/log.error if license features allowFips and `experimental.fipsMode.enabled` is `true`', () => { + fipsServiceSetup = fipsService.setup( + buildMockFipsServiceSetupParams('platinum', true, of({ allowFips: true })) + ); + fipsServiceSetup.validateLicenseForFips(); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should not throw Error/log.error if license features do not allowFips and `experimental.fipsMode.enabled` is `false`', () => { + fipsServiceSetup = fipsService.setup( + buildMockFipsServiceSetupParams('basic', false, of({ allowFips: false })) + ); + fipsServiceSetup.validateLicenseForFips(); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should throw Error/log.error if license features do not allowFips and `experimental.fipsMode.enabled` is `true`', () => { + fipsServiceSetup = fipsService.setup( + buildMockFipsServiceSetupParams('basic', true, of({ allowFips: false })) + ); + + // Because the Error is thrown from within a SafeSubscriber and cannot be hooked into + fipsServiceSetup.validateLicenseForFips(); + + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('monitoring check', () => { + describe('with experimental.fipsMode.enabled', () => { + let mockFeaturesSubject: BehaviorSubject<Partial<SecurityLicenseFeatures>>; + let mockIsAvailableSubject: BehaviorSubject<boolean>; + let mockFeatures$: Observable<Partial<SecurityLicenseFeatures>>; + let mockIsAvailable$: Observable<boolean>; + + beforeAll(() => { + mockFeaturesSubject = new BehaviorSubject<Partial<SecurityLicenseFeatures>>({ + allowFips: true, + }); + mockIsAvailableSubject = new BehaviorSubject<boolean>(true); + mockFeatures$ = mockFeaturesSubject.asObservable(); + mockIsAvailable$ = mockIsAvailableSubject.asObservable(); + fipsServiceSetup = fipsService.setup( + buildMockFipsServiceSetupParams('platinum', true, mockFeatures$, mockIsAvailable$) + ); + + fipsServiceSetup.validateLicenseForFips(); + }); + + beforeEach(() => { + mockFeaturesSubject.next({ allowFips: true }); + mockIsAvailableSubject.next(true); + }); + + it('should not log.error if license changes to unavailable and `experimental.fipsMode.enabled` is `true`', () => { + mockIsAvailableSubject.next(false); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should not log.error if license features continue to allowFips and `experimental.fipsMode.enabled` is `true`', () => { + mockFeaturesSubject.next({ allowFips: true }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should log.error if license features change to not allowFips and `experimental.fipsMode.enabled` is `true`', () => { + mockFeaturesSubject.next({ allowFips: false }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('with not experimental.fipsMode.enabled', () => { + let mockFeaturesSubject: BehaviorSubject<Partial<SecurityLicenseFeatures>>; + let mockIsAvailableSubject: BehaviorSubject<boolean>; + let mockFeatures$: Observable<Partial<SecurityLicenseFeatures>>; + let mockIsAvailable$: Observable<boolean>; + + beforeAll(() => { + mockFeaturesSubject = new BehaviorSubject<Partial<SecurityLicenseFeatures>>({ + allowFips: true, + }); + mockIsAvailableSubject = new BehaviorSubject<boolean>(true); + mockFeatures$ = mockFeaturesSubject.asObservable(); + mockIsAvailable$ = mockIsAvailableSubject.asObservable(); + + fipsServiceSetup = fipsService.setup( + buildMockFipsServiceSetupParams('platinum', false, mockFeatures$, mockIsAvailable$) + ); + + fipsServiceSetup.validateLicenseForFips(); + }); + + beforeEach(() => { + mockFeaturesSubject.next({ allowFips: true }); + mockIsAvailableSubject.next(true); + }); + + it('should not log.error if license changes to unavailable and `experimental.fipsMode.enabled` is `false`', () => { + mockIsAvailableSubject.next(false); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should not log.error if license features continue to allowFips and `experimental.fipsMode.enabled` is `false`', () => { + mockFeaturesSubject.next({ allowFips: true }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should not log.error if license change to not allowFips and `experimental.fipsMode.enabled` is `false`', () => { + mockFeaturesSubject.next({ allowFips: false }); + expect(logger.error).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/fips/fips_service.ts b/x-pack/plugins/security/server/fips/fips_service.ts new file mode 100644 index 000000000000..aa351ab48828 --- /dev/null +++ b/x-pack/plugins/security/server/fips/fips_service.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/logging'; +import type { SecurityLicense } from '@kbn/security-plugin-types-common'; + +import type { ConfigType } from '../config'; + +export interface FipsServiceSetupParams { + config: ConfigType; + license: SecurityLicense; +} + +export interface FipsServiceSetupInternal { + validateLicenseForFips: () => void; +} + +export class FipsService { + private readonly logger: Logger; + private isInitialLicenseLoaded: boolean; + + constructor(logger: Logger) { + this.logger = logger; + this.isInitialLicenseLoaded = false; + } + + setup({ config, license }: FipsServiceSetupParams): FipsServiceSetupInternal { + return { + validateLicenseForFips: () => this.validateLicenseForFips(config, license), + }; + } + + private validateLicenseForFips(config: ConfigType, license: SecurityLicense) { + license.features$.subscribe({ + next: (features) => { + const errorMessage = `Your current license level is ${license.getLicenseType()} and does not support running in FIPS mode.`; + + if (license.isLicenseAvailable() && !this.isInitialLicenseLoaded) { + if (config?.experimental.fipsMode.enabled && !license.getFeatures().allowFips) { + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + this.isInitialLicenseLoaded = true; + } + + if ( + this.isInitialLicenseLoaded && + license.isLicenseAvailable() && + config?.experimental.fipsMode.enabled && + !features.allowFips + ) { + this.logger.error( + `${errorMessage} Kibana will not be able to restart. Please upgrade your license to platinum or higher.` + ); + } + }, + error: (error) => { + this.logger.debug(`Unable to check license: ${error}`); + }, + }); + } +} diff --git a/x-pack/plugins/security/server/fips/index.ts b/x-pack/plugins/security/server/fips/index.ts new file mode 100644 index 000000000000..3af443516934 --- /dev/null +++ b/x-pack/plugins/security/server/fips/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 { FipsService } from './fips_service'; + +export type { FipsServiceSetupInternal, FipsServiceSetupParams } from './fips_service'; diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index b85652f71528..e47faeba525a 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -7,15 +7,13 @@ import type { TransportResult } from '@elastic/elasticsearch'; -import { apiKeysMock } from '@kbn/core-security-server-mocks'; +import { apiKeysMock, securityServiceMock } from '@kbn/core-security-server-mocks'; import { auditServiceMock } from './audit/mocks'; import { authenticationServiceMock } from './authentication/authentication_service.mock'; import { authorizationMock } from './authorization/index.mock'; import { userProfileServiceMock } from './user_profile/user_profile_service.mock'; import { licenseMock } from '../common/licensing/index.mock'; -import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock'; -import type { MockAuthenticatedUserProps } from '../common/model/authenticated_user.mock'; function createSetupMock() { const mockAuthz = authorizationMock.create(); @@ -84,6 +82,5 @@ export const securityMock = { createSetup: createSetupMock, createStart: createStartMock, createApiResponse: createApiResponseMock, - createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => - mockAuthenticatedUser(props), + createMockAuthenticatedUser: securityServiceMock.createMockAuthenticatedUser, }; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index be3d00b77cff..a82b45753845 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -121,6 +121,7 @@ describe('Security Plugin', () => { }, }, "getFeatures": [Function], + "getLicenseType": [Function], "getUnavailableReason": [Function], "hasAtLeast": [Function], "isEnabled": [Function], diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index c166b5f0613f..a500454f98fa 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -52,6 +52,8 @@ import { ElasticsearchService } from './elasticsearch'; import type { SecurityFeatureUsageServiceStart } from './feature_usage'; import { SecurityFeatureUsageService } from './feature_usage'; import { securityFeatures } from './features'; +import type { FipsServiceSetupInternal } from './fips'; +import { FipsService } from './fips'; import { defineRoutes } from './routes'; import { setupSavedObjects } from './saved_objects'; import type { Session } from './session_management'; @@ -180,6 +182,9 @@ export class SecurityPlugin return this.userProfileStart; }; + private readonly fipsService: FipsService; + private fipsServiceSetup?: FipsServiceSetupInternal; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -203,6 +208,8 @@ export class SecurityPlugin ); this.analyticsService = new AnalyticsService(this.initializerContext.logger.get('analytics')); + + this.fipsService = new FipsService(this.initializerContext.logger.get('fips')); } public setup( @@ -284,6 +291,9 @@ export class SecurityPlugin this.userProfileService.setup({ authz: this.authorizationSetup, license }); + this.fipsServiceSetup = this.fipsService.setup({ config, license }); + this.fipsServiceSetup.validateLicenseForFips(); + setupSpacesClient({ spaces, audit: this.auditSetup, diff --git a/x-pack/plugins/security/server/routes/analytics/record_violations.test.ts b/x-pack/plugins/security/server/routes/analytics/record_violations.test.ts index 97fe29320518..cf83ec1ad981 100644 --- a/x-pack/plugins/security/server/routes/analytics/record_violations.test.ts +++ b/x-pack/plugins/security/server/routes/analytics/record_violations.test.ts @@ -95,7 +95,7 @@ describe('POST /internal/security/analytics/_record_violations', () => { user_agent: 'jest', body: { disposition: 'report', - featureId: 'camera', + policyId: 'camera', }, }; diff --git a/x-pack/plugins/security/server/routes/analytics/record_violations.ts b/x-pack/plugins/security/server/routes/analytics/record_violations.ts index 8e59fc52831d..9ee4e1c66be3 100644 --- a/x-pack/plugins/security/server/routes/analytics/record_violations.ts +++ b/x-pack/plugins/security/server/routes/analytics/record_violations.ts @@ -90,8 +90,13 @@ export const permissionsPolicyViolationReportSchema = schema.object( { /** * The string identifying the policy-controlled feature whose policy has been violated. This string can be used for grouping and counting related reports. + * Spec mentions featureId, however the report that is sent from Chrome has policyId. This is to handle both cases. */ - featureId: schema.string(), + policyId: schema.maybe(schema.string()), + /** + * The string identifying the policy-controlled feature whose policy has been violated. This string can be used for grouping and counting related reports. + */ + featureId: schema.maybe(schema.string()), /** * If known, the file where the violation occured, or null otherwise. */ @@ -140,6 +145,7 @@ export function defineRecordViolations({ router, analyticsService }: RouteDefini schema.oneOf([cspViolationReportSchema, permissionsPolicyViolationReportSchema]) ), cspViolationReportSchema, + permissionsPolicyViolationReportSchema, ]), }, options: { diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index 87e9bf9e4495..b19ef41ca909 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -175,6 +175,7 @@ describe('Login view routes', () => { allowAuditLogging: true, showLogin: true, allowUserProfileCollaboration: true, + allowFips: false, }); const request = httpServerMock.createKibanaRequest(); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_request_schema.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_request_schema.test.ts index b7435c7dd86e..a22886b287c7 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_request_schema.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_request_schema.test.ts @@ -1272,6 +1272,7 @@ describe('rules schema', () => { { ruleType: 'saved_query', ruleMock: getCreateSavedQueryRulesSchemaMock() }, { ruleType: 'eql', ruleMock: getCreateEqlRuleSchemaMock() }, { ruleType: 'new_terms', ruleMock: getCreateNewTermsRulesSchemaMock() }, + { ruleType: 'machine_learning', ruleMock: getCreateMachineLearningRulesSchemaMock() }, ]; cases.forEach(({ ruleType, ruleMock }) => { diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts index 9bb1b26fafd9..83bf6778ec3e 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts @@ -468,14 +468,25 @@ export const MachineLearningRuleRequiredFields = z.object({ machine_learning_job_id: MachineLearningJobId, }); +export type MachineLearningRuleOptionalFields = z.infer<typeof MachineLearningRuleOptionalFields>; +export const MachineLearningRuleOptionalFields = z.object({ + alert_suppression: AlertSuppression.optional(), +}); + export type MachineLearningRulePatchFields = z.infer<typeof MachineLearningRulePatchFields>; -export const MachineLearningRulePatchFields = MachineLearningRuleRequiredFields.partial(); +export const MachineLearningRulePatchFields = MachineLearningRuleRequiredFields.partial().merge( + MachineLearningRuleOptionalFields +); export type MachineLearningRuleResponseFields = z.infer<typeof MachineLearningRuleResponseFields>; -export const MachineLearningRuleResponseFields = MachineLearningRuleRequiredFields; +export const MachineLearningRuleResponseFields = MachineLearningRuleRequiredFields.merge( + MachineLearningRuleOptionalFields +); export type MachineLearningRuleCreateFields = z.infer<typeof MachineLearningRuleCreateFields>; -export const MachineLearningRuleCreateFields = MachineLearningRuleRequiredFields; +export const MachineLearningRuleCreateFields = MachineLearningRuleRequiredFields.merge( + MachineLearningRuleOptionalFields +); export type MachineLearningRule = z.infer<typeof MachineLearningRule>; export const MachineLearningRule = SharedResponseProps.merge(MachineLearningRuleResponseFields); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml index de424af505c1..4ade72c15fbb 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml @@ -686,18 +686,27 @@ components: - machine_learning_job_id - anomaly_threshold + MachineLearningRuleOptionalFields: + type: object + properties: + alert_suppression: + $ref: './common_attributes.schema.yaml#/components/schemas/AlertSuppression' + MachineLearningRulePatchFields: allOf: - $ref: '#/components/schemas/MachineLearningRuleRequiredFields' x-modify: partial + - $ref: '#/components/schemas/MachineLearningRuleOptionalFields' MachineLearningRuleResponseFields: allOf: - $ref: '#/components/schemas/MachineLearningRuleRequiredFields' + - $ref: '#/components/schemas/MachineLearningRuleOptionalFields' MachineLearningRuleCreateFields: allOf: - $ref: '#/components/schemas/MachineLearningRuleRequiredFields' + - $ref: '#/components/schemas/MachineLearningRuleOptionalFields' MachineLearningRule: allOf: diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_outcome.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_outcome.ts index fd36a69dbde3..afa63c01744e 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_outcome.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_outcome.ts @@ -38,7 +38,51 @@ export const determineDiffOutcome = <TValue>( const baseEqlTarget = isEqual(baseVersion, targetVersion); const currentEqlTarget = isEqual(currentVersion, targetVersion); - if (baseVersion === MissingVersion) { + return getThreeWayDiffOutcome({ + baseEqlCurrent, + baseEqlTarget, + currentEqlTarget, + hasBaseVersion: baseVersion !== MissingVersion, + }); +}; + +/** + * Determines diff outcomes of array fields that do not care about order (e.g. `[1, 2 , 3] === [3, 2, 1]`) + */ +export const determineOrderAgnosticDiffOutcome = <TValue>( + baseVersion: TValue[] | MissingVersion, + currentVersion: TValue[], + targetVersion: TValue[] +): ThreeWayDiffOutcome => { + const baseSet = baseVersion === MissingVersion ? MissingVersion : new Set<TValue>(baseVersion); + const currentSet = new Set<TValue>(currentVersion); + const targetSet = new Set<TValue>(targetVersion); + const baseEqlCurrent = isEqual(baseSet, currentSet); + const baseEqlTarget = isEqual(baseSet, targetSet); + const currentEqlTarget = isEqual(currentSet, targetSet); + + return getThreeWayDiffOutcome({ + baseEqlCurrent, + baseEqlTarget, + currentEqlTarget, + hasBaseVersion: baseVersion !== MissingVersion, + }); +}; + +interface DetermineDiffOutcomeProps { + baseEqlCurrent: boolean; + baseEqlTarget: boolean; + currentEqlTarget: boolean; + hasBaseVersion: boolean; +} + +const getThreeWayDiffOutcome = ({ + baseEqlCurrent, + baseEqlTarget, + currentEqlTarget, + hasBaseVersion, +}: DetermineDiffOutcomeProps): ThreeWayDiffOutcome => { + if (!hasBaseVersion) { /** * We couldn't find the base version of the rule in the package so further * version comparison is not possible. We assume that the rule is not diff --git a/x-pack/plugins/security_solution/common/api/timeline/clean_draft_timelines/clean_draft_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/clean_draft_timelines/clean_draft_timelines_route_schema.yaml index 7e839763d52e..2a5d004507fb 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/clean_draft_timelines/clean_draft_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/clean_draft_timelines/clean_draft_timelines_route_schema.yaml @@ -29,7 +29,7 @@ paths: timelineType: $ref: '../model/components.yaml#/components/schemas/TimelineType' responses: - 200: + '200': description: Indicates that the draft timeline was successfully created. In the event the user already has a draft timeline, the existing draft timeline is cleared and returned. content: application/json: @@ -46,7 +46,7 @@ paths: $ref: '../model/components.yaml#/components/schemas/TimelineResponse' required: - data - 403: + '403': description: Indicates that the user does not have the required permissions to create a draft timeline. content: application:json: @@ -57,7 +57,7 @@ paths: type: string status_code: type: number - 409: + '409': description: Indicates that there is already a draft timeline with the given timelineId. content: application:json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/create_timelines/create_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/create_timelines/create_timelines_route_schema.yaml index 8c4585c83926..d2e281764294 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/create_timelines/create_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/create_timelines/create_timelines_route_schema.yaml @@ -16,7 +16,7 @@ paths: /api/timeline: post: operationId: createTimelines - description: Creates a new timeline. + summary: Creates a new timeline. tags: - access:securitySolution requestBody: @@ -52,7 +52,7 @@ paths: timeline: $ref: '../model/components.yaml#/components/schemas/SavedTimeline' responses: - 200: + '200': description: Indicates the timeline was successfully created. content: application/json: @@ -69,7 +69,7 @@ paths: $ref: '../model/components.yaml#/components/schemas/TimelineResponse' required: - data - 405: + '405': description: Indicates that there was an error in the timeline creation. content: application/json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml index 0f39551e757b..16901b9b0f80 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml @@ -13,7 +13,7 @@ paths: /api/note: delete: operationId: deleteNote - description: Deletes a note from a timeline. + summary: Deletes a note from a timeline. tags: - access:securitySolution requestBody: @@ -36,5 +36,12 @@ paths: type: string nullable: true responses: - 200: - description: Indicates the note was successfully deleted. \ No newline at end of file + '200': + description: Indicates the note was successfully deleted. + content: + application/json: + schema: + type: object + properties: + data: + type: object diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml index dba047199272..5be42a6696d6 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml @@ -16,7 +16,7 @@ paths: /api/timeline: delete: operationId: deleteTimelines - description: Deletes one or more timelines or timeline templates. + summary: Deletes one or more timelines or timeline templates. tags: - access:securitySolution requestBody: @@ -33,12 +33,13 @@ paths: type: array items: type: string - searchId: + searchIds: type: array + description: Saved search ids that should be deleted alongside the timelines items: type: string responses: - 200: + '200': description: Indicates the timeline was successfully deleted. content: application/json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route_schema.yaml index c846de607b90..360c53e6d72e 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/export_timelines/export_timelines_route_schema.yaml @@ -16,7 +16,7 @@ paths: /api/timeline/_export: post: operationId: exportTimelines - description: Exports timelines as an NDJSON file + summary: Exports timelines as an NDJSON file tags: - access:securitySolution parameters: @@ -26,7 +26,7 @@ paths: type: string description: The name of the file to export requestBody: - description: The id of the timelines to export + description: The ids of the timelines to export required: true content: application/json: @@ -39,14 +39,14 @@ paths: items: type: string responses: - 200: + '200': description: Indicates the timelines were successfully exported content: application/ndjson: schema: type: string description: NDJSON of the exported timelines - 400: + '400': description: Indicates that the export size limit was exceeded content: application/ndjson: diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_draft_timelines/get_draft_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_draft_timelines/get_draft_timelines_route_schema.yaml index 722f6c3ad0a5..c0a73bcaefee 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_draft_timelines/get_draft_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_draft_timelines/get_draft_timelines_route_schema.yaml @@ -2,9 +2,6 @@ openapi: 3.0.0 info: title: Elastic Security - Timeline - Get Draft Timelines API version: 8.9.0 -externalDocs: - url: https://www.elastic.co/guide/en/security/current/_get_timeline_timeline_template_by_savedobjectid.html - description: Documentation servers: - url: 'http://{kibana_host}:{port}' variables: @@ -16,7 +13,7 @@ paths: /api/timeline/_draft: get: operationId: getDraftTimelines - description: Retrieves the draft timeline for the current user. If the user does not have a draft timeline, an empty timeline is returned. + summary: Retrieves the draft timeline for the current user. If the user does not have a draft timeline, an empty timeline is returned. tags: - access:securitySolution parameters: @@ -25,7 +22,7 @@ paths: schema: $ref: '../model/components.yaml#/components/schemas/TimelineType' responses: - 200: + '200': description: Indicates that the draft timeline was successfully retrieved. content: application/json: @@ -40,7 +37,7 @@ paths: properties: timeline: $ref: '../model/components.yaml#/components/schemas/TimelineResponse' - 403: + '403': description: If a draft timeline was not found and we attempted to create one, it indicates that the user does not have the required permissions to create a draft timeline. content: application:json: @@ -51,7 +48,7 @@ paths: type: string status_code: type: number - 409: + '409': description: This should never happen, but if a draft timeline was not found and we attempted to create one, it indicates that there is already a draft timeline with the given timelineId. content: application:json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_timeline/get_timeline_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_timeline/get_timeline_route_schema.yaml index 9415a8ce2b60..0341a26c356c 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_timeline/get_timeline_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_timeline/get_timeline_route_schema.yaml @@ -3,7 +3,7 @@ info: title: Elastic Security - Timeline - Get Timeline API version: 8.9.0 externalDocs: - url: https://www.elastic.co/guide/en/security/current/_get_timeline_timeline_template_by_savedobjectid.html + url: https://www.elastic.co/guide/en/security/current/_get_timeline_or_timeline_template_by_savedobjectid.html description: Documentation servers: - url: 'http://{kibana_host}:{port}' @@ -16,7 +16,7 @@ paths: /api/timeline: get: operationId: getTimeline - description: Get an existing saved timeline or timeline template. This API is used to retrieve an existing saved timeline or timeline template. + summary: Get an existing saved timeline or timeline template. This API is used to retrieve an existing saved timeline or timeline template. tags: - access:securitySolution parameters: @@ -31,8 +31,8 @@ paths: type: string description: The ID of the timeline to retrieve responses: - 200: - description: Indicates that the draft timeline was successfully created. In the event the user already has a draft timeline, the existing draft timeline is cleared and returned. + '200': + description: Indicates that the (template) timeline was found and returned. content: application/json: schema: diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_timelines/get_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_timelines/get_timelines_route_schema.yaml index ac501f0d7729..beb452557cd8 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_timelines/get_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_timelines/get_timelines_route_schema.yaml @@ -16,7 +16,7 @@ paths: /api/timelines: get: operationId: getTimelines - description: This API is used to retrieve a list of existing saved timelines or timeline templates. + summary: This API is used to retrieve a list of existing saved timelines or timeline templates. tags: - access:securitySolution parameters: @@ -68,8 +68,8 @@ paths: - $ref: '../model/components.yaml#/components/schemas/TimelineStatus' - nullable: true responses: - 200: - description: Indicates that the draft timeline was successfully created. In the event the user already has a draft timeline, the existing draft timeline is cleared and returned. + '200': + description: Indicates that the (template) timelines were found and returned. content: application/json: schema: @@ -96,7 +96,7 @@ paths: type: number required: - data - 400: + '400': description: Bad request. The user supplied invalid data. content: application:json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/import_timelines/import_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/import_timelines/import_timelines_route_schema.yaml index d12a9ca1b921..7ada9a0e4a14 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/import_timelines/import_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/import_timelines/import_timelines_route_schema.yaml @@ -16,7 +16,7 @@ paths: /api/timeline/_import: post: operationId: importTimelines - description: Imports timelines. + summary: Imports timelines. tags: - access:securitySolution requestBody: @@ -40,7 +40,7 @@ paths: headers: type: object responses: - 200: + '200': description: Indicates the import of timelines was successful. content: application/json: @@ -52,7 +52,7 @@ paths: required: - data - 400: + '400': description: Indicates the import of timelines was unsuccessful because of an invalid file extension. content: application/json: @@ -66,7 +66,7 @@ paths: statusCode: type: number - 404: + '404': description: Indicates that we were unable to locate the saved object client necessary to handle the import. content: application/json: @@ -77,7 +77,7 @@ paths: type: string statusCode: type: number - 409: + '409': description: Indicates the import of timelines was unsuccessful. content: application/json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route_schema.yaml index ffad91cab963..247e6aa8e3f6 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route_schema.yaml @@ -13,7 +13,7 @@ paths: /api/timeline/_prepackaged: post: operationId: installPrepackedTimelines - description: Installs prepackaged timelines. + summary: Installs prepackaged timelines. tags: - access:securitySolution requestBody: @@ -41,7 +41,7 @@ paths: items: $ref: '../model/components.yaml#/components/schemas/SavedTimeline' responses: - 200: + '200': description: Indicates the installation of prepackaged timelines was successful. content: application/json: @@ -52,7 +52,7 @@ paths: $ref: '../model/components.yaml#/components/schemas/ImportTimelineResult' required: - data - 500: + '500': description: Indicates the installation of prepackaged timelines was unsuccessful. content: application:json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/patch_timelines/patch_timeline_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/patch_timelines/patch_timeline_route_schema.yaml index 4783e4241197..2a4f1e1fadfa 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/patch_timelines/patch_timeline_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/patch_timelines/patch_timeline_route_schema.yaml @@ -32,7 +32,7 @@ paths: timeline: $ref: '../model/components.yaml#/components/schemas/SavedTimeline' responses: - 200: + '200': description: Indicates that the draft timeline was successfully created. In the event the user already has a draft timeline, the existing draft timeline is cleared and returned. content: application/json: @@ -49,7 +49,7 @@ paths: $ref: '../model/components.yaml#/components/schemas/TimelineResponse' required: - data - 405: + '405': description: Indicates that the user does not have the required access to create a draft timeline. content: application/json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/persist_favorite/persist_favorite_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/persist_favorite/persist_favorite_route_schema.yaml index aef8f2b2cf4c..88eced8f9a84 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/persist_favorite/persist_favorite_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/persist_favorite/persist_favorite_route_schema.yaml @@ -1,6 +1,6 @@ openapi: 3.0.0 info: - title: Elastic Security - Timeline - Favorite API (https://www.elastic.co/guide/en/security/current/timeline-api-delete.html) + title: Elastic Security - Timeline - Favorite API version: 8.9.0 servers: - url: 'http://{kibana_host}:{port}' @@ -13,11 +13,11 @@ paths: /api/timeline/_favorite: patch: operationId: persistFavoriteRoute - description: Persists a given users favorite status of a timeline. + summary: Persists a given users favorite status of a timeline. tags: - access:securitySolution requestBody: - description: The required timeline fields used to create a new timeline along with optional fields that will be created if not provided. + description: The required fields used to favorite a (template) timeline. required: true content: application/json: @@ -38,7 +38,7 @@ paths: - $ref: '../model/components.yaml#/components/schemas/TimelineType' - nullable: true responses: - 200: + '200': description: Indicates the favorite status was successfully updated. content: application/json: @@ -52,7 +52,7 @@ paths: $ref: '../model/components.yaml#/components/schemas/FavoriteTimelineResponse' required: - data - 403: + '403': description: Indicates the user does not have the required permissions to persist the favorite status. content: application:json: diff --git a/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route_schema.yaml index f8ba6ecc9747..f0c9e140f241 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route_schema.yaml @@ -15,8 +15,8 @@ servers: paths: /api/note: patch: - operationId: persistNoteroute - description: Persists a note to a timeline. + operationId: persistNoteRoute + summary: Persists a note to a timeline. tags: - access:securitySolution requestBody: @@ -41,8 +41,8 @@ paths: type: string nullable: true responses: - 200: - description: Indicates the favorite status was successfully updated. + '200': + description: Indicates the note was successfully created. content: application/json: schema: diff --git a/x-pack/plugins/security_solution/common/api/timeline/pinned_events/pinned_events_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/pinned_events/pinned_events_route_schema.yaml index 6c39b80a782c..506cd0cc1554 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/pinned_events/pinned_events_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/pinned_events/pinned_events_route_schema.yaml @@ -1,7 +1,10 @@ openapi: 3.0.0 info: - title: Elastic Security - Timeline - Pinned Event API (https://www.elastic.co/guide/en/security/current/_pin_an_event_to_an_existing_timeline.html) + title: Elastic Security - Timeline - Pinned Event API version: 8.14.0 +externalDocs: + url: https://www.elastic.co/guide/en/security/current/_pin_an_event_to_an_existing_timeline.html + description: Documentation servers: - url: 'http://{kibana_host}:{port}' variables: @@ -13,7 +16,7 @@ paths: /api/pinned_event: patch: operationId: persistPinnedEventRoute - description: Persists a pinned event to a timeline. + summary: Persists a pinned event to a timeline. tags: - access:securitySolution requestBody: @@ -34,7 +37,7 @@ paths: timelineId: type: string responses: - 200: + '200': description: Indicate the event was successfully pinned in the timeline. content: application/json: 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 54c81cf93568..8e06f46f1f46 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/constants.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/constants.ts @@ -47,6 +47,7 @@ export const SUPPRESSIBLE_ALERT_RULES: Type[] = [ 'new_terms', 'threat_match', 'eql', + 'machine_learning', ]; export const SUPPRESSIBLE_ALERT_RULES_GA: Type[] = ['saved_query', 'query']; 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 2e5ac39936fa..a4db006a6746 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 @@ -236,9 +236,7 @@ describe('Alert Suppression Rules', () => { expect(isSuppressibleAlertRule('threat_match')).toBe(true); expect(isSuppressibleAlertRule('new_terms')).toBe(true); expect(isSuppressibleAlertRule('eql')).toBe(true); - - // Rule types that don't support alert suppression: - expect(isSuppressibleAlertRule('machine_learning')).toBe(false); + expect(isSuppressibleAlertRule('machine_learning')).toBe(true); }); test('should return false for an unknown rule type', () => { @@ -273,9 +271,7 @@ describe('Alert Suppression Rules', () => { expect(isSuppressionRuleConfiguredWithDuration('threat_match')).toBe(true); expect(isSuppressionRuleConfiguredWithDuration('new_terms')).toBe(true); expect(isSuppressionRuleConfiguredWithDuration('eql')).toBe(true); - - // Rule types that don't support alert suppression: - expect(isSuppressionRuleConfiguredWithDuration('machine_learning')).toBe(false); + expect(isSuppressionRuleConfiguredWithDuration('machine_learning')).toBe(true); }); test('should return false for an unknown rule type', () => { @@ -294,9 +290,7 @@ describe('Alert Suppression Rules', () => { expect(isSuppressionRuleConfiguredWithGroupBy('threat_match')).toBe(true); expect(isSuppressionRuleConfiguredWithGroupBy('new_terms')).toBe(true); expect(isSuppressionRuleConfiguredWithGroupBy('eql')).toBe(true); - - // Rule types that don't support alert suppression: - expect(isSuppressionRuleConfiguredWithGroupBy('machine_learning')).toBe(false); + expect(isSuppressionRuleConfiguredWithGroupBy('machine_learning')).toBe(true); }); test('should return false for a threshold rule type', () => { @@ -320,9 +314,7 @@ describe('Alert Suppression Rules', () => { expect(isSuppressionRuleConfiguredWithMissingFields('threat_match')).toBe(true); expect(isSuppressionRuleConfiguredWithMissingFields('new_terms')).toBe(true); expect(isSuppressionRuleConfiguredWithMissingFields('eql')).toBe(true); - - // Rule types that don't support alert suppression: - expect(isSuppressionRuleConfiguredWithMissingFields('machine_learning')).toBe(false); + expect(isSuppressionRuleConfiguredWithMissingFields('machine_learning')).toBe(true); }); test('should return false for a threshold rule type', () => { diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hearbeats.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hearbeats.ts index 10a3af979a12..ff6381cb9d3d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hearbeats.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hearbeats.ts @@ -34,7 +34,8 @@ interface EndpointHeartbeat { export const indexEndpointHeartbeats = async ( esClient: Client, log: ToolingLog, - count: number + count: number = 1, + unbilledCount: number = 1 ): Promise<IndexedEndpointHeartbeats> => { log.debug(`Indexing ${count} endpoint heartbeats`); const startTime = new Date(); @@ -59,22 +60,23 @@ export const indexEndpointHeartbeats = async ( return heartbeatDoc; }); - // billable: false are not billed - const invalidDocs: EndpointHeartbeat[] = [ - { + const unbilledDocs: EndpointHeartbeat[] = Array.from({ length: unbilledCount }).map((_, i) => { + const ingested = new Date(startTime.getTime() + i).toISOString(); + + return { '@timestamp': '2024-06-11T13:03:37Z', agent: { - id: 'agent-billable-false', + id: `agent-billable-false-${i}`, }, event: { agent_id_status: 'auth_metadata_missing', - ingested: new Date().toISOString(), + ingested, }, billable: false, - }, - ]; + }; + }); - const operations = docs.concat(invalidDocs).flatMap((doc) => [ + const operations = docs.concat(unbilledDocs).flatMap((doc) => [ { index: { _index: ENDPOINT_HEARTBEAT_INDEX, diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts index 362156ce3b5f..9917742d7b7c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts @@ -5,7 +5,9 @@ * 2.0. */ +import type { EntryMatch } from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_ARTIFACT_LIST_IDS } from '@kbn/securitysolution-list-constants'; +import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; export const BY_POLICY_ARTIFACT_TAG_PREFIX = 'policy:'; @@ -13,6 +15,21 @@ export const GLOBAL_ARTIFACT_TAG = `${BY_POLICY_ARTIFACT_TAG_PREFIX}all`; export const FILTER_PROCESS_DESCENDANTS_TAG = 'filter_process_descendants'; +export const PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY: EntryMatch = Object.freeze({ + field: 'event.category', + operator: 'included', + type: 'match', + value: 'process', +}); + +export const PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY_TEXT: string = `${ + PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY.field +} ${ + EVENT_FILTERS_OPERATORS.find( + ({ type }) => type === PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY.type + )?.message +} ${PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY.value}`; + // TODO: refact all uses of `ALL_ENDPOINT_ARTIFACTS_LIST_IDS to sue new const from shared package export const ALL_ENDPOINT_ARTIFACT_LIST_IDS = ENDPOINT_ARTIFACT_LIST_IDS; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/utils.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/utils.ts index 50b4861a345c..322cd87fd7be 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/utils.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/utils.ts @@ -23,7 +23,7 @@ export type TagFilter = (tag: string) => boolean; const POLICY_ID_START_POSITION = BY_POLICY_ARTIFACT_TAG_PREFIX.length; export const isArtifactGlobal = (item: Partial<Pick<ExceptionListItemSchema, 'tags'>>): boolean => { - return (item.tags ?? []).find((tag) => tag === GLOBAL_ARTIFACT_TAG) !== undefined; + return (item.tags ?? []).includes(GLOBAL_ARTIFACT_TAG); }; export const isArtifactByPolicy = (item: Pick<ExceptionListItemSchema, 'tags'>): boolean => { @@ -96,7 +96,7 @@ export const getEffectedPolicySelectionByTags = ( export const isFilterProcessDescendantsEnabled = ( item: Partial<Pick<ExceptionListItemSchema, 'tags'>> -): boolean => (item.tags ?? []).find((tag) => tag === FILTER_PROCESS_DESCENDANTS_TAG) !== undefined; +): boolean => (item.tags ?? []).includes(FILTER_PROCESS_DESCENDANTS_TAG); export const isFilterProcessDescendantsTag: TagFilter = (tag) => tag === FILTER_PROCESS_DESCENDANTS_TAG; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 53c5bdd8a657..66b5f4bd948a 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -153,6 +153,11 @@ export const allowedExperimentalValues = Object.freeze({ */ protectionUpdatesEnabled: true, + /** + * Enables AI assistant on rule creation form when query has error + */ + AIAssistantOnRuleCreationFormEnabled: false, + /** * Disables the timeline save tour. * This flag is used to disable the tour in cypress tests. @@ -170,6 +175,11 @@ export const allowedExperimentalValues = Object.freeze({ */ riskEnginePrivilegesRouteEnabled: true, + /** + * Enables alerts suppression for machine learning rules + */ + alertSuppressionForMachineLearningRuleEnabled: false, + /** * Enables experimental Experimental S1 integration data to be available in Analyzer */ @@ -227,7 +237,7 @@ export const allowedExperimentalValues = Object.freeze({ /** * Enables unified manifest that replaces existing user artifacts manifest SO with a new approach of creating a SO per package policy. */ - unifiedManifestEnabled: false, + unifiedManifestEnabled: true, /** * Enables Security AI Assistant's Flyout mode diff --git a/x-pack/plugins/security_solution/common/types/header_actions/index.ts b/x-pack/plugins/security_solution/common/types/header_actions/index.ts index 2bb0c4ff5f33..abbb5d115fc4 100644 --- a/x-pack/plugins/security_solution/common/types/header_actions/index.ts +++ b/x-pack/plugins/security_solution/common/types/header_actions/index.ts @@ -103,11 +103,16 @@ export interface ActionProps { setEventsDeleted: SetEventsDeleted; setEventsLoading: SetEventsLoading; showCheckboxes: boolean; + /** + * This prop is used to determine if the notes button should be displayed + * as the part of Row Actions + * */ showNotes?: boolean; tabType?: string; timelineId: string; toggleShowNotes?: () => void; width?: number; + disablePinAction?: boolean; } interface AdditionalControlColumnProps { diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index 14741ec4c37e..f682ca478a17 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -70,7 +70,8 @@ "dataViewFieldEditor", "osquery", "savedObjectsTaggingOss", - "guidedOnboarding" + "guidedOnboarding", + "integrationAssistant" ], "requiredBundles": [ "esUiShared", diff --git a/x-pack/plugins/security_solution/public/assistant/content/prompts/system/index.tsx b/x-pack/plugins/security_solution/public/assistant/content/prompts/system/index.tsx index 6b1976cd5207..ee9d4018365c 100644 --- a/x-pack/plugins/security_solution/public/assistant/content/prompts/system/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/content/prompts/system/index.tsx @@ -5,7 +5,11 @@ * 2.0. */ -import type { Prompt } from '@kbn/elastic-assistant'; +import { + PromptTypeEnum, + type PromptResponse, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; +import { APP_UI_ID } from '../../../../../common'; import { DEFAULT_SYSTEM_PROMPT_NAME, DEFAULT_SYSTEM_PROMPT_NON_I18N, @@ -16,20 +20,22 @@ import { /** * Base System Prompts for Security Solution. */ -export const BASE_SECURITY_SYSTEM_PROMPTS: Prompt[] = [ +export const BASE_SECURITY_SYSTEM_PROMPTS: PromptResponse[] = [ { id: 'default-system-prompt', content: DEFAULT_SYSTEM_PROMPT_NON_I18N, name: DEFAULT_SYSTEM_PROMPT_NAME, - promptType: 'system', + promptType: PromptTypeEnum.system, isDefault: true, isNewConversationDefault: true, + consumer: APP_UI_ID, }, { id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28', content: SUPERHERO_SYSTEM_PROMPT_NON_I18N, name: SUPERHERO_SYSTEM_PROMPT_NAME, - promptType: 'system', + promptType: PromptTypeEnum.system, + consumer: APP_UI_ID, isDefault: true, }, ]; diff --git a/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/index.tsx b/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/index.tsx index 799087f202e9..adb952d66121 100644 --- a/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/content/quick_prompts/index.tsx @@ -5,7 +5,11 @@ * 2.0. */ -import type { QuickPrompt } from '@kbn/elastic-assistant'; +import { + PromptTypeEnum, + type PromptResponse, +} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen'; +import { APP_UI_ID } from '../../../../common'; import * as i18n from './translations'; import { KNOWLEDGE_BASE_CATEGORY, @@ -19,51 +23,72 @@ import { * Useful if wanting to see all available QuickPrompts in one place, or if needing * to reference when constructing a new chat window to include a QuickPrompt. */ -export const BASE_SECURITY_QUICK_PROMPTS: QuickPrompt[] = [ +export const BASE_SECURITY_QUICK_PROMPTS: PromptResponse[] = [ { - title: i18n.ALERT_SUMMARIZATION_TITLE, - prompt: i18n.ALERT_SUMMARIZATION_PROMPT, + name: i18n.ALERT_SUMMARIZATION_TITLE, + content: i18n.ALERT_SUMMARIZATION_PROMPT, color: '#F68FBE', categories: [PROMPT_CONTEXT_ALERT_CATEGORY], isDefault: true, + id: i18n.ALERT_SUMMARIZATION_TITLE, + promptType: PromptTypeEnum.quick, + consumer: APP_UI_ID, }, { - title: i18n.ESQL_QUERY_GENERATION_TITLE, - prompt: i18n.ESQL_QUERY_GENERATION_PROMPT, + name: i18n.ESQL_QUERY_GENERATION_TITLE, + content: i18n.ESQL_QUERY_GENERATION_PROMPT, color: '#9170B8', categories: [KNOWLEDGE_BASE_CATEGORY], isDefault: true, + id: i18n.ESQL_QUERY_GENERATION_TITLE, + promptType: PromptTypeEnum.quick, + consumer: APP_UI_ID, }, { - title: i18n.RULE_CREATION_TITLE, - prompt: i18n.RULE_CREATION_PROMPT, + name: i18n.RULE_CREATION_TITLE, + content: i18n.RULE_CREATION_PROMPT, categories: [PROMPT_CONTEXT_DETECTION_RULES_CATEGORY], color: '#7DDED8', isDefault: true, + id: i18n.RULE_CREATION_TITLE, + promptType: PromptTypeEnum.quick, + consumer: APP_UI_ID, }, { - title: i18n.WORKFLOW_ANALYSIS_TITLE, - prompt: i18n.WORKFLOW_ANALYSIS_PROMPT, + name: i18n.WORKFLOW_ANALYSIS_TITLE, + content: i18n.WORKFLOW_ANALYSIS_PROMPT, color: '#36A2EF', isDefault: true, + id: i18n.WORKFLOW_ANALYSIS_TITLE, + promptType: PromptTypeEnum.quick, + consumer: APP_UI_ID, }, { - title: i18n.THREAT_INVESTIGATION_GUIDES_TITLE, - prompt: i18n.THREAT_INVESTIGATION_GUIDES_PROMPT, + name: i18n.THREAT_INVESTIGATION_GUIDES_TITLE, + content: i18n.THREAT_INVESTIGATION_GUIDES_PROMPT, categories: [PROMPT_CONTEXT_EVENT_CATEGORY], color: '#F3D371', isDefault: true, + id: i18n.THREAT_INVESTIGATION_GUIDES_TITLE, + promptType: PromptTypeEnum.quick, + consumer: APP_UI_ID, }, { - title: i18n.SPL_QUERY_CONVERSION_TITLE, - prompt: i18n.SPL_QUERY_CONVERSION_PROMPT, + name: i18n.SPL_QUERY_CONVERSION_TITLE, + content: i18n.SPL_QUERY_CONVERSION_PROMPT, color: '#BADA55', isDefault: true, + id: i18n.SPL_QUERY_CONVERSION_TITLE, + promptType: PromptTypeEnum.quick, + consumer: APP_UI_ID, }, { - title: i18n.AUTOMATION_TITLE, - prompt: i18n.AUTOMATION_PROMPT, + name: i18n.AUTOMATION_TITLE, + content: i18n.AUTOMATION_PROMPT, color: '#FFA500', isDefault: true, + id: i18n.AUTOMATION_TITLE, + promptType: PromptTypeEnum.quick, + consumer: APP_UI_ID, }, ]; diff --git a/x-pack/plugins/security_solution/public/assistant/provider.tsx b/x-pack/plugins/security_solution/public/assistant/provider.tsx index 2b5daf73fbf4..134bfb25c15a 100644 --- a/x-pack/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/plugins/security_solution/public/assistant/provider.tsx @@ -15,24 +15,28 @@ import { AssistantProvider as ElasticAssistantProvider, bulkUpdateConversations, getUserConversations, + getPrompts, + bulkUpdatePrompts, } from '@kbn/elastic-assistant'; import { once } from 'lodash/fp'; import type { HttpSetup } from '@kbn/core-http-browser'; import type { Message } from '@kbn/elastic-assistant-common'; import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; +import { useObservable } from 'react-use'; import { APP_ID } from '../../common'; import { useBasePath, useKibana } from '../common/lib/kibana'; import { useAssistantTelemetry } from './use_assistant_telemetry'; import { getComments } from './get_comments'; import { LOCAL_STORAGE_KEY, augmentMessageCodeBlocks } from './helpers'; -import { useBaseConversations } from './use_conversation_store'; -import { PROMPT_CONTEXTS } from './content/prompt_contexts'; import { BASE_SECURITY_QUICK_PROMPTS } from './content/quick_prompts'; import { BASE_SECURITY_SYSTEM_PROMPTS } from './content/prompts/system'; +import { useBaseConversations } from './use_conversation_store'; +import { PROMPT_CONTEXTS } from './content/prompt_contexts'; import { useAssistantAvailability } from './use_assistant_availability'; import { useAppToasts } from '../common/hooks/use_app_toasts'; import { useSignalIndex } from '../detections/containers/detection_engine/alerts/use_signal_index'; +import { licenseService } from '../common/hooks/use_license'; const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', { defaultMessage: 'Elastic AI Assistant', @@ -112,12 +116,28 @@ export const createConversations = async ( } }; +export const createBasePrompts = async (notifications: NotificationsStart, http: HttpSetup) => { + const promptsToCreate = [...BASE_SECURITY_QUICK_PROMPTS, ...BASE_SECURITY_SYSTEM_PROMPTS]; + + // post bulk create + const bulkResult = await bulkUpdatePrompts( + http, + { + create: promptsToCreate, + }, + notifications.toasts + ); + if (bulkResult && bulkResult.success) { + return true; + } +}; + /** * This component configures the Elastic AI Assistant context provider for the Security Solution app. */ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children }) => { const { - application: { navigateToApp }, + application: { navigateToApp, currentAppId$ }, http, notifications, storage, @@ -129,29 +149,59 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children }) const baseConversations = useBaseConversations(); const assistantAvailability = useAssistantAvailability(); const assistantTelemetry = useAssistantTelemetry(); - + const currentAppId = useObservable(currentAppId$, ''); + const hasEnterpriseLicence = licenseService.isEnterprise(); useEffect(() => { const migrateConversationsFromLocalStorage = once(async () => { - const res = await getUserConversations({ - http, - }); if ( + hasEnterpriseLicence && assistantAvailability.isAssistantEnabled && - assistantAvailability.hasAssistantPrivilege && - res.total === 0 + assistantAvailability.hasAssistantPrivilege ) { - await createConversations(notifications, http, storage); + const res = await getUserConversations({ + http, + }); + if (res.total === 0) { + await createConversations(notifications, http, storage); + } } }); migrateConversationsFromLocalStorage(); }, [ assistantAvailability.hasAssistantPrivilege, assistantAvailability.isAssistantEnabled, + hasEnterpriseLicence, http, notifications, storage, ]); + useEffect(() => { + const createSecurityPrompts = once(async () => { + if ( + hasEnterpriseLicence && + assistantAvailability.isAssistantEnabled && + assistantAvailability.hasAssistantPrivilege + ) { + const res = await getPrompts({ + http, + toasts: notifications.toasts, + }); + + if (res.total === 0) { + await createBasePrompts(notifications, http); + } + } + }); + createSecurityPrompts(); + }, [ + assistantAvailability.hasAssistantPrivilege, + assistantAvailability.isAssistantEnabled, + hasEnterpriseLicence, + http, + notifications, + ]); + const { signalIndexName } = useSignalIndex(); const alertsIndexPattern = signalIndexName ?? undefined; const toasts = useAppToasts() as unknown as IToasts; // useAppToasts is the current, non-deprecated method of getting the toasts service in the Security Solution, but it doesn't return the IToasts interface (defined by core) @@ -166,14 +216,13 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children }) docLinks={{ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }} basePath={basePath} basePromptContexts={Object.values(PROMPT_CONTEXTS)} - baseQuickPrompts={BASE_SECURITY_QUICK_PROMPTS} // to server and plugin start - baseSystemPrompts={BASE_SECURITY_SYSTEM_PROMPTS} // to server and plugin start baseConversations={baseConversations} getComments={getComments} http={http} navigateToApp={navigateToApp} title={ASSISTANT_TITLE} toasts={toasts} + currentAppId={currentAppId ?? 'securitySolutionUI'} > {children} </ElasticAssistantProvider> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx index 4e3e997f3a8d..874a4d1c99de 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx @@ -147,7 +147,7 @@ export const usePollApi = ({ if (connectorId == null || connectorId === '') { throw new Error('Invalid connector id'); } - // edge case - clearTimeout does not always work in time, + // edge case - clearTimeout does not always work in time // so we need to check if the connectorId has changed if (connectorId !== connectorIdRef.current) { return; diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx index 92ae4d094ed4..416dfcae71c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx @@ -169,31 +169,36 @@ const RowActionComponent = ({ tabType, ]); - const toggleShowNotes = useCallback( - () => - openFlyout({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: eventId, - indexName, - scopeId: tableId, - }, + const toggleShowNotes = useCallback(() => { + openFlyout({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId: tableId, }, - left: { - id: DocumentDetailsLeftPanelKey, - path: { - tab: LeftPanelNotesTab, - }, - params: { - id: eventId, - indexName, - scopeId: tableId, - }, + }, + left: { + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelNotesTab, }, - }), - [eventId, indexName, openFlyout, tableId] - ); + params: { + id: eventId, + indexName, + scopeId: tableId, + }, + }, + }); + telemetry.reportOpenNoteInExpandableFlyoutClicked({ + location: tableId, + }); + telemetry.reportDetailsFlyoutOpened({ + location: tableId, + panel: 'left', + }); + }, [eventId, indexName, openFlyout, tableId, telemetry]); const Action = controlColumn.rowCellRender; @@ -231,6 +236,7 @@ const RowActionComponent = ({ setEventsLoading={setEventsLoading} setEventsDeleted={setEventsDeleted} refetch={refetch} + showNotes={!expandableFlyoutDisabled && securitySolutionNotesEnabled ? true : false} /> )} </> diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/indicator_with_nested_objects.ts b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/indicator_with_nested_objects.ts new file mode 100644 index 000000000000..189cc45da4aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/indicator_with_nested_objects.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * This represents an indicator document with an array of objects as field + * values. This shape of indicator was previously causing render errors in the + * CTI UI. + */ +export const indicatorWithNestedObjects = { + 'threat.indicator.type': ['ipv4-addr'], + 'elastic_agent.version': ['8.10.4'], + 'event.category': ['threat'], + 'recordedfuture.risk_string': ['7/75'], + 'threat.indicator.provider': [ + 'Mastodon', + 'Twitter', + 'Recorded Future Command & Control Reports', + 'Recorded Future Sandbox - Malware C2 Extractions', + 'GitHub', + 'Recorded Future Command & Control Validation', + 'Malware Patrol', + 'Polyswarm Sandbox Analysis - Malware C2 Extractions', + 'Recorded Future Triage Malware Analysis - Malware C2 Extractions', + ], + 'agent.type': ['filebeat'], + 'agent.name': ['win-10'], + 'elastic_agent.snapshot': [false], + 'event.agent_id_status': ['verified'], + 'event.kind': ['enrichment'], + 'threat.feed.name': ['Recorded Future'], + 'elastic_agent.id': ['e8ffaf42-7436-4e39-b895-772bb86e6585'], + 'recordedfuture.name': ['188.116.21.141'], + 'data_stream.namespace': ['default'], + 'recordedfuture.evidence_details': [ + { + SourcesCount: 2, + SightingsCount: 2, + CriticalityLabel: 'Unusual', + Rule: 'Recently Reported as a Defanged IP', + EvidenceString: + '2 sightings on 2 sources: Mastodon, Twitter. Most recent link (Feb 13, 2024): https://ioc.exchange/@SarlackLab/111926194382069197', + Sources: ['source:pupSAn', 'source:BV5'], + Timestamp: '2024-02-13T21:03:10.000Z', + Name: 'recentDefanged', + MitigationString: '', + Criticality: 1, + }, + { + SourcesCount: 2, + SightingsCount: 12, + CriticalityLabel: 'Suspicious', + Rule: 'Historically Reported C&C Server', + EvidenceString: + '12 sightings on 2 sources: Recorded Future Command & Control Reports, Recorded Future Sandbox - Malware C2 Extractions. 188.116.21.141:20213 was reported as a command and control server for RedLine Stealer on Feb 10, 2024', + Sources: ['source:qU_q-9', 'source:oWAG20'], + Timestamp: '2024-02-10T08:22:27.790Z', + Name: 'reportedCnc', + MitigationString: '', + Criticality: 2, + }, + { + SourcesCount: 1, + SightingsCount: 2, + CriticalityLabel: 'Suspicious', + Rule: 'Recently Linked to Intrusion Method', + EvidenceString: + '2 sightings on 1 source: GitHub. 6 related intrusion methods including DDOS Toolkit, njRAT, Phishing, Remote Access Trojan, Stealware. Most recent link (Feb 13, 2024): https://github.com/0xDanielLopez/TweetFeed/commit/fd64eaa71f7e948d1cca1dc8c148b6515e878df5', + Sources: ['source:MIKjae'], + Timestamp: '2024-02-13T21:57:24.894Z', + Name: 'recentLinkedIntrusion', + MitigationString: '', + Criticality: 2, + }, + { + SourcesCount: 1, + SightingsCount: 11, + CriticalityLabel: 'Suspicious', + Rule: 'Previously Validated C&C Server', + EvidenceString: + '11 sightings on 1 source: Recorded Future Command & Control Validation. Recorded Future analysis validated 188.116.21.141:20213 as a command and control server for RedLine Stealer on Feb 22, 2024', + Sources: ['source:qGriFQ'], + Timestamp: '2024-02-22T00:06:26.000Z', + Name: 'validatedCnc', + MitigationString: '', + Criticality: 2, + }, + { + SourcesCount: 1, + SightingsCount: 1, + CriticalityLabel: 'Suspicious', + Rule: 'Recent Suspected C&C Server', + EvidenceString: + '1 sighting on 1 source: Malware Patrol. Malware Patrol identified 188.116.21.141:20213 as a command and control server for RecordBreaker Stealer on February 14, 2024.', + Sources: ['source:qs_-cU'], + Timestamp: '2024-02-14T10:55:01.908Z', + Name: 'recentSuspectedCnc', + MitigationString: '', + Criticality: 2, + }, + { + SourcesCount: 4, + SightingsCount: 26, + CriticalityLabel: 'Malicious', + Rule: 'Recently Reported C&C Server', + EvidenceString: + '26 sightings on 4 sources: Polyswarm Sandbox Analysis - Malware C2 Extractions, Recorded Future Command & Control Reports, Recorded Future Triage Malware Analysis - Malware C2 Extractions, Recorded Future Sandbox - Malware C2 Extractions. 188.116.21.141:20213 was reported as a command and control server for Redline Stealer on Feb 21, 2024', + Sources: ['source:hyihHO', 'source:qU_q-9', 'source:nTcIsu', 'source:oWAG20'], + Timestamp: '2024-02-21T08:22:44.811Z', + Name: 'recentReportedCnc', + MitigationString: '', + Criticality: 3, + }, + { + SourcesCount: 1, + SightingsCount: 3, + CriticalityLabel: 'Very Malicious', + Rule: 'Validated C&C Server', + EvidenceString: + '3 sightings on 1 source: Recorded Future Command & Control Validation. Recorded Future analysis validated 188.116.21.141:20213 as a command and control server for RedLine Stealer on Feb 24, 2024', + Sources: ['source:qGriFQ'], + Timestamp: '2024-02-24T00:52:16.000Z', + Name: 'recentValidatedCnc', + MitigationString: '', + Criticality: 4, + }, + ], + 'input.type': ['httpjson'], + 'data_stream.type': ['logs'], + 'event.risk_score': [98], + tags: ['forwarded', 'recordedfuture'], + 'event.ingested': ['2024-02-24T17:32:40.000Z'], + '@timestamp': ['2024-02-24T17:32:37.813Z'], + 'agent.id': ['e8ffaf42-7436-4e39-b895-772bb86e6585'], + 'threat.indicator.ip': ['188.116.21.141'], + 'ecs.version': ['8.11.0'], + 'data_stream.dataset': ['ti_recordedfuture.threat'], + 'event.created': ['2024-02-24T17:32:37.813Z'], + 'event.type': ['indicator'], + 'agent.ephemeral_id': ['0532c813-1434-4c76-800b-6abdf7eaf62c'], + 'agent.version': ['8.10.4'], + 'event.dataset': ['ti_recordedfuture.threat'], +} as const; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.test.tsx index c2b044b35cae..52b6493a25c2 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.test.tsx @@ -60,7 +60,7 @@ describe('getColumns', () => { describe('column actions', () => { let actionsColumn: Column; - const mockDataToUse = mockBrowserFields.agent; + const mockDataToUse = mockBrowserFields.agent.fields; const testValue = 'testValue'; const testData = { type: 'someType', diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_accordion_group.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_accordion_group.test.tsx new file mode 100644 index 000000000000..3462069e0aa1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_accordion_group.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; 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 { EnrichmentAccordionGroup } from './enrichment_accordion_group'; +import { TestProviders } from '../../../mock'; +import { indicatorWithNestedObjects } from '../__mocks__/indicator_with_nested_objects'; +import type { CtiEnrichment } from '../../../../../common/search_strategy'; + +describe('EnrichmentAccordionGroup', () => { + describe('with an indicator with an array of nested objects as a field value', () => { + it('renders the indicator without those fields', () => { + // @ts-expect-error this indicator intentionally does not conform to the CtiEnrichment type + const enrichments = [indicatorWithNestedObjects] as CtiEnrichment[]; + + const { getByTestId } = render( + <TestProviders> + <EnrichmentAccordionGroup enrichments={enrichments} /> + </TestProviders> + ); + + const enrichmentView = getByTestId('threat-details-view-0'); + + expect(enrichmentView).toBeInTheDocument(); + expect(enrichmentView).toHaveTextContent('ipv4-addr'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_accordion_group.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_accordion_group.tsx index 71100ee3bc07..da9b26ddc4e4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_accordion_group.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_accordion_group.tsx @@ -18,7 +18,12 @@ import { import type { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti'; import type { ThreatDetailsRow } from './helpers'; -import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment, getFirstSeen } from './helpers'; +import { + getEnrichmentIdentifiers, + isInvestigationTimeEnrichment, + getFirstSeen, + buildThreatDetailsItems, +} from './helpers'; import { EnrichmentButtonContent } from './enrichment_button_content'; import { ThreatSummaryTitle } from './threat_summary_title'; import { InspectButton } from '../../inspect'; @@ -26,8 +31,6 @@ import { QUERY_ID } from '../../../containers/cti/event_enrichment'; import * as i18n from './translations'; import { ThreatSummaryTable } from './threat_summary_table'; import { REFERENCE } from '../../../../../common/cti/constants'; -import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants'; -import { getFirstElement } from '../../../../../common/utils/data_retrieval'; const StyledEuiAccordion = styled(EuiAccordion)` .euiAccordion__triggerWrapper { @@ -82,19 +85,6 @@ const columns: Array<EuiBasicTableColumn<ThreatDetailsRow>> = [ }, ]; -const buildThreatDetailsItems = (enrichment: CtiEnrichment) => - Object.keys(enrichment) - .sort() - .map((field) => ({ - title: field.startsWith(DEFAULT_INDICATOR_SOURCE_PATH) - ? field.replace(`${DEFAULT_INDICATOR_SOURCE_PATH}`, 'indicator') - : field, - description: { - fieldName: field, - value: getFirstElement(enrichment[field]), - }, - })); - const EnrichmentAccordion: React.FC<{ enrichment: CtiEnrichment; index: number; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx index c8e8f2bfb24d..f9a0ac72b54a 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/enrichment_summary.tsx @@ -21,7 +21,6 @@ import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment } from './helpe import type { FieldsData } from '../types'; import type { - BrowserField, BrowserFields, TimelineEventsDetailsItem, } from '../../../../../common/search_strategy'; @@ -30,7 +29,6 @@ import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view import { getSourcererScopeId } from '../../../../helpers'; export interface ThreatSummaryDescription { - browserField: BrowserField; data: FieldsData | undefined; eventId: string; index: number; @@ -63,7 +61,6 @@ export const StyledEuiFlexGroup = styled(EuiFlexGroup)` `; const EnrichmentDescription: React.FC<ThreatSummaryDescription> = ({ - browserField, data, eventId, index, @@ -179,7 +176,6 @@ const EnrichmentSummaryComponent: React.FC<{ scopeId={scopeId} value={value} data={fieldsData} - browserField={browserField} isDraggable={isDraggable} isReadOnly={isReadOnly} /> @@ -210,7 +206,6 @@ const EnrichmentSummaryComponent: React.FC<{ scopeId={scopeId} value={value} data={fieldsData} - browserField={browserField} isDraggable={isDraggable} isReadOnly={isReadOnly} /> diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.test.tsx index 169a14fb4df7..b1573663313e 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.test.tsx @@ -12,6 +12,7 @@ import { getEnrichmentFields, parseExistingEnrichments, getEnrichmentIdentifiers, + buildThreatDetailsItems, } from './helpers'; describe('parseExistingEnrichments', () => { @@ -492,3 +493,133 @@ describe('getEnrichmentIdentifiers', () => { }); }); }); + +describe('buildThreatDetailsItems', () => { + it('returns an empty array if given an empty enrichment', () => { + expect(buildThreatDetailsItems({})).toEqual([]); + }); + + it('returns an array of threat details items', () => { + const enrichment = { + 'matched.field': ['matched field'], + 'matched.atomic': ['matched atomic'], + 'matched.type': ['matched type'], + 'feed.name': ['feed name'], + }; + expect(buildThreatDetailsItems(enrichment)).toEqual([ + { + description: { + fieldName: 'feed.name', + value: 'feed name', + }, + title: 'feed.name', + }, + { + description: { + fieldName: 'matched.atomic', + value: 'matched atomic', + }, + title: 'matched.atomic', + }, + { + description: { + fieldName: 'matched.field', + value: 'matched field', + }, + title: 'matched.field', + }, + { + description: { + fieldName: 'matched.type', + value: 'matched type', + }, + title: 'matched.type', + }, + ]); + }); + + it('retrieves the first value of an array field', () => { + const enrichment = { + array_values: ['first value', 'second value'], + }; + + expect(buildThreatDetailsItems(enrichment)).toEqual([ + { + title: 'array_values', + description: { + fieldName: 'array_values', + value: 'first value', + }, + }, + ]); + }); + + it('shortens indicator field names if they contain the default indicator path', () => { + const enrichment = { + 'threat.indicator.ip': ['127.0.0.1'], + }; + expect(buildThreatDetailsItems(enrichment)).toEqual([ + { + title: 'indicator.ip', + description: { + fieldName: 'threat.indicator.ip', + value: '127.0.0.1', + }, + }, + ]); + }); + + it('parses an object field', () => { + const enrichment = { + 'object_field.foo': ['bar'], + }; + + expect(buildThreatDetailsItems(enrichment)).toEqual([ + { + title: 'object_field.foo', + description: { + fieldName: 'object_field.foo', + value: 'bar', + }, + }, + ]); + }); + + describe('edge cases', () => { + describe('field responses for fields of type "flattened"', () => { + it('returns a note for the value of a flattened field containing a single object', () => { + const enrichment = { + flattened_object: [{ foo: 'bar' }], + }; + + expect(buildThreatDetailsItems(enrichment)).toEqual([ + { + title: 'flattened_object', + description: { + fieldName: 'flattened_object', + value: + 'This field contains nested object values, which are not rendered here. See the full document for all fields/values', + }, + }, + ]); + }); + + it('returns a note for the value of a flattened field containing an array of objects', () => { + const enrichment = { + array_field: [{ foo: 'bar' }, { baz: 'qux' }], + }; + + expect(buildThreatDetailsItems(enrichment)).toEqual([ + { + title: 'array_field', + description: { + fieldName: 'array_field', + value: + 'This field contains nested object values, which are not rendered here. See the full document for all fields/values', + }, + }, + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.tsx index f124d59581fb..84e2dab7e7aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/helpers.tsx @@ -5,9 +5,12 @@ * 2.0. */ -import { groupBy } from 'lodash'; +import { groupBy, isObject } from 'lodash'; import { getDataFromFieldsHits } from '../../../../../common/utils/field_formatters'; -import { ENRICHMENT_DESTINATION_PATH } from '../../../../../common/constants'; +import { + DEFAULT_INDICATOR_SOURCE_PATH, + ENRICHMENT_DESTINATION_PATH, +} from '../../../../../common/constants'; import { ENRICHMENT_TYPES, FIRST_SEEN, @@ -25,6 +28,7 @@ import type { } from '../../../../../common/search_strategy/security_solution/cti'; import { isValidEventField } from '../../../../../common/search_strategy/security_solution/cti'; import { getFirstElement } from '../../../../../common/utils/data_retrieval'; +import * as i18n from './translations'; export const isInvestigationTimeEnrichment = (type: string | undefined) => type === ENRICHMENT_TYPES.InvestigationTime; @@ -134,3 +138,24 @@ export interface ThreatDetailsRow { value: string; }; } + +interface ThreatDetailItem { + title: string; + description: { fieldName: string; value: unknown }; +} + +export const buildThreatDetailsItems = (enrichment: CtiEnrichment): ThreatDetailItem[] => + Object.keys(enrichment) + .sort() + .map((field) => { + const title = field.startsWith(DEFAULT_INDICATOR_SOURCE_PATH) + ? field.replace(`${DEFAULT_INDICATOR_SOURCE_PATH}`, 'indicator') + : field; + + let value = getFirstElement(enrichment[field]); + if (isObject(value)) { + value = i18n.NESTED_OBJECT_VALUES_NOT_RENDERED; + } + + return { title, description: { fieldName: field, value } }; + }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/translations.ts index 973b438c866c..ecc5dec40d99 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/translations.ts @@ -92,6 +92,14 @@ export const ENRICHED_DATA = i18n.translate( } ); +export const NESTED_OBJECT_VALUES_NOT_RENDERED = i18n.translate( + 'xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentObjectValuesNotRendered', + { + defaultMessage: + 'This field contains nested object values, which are not rendered here. See the full document for all fields/values', + } +); + export const CURRENT_RISK_LEVEL = (riskEntity: RiskScoreEntity) => i18n.translate('xpack.securitySolution.alertDetails.overview.hostRiskLevel', { defaultMessage: 'Current {riskEntity} risk level', diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx index 3848bb8a1529..9913c733446e 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx @@ -21,7 +21,6 @@ const eventId = 'TUWyf3wBFCFU0qRJTauW'; const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::']; const hostIpFieldFromBrowserField: BrowserField = { aggregatable: true, - fields: {}, format: '', indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], name: 'host.ip', @@ -33,7 +32,6 @@ const hostIpData: EventFieldsData = { ...hostIpFieldFromBrowserField, ariaRowindex: 35, field: 'host.ip', - fields: {}, format: '', isObjectArray: false, originalValue: [...hostIpValues], diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx index 2529122140b0..95c689036063 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx @@ -21,7 +21,6 @@ const hostIpData: EventFieldsData = { aggregatable: true, ariaRowindex: 35, field: 'host.ip', - fields: {}, format: '', indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], isObjectArray: false, @@ -87,7 +86,6 @@ describe('FieldValueCell', () => { aggregatable: false, ariaRowindex: 50, field: 'message', - fields: {}, format: '', indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], isObjectArray: false, @@ -102,7 +100,6 @@ describe('FieldValueCell', () => { const messageFieldFromBrowserField: BrowserField = { aggregatable: false, - fields: {}, format: '', indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], name: 'message', @@ -139,7 +136,6 @@ describe('FieldValueCell', () => { describe('when `BrowserField` metadata IS available', () => { const hostIpFieldFromBrowserField: BrowserField = { aggregatable: true, - fields: {}, format: '', indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], name: 'host.ip', diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx index 37f4f4559b50..02b73651ea6b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx @@ -18,7 +18,7 @@ export interface FieldValueCellProps { contextId: string; data: EventFieldsData | FieldsData; eventId: string; - fieldFromBrowserField?: BrowserField; + fieldFromBrowserField?: Partial<BrowserField>; getLinkValue?: (field: string) => string | null; isDraggable?: boolean; linkValue?: string | null | undefined; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx index d48d7cd0fdaa..7facc4e30149 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/prevalence_cell.test.tsx @@ -27,7 +27,6 @@ const eventId = 'TUWyf3wBFCFU0qRJTauW'; const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::']; const hostIpFieldFromBrowserField: BrowserField = { aggregatable: true, - fields: {}, format: '', indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], name: 'host.ip', @@ -39,7 +38,6 @@ const hostIpData: EventFieldsData = { ...hostIpFieldFromBrowserField, ariaRowindex: 35, field: 'host.ip', - fields: {}, format: '', isObjectArray: false, originalValue: [...hostIpValues], diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.test.tsx index 859d1b258c79..183e634a641c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.test.tsx @@ -24,7 +24,6 @@ const eventId = 'TUWyf3wBFCFU0qRJTauW'; const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::']; const hostIpFieldFromBrowserField: BrowserField = { aggregatable: true, - fields: {}, format: '', indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], name: 'host.ip', @@ -36,7 +35,6 @@ const hostIpData: EventFieldsData = { ...hostIpFieldFromBrowserField, ariaRowindex: 35, field: 'host.ip', - fields: {}, format: '', isObjectArray: false, originalValue: [...hostIpValues], @@ -58,7 +56,6 @@ const enrichedAgentStatusData: AlertSummaryRow['description'] = { format: '', type: '', aggregatable: false, - fields: {}, indexes: [], name: AGENT_STATUS_FIELD_NAME, searchable: false, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts index c9d8162af8f0..40611748c69c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts @@ -38,7 +38,7 @@ export interface UseActionCellDataProvider { eventId?: string; field: string; fieldFormat?: string; - fieldFromBrowserField?: BrowserField; + fieldFromBrowserField?: Partial<BrowserField>; fieldType?: string; isObjectArray?: boolean; linkValue?: string | null; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/types.ts b/x-pack/plugins/security_solution/public/common/components/event_details/types.ts index bc76ce88aa2f..87f72da37c8b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/types.ts @@ -20,7 +20,7 @@ export interface FieldsData { export interface EnrichedFieldInfo { data: FieldsData | EventFieldsData; eventId: string; - fieldFromBrowserField?: BrowserField; + fieldFromBrowserField?: Partial<BrowserField>; scopeId: string; values: string[] | null | undefined; linkValue?: string; diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx index a305c41cdc80..bc1ae98fe1be 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx @@ -45,6 +45,12 @@ jest.mock( }) ); +jest.mock('./add_note_icon_item', () => { + return { + AddEventNoteAction: jest.fn(() => <div data-test-subj="add-note-mock-action" />), + }; +}); + jest.mock('../../lib/kibana', () => { const originalKibanaLib = jest.requireActual('../../lib/kibana'); @@ -430,6 +436,28 @@ describe('Actions', () => { }); }); + describe('Show notes action', () => { + test('should show notes action if showNotes is true', () => { + const wrapper = mount( + <TestProviders> + <Actions {...defaultProps} showNotes={true} /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="add-note-mock-action"]').exists()).toBeTruthy(); + }); + + test('should NOT show notes action if showNotes is false', () => { + const wrapper = mount( + <TestProviders> + <Actions {...defaultProps} showNotes={false} /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="add-note-mock-action"]').exists()).toBeFalsy(); + }); + }); + describe('Expand action', () => { test('should not be visible if disableExpandAction is true', () => { const wrapper = mount( @@ -441,4 +469,26 @@ describe('Actions', () => { expect(wrapper.find('[data-test-subj="expand-event"]').exists()).toBeFalsy(); }); }); + + describe('Pin action', () => { + test('should hide pin Action by default', () => { + const wrapper = mount( + <TestProviders> + <Actions {...defaultProps} disableExpandAction /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="pin-event"]').exists()).toBeFalsy(); + }); + + test('should show pin Action by when disablePinAction = false', () => { + const wrapper = mount( + <TestProviders> + <Actions {...defaultProps} disableExpandAction disablePinAction={false} /> + </TestProviders> + ); + + expect(wrapper.find('[data-test-subj="pin-event"]').exists()).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx index 7d346d4fd2c7..9ae3f3adfed8 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx @@ -34,7 +34,6 @@ import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use import { ALERTS_ACTIONS } from '../../lib/apm/user_actions'; import { setActiveTabTimeline } from '../../../timelines/store/actions'; import { EventsTdContent } from '../../../timelines/components/timeline/styles'; -import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { AlertContextMenu } from '../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action'; import * as i18n from './translations'; @@ -43,12 +42,15 @@ import { AlertsCasesTourSteps, SecurityStepId } from '../guided_onboarding_tour/ import { isDetectionsAlertsTable } from '../top_n/helpers'; import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step'; import { DEFAULT_ACTION_BUTTON_WIDTH, isAlert } from './helpers'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; const ActionsContainer = styled.div` align-items: center; display: flex; `; +const emptyNotes: string[] = []; + const ActionsComponent: React.FC<ActionProps> = ({ ariaRowindex, columnValues, @@ -62,16 +64,12 @@ const ActionsComponent: React.FC<ActionProps> = ({ onRuleChange, showNotes, timelineId, - toggleShowNotes, refetch, + toggleShowNotes, + disablePinAction = true, }) => { const dispatch = useDispatch(); - const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( - 'securitySolutionNotesEnabled' - ); - const expandableFlyoutDisabled = useIsExperimentalFeatureEnabled('expandableFlyoutDisabled'); - const emptyNotes: string[] = []; const { timelineType } = useShallowEqualSelector((state) => isTimelineScope(timelineId) ? selectTimelineById(state, timelineId) : timelineDefaults ); @@ -110,8 +108,6 @@ const ActionsComponent: React.FC<ActionProps> = ({ ); }, [ecsData, eventType]); - const notes = useSelector((state: State) => selectNotesByDocumentId(state, eventId)); - const isDisabled = !useIsInvestigateInResolverActionEnabled(ecsData); const { setGlobalFullScreen } = useGlobalFullScreen(); const { setTimelineFullScreen } = useTimelineFullScreen(); @@ -220,6 +216,35 @@ const ActionsComponent: React.FC<ActionProps> = ({ onEventDetailsPanelOpened(); }, [activeStep, incrementStep, isTourAnchor, isTourShown, onEventDetailsPanelOpened]); + const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( + 'securitySolutionNotesEnabled' + ); + + const expandableFlyoutDisabled = useIsExperimentalFeatureEnabled('expandableFlyoutDisabled'); + + /* only applicable for new event based notes */ + const documentBasedNotes = useSelector((state: State) => selectNotesByDocumentId(state, eventId)); + + /* only applicable notes before event based notes */ + const timelineNoteIds = useMemo( + () => eventIdToNoteIds?.[eventId] ?? emptyNotes, + [eventIdToNoteIds, eventId] + ); + + const notesCount = useMemo( + () => + securitySolutionNotesEnabled && !expandableFlyoutDisabled + ? documentBasedNotes.length + : timelineNoteIds.length, + [documentBasedNotes, timelineNoteIds, securitySolutionNotesEnabled, expandableFlyoutDisabled] + ); + + const noteIds = useMemo(() => { + return securitySolutionNotesEnabled && !expandableFlyoutDisabled + ? documentBasedNotes.map((note) => note.noteId) + : timelineNoteIds; + }, [documentBasedNotes, timelineNoteIds, securitySolutionNotesEnabled, expandableFlyoutDisabled]); + return ( <ActionsContainer> <> @@ -254,51 +279,28 @@ const ActionsComponent: React.FC<ActionProps> = ({ /> )} </> - {securitySolutionNotesEnabled && !expandableFlyoutDisabled && toggleShowNotes && ( - <> - <AddEventNoteAction - ariaLabel={i18n.ADD_NOTES_FOR_ROW({ ariaRowindex, columnValues })} - key="add-event-note" - showNotes={false} - toggleShowNotes={toggleShowNotes} - timelineType={timelineType} - eventId={eventId} - notesCount={notes.length} - /> - <PinEventAction - ariaLabel={i18n.PIN_EVENT_FOR_ROW({ ariaRowindex, columnValues, isEventPinned })} - isAlert={isAlert(eventType)} - key="pin-event" - onPinClicked={handlePinClicked} - noteIds={eventIdToNoteIds ? eventIdToNoteIds[eventId] || emptyNotes : emptyNotes} - eventIsPinned={isEventPinned} - timelineType={timelineType} - /> - </> + {!isEventViewer && showNotes && ( + <AddEventNoteAction + ariaLabel={i18n.ADD_NOTES_FOR_ROW({ ariaRowindex, columnValues })} + key="add-event-note" + timelineType={timelineType} + notesCount={notesCount} + eventId={eventId} + toggleShowNotes={toggleShowNotes} + /> + )} + + {!isEventViewer && !disablePinAction && ( + <PinEventAction + ariaLabel={i18n.PIN_EVENT_FOR_ROW({ ariaRowindex, columnValues, isEventPinned })} + isAlert={isAlert(eventType)} + key="pin-event" + onPinClicked={handlePinClicked} + noteIds={noteIds} + eventIsPinned={isEventPinned} + timelineType={timelineType} + /> )} - {(!securitySolutionNotesEnabled || expandableFlyoutDisabled) && - !isEventViewer && - toggleShowNotes && ( - <> - <AddEventNoteAction - ariaLabel={i18n.ADD_NOTES_FOR_ROW({ ariaRowindex, columnValues })} - key="add-event-note" - showNotes={showNotes ?? false} - toggleShowNotes={toggleShowNotes} - timelineType={timelineType} - eventId={eventId} - /> - <PinEventAction - ariaLabel={i18n.PIN_EVENT_FOR_ROW({ ariaRowindex, columnValues, isEventPinned })} - isAlert={isAlert(eventType)} - key="pin-event" - onPinClicked={handlePinClicked} - noteIds={eventIdToNoteIds ? eventIdToNoteIds[eventId] || emptyNotes : emptyNotes} - eventIsPinned={isEventPinned} - timelineType={timelineType} - /> - </> - )} <AlertContextMenu ariaLabel={i18n.MORE_ACTIONS_FOR_ROW({ ariaRowindex, columnValues })} ariaRowindex={ariaRowindex} diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx index 9c3b793197c9..d3299d081659 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx @@ -6,59 +6,158 @@ */ import { TimelineType } from '../../../../common/api/timeline'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import React from 'react'; import { TestProviders } from '../../mock'; -import { useUserPrivileges } from '../user_privileges'; import { getEndpointPrivilegesInitialStateMock } from '../user_privileges/endpoint/mocks'; import { AddEventNoteAction } from './add_note_icon_item'; +import { NotesButton } from '../../../timelines/components/timeline/properties/helpers'; +import { useUserPrivileges } from '../user_privileges'; + +jest.mock('../../../timelines/components/timeline/properties/helpers', () => { + return { + NotesButton: jest.fn(), + }; +}); jest.mock('../user_privileges'); const useUserPrivilegesMock = useUserPrivileges as jest.Mock; +const NotesButtonMock = NotesButton as unknown as jest.Mock; + +const TestWrapper = (props: ComponentProps<typeof TestProviders>) => { + return <TestProviders {...props} />; +}; + +const toggleShowNotesMock = jest.fn(); + +const renderTestComponent = (props: Partial<ComponentProps<typeof AddEventNoteAction>> = {}) => { + const localProps: ComponentProps<typeof AddEventNoteAction> = { + timelineType: TimelineType.default, + eventId: 'event-1', + ariaLabel: 'Add Note', + toggleShowNotes: toggleShowNotesMock, + notesCount: 2, + ...props, + }; + + return render(<AddEventNoteAction {...localProps} />, { + wrapper: TestWrapper, + }); +}; + describe('AddEventNoteAction', () => { beforeEach(() => { jest.clearAllMocks(); + + useUserPrivilegesMock.mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, + endpointPrivileges: getEndpointPrivilegesInitialStateMock(), + }); + + NotesButtonMock.mockImplementation(({ isDisabled }: { isDisabled: boolean }) => ( + <button + type="button" + disabled={isDisabled} + data-test-subj="timeline-notes-button-small-mock" + /> + )); }); - describe('isDisabled', () => { - test('it disables the add note button when the user does NOT have crud privileges', () => { + describe('display notes button', () => { + test('should render button correctly when multiple notes exist', async () => { + renderTestComponent({ eventId: 'event-1' }); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small-mock')).not.toBeDisabled(); + }); + + expect(NotesButtonMock).toHaveBeenCalledWith( + expect.objectContaining({ + ariaLabel: 'Add Note', + 'data-test-subj': 'add-note', + isDisabled: false, + timelineType: TimelineType.default, + toggleShowNotes: expect.any(Function), + toolTip: '2 Notes available. Click to view them & add more.', + eventId: 'event-1', + notesCount: 2, + }), + expect.anything() + ); + }); + + test('should render button correctly when single note exists', async () => { + renderTestComponent({ eventId: 'event-2', notesCount: 1 }); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small-mock')).not.toBeDisabled(); + }); + + expect(NotesButtonMock).toHaveBeenCalledWith( + expect.objectContaining({ + ariaLabel: 'Add Note', + 'data-test-subj': 'add-note', + isDisabled: false, + timelineType: TimelineType.default, + toggleShowNotes: expect.any(Function), + toolTip: '1 Note available. Click to view it & add more.', + eventId: 'event-2', + notesCount: 1, + }), + expect.anything() + ); + }); + + test('should render button correctly when no note exist', async () => { + renderTestComponent({ eventId: 'event-3', notesCount: 0 }); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small-mock')).not.toBeDisabled(); + }); + + expect(NotesButtonMock).toHaveBeenCalledWith( + expect.objectContaining({ + ariaLabel: 'Add Note', + 'data-test-subj': 'add-note', + isDisabled: false, + timelineType: TimelineType.default, + toggleShowNotes: expect.any(Function), + toolTip: 'Add Note', + eventId: 'event-3', + notesCount: 0, + }), + expect.anything() + ); + }); + }); + + describe('button state', () => { + test('should disable the add note button when the user does NOT have crud privileges', () => { useUserPrivilegesMock.mockReturnValue({ kibanaSecuritySolutionsPrivileges: { crud: false, read: true }, endpointPrivileges: getEndpointPrivilegesInitialStateMock(), }); - render( - <TestProviders> - <AddEventNoteAction - showNotes={false} - timelineType={TimelineType.default} - toggleShowNotes={jest.fn} - /> - </TestProviders> - ); + renderTestComponent(); - expect(screen.getByTestId('timeline-notes-button-small')).toHaveProperty('disabled', true); + expect(screen.getByTestId('timeline-notes-button-small-mock')).toHaveProperty( + 'disabled', + true + ); }); - test('it enables the add note button when the user has crud privileges', () => { + test('should enable the add note button when the user has crud privileges', () => { useUserPrivilegesMock.mockReturnValue({ kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, endpointPrivileges: getEndpointPrivilegesInitialStateMock(), }); - render( - <TestProviders> - <AddEventNoteAction - showNotes={false} - timelineType={TimelineType.default} - toggleShowNotes={jest.fn} - /> - </TestProviders> - ); + renderTestComponent(); - expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + expect(screen.getByTestId('timeline-notes-button-small-mock')).not.toBeDisabled(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx index 82671b399ee6..ff9ad479e89c 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { NotesButton } from '../../../timelines/components/timeline/properties/helpers'; import { TimelineType } from '../../../../common/api/timeline'; import { useUserPrivileges } from '../user_privileges'; @@ -14,19 +14,17 @@ import { ActionIconItem } from './action_icon_item'; interface AddEventNoteActionProps { ariaLabel?: string; - showNotes: boolean; timelineType: TimelineType; - toggleShowNotes: () => void; + toggleShowNotes?: () => void | ((eventId: string) => void); eventId?: string; - /** + /* * Number of notes associated with the event */ - notesCount?: number; + notesCount: number; } const AddEventNoteActionComponent: React.FC<AddEventNoteActionProps> = ({ ariaLabel, - showNotes, timelineType, toggleShowNotes, eventId, @@ -34,12 +32,10 @@ const AddEventNoteActionComponent: React.FC<AddEventNoteActionProps> = ({ }) => { const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); - const tooltip = - notesCount && notesCount > 0 - ? i18n.NOTE_COUNT_TOOLTIP(notesCount) - : timelineType === TimelineType.template - ? i18n.NOTES_DISABLE_TOOLTIP - : i18n.NOTES_TOOLTIP; + const NOTES_TOOLTIP = useMemo( + () => (notesCount > 0 ? i18n.NOTES_COUNT_TOOLTIP({ notesCount }) : i18n.NOTES_ADD_TOOLTIP), + [notesCount] + ); return ( <ActionIconItem> @@ -47,10 +43,11 @@ const AddEventNoteActionComponent: React.FC<AddEventNoteActionProps> = ({ ariaLabel={ariaLabel} data-test-subj="add-note" isDisabled={kibanaSecuritySolutionsPrivileges.crud === false} - showNotes={showNotes} timelineType={timelineType} toggleShowNotes={toggleShowNotes} - toolTip={tooltip} + toolTip={ + timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : NOTES_TOOLTIP + } eventId={eventId} notesCount={notesCount} /> diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/translations.ts b/x-pack/plugins/security_solution/public/common/components/header_actions/translations.ts index 4db668cc78d1..10832ccfac1e 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/translations.ts @@ -21,19 +21,23 @@ export const NOTES_DISABLE_TOOLTIP = i18n.translate( } ); -export const NOTE_COUNT_TOOLTIP = (notesCount: number) => - i18n.translate('xpack.securitySolution.notes.noteCountTooltip', { - defaultMessage: '{notesCount} {notesCount, plural, one { note } other { notes }}', - values: { notesCount }, - }); - -export const NOTES_TOOLTIP = i18n.translate( +export const NOTES_ADD_TOOLTIP = i18n.translate( 'xpack.securitySolution.timeline.body.notes.addNoteTooltip', { - defaultMessage: 'Add note', + defaultMessage: 'Add Note', } ); +export const NOTES_COUNT_TOOLTIP = ({ notesCount }: { notesCount: number }) => + i18n.translate( + 'xpack.securitySolution.timeline.body.notes.addNote.multipleNotesAvailableTooltip', + { + values: { notesCount }, + defaultMessage: + '{notesCount} {notesCount, plural, one {Note} other {Notes} } available. Click to view {notesCount, plural, one {it} other {them}} & add more.', + } + ); + export const SORT_FIELDS = i18n.translate('xpack.securitySolution.timeline.sortFieldsButton', { defaultMessage: 'Sort fields', }); diff --git a/x-pack/plugins/security_solution/public/common/components/landing_page/onboarding/step_links/add_integration_buttons.tsx b/x-pack/plugins/security_solution/public/common/components/landing_page/onboarding/step_links/add_integration_buttons.tsx index 6943c32f08f8..2ee5928a4c78 100644 --- a/x-pack/plugins/security_solution/public/common/components/landing_page/onboarding/step_links/add_integration_buttons.tsx +++ b/x-pack/plugins/security_solution/public/common/components/landing_page/onboarding/step_links/add_integration_buttons.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { LinkButton } from '@kbn/security-solution-navigation/links'; import type { IconType } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiText, EuiTitle } from '@elastic/eui'; +import { useKibana } from '../../../../lib/kibana/kibana_react'; import { AddIntegrationsSteps } from '../types'; import { useStepContext } from '../context/step_context'; import { IntegrationsPageName } from './types'; @@ -153,32 +154,41 @@ const AddIntegrationPanel: React.FC<{ }); AddIntegrationPanel.displayName = 'AddIntegrationPanel'; -export const AddIntegrationButtons: React.FC = React.memo(() => ( - <EuiFlexGroup direction="column" className="step-paragraph" gutterSize="m"> - <EuiFlexItem grow={false}> - <AddIntegrationPanel - title={ADD_CLOUD_INTEGRATIONS_TITLE} - description={ADD_CLOUD_INTEGRATIONS_DESCRIPTION} - icon={CloudIntegrationsIcon} - buttonId={IntegrationsPageName.integrationsSecurityCloud} - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <AddIntegrationPanel - title={ADD_EDR_XDR_INTEGRATIONS_TITLE} - description={ADD_EDR_XDR_INTEGRATIONS_DESCRIPTION} - icon={EdrXdrIntegrationsIcon} - buttonId={IntegrationsPageName.integrationsSecurityEdrXrd} - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <AddIntegrationPanel - title={ADD_ALL_INTEGRATIONS_TITLE} - description={ADD_ALL_INTEGRATIONS_DESCRIPTION} - icon="logoSecurity" - buttonId={IntegrationsPageName.integrationsSecurity} - /> - </EuiFlexItem> - </EuiFlexGroup> -)); +export const AddIntegrationButtons: React.FC = React.memo(() => { + const { integrationAssistant } = useKibana().services; + const { CreateIntegrationCardButton } = integrationAssistant?.components ?? {}; + return ( + <EuiFlexGroup direction="column" className="step-paragraph" gutterSize="m"> + <EuiFlexItem grow={false}> + <AddIntegrationPanel + title={ADD_CLOUD_INTEGRATIONS_TITLE} + description={ADD_CLOUD_INTEGRATIONS_DESCRIPTION} + icon={CloudIntegrationsIcon} + buttonId={IntegrationsPageName.integrationsSecurityCloud} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <AddIntegrationPanel + title={ADD_EDR_XDR_INTEGRATIONS_TITLE} + description={ADD_EDR_XDR_INTEGRATIONS_DESCRIPTION} + icon={EdrXdrIntegrationsIcon} + buttonId={IntegrationsPageName.integrationsSecurityEdrXrd} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <AddIntegrationPanel + title={ADD_ALL_INTEGRATIONS_TITLE} + description={ADD_ALL_INTEGRATIONS_DESCRIPTION} + icon="logoSecurity" + buttonId={IntegrationsPageName.integrationsSecurity} + /> + </EuiFlexItem> + {CreateIntegrationCardButton && ( + <EuiFlexItem grow={false}> + <CreateIntegrationCardButton compressed /> + </EuiFlexItem> + )} + </EuiFlexGroup> + ); +}); AddIntegrationButtons.displayName = 'AddIntegrationButtons'; diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx index 2423d3493d9e..98f47334ceca 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import type { TimelineTypeLiteral } from '../../../../common/api/timeline'; import { appendSearch } from './helpers'; -export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) => +export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral | 'notes', search?: string) => `/${tabName}${appendSearch(search)}`; export const getTimelineUrl = (id: string, graphEventId?: string) => diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_config.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_config.ts new file mode 100644 index 000000000000..86551ad64b43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_config.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import type { DataViewFieldBase } from '@kbn/es-query'; + +import { getTermsAggregationFields } from '../../../../detection_engine/rule_creation_ui/components/step_define_rule/utils'; +import { useRuleFields } from '../../../../detection_engine/rule_management/logic/use_rule_fields'; +import type { BrowserField } from '../../../containers/source'; +import { useMlCapabilities } from './use_ml_capabilities'; +import { useMlRuleValidations } from './use_ml_rule_validations'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; +import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; + +export interface UseMlRuleConfigReturn { + hasMlAdminPermissions: boolean; + hasMlLicense: boolean; + mlFields: DataViewFieldBase[]; + mlFieldsLoading: boolean; + mlSuppressionFields: BrowserField[]; + noMlJobsStarted: boolean; + someMlJobsStarted: boolean; +} + +/** + * This hook is used to retrieve the various configurations and status needed for creating/editing an ML Rule in the Detection Engine UI. It composes several other ML hooks. + * + * @param machineLearningJobId The ID(s) of the ML job to retrieve the configuration for + * + * @returns {UseMlRuleConfigReturn} An object containing the various configurations and statuses needed for creating/editing an ML Rule + * + */ +export const useMLRuleConfig = ({ + machineLearningJobId, +}: { + machineLearningJobId: string[]; +}): UseMlRuleConfigReturn => { + const mlCapabilities = useMlCapabilities(); + const { someJobsStarted: someMlJobsStarted, noJobsStarted: noMlJobsStarted } = + useMlRuleValidations({ machineLearningJobId }); + const { loading: mlFieldsLoading, fields: mlFields } = useRuleFields({ + machineLearningJobId, + }); + const mlSuppressionFields = useMemo( + () => getTermsAggregationFields(mlFields as BrowserField[]), + [mlFields] + ); + + return { + hasMlAdminPermissions: hasMlAdminPermissions(mlCapabilities), + hasMlLicense: hasMlLicense(mlCapabilities), + mlFields, + mlFieldsLoading, + mlSuppressionFields, + noMlJobsStarted, + someMlJobsStarted, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_validations.test.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_validations.test.ts new file mode 100644 index 000000000000..6f14d6fe2a73 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_validations.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../mock'; +import { buildMockJobsSummary, getJobsSummaryResponseMock } from '../../ml_popover/api.mock'; +import { useInstalledSecurityJobs } from './use_installed_security_jobs'; + +import { useMlRuleValidations } from './use_ml_rule_validations'; + +jest.mock('./use_installed_security_jobs'); + +describe('useMlRuleValidations', () => { + const machineLearningJobId = ['test_job', 'test_job_2']; + + beforeEach(() => { + (useInstalledSecurityJobs as jest.Mock).mockReturnValue({ + loading: true, + jobs: [], + }); + }); + + it('returns loading state from inner hook', () => { + const { result, rerender } = renderHook(() => useMlRuleValidations({ machineLearningJobId }), { + wrapper: TestProviders, + }); + expect(result.current).toEqual(expect.objectContaining({ loading: true })); + + (useInstalledSecurityJobs as jest.Mock).mockReturnValueOnce({ + loading: false, + jobs: [], + }); + + rerender(); + + expect(result.current).toEqual(expect.objectContaining({ loading: false })); + }); + + it('returns no jobs started when no jobs are started', () => { + const { result } = renderHook(() => useMlRuleValidations({ machineLearningJobId }), { + wrapper: TestProviders, + }); + + expect(result.current).toEqual( + expect.objectContaining({ noJobsStarted: true, someJobsStarted: false }) + ); + }); + + it('returns some jobs started when some jobs are started', () => { + (useInstalledSecurityJobs as jest.Mock).mockReturnValueOnce({ + loading: false, + jobs: getJobsSummaryResponseMock([ + buildMockJobsSummary({ + id: machineLearningJobId[0], + jobState: 'opened', + datafeedState: 'started', + }), + buildMockJobsSummary({ + id: machineLearningJobId[1], + }), + ]), + }); + + const { result } = renderHook(() => useMlRuleValidations({ machineLearningJobId }), { + wrapper: TestProviders, + }); + + expect(result.current).toEqual( + expect.objectContaining({ noJobsStarted: false, someJobsStarted: true }) + ); + }); + + it('returns neither "no jobs started" nor "some jobs started" when all jobs are started', () => { + (useInstalledSecurityJobs as jest.Mock).mockReturnValueOnce({ + loading: false, + jobs: getJobsSummaryResponseMock([ + buildMockJobsSummary({ + id: machineLearningJobId[0], + jobState: 'opened', + datafeedState: 'started', + }), + buildMockJobsSummary({ + id: machineLearningJobId[1], + jobState: 'opened', + datafeedState: 'started', + }), + ]), + }); + + const { result } = renderHook(() => useMlRuleValidations({ machineLearningJobId }), { + wrapper: TestProviders, + }); + + expect(result.current).toEqual( + expect.objectContaining({ noJobsStarted: false, someJobsStarted: false }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_validations.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_validations.ts new file mode 100644 index 000000000000..81897c5d29b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_ml_rule_validations.ts @@ -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 { isJobStarted } from '../../../../../common/machine_learning/helpers'; +import { useInstalledSecurityJobs } from './use_installed_security_jobs'; + +export interface UseMlRuleValidationsParams { + machineLearningJobId: string[] | undefined; +} + +export interface UseMlRuleValidationsReturn { + loading: boolean; + noJobsStarted: boolean; + someJobsStarted: boolean; +} + +/** + * Hook to encapsulate some of our validation checks for ML rules. + * + * @param machineLearningJobId the ML Job IDs of the rule + * @returns validation state about the rule, relative to its ML jobs. + */ +export const useMlRuleValidations = ({ + machineLearningJobId, +}: UseMlRuleValidationsParams): UseMlRuleValidationsReturn => { + const { jobs: installedJobs, loading } = useInstalledSecurityJobs(); + const ruleMlJobs = installedJobs.filter((installedJob) => + (machineLearningJobId ?? []).includes(installedJob.id) + ); + const numberOfRuleMlJobsStarted = ruleMlJobs.filter((job) => + isJobStarted(job.jobState, job.datafeedState) + ).length; + const noMlJobsStarted = numberOfRuleMlJobsStarted === 0; + const someMlJobsStarted = !noMlJobsStarted && numberOfRuleMlJobsStarted !== ruleMlJobs.length; + + return { loading, noJobsStarted: noMlJobsStarted, someJobsStarted: someMlJobsStarted }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts index 2000db1807cb..fdd9d66ebaf9 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts @@ -100,6 +100,16 @@ export const mockJobsSummaryResponse: MlSummaryJob[] = [ }, ]; +export const getJobsSummaryResponseMock = (additionalJobs: MlSummaryJob[]): MlSummaryJob[] => [ + ...mockJobsSummaryResponse, + ...additionalJobs, +]; + +export const buildMockJobsSummary = (overrides: Partial<MlSummaryJob>): MlSummaryJob => ({ + ...mockJobsSummaryResponse[0], + ...overrides, +}); + export const mockGetModuleResponse: Module[] = [ { id: 'security_linux_v3', diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx index 8d0b63d8b32f..567d7e038b5a 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx @@ -6,6 +6,7 @@ */ import type { MlSummaryJob } from '@kbn/ml-plugin/public'; +import { isSecurityJob } from '../../../../../common/machine_learning/is_security_job'; import type { AugmentedSecurityJobFields, Module, @@ -111,13 +112,11 @@ export const getInstalledJobs = ( moduleJobs: SecurityJob[], compatibleModuleIds: string[] ): SecurityJob[] => - jobSummaryData - .filter(({ groups }) => groups.includes('siem') || groups.includes('security')) - .map<SecurityJob>((jobSummary) => ({ - ...jobSummary, - ...getAugmentedFields(jobSummary.id, moduleJobs, compatibleModuleIds), - isInstalled: true, - })); + jobSummaryData.filter(isSecurityJob).map((jobSummary) => ({ + ...jobSummary, + ...getAugmentedFields(jobSummary.id, moduleJobs, compatibleModuleIds), + isInstalled: true, + })); /** * Combines installed jobs + moduleSecurityJobs that don't overlap and sorts by name asc 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 d16fd182928d..f42f77f19a0f 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 @@ -46,6 +46,9 @@ export enum TELEMETRY_EVENT { ADD_INVESTIGATION_FIELDS = 'add_investigation_fields', SET_INVESTIGATION_FIELDS = 'set_investigation_fields', DELETE_INVESTIGATION_FIELDS = 'delete_investigation_fields', + + // AI assistant on rule creation form + OPEN_ASSISTANT_ON_RULE_QUERY_ERROR = 'open_assistant_on_rule_query_error', } export enum TelemetryEventTypes { @@ -76,6 +79,13 @@ export enum TelemetryEventTypes { OnboardingHubStepOpen = 'Onboarding Hub Step Open', OnboardingHubStepFinished = 'Onboarding Hub Step Finished', OnboardingHubStepLinkClicked = 'Onboarding Hub Step Link Clicked', + ManualRuleRunOpenModal = 'Manual Rule Run Open Modal', + ManualRuleRunExecute = 'Manual Rule Run Execute', + ManualRuleRunCancelJob = 'Manual Rule Run Cancel Job', + EventLogFilterByRunType = 'Event Log Filter By Run Type', + EventLogShowSourceEventDateRange = 'Event Log -> Show Source -> Event Date Range', + OpenNoteInExpandableFlyoutClicked = 'Open Note In Expandable Flyout Clicked', + AddNoteFromExpandableFlyoutClicked = 'Add Note From Expandable Flyout Clicked', } export enum ML_JOB_TELEMETRY_STATUS { diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/event_log/index.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/event_log/index.ts new file mode 100644 index 000000000000..c30efcee10cf --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/event_log/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EventLogTelemetryEvent } from './types'; +import { TelemetryEventTypes } from '../../constants'; + +export const eventLogFilterByRunTypeEvent: EventLogTelemetryEvent = { + eventType: TelemetryEventTypes.EventLogFilterByRunType, + schema: { + runType: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'Filter event log by run type', + }, + }, + }, + }, +}; + +export const eventLogShowSourceEventDateRangeEvent: EventLogTelemetryEvent = { + eventType: TelemetryEventTypes.EventLogShowSourceEventDateRange, + schema: { + isVisible: { + type: 'boolean', + _meta: { + description: 'Show source event date range', + optional: false, + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/event_log/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/event_log/types.ts new file mode 100644 index 000000000000..b196c9010b25 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/event_log/types.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 { RootSchema } from '@kbn/core/public'; +import type { TelemetryEventTypes } from '../../constants'; + +export interface ReportEventLogFilterByRunTypeParams { + runType: string[]; +} +export interface ReportEventLogShowSourceEventDateRangeParams { + isVisible: boolean; +} + +export type ReportEventLogTelemetryEventParams = + | ReportEventLogFilterByRunTypeParams + | ReportEventLogShowSourceEventDateRangeParams; + +export type EventLogTelemetryEvent = + | { + eventType: TelemetryEventTypes.EventLogFilterByRunType; + schema: RootSchema<ReportEventLogFilterByRunTypeParams>; + } + | { + eventType: TelemetryEventTypes.EventLogShowSourceEventDateRange; + schema: RootSchema<ReportEventLogShowSourceEventDateRangeParams>; + }; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/manual_rule_run/index.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/manual_rule_run/index.ts new file mode 100644 index 000000000000..a1476944d980 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/manual_rule_run/index.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; 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 manualRuleRunOpenModalEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.ManualRuleRunOpenModal, + schema: { + type: { + type: 'keyword', + _meta: { + description: 'Open manual rule run modal (single|bulk)', + optional: false, + }, + }, + }, +}; + +export const manualRuleRunExecuteEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.ManualRuleRunExecute, + schema: { + rangeInMs: { + type: 'integer', + _meta: { + description: + 'The time range (expressed in milliseconds) against which the manual rule run was executed', + optional: false, + }, + }, + status: { + type: 'keyword', + _meta: { + description: + 'Outcome state of the manual rule run. Possible values are "success" and "error"', + optional: false, + }, + }, + rulesCount: { + type: 'integer', + _meta: { + description: 'Number of rules that were executed in the manual rule run', + optional: false, + }, + }, + }, +}; + +export const manualRuleRunCancelJobEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.ManualRuleRunCancelJob, + schema: { + totalTasks: { + type: 'integer', + _meta: { + description: + 'Total number of scheduled tasks (rule executions) at the moment of backfill cancellation', + optional: false, + }, + }, + completedTasks: { + type: 'integer', + _meta: { + description: 'Number of completed rule executions at the moment of backfill cancellation', + optional: false, + }, + }, + errorTasks: { + type: 'integer', + _meta: { + description: 'Number of error rule executions at the moment of backfill cancellation', + optional: false, + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/manual_rule_run/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/manual_rule_run/types.ts new file mode 100644 index 000000000000..a58b0adf4550 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/manual_rule_run/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { RootSchema } from '@kbn/core/public'; +import type { TelemetryEventTypes } from '../../constants'; + +export interface ReportManualRuleRunOpenModalParams { + type: 'single' | 'bulk'; +} + +export interface ReportManualRuleRunExecuteParams { + rangeInMs: number; + rulesCount: number; + status: 'success' | 'error'; +} + +export interface ReportManualRuleRunCancelJobParams { + totalTasks: number; + completedTasks: number; + errorTasks: number; +} + +export type ReportManualRuleRunTelemetryEventParams = + | ReportManualRuleRunOpenModalParams + | ReportManualRuleRunExecuteParams + | ReportManualRuleRunCancelJobParams; + +export type ManualRuleRunTelemetryEvent = + | { + eventType: TelemetryEventTypes.ManualRuleRunOpenModal; + schema: RootSchema<ReportManualRuleRunOpenModalParams>; + } + | { + eventType: TelemetryEventTypes.ManualRuleRunExecute; + schema: RootSchema<ReportManualRuleRunExecuteParams>; + } + | { + eventType: TelemetryEventTypes.ManualRuleRunCancelJob; + schema: RootSchema<ReportManualRuleRunCancelJobParams>; + }; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/notes/index.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/notes/index.ts new file mode 100644 index 000000000000..c560f69730d3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/notes/index.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TelemetryEvent } from '../../types'; +import { TelemetryEventTypes } from '../../constants'; + +export const openNoteInExpandableFlyoutClickedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.OpenNoteInExpandableFlyoutClicked, + schema: { + location: { + type: 'text', + _meta: { + description: 'Table ID or timeline ID', + optional: false, + }, + }, + }, +}; + +export const addNoteFromExpandableFlyoutClickedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.AddNoteFromExpandableFlyoutClicked, + schema: { + isRelatedToATimeline: { + type: 'boolean', + _meta: { + description: 'If the note was added related to a saved timeline', + optional: false, + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/notes/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/notes/types.ts new file mode 100644 index 000000000000..a785f2f8493e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/notes/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { RootSchema } from '@kbn/core/public'; +import type { TelemetryEventTypes } from '../../constants'; + +export interface OpenNoteInExpandableFlyoutClickedParams { + location: string; +} + +export interface AddNoteFromExpandableFlyoutClickedParams { + isRelatedToATimeline: boolean; +} + +export type NotesTelemetryEventParams = + | OpenNoteInExpandableFlyoutClickedParams + | AddNoteFromExpandableFlyoutClickedParams; + +export type NotesTelemetryEvents = + | { + eventType: TelemetryEventTypes.OpenNoteInExpandableFlyoutClicked; + schema: RootSchema<OpenNoteInExpandableFlyoutClickedParams>; + } + | { + eventType: TelemetryEventTypes.AddNoteFromExpandableFlyoutClicked; + schema: RootSchema<AddNoteFromExpandableFlyoutClickedParams>; + }; 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 8fe949fc783e..d1f9502346a0 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 @@ -38,6 +38,16 @@ import { onboardingHubStepLinkClickedEvent, onboardingHubStepOpenEvent, } from './onboarding'; +import { + manualRuleRunCancelJobEvent, + manualRuleRunExecuteEvent, + manualRuleRunOpenModalEvent, +} from './manual_rule_run'; +import { eventLogFilterByRunTypeEvent, eventLogShowSourceEventDateRangeEvent } from './event_log'; +import { + addNoteFromExpandableFlyoutClickedEvent, + openNoteInExpandableFlyoutClickedEvent, +} from './notes'; const mlJobUpdateEvent: TelemetryEvent = { eventType: TelemetryEventTypes.MLJobUpdate, @@ -175,4 +185,11 @@ export const telemetryEvents = [ onboardingHubStepOpenEvent, onboardingHubStepLinkClickedEvent, onboardingHubStepFinishedEvent, + manualRuleRunCancelJobEvent, + manualRuleRunExecuteEvent, + manualRuleRunOpenModalEvent, + eventLogFilterByRunTypeEvent, + eventLogShowSourceEventDateRangeEvent, + openNoteInExpandableFlyoutClickedEvent, + addNoteFromExpandableFlyoutClickedEvent, ]; 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 747a0a3a5777..02342cb4257b 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 @@ -35,4 +35,11 @@ export const createTelemetryClientMock = (): jest.Mocked<TelemetryClientStart> = reportAssetCriticalityCsvPreviewGenerated: jest.fn(), reportAssetCriticalityFileSelected: jest.fn(), reportAssetCriticalityCsvImported: jest.fn(), + reportEventLogFilterByRunType: jest.fn(), + reportEventLogShowSourceEventDateRange: jest.fn(), + reportManualRuleRunCancelJob: jest.fn(), + reportManualRuleRunExecute: jest.fn(), + reportManualRuleRunOpenModal: jest.fn(), + reportOpenNoteInExpandableFlyoutClicked: jest.fn(), + reportAddNoteFromExpandableFlyoutClicked: 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 266b3c737eb6..0023064adac6 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 @@ -6,6 +6,10 @@ */ import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import type { + AddNoteFromExpandableFlyoutClickedParams, + OpenNoteInExpandableFlyoutClickedParams, +} from './events/notes/types'; import type { TelemetryClientStart, ReportAlertsGroupingChangedParams, @@ -35,6 +39,11 @@ import type { OnboardingHubStepLinkClickedParams, OnboardingHubStepOpenParams, OnboardingHubStepFinishedParams, + ReportManualRuleRunCancelJobParams, + ReportManualRuleRunExecuteParams, + ReportManualRuleRunOpenModalParams, + ReportEventLogShowSourceEventDateRangeParams, + ReportEventLogFilterByRunTypeParams, } from './types'; import { TelemetryEventTypes } from './constants'; @@ -168,4 +177,38 @@ export class TelemetryClient implements TelemetryClientStart { public reportOnboardingHubStepLinkClicked = (params: OnboardingHubStepLinkClickedParams) => { this.analytics.reportEvent(TelemetryEventTypes.OnboardingHubStepLinkClicked, params); }; + + public reportManualRuleRunOpenModal = (params: ReportManualRuleRunOpenModalParams) => { + this.analytics.reportEvent(TelemetryEventTypes.ManualRuleRunOpenModal, params); + }; + + public reportManualRuleRunExecute = (params: ReportManualRuleRunExecuteParams) => { + this.analytics.reportEvent(TelemetryEventTypes.ManualRuleRunExecute, params); + }; + + public reportManualRuleRunCancelJob = (params: ReportManualRuleRunCancelJobParams) => { + this.analytics.reportEvent(TelemetryEventTypes.ManualRuleRunCancelJob, params); + }; + + public reportEventLogFilterByRunType = (params: ReportEventLogFilterByRunTypeParams) => { + this.analytics.reportEvent(TelemetryEventTypes.EventLogFilterByRunType, params); + }; + + public reportEventLogShowSourceEventDateRange( + params: ReportEventLogShowSourceEventDateRangeParams + ): void { + this.analytics.reportEvent(TelemetryEventTypes.EventLogShowSourceEventDateRange, params); + } + + public reportOpenNoteInExpandableFlyoutClicked = ( + params: OpenNoteInExpandableFlyoutClickedParams + ) => { + this.analytics.reportEvent(TelemetryEventTypes.OpenNoteInExpandableFlyoutClicked, params); + }; + + public reportAddNoteFromExpandableFlyoutClicked = ( + params: AddNoteFromExpandableFlyoutClickedParams + ) => { + this.analytics.reportEvent(TelemetryEventTypes.AddNoteFromExpandableFlyoutClicked, 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 9e7a49a91497..49c78dc50fee 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 @@ -53,6 +53,25 @@ import type { OnboardingHubStepOpenParams, OnboardingHubTelemetryEvent, } from './events/onboarding/types'; +import type { + ManualRuleRunTelemetryEvent, + ReportManualRuleRunOpenModalParams, + ReportManualRuleRunExecuteParams, + ReportManualRuleRunCancelJobParams, + ReportManualRuleRunTelemetryEventParams, +} from './events/manual_rule_run/types'; +import type { + EventLogTelemetryEvent, + ReportEventLogFilterByRunTypeParams, + ReportEventLogShowSourceEventDateRangeParams, + ReportEventLogTelemetryEventParams, +} from './events/event_log/types'; +import type { + AddNoteFromExpandableFlyoutClickedParams, + NotesTelemetryEventParams, + NotesTelemetryEvents, + OpenNoteInExpandableFlyoutClickedParams, +} from './events/notes/types'; export * from './events/ai_assistant/types'; export * from './events/alerts_grouping/types'; @@ -70,6 +89,8 @@ export type { ReportAssetCriticalityCsvImportedParams, } from './events/entity_analytics/types'; export * from './events/document_details/types'; +export * from './events/manual_rule_run/types'; +export * from './events/event_log/types'; export interface TelemetryServiceSetupParams { analytics: AnalyticsServiceSetup; @@ -112,7 +133,10 @@ export type TelemetryEventParams = | ReportDocumentDetailsTelemetryEventParams | OnboardingHubStepOpenParams | OnboardingHubStepFinishedParams - | OnboardingHubStepLinkClickedParams; + | OnboardingHubStepLinkClickedParams + | ReportManualRuleRunTelemetryEventParams + | ReportEventLogTelemetryEventParams + | NotesTelemetryEventParams; export interface TelemetryClientStart { reportAlertsGroupingChanged(params: ReportAlertsGroupingChangedParams): void; @@ -155,6 +179,21 @@ export interface TelemetryClientStart { reportOnboardingHubStepOpen(params: OnboardingHubStepOpenParams): void; reportOnboardingHubStepFinished(params: OnboardingHubStepFinishedParams): void; reportOnboardingHubStepLinkClicked(params: OnboardingHubStepLinkClickedParams): void; + + // manual rule run + reportManualRuleRunOpenModal(params: ReportManualRuleRunOpenModalParams): void; + reportManualRuleRunExecute(params: ReportManualRuleRunExecuteParams): void; + reportManualRuleRunCancelJob(params: ReportManualRuleRunCancelJobParams): void; + + // event log + reportEventLogFilterByRunType(params: ReportEventLogFilterByRunTypeParams): void; + reportEventLogShowSourceEventDateRange( + params: ReportEventLogShowSourceEventDateRangeParams + ): void; + + // new notes + reportOpenNoteInExpandableFlyoutClicked(params: OpenNoteInExpandableFlyoutClickedParams): void; + reportAddNoteFromExpandableFlyoutClicked(params: AddNoteFromExpandableFlyoutClickedParams): void; } export type TelemetryEvent = @@ -179,4 +218,7 @@ export type TelemetryEvent = eventType: TelemetryEventTypes.BreadcrumbClicked; schema: RootSchema<ReportBreadcrumbClickedParams>; } - | OnboardingHubTelemetryEvent; + | OnboardingHubTelemetryEvent + | ManualRuleRunTelemetryEvent + | EventLogTelemetryEvent + | NotesTelemetryEvents; diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 6aa38d25806a..cacbbd243be7 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -71,7 +71,19 @@ export const mockSourcererState: SourcererState = { export const mockGlobalState: State = { app: { - notesById: {}, + notesById: { + '1': { + created: new Date('2024-07-02T08:32:29.233Z'), + id: '1', + lastEdit: new Date('2024-07-02T08:32:29.233Z'), + note: 'New Note', + user: 'elastic', + saveObjectId: 'c1a44f63-eb20-4c65-a050-eb9e842d8492', + version: 'WzIyNDUsMV0=', + eventId: '1', + timelineId: 'some-timeline-id', + }, + }, errors: [ { id: 'error-id-1', title: 'title-1', message: ['error-message-1'] }, { id: 'error-id-2', title: 'title-2', message: ['error-message-2'] }, @@ -323,6 +335,7 @@ export const mockGlobalState: State = { timelineById: { [TimelineId.test]: { activeTab: TimelineTabs.query, + createdBy: 'elastic', prevActiveTab: TimelineTabs.notes, dataViewId: DEFAULT_DATA_VIEW_ID, deletedEventIds: [], @@ -341,7 +354,7 @@ export const mockGlobalState: State = { tiebreakerField: '', timestampField: '@timestamp', }, - eventIdToNoteIds: {}, + eventIdToNoteIds: { '1': ['1'] }, excludedRowRendererIds: [], expandedDetail: {}, highlightedDropAndProviderId: '', @@ -504,10 +517,9 @@ export const mockGlobalState: State = { discover: getMockDiscoverInTimelineState(), dataViewPicker: dataViewPickerInitialState, notes: { - ids: ['1'], entities: { '1': { - eventId: 'event-id', + eventId: '1', // should be a valid id based on mockTimelineData noteId: '1', note: 'note-1', timelineId: 'timeline-1', @@ -518,15 +530,31 @@ export const mockGlobalState: State = { version: 'version', }, }, + ids: ['1'], status: { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, createNote: null, - deleteNote: null, + deleteNotes: null, + fetchNotes: null, + }, + pagination: { + page: 1, + perPage: 10, + total: 0, + }, + sort: { + field: 'created' as const, + direction: 'desc' as const, }, + filter: '', + search: '', + selectedIds: [], + pendingDeleteIds: [], }, }; diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx index b9a83fd280b1..04860ba9c6c7 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx @@ -50,6 +50,7 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({ http={mockHttp} navigateToApp={mockNavigateToApp} baseConversations={BASE_SECURITY_CONVERSATIONS} + currentAppId={'test'} > {children} </AssistantProvider> diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 7c2392445099..0800d5cc14c8 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -11,7 +11,7 @@ import type { DataTableModel } from '@kbn/securitysolution-data-table'; import { VIEW_SELECTION } from '../../../common/constants'; import type { TimelineResult } from '../../../common/api/timeline'; import { TimelineId, TimelineTabs } from '../../../common/types/timeline'; -import { TimelineType, TimelineStatus } from '../../../common/api/timeline'; +import { RowRendererId, TimelineType, TimelineStatus } from '../../../common/api/timeline'; import type { OpenTimelineResult } from '../../timelines/components/open_timeline/types'; import type { TimelineEventsDetailsItem } from '../../../common/search_strategy'; @@ -2068,7 +2068,26 @@ export const defaultTimelineProps: CreateTimelineProps = { }, eventIdToNoteIds: {}, eventType: 'all', - excludedRowRendererIds: [], + excludedRowRendererIds: [ + RowRendererId.alert, + RowRendererId.alerts, + RowRendererId.auditd, + RowRendererId.auditd_file, + RowRendererId.library, + RowRendererId.netflow, + RowRendererId.plain, + RowRendererId.registry, + RowRendererId.suricata, + RowRendererId.system, + RowRendererId.system_dns, + RowRendererId.system_endgame_process, + RowRendererId.system_file, + RowRendererId.system_fim, + RowRendererId.system_security_event, + RowRendererId.system_socket, + RowRendererId.threat_match, + RowRendererId.zeek, + ], expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx index affe83b0682c..b25b235213e6 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx @@ -128,10 +128,6 @@ export const DashboardsLandingPage = () => { </> )} - <EuiTitle size="xxxs"> - <h2>{i18n.DASHBOARDS_PAGE_SECTION_DEFAULT}</h2> - </EuiTitle> - <EuiHorizontalRule margin="s" /> <LandingLinksImageCards items={links} urlState={urlState} @@ -142,7 +138,7 @@ export const DashboardsLandingPage = () => { {canReadDashboard && securityTagsExist && initialFilter && ( <> <EuiSpacer size="m" /> - <EuiTitle size="xxxs"> + <EuiTitle size="xxs"> <h2>{i18n.DASHBOARDS_PAGE_SECTION_CUSTOM}</h2> </EuiTitle> <EuiHorizontalRule margin="s" /> diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/translations.ts b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/translations.ts index 71bafc6a8099..256c3a71a33e 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/translations.ts +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/translations.ts @@ -13,16 +13,9 @@ export const DASHBOARDS_PAGE_CREATE_BUTTON = i18n.translate( } ); -export const DASHBOARDS_PAGE_SECTION_DEFAULT = i18n.translate( - 'xpack.securitySolution.dashboards.landing.section.default', - { - defaultMessage: 'DEFAULT', - } -); - export const DASHBOARDS_PAGE_SECTION_CUSTOM = i18n.translate( 'xpack.securitySolution.dashboards.landing.section.custom', { - defaultMessage: 'CUSTOM', + defaultMessage: 'Custom Dashboards', } ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts index 1f0bcb6596b4..484f78c53f0e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts @@ -66,7 +66,7 @@ export const esqlValidator = async ( if (isMissingMetadataOperator) { return { code: ERROR_CODES.ERR_MISSING_ID_FIELD_FROM_RESULT, - message: i18n.ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR, + message: i18n.ESQL_VALIDATION_MISSING_METADATA_OPERATOR_IN_QUERY_ERROR, }; } @@ -84,7 +84,7 @@ export const esqlValidator = async ( if (!isEsqlQueryAggregating && !isIdFieldPresent) { return { code: ERROR_CODES.ERR_MISSING_ID_FIELD_FROM_RESULT, - message: i18n.ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR, + message: i18n.ESQL_VALIDATION_MISSING_ID_FIELD_IN_QUERY_ERROR, }; } } catch (error) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts index 21dbb474b123..245924b3d7e0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts @@ -20,8 +20,15 @@ export const esqlValidationErrorMessage = (message: string) => defaultMessage: 'Error validating ES|QL: "{message}"', }); -export const ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR = i18n.translate( - 'xpack.securitySolution.detectionEngine.esqlValidation.missingIdInQueryError', +export const ESQL_VALIDATION_MISSING_METADATA_OPERATOR_IN_QUERY_ERROR = i18n.translate( + 'xpack.securitySolution.detectionEngine.esqlValidation.missingMetadataOperatorInQueryError', + { + defaultMessage: `Queries that don’t use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* [metadata _id, _version, _index].`, + } +); + +export const ESQL_VALIDATION_MISSING_ID_FIELD_IN_QUERY_ERROR = i18n.translate( + 'xpack.securitySolution.detectionEngine.esqlValidation.missingIdFieldInQueryError', { defaultMessage: `Queries that don’t use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* metadata _id, _version, _index. In addition, the metadata properties (_id, _version, and _index) must be returned in the query response.`, } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx new file mode 100644 index 000000000000..9ec6cba770c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render } from '@testing-library/react'; + +import { TestProviders } from '../../../../common/mock'; +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; + +import { AiAssistant } from '.'; + +jest.mock('../../../../assistant/use_assistant_availability', () => ({ + useAssistantAvailability: jest.fn(), +})); + +const useAssistantAvailabilityMock = useAssistantAvailability as jest.Mock; + +describe('AiAssistant', () => { + beforeEach(() => { + useAssistantAvailabilityMock.mockReturnValue({ hasAssistantPrivilege: true }); + }); + it('does not render chat component when does not have hasAssistantPrivilege', () => { + useAssistantAvailabilityMock.mockReturnValue({ hasAssistantPrivilege: false }); + + const { container } = render(<AiAssistant getFields={jest.fn()} />, { + wrapper: TestProviders, + }); + + expect(container).toBeEmptyDOMElement(); + }); + it('renders chat component when has hasAssistantPrivilege', () => { + render(<AiAssistant getFields={jest.fn()} />, { + wrapper: TestProviders, + }); + + expect(screen.getByTestId('newChatLink')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx new file mode 100644 index 000000000000..83ce75d53f79 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { NewChat, AssistantAvatar } from '@kbn/elastic-assistant'; + +import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../common/lib/telemetry'; +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; +import * as i18nAssistant from '../../../../detections/pages/detection_engine/rules/translations'; +import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; +import type { FormHook, ValidationError } from '../../../../shared_imports'; + +import * as i18n from './translations'; + +const getLanguageName = (language: string | undefined) => { + let modifiedLanguage = language; + if (language === 'eql') { + modifiedLanguage = 'EQL(Event Query Language)'; + } + if (language === 'esql') { + modifiedLanguage = 'ES|QL(The Elasticsearch Query Language)'; + } + + return modifiedLanguage; +}; + +const retrieveErrorMessages = (errors: ValidationError[]): string => + errors + .flatMap(({ message, messages }) => [message, ...(Array.isArray(messages) ? messages : [])]) + .join(', '); + +interface AiAssistantProps { + getFields: FormHook<DefineStepRule>['getFields']; + language?: string | undefined; +} + +const AiAssistantComponent: React.FC<AiAssistantProps> = ({ getFields, language }) => { + const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability(); + + const languageName = getLanguageName(language); + + const getPromptContext = useCallback(async () => { + const queryField = getFields().queryBar; + const { query } = (queryField.value as DefineStepRule['queryBar']).query; + + if (!query) { + return ''; + } + + if (queryField.errors.length === 0) { + return `No errors in ${languageName} language query detected. Current query: ${query.trim()}`; + } + + return `${languageName} language query written for Elastic Security Detection rules: \"${query.trim()}\" +returns validation error on form: \"${retrieveErrorMessages(queryField.errors)}\" +Fix ${languageName} language query and give an example of it in markdown format that can be copied. +Proposed solution should be valid and must not contain new line symbols (\\n)`; + }, [getFields, languageName]); + + const onShowOverlay = useCallback(() => { + track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.OPEN_ASSISTANT_ON_RULE_QUERY_ERROR); + }, []); + + if (!hasAssistantPrivilege) { + return null; + } + + return ( + <> + <EuiSpacer size="s" /> + + <FormattedMessage + id="xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantHelpText" + defaultMessage="{AiAssistantNewChatLink} to help resolve this error." + values={{ + AiAssistantNewChatLink: ( + <NewChat + asLink={true} + category="detection-rules" + conversationId={i18nAssistant.DETECTION_RULES_CONVERSATION_ID} + description={i18n.ASK_ASSISTANT_DESCRIPTION} + getPromptContext={getPromptContext} + suggestedUserPrompt={i18n.ASK_ASSISTANT_USER_PROMPT(languageName)} + tooltip={i18n.ASK_ASSISTANT_TOOLTIP} + iconType={null} + onShowOverlay={onShowOverlay} + isAssistantEnabled={isAssistantEnabled} + > + <AssistantAvatar size="xxs" /> {i18n.ASK_ASSISTANT_ERROR_BUTTON} + </NewChat> + ), + }} + /> + </> + ); +}; + +export const AiAssistant = React.memo(AiAssistantComponent); +AiAssistant.displayName = 'AiAssistant'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts new file mode 100644 index 000000000000..f80f108aefda --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ASK_ASSISTANT_ERROR_BUTTON = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistant', + { + defaultMessage: 'Ask Assistant', + } +); + +export const ASK_ASSISTANT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantDesc', + { + defaultMessage: 'Rule query error', + } +); + +export const ASK_ASSISTANT_USER_PROMPT = (language: string | undefined) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantUserPrompt', + { + defaultMessage: + 'Explain all the errors present in the {language} query above. Generate a new working query, making sure all the errors are resolved in your response.', + values: { language }, + } + ); + +export const ASK_ASSISTANT_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantToolTip', + { + defaultMessage: 'Fix query or generate new one', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.test.tsx index 869504169712..f5a7e3963435 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.test.tsx @@ -14,7 +14,6 @@ import { buildListItems, getDescriptionItem, } from '.'; -import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { FilterManager, UI_SETTINGS } from '@kbn/data-plugin/public'; import type { Filter } from '@kbn/es-query'; @@ -575,7 +574,6 @@ describe('description_step', () => { }); describe('alert suppression', () => { - const ruleTypesWithoutSuppression: Type[] = ['machine_learning']; const suppressionFields = { groupByDuration: { unit: 'm', @@ -587,23 +585,6 @@ describe('description_step', () => { suppressionMissingFields: 'suppress', }; describe('groupByDuration', () => { - ruleTypesWithoutSuppression.forEach((ruleType) => { - test(`should be empty if rule is ${ruleType}`, () => { - const result: ListItems[] = getDescriptionItem( - 'groupByDuration', - 'label', - { - ruleType, - ...suppressionFields, - }, - mockFilterManager, - mockLicenseService - ); - - expect(result).toEqual([]); - }); - }); - ['query', 'saved_query'].forEach((ruleType) => { test(`should be empty if groupByFields empty for ${ruleType} rule`, () => { const result: ListItems[] = getDescriptionItem( @@ -686,22 +667,21 @@ describe('description_step', () => { }); describe('groupByFields', () => { - [...ruleTypesWithoutSuppression, 'threshold'].forEach((ruleType) => { - test(`should be empty if rule is ${ruleType}`, () => { - const result: ListItems[] = getDescriptionItem( - 'groupByFields', - 'label', - { - ruleType, - ...suppressionFields, - }, - mockFilterManager, - mockLicenseService - ); + test(`should be empty if rule type is 'threshold'`, () => { + const result: ListItems[] = getDescriptionItem( + 'groupByFields', + 'label', + { + ruleType: 'threshold', + ...suppressionFields, + }, + mockFilterManager, + mockLicenseService + ); - expect(result).toEqual([]); - }); + expect(result).toEqual([]); }); + ['query', 'saved_query'].forEach((ruleType) => { test(`should return item for ${ruleType} rule`, () => { const result: ListItems[] = getDescriptionItem( @@ -720,22 +700,21 @@ describe('description_step', () => { }); describe('suppressionMissingFields', () => { - [...ruleTypesWithoutSuppression, 'threshold'].forEach((ruleType) => { - test(`should be empty if rule is ${ruleType}`, () => { - const result: ListItems[] = getDescriptionItem( - 'suppressionMissingFields', - 'label', - { - ruleType, - ...suppressionFields, - }, - mockFilterManager, - mockLicenseService - ); + test(`should be empty if rule type is 'threshold'`, () => { + const result: ListItems[] = getDescriptionItem( + 'suppressionMissingFields', + 'label', + { + ruleType: 'threshold', + ...suppressionFields, + }, + mockFilterManager, + mockLicenseService + ); - expect(result).toEqual([]); - }); + expect(result).toEqual([]); }); + ['query', 'saved_query'].forEach((ruleType) => { test(`should return item for ${ruleType} rule`, () => { const result: ListItems[] = getDescriptionItem( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.test.tsx index 05fa86a1fa1d..0ba6bea89e0f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.test.tsx @@ -73,7 +73,6 @@ describe('QueryBarDefineRule', () => { <TestProviders> <Router history={mockHistory}> <QueryBarDefineRule - browserFields={{}} isLoading={false} indexPattern={{ fields: [], title: 'title' }} onCloseTimelineSearch={jest.fn()} @@ -96,7 +95,6 @@ describe('QueryBarDefineRule', () => { <TestProviders> <Router history={mockHistory}> <QueryBarDefineRule - browserFields={{}} isLoading={false} indexPattern={{ fields: [], title: 'title' }} onCloseTimelineSearch={jest.fn()} @@ -122,7 +120,6 @@ describe('QueryBarDefineRule', () => { <TestProviders> <Router history={mockHistory}> <QueryBarDefineRule - browserFields={{}} isLoading={false} indexPattern={{ fields: [], title: 'title' }} onCloseTimelineSearch={jest.fn()} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.tsx index 0a293955d0f7..43330bdd858e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar/index.tsx @@ -13,7 +13,6 @@ import type { DataViewBase, Filter, Query } from '@kbn/es-query'; import type { SavedQuery } from '@kbn/data-plugin/public'; import { FilterManager } from '@kbn/data-plugin/public'; -import type { BrowserFields } from '../../../../common/containers/source'; import { OpenTimelineModal } from '../../../../timelines/components/open_timeline/open_timeline_modal'; import type { ActionTimelineToShow } from '../../../../timelines/components/open_timeline/types'; import { QueryBar } from '../../../../common/components/query_bar'; @@ -31,7 +30,6 @@ export interface FieldValueQueryBar { title?: string; } export interface QueryBarDefineRuleProps { - browserFields: BrowserFields; dataTestSubj: string; field: FieldHook; idAria: string; @@ -74,7 +72,6 @@ const savedQueryToFieldValue = (savedQuery: SavedQuery): FieldValueQueryBar => ( export const QueryBarDefineRule = ({ defaultSavedQuery, - browserFields, dataTestSubj, field, idAria, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx index f9ef3a1cf464..23ac62df2766 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx @@ -10,6 +10,7 @@ import { screen, fireEvent, render, within, act, waitFor } from '@testing-librar import type { Type as RuleType } from '@kbn/securitysolution-io-ts-alerting-types'; import type { DataViewBase } from '@kbn/es-query'; import { StepDefineRule, aggregatableFields } from '.'; +import type { StepDefineRuleProps } from '.'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline'; import { TestProviders } from '../../../../common/mock'; @@ -24,6 +25,7 @@ import { createIndexPatternField, getSelectToggleButtonForName, } from '../../../rule_creation/components/required_fields/required_fields.test'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; // Mocks integrations jest.mock('../../../fleet_integrations/api'); @@ -35,6 +37,23 @@ jest.mock('../../../../common/components/query_bar', () => { }; }); +jest.mock('../../../rule_creation/components/pick_timeline', () => ({ + PickTimeline: 'pick-timeline', +})); + +jest.mock('../ai_assistant', () => { + return { + AiAssistant: jest.fn(() => { + return <div data-test-subj="ai-assistant" />; + }), + }; +}); + +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn(), +})); + +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; const mockRedirectLegacyUrl = jest.fn(); const mockGetLegacyUrlConflict = jest.fn(); jest.mock('../../../../common/lib/kibana', () => { @@ -610,6 +629,45 @@ describe('StepDefineRule', () => { ); }); }); + + describe('AI assistant', () => { + beforeEach(() => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + }); + it('renders assistant when query is not valid', () => { + render(<TestForm formProps={{ isQueryBarValid: false, ruleType: 'query' }} />, { + wrapper: TestProviders, + }); + + expect(screen.getByTestId('ai-assistant')).toBeInTheDocument(); + }); + + it('does not render assistant when feature flag is disabled', () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + + render(<TestForm formProps={{ isQueryBarValid: false, ruleType: 'query' }} />, { + wrapper: TestProviders, + }); + + expect(screen.queryByTestId('ai-assistant')).toBe(null); + }); + + it('does not render assistant when query is valid', () => { + render(<TestForm formProps={{ isQueryBarValid: true, ruleType: 'query' }} />, { + wrapper: TestProviders, + }); + + expect(screen.queryByTestId('ai-assistant')).toBe(null); + }); + + it('does not render assistant for ML rule type', () => { + render(<TestForm formProps={{ isQueryBarValid: false, ruleType: 'machine_learning' }} />, { + wrapper: TestProviders, + }); + + expect(screen.queryByTestId('ai-assistant')).toBe(null); + }); + }); }); interface TestFormProps { @@ -617,6 +675,7 @@ interface TestFormProps { ruleType?: RuleType; indexPattern?: DataViewBase; onSubmit?: FormSubmitHandler<DefineStepRule>; + formProps?: Partial<StepDefineRuleProps>; } function TestForm({ @@ -624,6 +683,7 @@ function TestForm({ ruleType = stepDefineDefaultValue.ruleType, indexPattern = { fields: [], title: '' }, onSubmit, + formProps, }: TestFormProps): JSX.Element { const [selectedEqlOptions, setSelectedEqlOptions] = useState(stepDefineDefaultValue.eqlOptions); const { form } = useForm({ @@ -644,7 +704,6 @@ function TestForm({ setOptionsSelected={setSelectedEqlOptions} indexPattern={indexPattern} isIndexPatternLoading={false} - browserFields={{}} isQueryBarValid={true} setIsQueryBarValid={jest.fn()} setIsThreatQueryBarValid={jest.fn()} @@ -658,6 +717,7 @@ function TestForm({ queryBarSavedId="" thresholdFields={[]} enableThresholdSuppression={false} + {...formProps} /> <button type="button" onClick={form.submit}> {'Submit'} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx index 839454922a14..5f9e877e1c2d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx @@ -26,7 +26,6 @@ import { i18n as i18nCore } from '@kbn/i18n'; import { isEqual, isEmpty } from 'lodash'; import type { FieldSpec } from '@kbn/data-views-plugin/common'; import usePrevious from 'react-use/lib/usePrevious'; -import type { BrowserFields } from '@kbn/timelines-plugin/common'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { useQueryClient } from '@tanstack/react-query'; @@ -36,9 +35,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useSetFieldValueWithCallback } from '../../../../common/utils/use_set_field_value_cb'; import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; -import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; -import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; -import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import type { EqlOptionsSelected, FieldsEqlOptions } from '../../../../../common/search_strategy'; import { filterRuleFieldsForType, getStepDataDataSource } from '../../pages/rule_creation/helpers'; import type { @@ -58,6 +54,7 @@ import { MlJobSelect } from '../../../rule_creation/components/ml_job_select'; import { PickTimeline } from '../../../rule_creation/components/pick_timeline'; import { StepContentWrapper } from '../../../rule_creation/components/step_content_wrapper'; import { ThresholdInput } from '../threshold_input'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { SuppressionInfoIcon } from '../suppression_info_icon'; import { EsqlInfoIcon } from '../../../rule_creation/components/esql_info_icon'; import { @@ -102,14 +99,16 @@ import { MINIMUM_LICENSE_FOR_SUPPRESSION } from '../../../../../common/detection import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; import { useAllEsqlRuleFields } from '../../hooks'; import { useAlertSuppression } from '../../../rule_management/logic/use_alert_suppression'; +import { AiAssistant } from '../ai_assistant'; import { RelatedIntegrations } from '../../../rule_creation/components/related_integrations'; +import { useMLRuleConfig } from '../../../../common/components/ml/hooks/use_ml_rule_config'; const CommonUseField = getUseField({ component: Field }); const StyledVisibleContainer = styled.div<{ isVisible: boolean }>` display: ${(props) => (props.isVisible ? 'block' : 'none')}; `; -interface StepDefineRuleProps extends RuleStepProps { +export interface StepDefineRuleProps extends RuleStepProps { indicesConfig: string[]; threatIndicesConfig: string[]; defaultSavedQuery?: SavedQuery; @@ -118,7 +117,6 @@ interface StepDefineRuleProps extends RuleStepProps { setOptionsSelected: React.Dispatch<React.SetStateAction<EqlOptionsSelected>>; indexPattern: DataViewBase; isIndexPatternLoading: boolean; - browserFields: BrowserFields; isQueryBarValid: boolean; setIsQueryBarValid: (valid: boolean) => void; setIsThreatQueryBarValid: (valid: boolean) => void; @@ -167,41 +165,52 @@ const IntendedRuleTypeEuiFormRow = styled(RuleTypeEuiFormRow)` // eslint-disable-next-line complexity const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ - isLoading, - isUpdateView = false, - kibanaDataViews, - indicesConfig, - threatIndicesConfig, + dataSourceType, defaultSavedQuery, + enableThresholdSuppression, form, - optionsSelected, - setOptionsSelected, + groupByFields, + index, indexPattern, + indicesConfig, isIndexPatternLoading, - browserFields, + isLoading, isQueryBarValid, + isUpdateView = false, + kibanaDataViews, + optionsSelected, + queryBarSavedId, + queryBarTitle, + ruleType, setIsQueryBarValid, setIsThreatQueryBarValid, - ruleType, - index, - threatIndex, - groupByFields, - dataSourceType, + setOptionsSelected, shouldLoadQueryDynamically, - queryBarTitle, - queryBarSavedId, + threatIndex, + threatIndicesConfig, thresholdFields, - enableThresholdSuppression, }) => { const queryClient = useQueryClient(); const { isSuppressionEnabled: isAlertSuppressionEnabled } = useAlertSuppression(ruleType); - const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [indexModified, setIndexModified] = useState(false); const [threatIndexModified, setThreatIndexModified] = useState(false); const license = useLicense(); + const [{ machineLearningJobId }] = useFormData<DefineStepRule>({ + form, + watch: ['machineLearningJobId'], + }); + const { + hasMlAdminPermissions, + hasMlLicense, + mlFieldsLoading, + mlSuppressionFields, + noMlJobsStarted, + someMlJobsStarted, + } = useMLRuleConfig({ machineLearningJobId }); + const esqlQueryRef = useRef<DefineStepRule['queryBar'] | undefined>(undefined); const isAlertSuppressionLicenseValid = license.isAtLeast(MINIMUM_LICENSE_FOR_SUPPRESSION); @@ -209,6 +218,9 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ const isThresholdRule = getIsThresholdRule(ruleType); const alertSuppressionUpsellingMessage = useUpsellingMessage('alert_suppression_rule_form'); + const isAIAssistantEnabled = useIsExperimentalFeatureEnabled( + 'AIAssistantOnRuleCreationFormEnabled' + ); const { getFields, reset, setFieldValue } = form; const setRuleTypeCallback = useSetFieldValueWithCallback({ @@ -278,10 +290,8 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ [aggFields] ); - const [ - threatIndexPatternsLoading, - { browserFields: threatBrowserFields, indexPatterns: threatIndexPatterns }, - ] = useFetchIndex(threatIndex); + const [threatIndexPatternsLoading, { indexPatterns: threatIndexPatterns }] = + useFetchIndex(threatIndex); // reset form when rule type changes useEffect(() => { @@ -437,7 +447,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ <ThreatMatchInput handleResetThreatIndices={handleResetThreatIndices} indexPatterns={indexPattern} - threatBrowserFields={threatBrowserFields} threatIndexModified={threatIndexModified} threatIndexPatterns={threatIndexPatterns} threatIndexPatternsLoading={threatIndexPatternsLoading} @@ -449,7 +458,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ handleResetThreatIndices, indexPattern, setIsThreatQueryBarValid, - threatBrowserFields, threatIndexModified, threatIndexPatterns, threatIndexPatternsLoading, @@ -469,6 +477,24 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ isEqlSequenceQuery(queryBar?.query?.query as string) && groupByFields.length === 0; + const isSuppressionGroupByDisabled = + !isAlertSuppressionLicenseValid || + areSuppressionFieldsDisabledBySequence || + isEsqlSuppressionLoading || + (isMlRule(ruleType) && (noMlJobsStarted || mlFieldsLoading || !mlSuppressionFields.length)); + + const suppressionGroupByDisabledText = areSuppressionFieldsDisabledBySequence + ? i18n.EQL_SEQUENCE_SUPPRESSION_DISABLE_TOOLTIP + : isMlRule(ruleType) && noMlJobsStarted + ? i18n.MACHINE_LEARNING_SUPPRESSION_DISABLED_LABEL + : alertSuppressionUpsellingMessage; + + const suppressionGroupByFields = isEsqlRule(ruleType) + ? esqlSuppressionFields + : isMlRule(ruleType) + ? mlSuppressionFields + : termsAggregationFields; + /** * Component that allows selection of suppression intervals disabled: * - if suppression license is not valid(i.e. less than platinum) @@ -788,7 +814,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ component={QueryBarDefineRule} componentProps={ { - browserFields, idAria: 'detectionEngineStepDefineRuleQueryBar', indexPattern, isDisabled: isLoading || shouldLoadQueryDynamically || timelineQueryLoading, @@ -808,7 +833,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ [ handleOpenTimelineSearch, shouldLoadQueryDynamically, - browserFields, indexPattern, isLoading, timelineQueryLoading, @@ -863,10 +887,10 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ () => ({ describedByIds: ['detectionEngineStepDefineRuleType'], isUpdateView, - hasValidLicense: hasMlLicense(mlCapabilities), - isMlAdmin: hasMlAdminPermissions(mlCapabilities), + hasValidLicense: hasMlLicense, + isMlAdmin: hasMlAdminPermissions, }), - [isUpdateView, mlCapabilities] + [hasMlAdminPermissions, hasMlLicense, isUpdateView] ); return ( @@ -928,6 +952,11 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ )} </> </RuleTypeEuiFormRow> + + {isAIAssistantEnabled && !isMlRule(ruleType) && !isQueryBarValid && ( + <AiAssistant getFields={form.getFields} language={queryBar?.query?.language} /> + )} + {isQueryRule(ruleType) && ( <> <EuiSpacer size="s" /> @@ -1068,22 +1097,22 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ </EuiText> } > - <UseField - path="groupByFields" - component={MultiSelectFieldsAutocomplete} - componentProps={{ - browserFields: isEsqlRule(ruleType) - ? esqlSuppressionFields - : termsAggregationFields, - isDisabled: - !isAlertSuppressionLicenseValid || - areSuppressionFieldsDisabledBySequence || - isEsqlSuppressionLoading, - disabledText: areSuppressionFieldsDisabledBySequence - ? i18n.EQL_SEQUENCE_SUPPRESSION_DISABLE_TOOLTIP - : alertSuppressionUpsellingMessage, - }} - /> + <> + <UseField + path="groupByFields" + component={MultiSelectFieldsAutocomplete} + componentProps={{ + browserFields: suppressionGroupByFields, + isDisabled: isSuppressionGroupByDisabled, + disabledText: suppressionGroupByDisabledText, + }} + /> + {someMlJobsStarted && ( + <EuiText size="xs" color="warning"> + {i18n.MACHINE_LEARNING_SUPPRESSION_INCOMPLETE_LABEL} + </EuiText> + )} + </> </RuleTypeEuiFormRow> <IntendedRuleTypeEuiFormRow 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 ef2a6adcc57c..7d7bb9c4a925 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 @@ -234,6 +234,21 @@ export const EQL_SEQUENCE_SUPPRESSION_GROUPBY_VALIDATION_TEXT = i18n.translate( } ); +export const MACHINE_LEARNING_SUPPRESSION_DISABLED_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.machineLearningSuppressionDisabledLabel', + { + defaultMessage: 'To enable alert suppression, start relevant Machine Learning jobs.', + } +); + +export const MACHINE_LEARNING_SUPPRESSION_INCOMPLETE_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.machineLearningSuppressionIncompleteLabel', + { + defaultMessage: + 'This list of fields might be incomplete as some Machine Learning jobs are not running. Start all relevant jobs for a complete list.', + } +); + export const GROUP_BY_TECH_PREVIEW_LABEL_APPEND = i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsTechPreviewLabelAppend', { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/use_experimental_feature_fields_transform.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/use_experimental_feature_fields_transform.ts index c92c35688dd3..1bca12d46111 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/use_experimental_feature_fields_transform.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/use_experimental_feature_fields_transform.ts @@ -8,7 +8,7 @@ import { useCallback } from 'react'; import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { isEsqlRule } from '../../../../../common/detection_engine/utils'; +import { isEsqlRule, isMlRule } from '../../../../../common/detection_engine/utils'; /** * transforms DefineStepRule fields according to experimental feature flags @@ -16,6 +16,9 @@ import { isEsqlRule } from '../../../../../common/detection_engine/utils'; export const useExperimentalFeatureFieldsTransform = <T extends Partial<DefineStepRule>>(): (( fields: T ) => T) => { + const isAlertSuppressionForMachineLearningRuleEnabled = useIsExperimentalFeatureEnabled( + 'alertSuppressionForMachineLearningRuleEnabled' + ); const isAlertSuppressionForEsqlRuleEnabled = useIsExperimentalFeatureEnabled( 'alertSuppressionForEsqlRuleEnabled' ); @@ -23,7 +26,8 @@ export const useExperimentalFeatureFieldsTransform = <T extends Partial<DefineSt const transformer = useCallback( (fields: T) => { const isSuppressionDisabled = - isEsqlRule(fields.ruleType) && !isAlertSuppressionForEsqlRuleEnabled; + (isMlRule(fields.ruleType) && !isAlertSuppressionForMachineLearningRuleEnabled) || + (isEsqlRule(fields.ruleType) && !isAlertSuppressionForEsqlRuleEnabled); // reset any alert suppression values hidden behind feature flag if (isSuppressionDisabled) { @@ -38,7 +42,7 @@ export const useExperimentalFeatureFieldsTransform = <T extends Partial<DefineSt return fields; }, - [isAlertSuppressionForEsqlRuleEnabled] + [isAlertSuppressionForEsqlRuleEnabled, isAlertSuppressionForMachineLearningRuleEnabled] ); return transformer; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/threatmatch_input/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/threatmatch_input/index.tsx index 2d23dbb7578c..c2b04b7d15da 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/threatmatch_input/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/threatmatch_input/index.tsx @@ -10,7 +10,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui'; import type { DataViewBase } from '@kbn/es-query'; import type { ThreatMapEntries } from '../../../../common/components/threat_match/types'; import { ThreatMatchComponent } from '../../../../common/components/threat_match'; -import type { BrowserField } from '../../../../common/containers/source'; import type { FieldHook } from '../../../../shared_imports'; import { Field, @@ -28,7 +27,6 @@ const CommonUseField = getUseField({ component: Field }); interface ThreatMatchInputProps { threatMapping: FieldHook; - threatBrowserFields: Readonly<Record<string, Partial<BrowserField>>>; threatIndexPatterns: DataViewBase; indexPatterns: DataViewBase; threatIndexPatternsLoading: boolean; @@ -44,7 +42,6 @@ const ThreatMatchInputComponent: React.FC<ThreatMatchInputProps> = ({ indexPatterns, threatIndexPatterns, threatIndexPatternsLoading, - threatBrowserFields, onValidityChange, }: ThreatMatchInputProps) => { const { setValue, value: threatItems } = threatMapping; @@ -101,7 +98,6 @@ const ThreatMatchInputComponent: React.FC<ThreatMatchInputProps> = ({ }} component={QueryBarDefineRule} componentProps={{ - browserFields: threatBrowserFields, idAria: 'detectionEngineStepDefineThreatRuleQueryBar', indexPattern: threatIndexPatterns, isDisabled: false, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts index b61cdbc386ee..5154f0aaffba 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts @@ -587,6 +587,32 @@ describe('helpers', () => { expect(result).toEqual(expected); }); + + it('returns suppression fields for machine_learning rules', () => { + const mockStepData: DefineStepRule = { + ...mockData, + ruleType: 'machine_learning', + machineLearningJobId: ['some_jobert_id'], + anomalyThreshold: 44, + groupByFields: ['event.type'], + groupByRadioSelection: GroupByOptions.PerTimePeriod, + groupByDuration: { value: 10, unit: 'm' }, + }; + const result = formatDefineStepData(mockStepData); + + const expected: DefineStepRuleJson = { + machine_learning_job_id: ['some_jobert_id'], + anomaly_threshold: 44, + type: 'machine_learning', + alert_suppression: { + group_by: ['event.type'], + duration: { value: 10, unit: 'm' }, + missing_fields_strategy: 'suppress', + }, + }; + + expect(result).toEqual(expect.objectContaining(expected)); + }); }); describe('formatScheduleStepData', () => { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts index f281b3b6b4a2..8cda58eeeb54 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts @@ -439,6 +439,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep ? { anomaly_threshold: ruleFields.anomalyThreshold, machine_learning_job_id: ruleFields.machineLearningJobId, + ...alertSuppressionFields, } : isThresholdFields(ruleFields) ? { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx index 806ea9f336bd..6fc19c2b2411 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx @@ -262,7 +262,7 @@ const CreateRulePageComponent: React.FC = () => { }; fetchDV(); }, [dataViews]); - const { indexPattern, isIndexPatternLoading, browserFields } = useRuleIndexPattern({ + const { indexPattern, isIndexPatternLoading } = useRuleIndexPattern({ dataSourceType: defineStepData.dataSourceType, index: memoizedIndex, dataViewId: defineStepData.dataViewId, @@ -504,7 +504,6 @@ const CreateRulePageComponent: React.FC = () => { setOptionsSelected={setEqlOptionsSelected} indexPattern={indexPattern} isIndexPatternLoading={isIndexPatternLoading} - browserFields={browserFields} isQueryBarValid={isQueryBarValid} setIsQueryBarValid={setIsQueryBarValid} setIsThreatQueryBarValid={setIsThreatQueryBarValid} @@ -530,7 +529,6 @@ const CreateRulePageComponent: React.FC = () => { ), [ activeStep, - browserFields, dataViewOptions, defineRuleNextStep, defineStepData.dataSourceType, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx index 47b67c8ed720..b5b87b528d01 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx @@ -210,7 +210,7 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { }); const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]); - const { indexPattern, isIndexPatternLoading, browserFields } = useRuleIndexPattern({ + const { indexPattern, isIndexPatternLoading } = useRuleIndexPattern({ dataSourceType: defineStepData.dataSourceType, index: memoizedIndex, dataViewId: defineStepData.dataViewId, @@ -245,7 +245,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { key="defineStep" indexPattern={indexPattern} isIndexPatternLoading={isIndexPatternLoading} - browserFields={browserFields} isQueryBarValid={isQueryBarValid} setIsQueryBarValid={setIsQueryBarValid} setIsThreatQueryBarValid={setIsThreatQueryBarValid} @@ -371,7 +370,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { setEqlOptionsSelected, indexPattern, isIndexPatternLoading, - browserFields, isQueryBarValid, defineStepData, aboutStepData, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx index 584a9a4e4902..3a221836e3a3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { noop } from 'lodash/fp'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { coreMock } from '@kbn/core/public/mocks'; import { TestProviders } from '../../../../../common/mock'; @@ -18,10 +18,30 @@ import { useExecutionResults } from '../../../../rule_monitoring'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; import { useRuleDetailsContext } from '../rule_details_context'; import { ExecutionLogTable } from './execution_log_table'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { useKibana as mockUseKibana } from '../../../../../common/lib/kibana/__mocks__'; jest.mock('../../../../../sourcerer/containers'); jest.mock('../../../../rule_monitoring/components/execution_results_table/use_execution_results'); jest.mock('../rule_details_context'); +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../common/hooks/use_experimental_features', () => { + return { + useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), + }; +}); + +const mockTelemetry = { + reportEventLogShowSourceEventDateRange: jest.fn(), +}; + +const mockedUseKibana = { + ...mockUseKibana(), + services: { + ...mockUseKibana().services, + telemetry: mockTelemetry, + }, +}; const coreStart = coreMock.createStart(); @@ -42,6 +62,11 @@ mockUseRuleExecutionEvents.mockReturnValue({ }); describe('ExecutionLogTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue(mockedUseKibana); + }); + test('Shows total events returned', () => { const ruleDetailsContext = useRuleDetailsContextMock.create(); (useRuleDetailsContext as jest.Mock).mockReturnValue(ruleDetailsContext); @@ -50,4 +75,22 @@ describe('ExecutionLogTable', () => { }); expect(screen.getByTestId('executionsShowing')).toHaveTextContent('Showing 7 rule executions'); }); + + test('should call telemetry when the "Show Source Event Time Range" switch is toggled', async () => { + const ruleDetailsContext = useRuleDetailsContextMock.create(); + (useRuleDetailsContext as jest.Mock).mockReturnValue(ruleDetailsContext); + + const { getByText } = render( + <ExecutionLogTable ruleId={'0'} selectAlertsTab={noop} {...coreStart} />, + { + wrapper: TestProviders, + } + ); + + const switchButton = getByText('Show source event time range'); + + fireEvent.click(switchButton); + + expect(mockTelemetry.reportEventLogShowSourceEventDateRange).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx index 981f80f36f74..37037719f8e4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_table.tsx @@ -9,7 +9,12 @@ import React, { useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import moment from 'moment'; -import type { OnTimeChangeProps, OnRefreshProps, OnRefreshChangeProps } from '@elastic/eui'; +import type { + OnTimeChangeProps, + OnRefreshProps, + OnRefreshChangeProps, + EuiSwitchEvent, +} from '@elastic/eui'; import { EuiTextColor, EuiFlexGroup, @@ -120,6 +125,7 @@ const ExecutionLogTableComponent: React.FC<ExecutionLogTableProps> = ({ }, storage, timelines, + telemetry, } = useKibana().services; const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); @@ -453,6 +459,17 @@ const ExecutionLogTableComponent: React.FC<ExecutionLogTableProps> = ({ renderItem: renderExpandedItem, }); + const handleShowSourceEventTimeRange = useCallback( + (e: EuiSwitchEvent) => { + const isVisible = e.target.checked; + onShowSourceEventTimeRange(isVisible); + telemetry.reportEventLogShowSourceEventDateRange({ + isVisible, + }); + }, + [onShowSourceEventTimeRange, telemetry] + ); + const executionLogColumns = useMemo(() => { const columns = [...EXECUTION_LOG_COLUMNS].filter((item) => { if ('field' in item) { @@ -569,7 +586,7 @@ const ExecutionLogTableComponent: React.FC<ExecutionLogTableProps> = ({ label={i18n.RULE_EXECUTION_LOG_SHOW_SOURCE_EVENT_TIME_RANGE} checked={showSourceEventTimeRange} compressed={true} - onChange={(e) => onShowSourceEventTimeRange(e.target.checked)} + onChange={handleShowSourceEventTimeRange} /> )} <UtilitySwitch 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 3a2a608d8443..2bacc44b15a7 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 @@ -35,7 +35,7 @@ const DEFAULT_PAGE_SIZE = 10; const getBackfillsTableColumns = (hasCRUDPermissions: boolean) => { const stopAction = { name: i18n.BACKFILLS_TABLE_COLUMN_ACTION, - render: (item: BackfillRow) => <StopBackfill id={item.id} />, + render: (item: BackfillRow) => <StopBackfill backfill={item} />, width: '10%', }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/stop_backfill.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/stop_backfill.test.tsx new file mode 100644 index 000000000000..b2cdd83d6f43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/stop_backfill.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useDeleteBackfill } from '../../api/hooks/use_delete_backfill'; +import { StopBackfill } from './stop_backfill'; +import { TestProviders } from '../../../../common/mock'; +import { useKibana } from '../../../../common/lib/kibana'; +import * as i18n from '../../translations'; +import type { BackfillRow } from '../../types'; + +jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../api/hooks/use_delete_backfill'); +jest.mock('../../../../common/lib/kibana'); + +const mockUseAppToasts = useAppToasts as jest.Mock; +const mockUseDeleteBackfill = useDeleteBackfill as jest.Mock; +const mockUseKibana = useKibana as jest.Mock; + +describe('StopBackfill', () => { + const mockTelemetry = { + reportManualRuleRunCancelJob: jest.fn(), + }; + + const addSuccess = jest.fn(); + const addError = jest.fn(); + + const backfill = { + id: 'backfill-id', + total: 10, + complete: 5, + error: 1, + duration: '1h', + enabled: true, + running: 1, + pending: 1, + timeout: 1, + end: '2024-06-28T12:05:38.955Z', + start: '2024-06-28T12:00:00.000Z', + status: 'pending', + created_at: '2024-06-28T12:05:42.572Z', + space_id: 'default', + rule: { + name: 'Rule', + }, + schedule: [ + { + run_at: '2024-06-28T13:00:00.000Z', + status: 'pending', + interval: '1h', + }, + ], + } as BackfillRow; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseAppToasts.mockReturnValue({ + addSuccess, + addError, + }); + + mockUseKibana.mockReturnValue({ + services: { + telemetry: mockTelemetry, + }, + }); + }); + + it('should call deleteBackfillMutation and telemetry when confirmed', async () => { + mockUseDeleteBackfill.mockImplementation((options) => ({ + mutate: () => { + if (options.onSuccess) { + options.onSuccess(); + } + }, + })); + + const { getByTestId } = render(<StopBackfill backfill={backfill} />, { + wrapper: TestProviders, + }); + + fireEvent.click(getByTestId('rule-backfills-delete-button')); + fireEvent.click(getByTestId('confirmModalConfirmButton')); + + await waitFor(() => { + expect(mockTelemetry.reportManualRuleRunCancelJob).toHaveBeenCalledWith({ + totalTasks: backfill.total, + completedTasks: backfill.complete, + errorTasks: backfill.error, + }); + }); + + expect(addSuccess).toHaveBeenCalledWith(i18n.BACKFILLS_TABLE_STOP_CONFIRMATION_SUCCESS); + }); + + it('should call addError on deleteBackfillMutation error', async () => { + mockUseDeleteBackfill.mockImplementation((options) => ({ + mutate: () => { + if (options.onError) { + options.onError(new Error('Error stopping backfill')); + } + }, + })); + + const { getByTestId } = render(<StopBackfill backfill={backfill} />, { + wrapper: TestProviders, + }); + + fireEvent.click(getByTestId('rule-backfills-delete-button')); + fireEvent.click(getByTestId('confirmModalConfirmButton')); + + await waitFor(() => { + expect(addError).toHaveBeenCalledWith(expect.any(Error), { + title: i18n.BACKFILLS_TABLE_STOP_CONFIRMATION_ERROR, + toastMessage: 'Error stopping backfill', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/stop_backfill.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/stop_backfill.tsx index 6dfca1922d2a..84acf0b014d6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/stop_backfill.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/stop_backfill.tsx @@ -10,12 +10,20 @@ import { EuiButtonEmpty, EuiConfirmModal } from '@elastic/eui'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useDeleteBackfill } from '../../api/hooks/use_delete_backfill'; import * as i18n from '../../translations'; +import type { BackfillRow } from '../../types'; +import { useKibana } from '../../../../common/lib/kibana'; -export const StopBackfill = ({ id }: { id: string }) => { +export const StopBackfill = ({ backfill }: { backfill: BackfillRow }) => { + const { telemetry } = useKibana().services; const { addSuccess, addError } = useAppToasts(); const deleteBackfillMutation = useDeleteBackfill({ onSuccess: () => { closeModal(); + telemetry.reportManualRuleRunCancelJob({ + totalTasks: backfill.total, + completedTasks: backfill.complete, + errorTasks: backfill.error, + }); addSuccess(i18n.BACKFILLS_TABLE_STOP_CONFIRMATION_SUCCESS); }, onError: (error) => { @@ -29,7 +37,7 @@ export const StopBackfill = ({ id }: { id: string }) => { const showModal = () => setIsModalVisible(true); const closeModal = () => setIsModalVisible(false); const onConfirm = () => { - deleteBackfillMutation.mutate({ backfillId: id }); + deleteBackfillMutation.mutate({ backfillId: backfill.id }); }; return ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx index 94c3f7e36acd..36bdf8a8bf82 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.test.tsx @@ -5,20 +5,38 @@ * 2.0. */ -import { INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH } from '@kbn/alerting-plugin/common'; import { act, renderHook } from '@testing-library/react-hooks'; import moment from 'moment'; import { useKibana } from '../../../common/lib/kibana'; +import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__'; import { TestProviders } from '../../../common/mock'; import { useScheduleRuleRun } from './use_schedule_rule_run'; +const mockUseScheduleRuleRunMutation = jest.fn(); + jest.mock('../../../common/lib/kibana'); +jest.mock('../api/hooks/use_schedule_rule_run_mutation', () => ({ + useScheduleRuleRunMutation: () => { + return { + mutateAsync: mockUseScheduleRuleRunMutation, + }; + }, +})); -const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>; +const mockedUseKibana = { + ...mockUseKibana(), + services: { + ...mockUseKibana().services, + telemetry: { + reportManualRuleRunExecute: jest.fn(), + }, + }, +}; describe('When using the `useScheduleRuleRun()` hook', () => { beforeEach(() => { jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue(mockedUseKibana); }); it('should send schedule rule run request', async () => { @@ -31,13 +49,61 @@ describe('When using the `useScheduleRuleRun()` hook', () => { result.current.scheduleRuleRun({ ruleIds: ['rule-1'], timeRange }); }); - await waitFor(() => (useKibanaMock().services.http.fetch as jest.Mock).mock.calls.length > 0); + await waitFor(() => { + return mockUseScheduleRuleRunMutation.mock.calls.length > 0; + }); - expect(useKibanaMock().services.http.fetch).toHaveBeenCalledWith( - INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH, + expect(mockUseScheduleRuleRunMutation).toHaveBeenCalledWith( expect.objectContaining({ - body: `[{"rule_id":"rule-1","start":"${timeRange.startDate.toISOString()}","end":"${timeRange.endDate.toISOString()}"}]`, + ruleIds: ['rule-1'], + timeRange, }) ); }); + + it('should call reportManualRuleRunExecute with success status on success', async () => { + const { result, waitFor } = renderHook(() => useScheduleRuleRun(), { + wrapper: TestProviders, + }); + + const timeRange = { startDate: moment().subtract(1, 'd'), endDate: moment() }; + mockUseScheduleRuleRunMutation.mockResolvedValueOnce([{ id: 'rule-1' }]); + + act(() => { + result.current.scheduleRuleRun({ ruleIds: ['rule-1'], timeRange }); + }); + + await waitFor(() => { + return mockUseScheduleRuleRunMutation.mock.calls.length > 0; + }); + + expect(mockedUseKibana.services.telemetry.reportManualRuleRunExecute).toHaveBeenCalledWith({ + rangeInMs: timeRange.endDate.diff(timeRange.startDate), + status: 'success', + rulesCount: 1, + }); + }); + + it('should call reportManualRuleRunExecute with error status on failure', async () => { + const { result, waitFor } = renderHook(() => useScheduleRuleRun(), { + wrapper: TestProviders, + }); + + const timeRange = { startDate: moment().subtract(1, 'd'), endDate: moment() }; + mockUseScheduleRuleRunMutation.mockRejectedValueOnce(new Error('Error scheduling rule run')); + + act(() => { + result.current.scheduleRuleRun({ ruleIds: ['rule-1'], timeRange }); + }); + + await waitFor(() => { + return mockUseScheduleRuleRunMutation.mock.calls.length > 0; + }); + + expect(mockedUseKibana.services.telemetry.reportManualRuleRunExecute).toHaveBeenCalledWith({ + rangeInMs: timeRange.endDate.diff(timeRange.startDate), + status: 'error', + rulesCount: 1, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts index 7c00c4294acd..7599d8685d3c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_schedule_rule_run.ts @@ -7,6 +7,7 @@ import { useCallback } from 'react'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { useKibana } from '../../../common/lib/kibana'; import { useScheduleRuleRunMutation } from '../api/hooks/use_schedule_rule_run_mutation'; import type { ScheduleBackfillProps } from '../types'; @@ -15,18 +16,29 @@ import * as i18n from '../translations'; export function useScheduleRuleRun() { const { mutateAsync } = useScheduleRuleRunMutation(); const { addError, addSuccess } = useAppToasts(); + const { telemetry } = useKibana().services; const scheduleRuleRun = useCallback( async (options: ScheduleBackfillProps) => { try { const results = await mutateAsync(options); + telemetry.reportManualRuleRunExecute({ + rangeInMs: options.timeRange.endDate.diff(options.timeRange.startDate), + status: 'success', + rulesCount: options.ruleIds.length, + }); addSuccess(i18n.BACKFILL_SCHEDULE_SUCCESS(results.length)); return results; } catch (error) { addError(error, { title: i18n.BACKFILL_SCHEDULE_ERROR_TITLE }); + telemetry.reportManualRuleRunExecute({ + rangeInMs: options.timeRange.endDate.diff(options.timeRange.startDate), + status: 'error', + rulesCount: options.ruleIds.length, + }); } }, - [addError, addSuccess, mutateAsync] + [addError, addSuccess, mutateAsync, telemetry] ); return { scheduleRuleRun }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.test.tsx index d12a5ff97d50..fb00b73e88ff 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.test.tsx @@ -37,18 +37,38 @@ describe('useAlertSuppression', () => { expect(result.current.isSuppressionEnabled).toBe(false); }); - it('should return isSuppressionEnabled false if ES|QL Feature Flag is disabled', () => { - const { result } = renderHook(() => useAlertSuppression('esql')); + describe('ML rules', () => { + it('is true if the feature flag is enabled', () => { + jest + .spyOn(useIsExperimentalFeatureEnabledMock, 'useIsExperimentalFeatureEnabled') + .mockReset() + .mockReturnValue(true); + const { result } = renderHook(() => useAlertSuppression('machine_learning')); - expect(result.current.isSuppressionEnabled).toBe(false); + expect(result.current.isSuppressionEnabled).toBe(true); + }); + + it('is false if the feature flag is disabled', () => { + const { result } = renderHook(() => useAlertSuppression('machine_learning')); + + expect(result.current.isSuppressionEnabled).toBe(false); + }); }); - it('should return isSuppressionEnabled true if ES|QL Feature Flag is enabled', () => { - jest - .spyOn(useIsExperimentalFeatureEnabledMock, 'useIsExperimentalFeatureEnabled') - .mockImplementation((flag) => flag === 'alertSuppressionForEsqlRuleEnabled'); - const { result } = renderHook(() => useAlertSuppression('esql')); + describe('ES|QL rules', () => { + it('should return isSuppressionEnabled false if ES|QL Feature Flag is disabled', () => { + const { result } = renderHook(() => useAlertSuppression('esql')); + + expect(result.current.isSuppressionEnabled).toBe(false); + }); + + it('should return isSuppressionEnabled true if ES|QL Feature Flag is enabled', () => { + jest + .spyOn(useIsExperimentalFeatureEnabledMock, 'useIsExperimentalFeatureEnabled') + .mockImplementation((flag) => flag === 'alertSuppressionForEsqlRuleEnabled'); + const { result } = renderHook(() => useAlertSuppression('esql')); - expect(result.current.isSuppressionEnabled).toBe(true); + expect(result.current.isSuppressionEnabled).toBe(true); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.tsx index 1c9f139633c8..6d0ecefe8345 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_alert_suppression.tsx @@ -6,7 +6,7 @@ */ import { useCallback } from 'react'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; -import { isSuppressibleAlertRule } from '../../../../common/detection_engine/utils'; +import { isMlRule, isSuppressibleAlertRule } from '../../../../common/detection_engine/utils'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; export interface UseAlertSuppressionReturn { @@ -14,6 +14,9 @@ export interface UseAlertSuppressionReturn { } export const useAlertSuppression = (ruleType: Type | undefined): UseAlertSuppressionReturn => { + const isAlertSuppressionForMachineLearningRuleEnabled = useIsExperimentalFeatureEnabled( + 'alertSuppressionForMachineLearningRuleEnabled' + ); const isAlertSuppressionForEsqlRuleEnabled = useIsExperimentalFeatureEnabled( 'alertSuppressionForEsqlRuleEnabled' ); @@ -27,8 +30,16 @@ export const useAlertSuppression = (ruleType: Type | undefined): UseAlertSuppres return isSuppressibleAlertRule(ruleType) && isAlertSuppressionForEsqlRuleEnabled; } + if (isMlRule(ruleType)) { + return isSuppressibleAlertRule(ruleType) && isAlertSuppressionForMachineLearningRuleEnabled; + } + return isSuppressibleAlertRule(ruleType); - }, [ruleType, isAlertSuppressionForEsqlRuleEnabled]); + }, [ + isAlertSuppressionForEsqlRuleEnabled, + isAlertSuppressionForMachineLearningRuleEnabled, + ruleType, + ]); return { isSuppressionEnabled: isSuppressionEnabledForRuleType(), diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_rule_fields.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_rule_fields.ts new file mode 100644 index 000000000000..c0f34c5502f9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_rule_fields.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 { DataViewFieldBase } from '@kbn/es-query'; + +import { useRuleIndices } from './use_rule_indices'; +import { useFetchIndex } from '../../../common/containers/source'; + +interface UseRuleFieldParams { + machineLearningJobId?: string[]; + indexPattern?: string[]; +} + +interface UseRuleFieldsReturn { + loading: boolean; + fields: DataViewFieldBase[]; +} + +export const useRuleFields = ({ + machineLearningJobId, + indexPattern, +}: UseRuleFieldParams): UseRuleFieldsReturn => { + const { ruleIndices } = useRuleIndices(machineLearningJobId, indexPattern); + const [ + loading, + { + indexPatterns: { fields }, + }, + ] = useFetchIndex(ruleIndices); + + return { loading, fields }; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/investigation_fields_form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/investigation_fields_form.tsx index 449664e20222..618def872a54 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/investigation_fields_form.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/investigation_fields_form.tsx @@ -164,8 +164,7 @@ const InvestigationFieldsFormComponent = ({ > <FormattedMessage id="xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.setInvestigationFieldsWarningCallout" - defaultMessage="You’re about to overwrite custom highlighted fields for {rulesCount, plural, one {# selected rule} other {# selected rules}}, press Save to - apply changes." + defaultMessage="You’re about to overwrite custom highlighted fields for the {rulesCount, plural, one {# rule} other {# rules}} you selected. To apply and save the changes, click Save." values={{ rulesCount }} /> </EuiCallOut> 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 5ea5d3d456f1..c93e5040d7ac 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 @@ -239,6 +239,9 @@ export const useBulkActions = ({ } const modalManualRuleRunConfirmationResult = await showManualRuleRunConfirmation(); + startServices.telemetry.reportManualRuleRunOpenModal({ + type: 'bulk', + }); if (modalManualRuleRunConfirmationResult === null) { return; } @@ -253,6 +256,14 @@ export const useBulkActions = ({ end_date: modalManualRuleRunConfirmationResult.endDate.toISOString(), }, }); + + startServices.telemetry.reportManualRuleRunExecute({ + rangeInMs: modalManualRuleRunConfirmationResult.endDate.diff( + modalManualRuleRunConfirmationResult.startDate + ), + status: 'success', + rulesCount: enabledIds.length, + }); }; const handleBulkEdit = (bulkEditActionType: BulkActionEditType) => async () => { 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 4af2fdd7ef35..984df06342a1 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 @@ -36,7 +36,10 @@ export const useRulesTableActions = ({ showManualRuleRunConfirmation: () => Promise<TimeRange | null>; confirmDeletion: () => Promise<boolean>; }): Array<DefaultItemAction<Rule>> => { - const { navigateToApp } = useKibana().services.application; + const { + application: { navigateToApp }, + telemetry, + } = useKibana().services; const hasActionsPrivileges = useHasActionsPrivileges(); const { startTransaction } = useStartTransaction(); const { executeBulkAction } = useExecuteBulkAction(); @@ -129,6 +132,9 @@ export const useRulesTableActions = ({ onClick: async (rule: Rule) => { startTransaction({ name: SINGLE_RULE_ACTIONS.MANUAL_RULE_RUN }); const modalManualRuleRunConfirmationResult = await showManualRuleRunConfirmation(); + telemetry.reportManualRuleRunOpenModal({ + type: 'single', + }); if (modalManualRuleRunConfirmationResult === null) { return; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/basic/filters/execution_run_type_filter/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/basic/filters/execution_run_type_filter/index.test.tsx new file mode 100644 index 000000000000..50c35e7a6e52 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/basic/filters/execution_run_type_filter/index.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ExecutionRunTypeFilter } from '.'; +import { RuleRunTypeEnum } from '../../../../../../../common/api/detection_engine/rule_monitoring'; +import { useKibana } from '../../../../../../common/lib/kibana'; + +jest.mock('../../../../../../common/lib/kibana'); + +const mockTelemetry = { + reportEventLogFilterByRunType: jest.fn(), +}; + +const mockUseKibana = useKibana as jest.Mock; + +mockUseKibana.mockReturnValue({ + services: { + telemetry: mockTelemetry, + }, +}); + +const items = [RuleRunTypeEnum.backfill, RuleRunTypeEnum.standard]; + +describe('ExecutionRunTypeFilter', () => { + it('calls telemetry.reportEventLogFilterByRunType on selection change', () => { + const handleChange = jest.fn(); + + render(<ExecutionRunTypeFilter items={items} selectedItems={[]} onChange={handleChange} />); + + const select = screen.getByText('Run type'); + fireEvent.click(select); + + const manualRun = screen.getByText('Manual'); + fireEvent.click(manualRun); + + expect(handleChange).toHaveBeenCalledWith([RuleRunTypeEnum.backfill]); + expect(mockTelemetry.reportEventLogFilterByRunType).toHaveBeenCalledWith({ + runType: [RuleRunTypeEnum.backfill], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/basic/filters/execution_run_type_filter/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/basic/filters/execution_run_type_filter/index.tsx index 773e64e71ffc..9f144410a759 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/basic/filters/execution_run_type_filter/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/basic/filters/execution_run_type_filter/index.tsx @@ -14,6 +14,7 @@ import { RULE_EXECUTION_TYPE_BACKFILL, RULE_EXECUTION_TYPE_STANDARD, } from '../../../../../../common/translations'; +import { useKibana } from '../../../../../../common/lib/kibana'; interface ExecutionRunTypeFilterProps { items: RuleRunType[]; @@ -26,6 +27,8 @@ const ExecutionRunTypeFilterComponent: React.FC<ExecutionRunTypeFilterProps> = ( selectedItems, onChange, }) => { + const { telemetry } = useKibana().services; + const renderItem = useCallback((item: RuleRunType) => { if (item === RuleRunTypeEnum.backfill) { return RULE_EXECUTION_TYPE_BACKFILL; @@ -36,13 +39,21 @@ const ExecutionRunTypeFilterComponent: React.FC<ExecutionRunTypeFilterProps> = ( } }, []); + const handleSelectionChange = useCallback( + (types: RuleRunType[]) => { + onChange(types); + telemetry.reportEventLogFilterByRunType({ runType: types }); + }, + [onChange, telemetry] + ); + return ( <MultiselectFilter<RuleRunType> dataTestSubj="ExecutionRunTypeFilter" title={i18n.FILTER_TITLE} items={items} selectedItems={selectedItems} - onSelectionChange={onChange} + onSelectionChange={handleSelectionChange} renderItem={renderItem} /> ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 5049ccd92e11..91ef01befe17 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -384,7 +384,7 @@ describe('alert actions', () => { }, eventIdToNoteIds: {}, eventType: 'all', - excludedRowRendererIds: [], + excludedRowRendererIds: defaultTimelineProps.timeline.excludedRowRendererIds, expandedDetail: {}, filters: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index c1465be7e67e..b88ca5ff6ab8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -30,6 +30,7 @@ import { TIMESTAMP, } from '@kbn/rule-data-utils'; +import type { Type as RuleType } from '@kbn/securitysolution-io-ts-alerting-types'; import { lastValueFrom } from 'rxjs'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import type { DataTableModel } from '@kbn/securitysolution-data-table'; @@ -42,7 +43,13 @@ import { ALERT_NEW_TERMS, ALERT_RULE_INDICES, } from '../../../../common/field_maps/field_names'; -import { isEqlRule, isEsqlRule } from '../../../../common/detection_engine/utils'; +import { + isEqlRule, + isEsqlRule, + isMlRule, + isNewTermsRule, + isThresholdRule, +} from '../../../../common/detection_engine/utils'; import type { TimelineResult } from '../../../../common/api/timeline'; import { TimelineId } from '../../../../common/types/timeline'; import { TimelineStatus, TimelineType } from '../../../../common/api/timeline'; @@ -266,31 +273,16 @@ export const isEqlAlertWithGroupId = (ecsData: Ecs): boolean => { return isEql && groupId?.length > 0; }; -export const isThresholdAlert = (ecsData: Ecs): boolean => { - const ruleType = getField(ecsData, ALERT_RULE_TYPE); - return ( - ruleType === 'threshold' || - (Array.isArray(ruleType) && ruleType.length > 0 && ruleType[0] === 'threshold') - ); -}; - -export const isEqlAlert = (ecsData: Ecs): boolean => { +const getRuleType = (ecsData: Ecs): RuleType | undefined => { const ruleType = getField(ecsData, ALERT_RULE_TYPE); - return isEqlRule(ruleType) || (Array.isArray(ruleType) && isEqlRule(ruleType[0])); + return Array.isArray(ruleType) ? ruleType[0] : ruleType; }; -export const isEsqlAlert = (ecsData: Ecs): boolean => { - const ruleType = getField(ecsData, ALERT_RULE_TYPE); - return isEsqlRule(ruleType) || (Array.isArray(ruleType) && isEsqlRule(ruleType[0])); -}; - -export const isNewTermsAlert = (ecsData: Ecs): boolean => { - const ruleType = getField(ecsData, ALERT_RULE_TYPE); - return ( - ruleType === 'new_terms' || - (Array.isArray(ruleType) && ruleType.length > 0 && ruleType[0] === 'new_terms') - ); -}; +const isNewTermsAlert = (ecsData: Ecs): boolean => isNewTermsRule(getRuleType(ecsData)); +const isEsqlAlert = (ecsData: Ecs): boolean => isEsqlRule(getRuleType(ecsData)); +const isEqlAlert = (ecsData: Ecs): boolean => isEqlRule(getRuleType(ecsData)); +const isThresholdAlert = (ecsData: Ecs): boolean => isThresholdRule(getRuleType(ecsData)); +const isMlAlert = (ecsData: Ecs): boolean => isMlRule(getRuleType(ecsData)); const isSuppressedAlert = (ecsData: Ecs): boolean => { return getField(ecsData, ALERT_SUPPRESSION_DOCS_COUNT) != null; @@ -1035,7 +1027,12 @@ export const sendAlertToTimelineAction = async ({ getExceptionFilter ); // The Query field should remain unpopulated with the suppressed EQL/ES|QL alert. - } else if (isSuppressedAlert(ecsData) && !isEqlAlert(ecsData) && !isEsqlAlert(ecsData)) { + } else if ( + isSuppressedAlert(ecsData) && + !isEqlAlert(ecsData) && + !isEsqlAlert(ecsData) && + !isMlAlert(ecsData) + ) { return createSuppressedTimeline( ecsData, createTimeline, @@ -1106,7 +1103,12 @@ export const sendAlertToTimelineAction = async ({ } else if (isNewTermsAlert(ecsData)) { return createNewTermsTimeline(ecsData, createTimeline, noteContent, {}, getExceptionFilter); // The Query field should remain unpopulated with the suppressed EQL/ES|QL alert. - } else if (isSuppressedAlert(ecsData) && !isEqlAlert(ecsData) && !isEsqlAlert(ecsData)) { + } else if ( + isSuppressedAlert(ecsData) && + !isEqlAlert(ecsData) && + !isEsqlAlert(ecsData) && + !isMlAlert(ecsData) + ) { return createSuppressedTimeline(ecsData, createTimeline, noteContent, {}, getExceptionFilter); } else { let { dataProviders, filters } = buildTimelineDataProviderOrFilter( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx index 0cbd2daa5822..1035a508f701 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -163,6 +163,9 @@ export const useInvestigateInTimeline = ({ columns: unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders, indexNames: timeline.indexNames ?? [], show: true, + excludedRowRendererIds: unifiedComponentsInTimelineEnabled + ? timeline.excludedRowRendererIds + : [], }, to: toTimeline, ruleNote, 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 43a9246d8d5c..298ae1c50353 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 @@ -28,12 +28,17 @@ jest.mock('../../../../detection_engine/rule_management/logic/bulk_actions/use_b jest.mock('../../../../detection_engine/rule_gaps/logic/use_schedule_rule_run'); jest.mock('../../../../common/lib/apm/use_start_transaction'); jest.mock('../../../../common/hooks/use_app_toasts'); +const mockReportManualRuleRunOpenModal = jest.fn(); jest.mock('../../../../common/lib/kibana', () => { const actual = jest.requireActual('../../../../common/lib/kibana'); return { ...actual, useKibana: jest.fn().mockReturnValue({ services: { + telemetry: { + reportManualRuleRunOpenModal: (params: { type: 'single' | 'bulk' }) => + mockReportManualRuleRunOpenModal(params), + }, application: { navigateToApp: jest.fn(), }, @@ -287,5 +292,27 @@ describe('RuleActionsOverflow', () => { 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( + <RuleActionsOverflow + showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation} + showManualRuleRunConfirmation={showManualRuleRunConfirmation} + rule={mockRule('id')} + userHasPermissions + canDuplicateRuleWithActions={true} + confirmDeletion={() => Promise.resolve(true)} + />, + { wrapper: TestProviders } + ); + fireEvent.click(getByTestId('rules-details-popover-button-icon')); + fireEvent.click(getByTestId('rules-details-manual-rule-run')); + + await waitFor(() => { + expect(mockReportManualRuleRunOpenModal).toHaveBeenCalledWith({ + type: 'single', + }); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index 6ed110483ecc..f1889efd1a55 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -68,7 +68,10 @@ const RuleActionsOverflowComponent = ({ confirmDeletion, }: RuleActionsOverflowComponentProps) => { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); - const { navigateToApp } = useKibana().services.application; + const { + application: { navigateToApp }, + telemetry, + } = useKibana().services; const { startTransaction } = useStartTransaction(); const { executeBulkAction } = useExecuteBulkAction({ suppressSuccessToast: true }); const { bulkExport } = useBulkExport(); @@ -166,6 +169,9 @@ const RuleActionsOverflowComponent = ({ closePopover(); const modalManualRuleRunConfirmationResult = await showManualRuleRunConfirmation(); + telemetry.reportManualRuleRunOpenModal({ + type: 'single', + }); if (modalManualRuleRunConfirmationResult === null) { return; } @@ -221,6 +227,7 @@ const RuleActionsOverflowComponent = ({ confirmDeletion, scheduleRuleRun, isManualRuleRunEnabled, + telemetry, ] ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx index b5e7737be38f..23c2d2e7b9f6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx @@ -64,6 +64,7 @@ const ContextWrapper: FC<PropsWithChildren<unknown>> = ({ children }) => ( http={mockHttp} navigateToApp={mockNavigationToApp} baseConversations={BASE_SECURITY_CONVERSATIONS} + currentAppId={'security'} > {children} </AssistantProvider> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 4fa96cf519bb..ca42502d93c4 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -489,7 +489,7 @@ export const BULK_EDIT_FLYOUT_FORM_ADD_INVESTIGATION_FIELDS_REQUIRED_ERROR = i18 export const BULK_EDIT_FLYOUT_FORM_ADD_INVESTIGATION_FIELDS_OVERWRITE_LABEL = i18n.translate( 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addInvestigationFieldsOverwriteCheckboxLabel', { - defaultMessage: "Overwrite all selected rules' custom highlighted fields", + defaultMessage: 'Overwrite the custom highlighted fields for the selected rules', } ); @@ -504,7 +504,7 @@ export const BULK_EDIT_FLYOUT_FORM_ADD_INVESTIGATION_FIELDS_HELP_TEXT = i18n.tra 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addInvestigationFieldsComboboxHelpText', { defaultMessage: - 'Enter fields that you would like to add. By default, the dropdown includes fields of the index patterns defined in Security Solution advanced settings.', + 'Enter fields that you want to add. You can choose from any of the fields included in the default Elastic Security indices.', } ); @@ -526,7 +526,7 @@ export const BULK_EDIT_FLYOUT_FORM_DELETE_INVESTIGATION_FIELDS_HELP_TEXT = i18n. 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteInvestigationFieldsComboboxHelpText', { defaultMessage: - 'Enter fields that you would like to delete. By default, the dropdown includes fields of the index patterns defined in Security Solution advanced settings.', + 'Enter the fields that you want to remove from the selected rules. After you remove these fields, they will no longer appear in the Highlighted fields section of the alerts generated by selected rules.', } ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx index 9c3319aa9e9d..980cb97d6eda 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx @@ -18,6 +18,7 @@ import { import { ReqStatus } from '../../../../notes/store/notes.slice'; import { useIsTimelineFlyoutOpen } from '../../shared/hooks/use_is_timeline_flyout_open'; import { TimelineId } from '../../../../../common/types'; +import userEvent from '@testing-library/user-event'; jest.mock('../../shared/hooks/use_is_timeline_flyout_open'); @@ -56,11 +57,24 @@ describe('AddNote', () => { it('should create note', () => { const { getByTestId } = renderAddNote(); + userEvent.type(getByTestId('euiMarkdownEditorTextArea'), 'new note'); getByTestId(ADD_NOTE_BUTTON_TEST_ID).click(); expect(mockDispatch).toHaveBeenCalled(); }); + it('should disable add button markdown editor if invalid', () => { + const { getByTestId } = renderAddNote(); + + const addButton = getByTestId(ADD_NOTE_BUTTON_TEST_ID); + + expect(addButton).toHaveAttribute('disabled'); + + userEvent.type(getByTestId('euiMarkdownEditorTextArea'), 'new note'); + + expect(addButton).not.toHaveAttribute('disabled'); + }); + it('should render the add note button in loading state while creating a new note', () => { const store = createMockStore({ ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx index d89bcfb23a97..6eea833420cb 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiCheckbox, @@ -20,6 +20,7 @@ import { import { css } from '@emotion/react'; import { useDispatch, useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../common/lib/kibana'; import { TimelineId } from '../../../../../common/types'; import { timelineSelectors } from '../../../../timelines/store'; import { useIsTimelineFlyoutOpen } from '../../shared/hooks/use_is_timeline_flyout_open'; @@ -80,9 +81,11 @@ export interface AddNewNoteProps { * The checkbox is automatically checked if the flyout is opened from a timeline and that timeline is saved. It is disabled if the flyout is NOT opened from a timeline. */ export const AddNote = memo(({ eventId }: AddNewNoteProps) => { + const { telemetry } = useKibana().services; const dispatch = useDispatch(); const { addError: addErrorToast } = useAppToasts(); const [editorValue, setEditorValue] = useState(''); + const [isMarkdownInvalid, setIsMarkdownInvalid] = useState(false); const activeTimeline = useSelector((state: State) => timelineSelectors.selectTimelineById(state, TimelineId.active) @@ -109,8 +112,11 @@ export const AddNote = memo(({ eventId }: AddNewNoteProps) => { }, }) ); + telemetry.reportAddNoteFromExpandableFlyoutClicked({ + isRelatedToATimeline: checked && activeTimeline?.savedObjectId !== null, + }); setEditorValue(''); - }, [activeTimeline?.savedObjectId, checked, dispatch, editorValue, eventId]); + }, [activeTimeline?.savedObjectId, checked, dispatch, editorValue, eventId, telemetry]); // show a toast if the create note call fails useEffect(() => { @@ -121,8 +127,15 @@ export const AddNote = memo(({ eventId }: AddNewNoteProps) => { } }, [addErrorToast, createError, createStatus]); - const checkBoxDisabled = - !isTimelineFlyout || (isTimelineFlyout && activeTimeline.savedObjectId == null); + const buttonDisabled = useMemo( + () => editorValue.trim().length === 0 || isMarkdownInvalid, + [editorValue, isMarkdownInvalid] + ); + + const checkBoxDisabled = useMemo( + () => !isTimelineFlyout || (isTimelineFlyout && activeTimeline?.savedObjectId == null), + [activeTimeline?.savedObjectId, isTimelineFlyout] + ); return ( <> @@ -133,7 +146,7 @@ export const AddNote = memo(({ eventId }: AddNewNoteProps) => { value={editorValue} onChange={setEditorValue} ariaLabel={MARKDOWN_ARIA_LABEL} - setIsMarkdownInvalid={() => {}} + setIsMarkdownInvalid={setIsMarkdownInvalid} /> </EuiComment> </EuiCommentList> @@ -167,6 +180,7 @@ export const AddNote = memo(({ eventId }: AddNewNoteProps) => { <EuiButton onClick={addNote} isLoading={createStatus === ReqStatus.Loading} + disabled={buttonDisabled} data-test-subj={ADD_NOTE_BUTTON_TEST_ID} > {ADD_NOTE_BUTTON} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx index f183df7f9591..1fce080352a0 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.test.tsx @@ -8,24 +8,49 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; -import { EuiBasicTable } from '@elastic/eui'; -import { CorrelationsDetailsAlertsTable, columns } from './correlations_details_alerts_table'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { CorrelationsDetailsAlertsTable } from './correlations_details_alerts_table'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; +import { CORRELATIONS_DETAILS_ALERT_PREVIEW_BUTTON_TEST_ID } from './test_ids'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; +import { mockContextValue } from '../../shared/mocks/mock_context'; +import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys'; +import { ALERT_PREVIEW_BANNER } from '../../preview'; +import { DocumentDetailsContext } from '../../shared/context'; jest.mock('../hooks/use_paginated_alerts'); -jest.mock('@elastic/eui', () => ({ - ...jest.requireActual('@elastic/eui'), - EuiBasicTable: jest.fn(() => <div data-testid="mock-euibasictable" />), +jest.mock('../../../../common/hooks/use_experimental_features'); +const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; + +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), + ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}</>, })); const TEST_ID = 'TEST'; -const scopeId = 'scopeId'; -const eventId = 'eventId'; +const alertIds = ['id1', 'id2', 'id3']; -describe('CorrelationsDetailsAlertsTable', () => { - const alertIds = ['id1', 'id2', 'id3']; +const renderCorrelationsTable = (panelContext: DocumentDetailsContext) => + render( + <TestProviders> + <DocumentDetailsContext.Provider value={panelContext}> + <CorrelationsDetailsAlertsTable + title={<p>{'title'}</p>} + loading={false} + alertIds={alertIds} + scopeId={mockContextValue.scopeId} + eventId={mockContextValue.eventId} + data-test-subj={TEST_ID} + /> + </DocumentDetailsContext.Provider> + </TestProviders> + ); +describe('CorrelationsDetailsAlertsTable', () => { beforeEach(() => { + jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); jest.mocked(usePaginatedAlerts).mockReturnValue({ setPagination: jest.fn(), setSorting: jest.fn(), @@ -64,44 +89,45 @@ describe('CorrelationsDetailsAlertsTable', () => { }); it('renders EuiBasicTable with correct props', () => { - const { getByTestId } = render( - <TestProviders> - <CorrelationsDetailsAlertsTable - title={<p>{'title'}</p>} - loading={false} - alertIds={alertIds} - scopeId={scopeId} - eventId={eventId} - data-test-subj={TEST_ID} - /> - </TestProviders> - ); + const { getByTestId, queryByTestId, queryAllByRole } = + renderCorrelationsTable(mockContextValue); + expect(getByTestId(`${TEST_ID}InvestigateInTimeline`)).toBeInTheDocument(); + expect(getByTestId(`${TEST_ID}Table`)).toBeInTheDocument(); + expect( + queryByTestId(CORRELATIONS_DETAILS_ALERT_PREVIEW_BUTTON_TEST_ID) + ).not.toBeInTheDocument(); expect(jest.mocked(usePaginatedAlerts)).toHaveBeenCalled(); - expect(jest.mocked(EuiBasicTable)).toHaveBeenCalledWith( - expect.objectContaining({ - loading: false, - items: [ - { - '@timestamp': '2022-01-01', - 'kibana.alert.rule.name': 'Rule1', - 'kibana.alert.reason': 'Reason1', - 'kibana.alert.severity': 'Severity1', - }, - { - '@timestamp': '2022-01-02', - 'kibana.alert.rule.name': 'Rule2', - 'kibana.alert.reason': 'Reason2', - 'kibana.alert.severity': 'Severity2', - }, - ], - columns, - pagination: { pageIndex: 0, pageSize: 5, totalItemCount: 10, pageSizeOptions: [5, 10, 20] }, - sorting: { sort: { field: '@timestamp', direction: 'asc' }, enableAllColumns: true }, - }), - expect.anything() - ); + expect(queryAllByRole('columnheader').length).toBe(4); + expect(queryAllByRole('row').length).toBe(3); // 1 header row and 2 data rows + expect(queryAllByRole('row')[1].textContent).toContain('Jan 1, 2022 @ 00:00:00.000'); + expect(queryAllByRole('row')[1].textContent).toContain('Reason1'); + expect(queryAllByRole('row')[1].textContent).toContain('Rule1'); + expect(queryAllByRole('row')[1].textContent).toContain('Severity1'); + }); + + it('renders open preview button when feature flag is on', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + const { getByTestId, getAllByTestId } = renderCorrelationsTable({ + ...mockContextValue, + isPreviewMode: true, + }); + + expect(getByTestId(`${TEST_ID}InvestigateInTimeline`)).toBeInTheDocument(); + expect(getAllByTestId(CORRELATIONS_DETAILS_ALERT_PREVIEW_BUTTON_TEST_ID).length).toBe(2); + + getAllByTestId(CORRELATIONS_DETAILS_ALERT_PREVIEW_BUTTON_TEST_ID)[0].click(); + expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ + id: DocumentDetailsPreviewPanelKey, + params: { + id: '1', + indexName: 'index', + scopeId: mockContextValue.scopeId, + banner: ALERT_PREVIEW_BANNER, + isPreviewMode: true, + }, + }); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx index 557c8bae274f..bf1a28201fc8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx @@ -7,13 +7,17 @@ import type { ReactElement, ReactNode } from 'react'; import React, { type FC, useMemo, useCallback } from 'react'; -import { type Criteria, EuiBasicTable, formatDate } from '@elastic/eui'; +import { type Criteria, EuiBasicTable, formatDate, EuiButtonIcon } from '@elastic/eui'; import { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import type { Filter } from '@kbn/es-query'; import { isRight } from 'fp-ts/lib/Either'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { ALERT_REASON, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { CORRELATIONS_DETAILS_ALERT_PREVIEW_BUTTON_TEST_ID } from './test_ids'; import { CellTooltipWrapper } from '../../shared/components/cell_tooltip_wrapper'; import type { DataProvider } from '../../../../../common/types'; import { SeverityBadge } from '../../../../common/components/severity_badge'; @@ -22,80 +26,50 @@ import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import { InvestigateInTimelineButton } from '../../../../common/components/event_details/table/investigate_in_timeline_button'; import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations'; import { getDataProvider } from '../../../../common/components/event_details/table/use_action_cell_data_provider'; +import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys'; +import { ALERT_PREVIEW_BANNER } from '../../preview'; export const TIMESTAMP_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; const dataProviderLimit = 5; -export const columns = [ - { - field: '@timestamp', - name: ( - <FormattedMessage - id="xpack.securitySolution.flyout.left.insights.correlations.timestampColumnLabel" - defaultMessage="Timestamp" - /> - ), - truncateText: true, - dataType: 'date' as const, - render: (value: string) => { - const date = formatDate(value, TIMESTAMP_DATE_FORMAT); - return ( - <CellTooltipWrapper tooltip={date}> - <span>{date}</span> - </CellTooltipWrapper> - ); - }, - }, - { - field: ALERT_RULE_NAME, - name: ( - <FormattedMessage - id="xpack.securitySolution.flyout.left.insights.correlations.ruleColumnLabel" - defaultMessage="Rule" - /> - ), - truncateText: true, - render: (value: string) => ( - <CellTooltipWrapper tooltip={value}> - <span>{value}</span> - </CellTooltipWrapper> - ), - }, - { - field: ALERT_REASON, - name: ( - <FormattedMessage - id="xpack.securitySolution.flyout.left.insights.correlations.reasonColumnLabel" - defaultMessage="Reason" - /> - ), - truncateText: true, - render: (value: string) => ( - <CellTooltipWrapper tooltip={value} anchorPosition="left"> - <span>{value}</span> - </CellTooltipWrapper> - ), - }, - { - field: 'kibana.alert.severity', - name: ( - <FormattedMessage - id="xpack.securitySolution.flyout.left.insights.correlations.severityColumnLabel" - defaultMessage="Severity" - /> - ), - truncateText: true, - render: (value: string) => { - const decodedSeverity = Severity.decode(value); - const renderValue = isRight(decodedSeverity) ? ( - <SeverityBadge value={decodedSeverity.right} /> - ) : ( - <p>{value}</p> - ); - return <CellTooltipWrapper tooltip={value}>{renderValue}</CellTooltipWrapper>; - }, - }, -]; +interface AlertPreviewButtonProps { + /** + * Id of the document + */ + id: string; + /** + * Name of the index used in the parent's page + */ + indexName: string; +} + +const AlertPreviewButton: FC<AlertPreviewButtonProps> = ({ id, indexName }) => { + const { openPreviewPanel } = useExpandableFlyoutApi(); + const { scopeId } = useDocumentDetailsContext(); + + const openAlertPreview = useCallback( + () => + openPreviewPanel({ + id: DocumentDetailsPreviewPanelKey, + params: { + id, + indexName, + scopeId, + isPreviewMode: true, + banner: ALERT_PREVIEW_BANNER, + }, + }), + [openPreviewPanel, id, indexName, scopeId] + ); + + return ( + <EuiButtonIcon + iconType="expand" + data-test-subj={CORRELATIONS_DETAILS_ALERT_PREVIEW_BUTTON_TEST_ID} + onClick={openAlertPreview} + /> + ); +}; export interface CorrelationsDetailsAlertsTableProps { /** @@ -149,6 +123,7 @@ export const CorrelationsDetailsAlertsTable: FC<CorrelationsDetailsAlertsTablePr sorting, error, } = usePaginatedAlerts(alertIds || []); + const isPreviewEnabled = useIsExperimentalFeatureEnabled('entityAlertPreviewEnabled'); const onTableChange = useCallback( ({ page, sort }: Criteria<Record<string, unknown>>) => { @@ -166,13 +141,17 @@ export const CorrelationsDetailsAlertsTable: FC<CorrelationsDetailsAlertsTablePr const mappedData = useMemo(() => { return data - .map((hit) => hit.fields) - .map((fields = {}) => - Object.keys(fields).reduce((result, fieldName) => { - result[fieldName] = fields?.[fieldName]?.[0] || fields?.[fieldName]; + .map((hit) => ({ fields: hit.fields ?? {}, id: hit._id, index: hit._index })) + .map((dataWithMeta) => { + const res = Object.keys(dataWithMeta.fields).reduce((result, fieldName) => { + result[fieldName] = + dataWithMeta.fields?.[fieldName]?.[0] || dataWithMeta.fields?.[fieldName]; return result; - }, {} as Record<string, unknown>) - ); + }, {} as Record<string, unknown>); + res.id = dataWithMeta.id; + res.index = dataWithMeta.index; + return res; + }); }, [data]); const shouldUseFilters = Boolean( @@ -187,6 +166,90 @@ export const CorrelationsDetailsAlertsTable: FC<CorrelationsDetailsAlertsTablePr [alertIds, shouldUseFilters] ); + const columns = useMemo( + () => [ + ...(isPreviewEnabled + ? [ + { + render: (row: Record<string, unknown>) => ( + <AlertPreviewButton id={row.id as string} indexName={row.index as string} /> + ), + width: '5%', + }, + ] + : []), + { + field: '@timestamp', + name: ( + <FormattedMessage + id="xpack.securitySolution.flyout.left.insights.correlations.timestampColumnLabel" + defaultMessage="Timestamp" + /> + ), + truncateText: true, + dataType: 'date' as const, + render: (value: string) => { + const date = formatDate(value, TIMESTAMP_DATE_FORMAT); + return ( + <CellTooltipWrapper tooltip={date}> + <span>{date}</span> + </CellTooltipWrapper> + ); + }, + }, + { + field: ALERT_RULE_NAME, + name: ( + <FormattedMessage + id="xpack.securitySolution.flyout.left.insights.correlations.ruleColumnLabel" + defaultMessage="Rule" + /> + ), + truncateText: true, + render: (value: string) => ( + <CellTooltipWrapper tooltip={value}> + <span>{value}</span> + </CellTooltipWrapper> + ), + }, + { + field: ALERT_REASON, + name: ( + <FormattedMessage + id="xpack.securitySolution.flyout.left.insights.correlations.reasonColumnLabel" + defaultMessage="Reason" + /> + ), + truncateText: true, + render: (value: string) => ( + <CellTooltipWrapper tooltip={value} anchorPosition="left"> + <span>{value}</span> + </CellTooltipWrapper> + ), + }, + { + field: 'kibana.alert.severity', + name: ( + <FormattedMessage + id="xpack.securitySolution.flyout.left.insights.correlations.severityColumnLabel" + defaultMessage="Severity" + /> + ), + truncateText: true, + render: (value: string) => { + const decodedSeverity = Severity.decode(value); + const renderValue = isRight(decodedSeverity) ? ( + <SeverityBadge value={decodedSeverity.right} /> + ) : ( + <p>{value}</p> + ); + return <CellTooltipWrapper tooltip={value}>{renderValue}</CellTooltipWrapper>; + }, + }, + ], + [isPreviewEnabled] + ); + return ( <ExpandablePanel header={{ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx index 8491804e1a57..6678732ca782 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx @@ -41,7 +41,7 @@ jest.mock('react-redux', () => { const renderNotesList = () => render( <TestProviders> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); @@ -69,7 +69,7 @@ describe('NotesList', () => { const { getByTestId } = render( <TestProviders store={store}> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); @@ -115,7 +115,7 @@ describe('NotesList', () => { render( <TestProviders store={store}> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); @@ -131,7 +131,7 @@ describe('NotesList', () => { ...mockGlobalState.notes, entities: { '1': { - eventId: 'event-id', + eventId: '1', noteId: '1', note: 'note-1', timelineId: '', @@ -147,7 +147,7 @@ describe('NotesList', () => { const { getByTestId } = render( <TestProviders store={store}> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)); @@ -169,7 +169,7 @@ describe('NotesList', () => { const { getByTestId } = render( <TestProviders store={store}> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); @@ -196,14 +196,14 @@ describe('NotesList', () => { ...mockGlobalState.notes, status: { ...mockGlobalState.notes.status, - deleteNote: ReqStatus.Loading, + deleteNotes: ReqStatus.Loading, }, }, }); const { getByTestId } = render( <TestProviders store={store}> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); @@ -217,18 +217,18 @@ describe('NotesList', () => { ...mockGlobalState.notes, status: { ...mockGlobalState.notes.status, - deleteNote: ReqStatus.Failed, + deleteNotes: ReqStatus.Failed, }, error: { ...mockGlobalState.notes.error, - deleteNote: { type: 'http', status: 500 }, + deleteNotes: { type: 'http', status: 500 }, }, }, }); render( <TestProviders store={store}> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); @@ -261,7 +261,7 @@ describe('NotesList', () => { ...mockGlobalState.notes, entities: { '1': { - eventId: 'event-id', + eventId: '1', noteId: '1', note: 'note-1', timelineId: '', @@ -277,7 +277,7 @@ describe('NotesList', () => { const { queryByTestId } = render( <TestProviders store={store}> - <NotesList eventId={'event-id'} /> + <NotesList eventId={'1'} /> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx index c27f8441c103..51ee119499fd 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx @@ -30,11 +30,11 @@ import { import type { State } from '../../../../common/store'; import type { Note } from '../../../../../common/api/timeline'; import { - deleteNote, + deleteNotes, ReqStatus, selectCreateNoteStatus, - selectDeleteNoteError, - selectDeleteNoteStatus, + selectDeleteNotesError, + selectDeleteNotesStatus, selectFetchNotesByDocumentIdsError, selectFetchNotesByDocumentIdsStatus, selectNotesByDocumentId, @@ -91,14 +91,14 @@ export const NotesList = memo(({ eventId }: NotesListProps) => { const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); - const deleteStatus = useSelector((state: State) => selectDeleteNoteStatus(state)); - const deleteError = useSelector((state: State) => selectDeleteNoteError(state)); + const deleteStatus = useSelector((state: State) => selectDeleteNotesStatus(state)); + const deleteError = useSelector((state: State) => selectDeleteNotesError(state)); const [deletingNoteId, setDeletingNoteId] = useState(''); const deleteNoteFc = useCallback( (noteId: string) => { setDeletingNoteId(noteId); - dispatch(deleteNote({ id: noteId })); + dispatch(deleteNotes({ ids: [noteId] })); }, [dispatch] ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx index 063ebce7354a..52468f0aedbb 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; +import { DocumentDetailsContext } from '../../shared/context'; +import { mockContextValue } from '../../shared/mocks/mock_context'; import { CORRELATIONS_DETAILS_BY_ANCESTRY_SECTION_TABLE_TEST_ID, CORRELATIONS_DETAILS_BY_ANCESTRY_SECTION_TEST_ID, @@ -41,7 +43,9 @@ const TITLE_TEXT = EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID( const renderRelatedAlertsByAncestry = () => render( <TestProviders> - <RelatedAlertsByAncestry documentId={documentId} indices={indices} scopeId={scopeId} /> + <DocumentDetailsContext.Provider value={mockContextValue}> + <RelatedAlertsByAncestry documentId={documentId} indices={indices} scopeId={scopeId} /> + </DocumentDetailsContext.Provider> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx index e8334613d1d2..3cf2d93896bc 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; +import { DocumentDetailsContext } from '../../shared/context'; +import { mockContextValue } from '../../shared/mocks/mock_context'; import { CORRELATIONS_DETAILS_BY_SOURCE_SECTION_TEST_ID, CORRELATIONS_DETAILS_BY_SOURCE_SECTION_TABLE_TEST_ID, @@ -41,11 +43,13 @@ const TITLE_TEXT = EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID( const renderRelatedAlertsBySameSourceEvent = () => render( <TestProviders> - <RelatedAlertsBySameSourceEvent - originalEventId={originalEventId} - scopeId={scopeId} - eventId={eventId} - /> + <DocumentDetailsContext.Provider value={mockContextValue}> + <RelatedAlertsBySameSourceEvent + originalEventId={originalEventId} + scopeId={scopeId} + eventId={eventId} + /> + </DocumentDetailsContext.Provider> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx index ca5489b13c8c..0120f462b9ac 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; +import { DocumentDetailsContext } from '../../shared/context'; +import { mockContextValue } from '../../shared/mocks/mock_context'; import { CORRELATIONS_DETAILS_BY_SESSION_SECTION_TABLE_TEST_ID, CORRELATIONS_DETAILS_BY_SESSION_SECTION_TEST_ID, @@ -41,7 +43,9 @@ const TITLE_TEXT = EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID( const renderRelatedAlertsBySession = () => render( <TestProviders> - <RelatedAlertsBySession entityId={entityId} scopeId={scopeId} eventId={eventId} /> + <DocumentDetailsContext.Provider value={mockContextValue}> + <RelatedAlertsBySession entityId={entityId} scopeId={scopeId} eventId={eventId} /> + </DocumentDetailsContext.Provider> </TestProviders> ); 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 95ec61d66fff..c5bf2abd6988 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 @@ -69,6 +69,9 @@ export const THREAT_INTELLIGENCE_DETAILS_LOADING_TEST_ID = export const CORRELATIONS_DETAILS_TEST_ID = `${PREFIX}CorrelationsDetails` as const; +export const CORRELATIONS_DETAILS_ALERT_PREVIEW_BUTTON_TEST_ID = + `${CORRELATIONS_DETAILS_TEST_ID}AlertPreviewButton` as const; + export const CORRELATIONS_DETAILS_BY_ANCESTRY_SECTION_TEST_ID = `${CORRELATIONS_DETAILS_TEST_ID}AlertsByAncestrySection` as const; export const CORRELATIONS_DETAILS_BY_ANCESTRY_SECTION_TABLE_TEST_ID = diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.test.tsx new file mode 100644 index 000000000000..8210bd2dd2f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.test.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 { render } from '@testing-library/react'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { DocumentDetailsRightPanelKey } from '../shared/constants/panel_keys'; +import { mockFlyoutApi } from '../shared/mocks/mock_flyout_context'; +import { mockContextValue } from '../shared/mocks/mock_context'; +import { DocumentDetailsContext } from '../shared/context'; +import { PreviewPanelFooter } from './footer'; +import { PREVIEW_FOOTER_TEST_ID, PREVIEW_FOOTER_LINK_TEST_ID } from './test_ids'; + +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), + ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}</>, +})); + +describe('<PreviewPanelFooter />', () => { + beforeAll(() => { + jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); + }); + + it('should render footer', () => { + const { getByTestId } = render( + <DocumentDetailsContext.Provider value={mockContextValue}> + <PreviewPanelFooter /> + </DocumentDetailsContext.Provider> + ); + expect(getByTestId(PREVIEW_FOOTER_TEST_ID)).toBeInTheDocument(); + }); + + it('should open document details flyout when clicked', () => { + const { getByTestId } = render( + <DocumentDetailsContext.Provider value={mockContextValue}> + <PreviewPanelFooter /> + </DocumentDetailsContext.Provider> + ); + + getByTestId(PREVIEW_FOOTER_LINK_TEST_ID).click(); + expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: mockContextValue.eventId, + indexName: mockContextValue.indexName, + scopeId: mockContextValue.scopeId, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.tsx new file mode 100644 index 000000000000..38c7e6114856 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.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 { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { FlyoutFooter } from '../../shared/components/flyout_footer'; +import { DocumentDetailsRightPanelKey } from '../shared/constants/panel_keys'; +import { useDocumentDetailsContext } from '../shared/context'; +import { PREVIEW_FOOTER_TEST_ID, PREVIEW_FOOTER_LINK_TEST_ID } from './test_ids'; + +/** + * Footer at the bottom of preview panel with a link to open document details flyout + */ +export const PreviewPanelFooter = () => { + const { eventId, indexName, scopeId } = useDocumentDetailsContext(); + const { openFlyout } = useExpandableFlyoutApi(); + + const openDocumentFlyout = useCallback(() => { + openFlyout({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + }); + }, [openFlyout, eventId, indexName, scopeId]); + + return ( + <FlyoutFooter data-test-subj={PREVIEW_FOOTER_TEST_ID}> + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiLink + onClick={openDocumentFlyout} + target="_blank" + data-test-subj={PREVIEW_FOOTER_LINK_TEST_ID} + > + {i18n.translate('xpack.securitySolution.flyout.preview.openFlyoutLabel', { + defaultMessage: 'Show full alert details', + })} + </EuiLink> + </EuiFlexItem> + </EuiFlexGroup> + </FlyoutFooter> + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/preview/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/preview/index.tsx new file mode 100644 index 000000000000..ff65d4c264c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/preview/index.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FC } from 'react'; +import React, { memo } from 'react'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { i18n } from '@kbn/i18n'; +import { DocumentDetailsPreviewPanelKey } from '../shared/constants/panel_keys'; +import { useTabs } from '../right/hooks/use_tabs'; +import { useFlyoutIsExpandable } from '../right/hooks/use_flyout_is_expandable'; +import { useDocumentDetailsContext } from '../shared/context'; +import type { DocumentDetailsProps } from '../shared/types'; +import { PanelHeader } from '../right/header'; +import { PanelContent } from '../right/content'; +import { PreviewPanelFooter } from './footer'; +import type { RightPanelTabType } from '../right/tabs'; + +export const ALERT_PREVIEW_BANNER = { + title: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.correlations.alertPreviewTitle', + { + defaultMessage: 'Preview alert details', + } + ), + backgroundColor: 'warning', + textColor: 'warning', +}; + +/** + * Panel to be displayed in the document details expandable flyout on top of right section + */ +export const PreviewPanel: FC<Partial<DocumentDetailsProps>> = memo(({ path }) => { + const { openPreviewPanel } = useExpandableFlyoutApi(); + const { eventId, indexName, scopeId, getFieldsData, dataAsNestedObject } = + useDocumentDetailsContext(); + const flyoutIsExpandable = useFlyoutIsExpandable({ getFieldsData, dataAsNestedObject }); + + const { tabsDisplayed, selectedTabId } = useTabs({ flyoutIsExpandable, path }); + + const setSelectedTabId = (tabId: RightPanelTabType['id']) => { + openPreviewPanel({ + id: DocumentDetailsPreviewPanelKey, + path: { + tab: tabId, + }, + params: { + id: eventId, + indexName, + scopeId, + isPreviewMode: true, + banner: ALERT_PREVIEW_BANNER, + }, + }); + }; + + return ( + <> + <PanelHeader + tabs={tabsDisplayed} + selectedTabId={selectedTabId} + setSelectedTabId={setSelectedTabId} + style={{ marginTop: '-15px' }} + /> + <PanelContent tabs={tabsDisplayed} selectedTabId={selectedTabId} /> + <PreviewPanelFooter /> + </> + ); +}); + +PreviewPanel.displayName = 'PreviewPanel'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/preview/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/preview/test_ids.ts new file mode 100644 index 000000000000..75318d8d18be --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/preview/test_ids.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 { PREFIX } from '../../shared/test_ids'; + +export const PREVIEW_FOOTER_TEST_ID = `${PREFIX}PreviewFooter` as const; +export const PREVIEW_FOOTER_LINK_TEST_ID = `${PREVIEW_FOOTER_TEST_ID}Link` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx index 2145d3efff12..fb6b2f4edcfb 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx @@ -141,6 +141,26 @@ describe('<CorrelationsOverview />', () => { expect(queryByTestId(TITLE_TEXT_TEST_ID)).not.toBeInTheDocument(); }); + it('should not render link when isPreviewMode is true', () => { + jest + .mocked(useShowRelatedAlertsByAncestry) + .mockReturnValue({ show: false, documentId: 'event-id' }); + jest + .mocked(useShowRelatedAlertsBySameSourceEvent) + .mockReturnValue({ show: false, originalEventId }); + jest.mocked(useShowRelatedAlertsBySession).mockReturnValue({ show: false }); + jest.mocked(useShowRelatedCases).mockReturnValue(false); + jest.mocked(useShowSuppressedAlerts).mockReturnValue({ show: false, alertSuppressionCount: 0 }); + + const { getByTestId, queryByTestId } = render( + renderCorrelationsOverview({ ...panelContextValue, isPreviewMode: true }) + ); + expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_LINK_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(TITLE_TEXT_TEST_ID)).toBeInTheDocument(); + }); + it('should show component with all rows in expandable panel', () => { jest .mocked(useShowRelatedAlertsByAncestry) diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx index 3bf57aa91696..d4e9a6fe5811 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx @@ -6,7 +6,7 @@ */ import { get } from 'lodash'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -42,8 +42,15 @@ import { * and the SummaryPanel component for data rendering. */ export const CorrelationsOverview: React.FC = () => { - const { dataAsNestedObject, eventId, indexName, getFieldsData, scopeId, isPreview } = - useDocumentDetailsContext(); + const { + dataAsNestedObject, + eventId, + indexName, + getFieldsData, + scopeId, + isPreview, + isPreviewMode, + } = useDocumentDetailsContext(); const { openLeftPanel } = useExpandableFlyoutApi(); const { isTourShown, activeStep } = useTourContext(); @@ -95,6 +102,22 @@ export const CorrelationsOverview: React.FC = () => { const ruleType = get(dataAsNestedObject, ALERT_RULE_TYPE)?.[0]; + const link = useMemo( + () => + !isPreviewMode + ? { + callback: goToCorrelationsTab, + tooltip: ( + <FormattedMessage + id="xpack.securitySolution.flyout.right.insights.correlations.overviewTooltip" + defaultMessage="Show all correlations" + /> + ), + } + : undefined, + [isPreviewMode, goToCorrelationsTab] + ); + return ( <ExpandablePanel header={{ @@ -104,16 +127,8 @@ export const CorrelationsOverview: React.FC = () => { defaultMessage="Correlations" /> ), - link: { - callback: goToCorrelationsTab, - tooltip: ( - <FormattedMessage - id="xpack.securitySolution.flyout.right.insights.correlations.overviewTooltip" - defaultMessage="Show all correlations" - /> - ), - }, - iconType: 'arrowStart', + link, + iconType: !isPreviewMode ? 'arrowStart' : undefined, }} data-test-subj={CORRELATIONS_TEST_ID} > diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx index 7c8c119b31c6..92248c6de282 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx @@ -97,6 +97,18 @@ describe('<EntitiesOverview />', () => { expect(queryByTestId(TITLE_TEXT_TEST_ID)).not.toBeInTheDocument(); }); + it('should not render link if isPreviewMode is true', () => { + const { getByTestId, queryByTestId } = renderEntitiesOverview({ + ...mockContextValue, + isPreviewMode: true, + }); + + expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_LINK_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(TITLE_TEXT_TEST_ID)).toBeInTheDocument(); + }); + it('should render user and host', () => { const { getByTestId, queryByText } = renderEntitiesOverview(mockContextValue); expect(getByTestId(ENTITIES_USER_OVERVIEW_TEST_ID)).toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx index 51ec7f002ed0..16fe6cbe1c1e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -23,7 +23,7 @@ import { ENTITIES_TAB_ID } from '../../left/components/entities_details'; * Entities section under Insights section, overview tab. It contains a preview of host and user information. */ export const EntitiesOverview: React.FC = () => { - const { eventId, getFieldsData, indexName, scopeId } = useDocumentDetailsContext(); + const { eventId, getFieldsData, indexName, scopeId, isPreviewMode } = useDocumentDetailsContext(); const { openLeftPanel } = useExpandableFlyoutApi(); const hostName = getField(getFieldsData('host.name')); const userName = getField(getFieldsData('user.name')); @@ -43,6 +43,22 @@ export const EntitiesOverview: React.FC = () => { }); }, [eventId, openLeftPanel, indexName, scopeId]); + const link = useMemo( + () => + !isPreviewMode + ? { + callback: goToEntitiesTab, + tooltip: ( + <FormattedMessage + id="xpack.securitySolution.flyout.right.insights.entities.entitiesTooltip" + defaultMessage="Show all entities" + /> + ), + } + : undefined, + [goToEntitiesTab, isPreviewMode] + ); + return ( <> <ExpandablePanel @@ -53,16 +69,8 @@ export const EntitiesOverview: React.FC = () => { defaultMessage="Entities" /> ), - link: { - callback: goToEntitiesTab, - tooltip: ( - <FormattedMessage - id="xpack.securitySolution.flyout.right.insights.entities.entitiesTooltip" - defaultMessage="Show all entities" - /> - ), - }, - iconType: 'arrowStart', + link, + iconType: !isPreviewMode ? 'arrowStart' : undefined, }} data-test-subj={INSIGHTS_ENTITIES_TEST_ID} > diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx index 128ca3b643af..1ccf79664512 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx @@ -27,8 +27,9 @@ jest.mock('@kbn/expandable-flyout', () => ({ useExpandableFlyoutApi: jest.fn() } const mockFlyoutContextValue = { openLeftPanel: jest.fn() }; -const NO_DATA_MESSAGE = 'Investigation guideThere’s no investigation guide for this rule.'; +const NO_DATA_MESSAGE = "Investigation guideThere's no investigation guide for this rule."; const PREVIEW_MESSAGE = 'Investigation guide is not available in alert preview.'; +const OPEN_FLYOUT_MESSAGE = 'Open alert details to access investigation guides.'; const renderInvestigationGuide = () => render( @@ -107,6 +108,12 @@ describe('<InvestigationGuide />', () => { }); it('should render preview message when flyout is in preview', () => { + (useInvestigationGuide as jest.Mock).mockReturnValue({ + loading: false, + error: false, + basicAlertData: { ruleId: 'ruleId' }, + ruleNote: 'test note', + }); const { queryByTestId, getByTestId } = render( <IntlProvider locale="en"> <DocumentDetailsContext.Provider value={{ ...mockContextValue, isPreview: true }}> @@ -119,6 +126,19 @@ describe('<InvestigationGuide />', () => { expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toHaveTextContent(PREVIEW_MESSAGE); }); + it('should render open flyout message if isPreviewMode is true', () => { + const { queryByTestId, getByTestId } = render( + <IntlProvider locale="en"> + <DocumentDetailsContext.Provider value={{ ...mockContextValue, isPreviewMode: true }}> + <InvestigationGuide /> + </DocumentDetailsContext.Provider> + </IntlProvider> + ); + + expect(queryByTestId(INVESTIGATION_GUIDE_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toHaveTextContent(OPEN_FLYOUT_MESSAGE); + }); + it('should navigate to investigation guide when clicking on button', () => { (useInvestigationGuide as jest.Mock).mockReturnValue({ loading: false, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx index 33fa0db42c45..efbe095ae9e4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSkeletonText } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -25,7 +25,7 @@ import { */ export const InvestigationGuide: React.FC = () => { const { openLeftPanel } = useExpandableFlyoutApi(); - const { eventId, indexName, scopeId, dataFormattedForFieldBrowser, isPreview } = + const { eventId, indexName, scopeId, dataFormattedForFieldBrowser, isPreview, isPreviewMode } = useDocumentDetailsContext(); const { loading, error, basicAlertData, ruleNote } = useInvestigationGuide({ @@ -46,6 +46,11 @@ export const InvestigationGuide: React.FC = () => { }); }, [eventId, indexName, openLeftPanel, scopeId]); + const hasInvesigationGuide = useMemo( + () => !error && basicAlertData && basicAlertData.ruleId && ruleNote, + [error, basicAlertData, ruleNote] + ); + return ( <EuiFlexGroup direction="column" gutterSize="s" data-test-subj={INVESTIGATION_GUIDE_TEST_ID}> <EuiFlexItem> @@ -71,7 +76,12 @@ export const InvestigationGuide: React.FC = () => { { defaultMessage: 'investigation guide' } )} /> - ) : !error && basicAlertData.ruleId && ruleNote ? ( + ) : hasInvesigationGuide && isPreviewMode ? ( + <FormattedMessage + id="xpack.securitySolution.flyout.right.investigation.investigationGuide.openFlyoutMessage" + defaultMessage="Open alert details to access investigation guides." + /> + ) : hasInvesigationGuide ? ( <EuiFlexItem> <EuiButton onClick={goToInvestigationsTab} @@ -93,7 +103,7 @@ export const InvestigationGuide: React.FC = () => { ) : ( <FormattedMessage id="xpack.securitySolution.flyout.right.investigation.investigationGuide.noDataDescription" - defaultMessage="There’s no investigation guide for this rule." + defaultMessage="There's no investigation guide for this rule." /> )} </EuiFlexGroup> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx index d2fa414ac746..6b3e287d8091 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx @@ -72,6 +72,23 @@ describe('<PrevalenceOverview />', () => { expect(queryByTestId(TITLE_TEXT_TEST_ID)).not.toBeInTheDocument(); }); + it('should not render link and icon if isPreviewMode is true', () => { + (usePrevalence as jest.Mock).mockReturnValue({ + loading: false, + error: false, + data: [], + }); + + const { getByTestId, queryByTestId } = renderPrevalenceOverview({ + ...mockContextValue, + isPreviewMode: true, + }); + expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_LINK_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(TITLE_TEXT_TEST_ID)).toBeInTheDocument(); + }); + it('should render loading', () => { (usePrevalence as jest.Mock).mockReturnValue({ loading: true, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx index 7135df1ec79e..3776e486b426 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx @@ -28,8 +28,14 @@ const DEFAULT_TO = 'now'; * The component fetches the necessary data at once. The loading and error states are handled by the ExpandablePanel component. */ export const PrevalenceOverview: FC = () => { - const { eventId, indexName, dataFormattedForFieldBrowser, scopeId, investigationFields } = - useDocumentDetailsContext(); + const { + eventId, + indexName, + dataFormattedForFieldBrowser, + scopeId, + investigationFields, + isPreviewMode, + } = useDocumentDetailsContext(); const { openLeftPanel } = useExpandableFlyoutApi(); const goPrevalenceTab = useCallback(() => { @@ -67,6 +73,21 @@ export const PrevalenceOverview: FC = () => { ), [data] ); + const link = useMemo( + () => + !isPreviewMode + ? { + callback: goPrevalenceTab, + tooltip: ( + <FormattedMessage + id="xpack.securitySolution.flyout.right.insights.prevalence.prevalenceTooltip" + defaultMessage="Show all prevalence" + /> + ), + } + : undefined, + [goPrevalenceTab, isPreviewMode] + ); return ( <ExpandablePanel @@ -77,16 +98,8 @@ export const PrevalenceOverview: FC = () => { defaultMessage="Prevalence" /> ), - link: { - callback: goPrevalenceTab, - tooltip: ( - <FormattedMessage - id="xpack.securitySolution.flyout.right.insights.prevalence.prevalenceTooltip" - defaultMessage="Show all prevalence" - /> - ), - }, - iconType: 'arrowStart', + link, + iconType: !isPreviewMode ? 'arrowStart' : undefined, }} content={{ loading, error }} data-test-subj={PREVALENCE_TEST_ID} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx index a401b8da14ad..5ae2e2741f5b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx @@ -22,6 +22,7 @@ import { useExpandSection } from '../hooks/use_expand_section'; jest.mock('../hooks/use_expand_section'); const PREVIEW_MESSAGE = 'Response is not available in alert preview.'; +const OPEN_FLYOUT_MESSAGE = 'Open alert details to access response actions.'; const renderResponseSection = () => render( @@ -99,6 +100,21 @@ describe('<ResponseSection />', () => { expect(getByTestId(RESPONSE_SECTION_CONTENT_TEST_ID)).toHaveTextContent(PREVIEW_MESSAGE); }); + it('should render open details flyout message if flyout is in preview', () => { + (useExpandSection as jest.Mock).mockReturnValue(true); + + const { getByTestId } = render( + <IntlProvider locale="en"> + <TestProvider> + <DocumentDetailsContext.Provider value={{ ...mockContextValue, isPreviewMode: true }}> + <ResponseSection /> + </DocumentDetailsContext.Provider> + </TestProvider> + </IntlProvider> + ); + expect(getByTestId(RESPONSE_SECTION_CONTENT_TEST_ID)).toHaveTextContent(OPEN_FLYOUT_MESSAGE); + }); + it('should render empty component if document is not signal', () => { (useExpandSection as jest.Mock).mockReturnValue(true); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx index 6a802dfd94cf..19c77b6d9478 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx @@ -21,7 +21,7 @@ const KEY = 'response'; * Most bottom section of the overview tab. It contains a summary of the response tab. */ export const ResponseSection = memo(() => { - const { isPreview, getFieldsData } = useDocumentDetailsContext(); + const { isPreview, getFieldsData, isPreviewMode } = useDocumentDetailsContext(); const expanded = useExpandSection({ title: KEY, defaultValue: false }); @@ -47,6 +47,11 @@ export const ResponseSection = memo(() => { id="xpack.securitySolution.flyout.right.response.previewMessage" defaultMessage="Response is not available in alert preview." /> + ) : isPreviewMode ? ( + <FormattedMessage + id="xpack.securitySolution.flyout.right.response.openFlyoutMessage" + defaultMessage="Open alert details to access response actions." + /> ) : ( <ResponseButton /> )} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx index 542aae9ce18c..2e2ffa99efe4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx @@ -85,6 +85,21 @@ describe('<ThreatIntelligenceOverview />', () => { expect(queryByTestId(TITLE_TEXT_TEST_ID)).not.toBeInTheDocument(); }); + it('should not render link if isPrenviewMode is true', () => { + (useFetchThreatIntelligence as jest.Mock).mockReturnValue({ + loading: false, + }); + + const { getByTestId, queryByTestId } = render( + renderThreatIntelligenceOverview({ ...panelContextValue, isPreviewMode: true }) + ); + + expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_ICON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(TITLE_LINK_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(TITLE_TEXT_TEST_ID)).toBeInTheDocument(); + }); + it('should render 1 match detected and 1 field enriched', () => { (useFetchThreatIntelligence as jest.Mock).mockReturnValue({ loading: false, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx index 1bc0191f8bce..ca47113ad12c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx @@ -6,7 +6,7 @@ */ import type { FC } from 'react'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -25,7 +25,8 @@ import { THREAT_INTELLIGENCE_TAB_ID } from '../../left/components/threat_intelli * and the SummaryPanel component for data rendering. */ export const ThreatIntelligenceOverview: FC = () => { - const { eventId, indexName, scopeId, dataFormattedForFieldBrowser } = useDocumentDetailsContext(); + const { eventId, indexName, scopeId, dataFormattedForFieldBrowser, isPreviewMode } = + useDocumentDetailsContext(); const { openLeftPanel } = useExpandableFlyoutApi(); const goToThreatIntelligenceTab = useCallback(() => { @@ -47,6 +48,22 @@ export const ThreatIntelligenceOverview: FC = () => { dataFormattedForFieldBrowser, }); + const link = useMemo( + () => + !isPreviewMode + ? { + callback: goToThreatIntelligenceTab, + tooltip: ( + <FormattedMessage + id="xpack.securitySolution.flyout.right.insights.threatIntelligence.threatIntelligenceTooltip" + defaultMessage="Show all threat intelligence" + /> + ), + } + : undefined, + [isPreviewMode, goToThreatIntelligenceTab] + ); + return ( <ExpandablePanel header={{ @@ -56,16 +73,8 @@ export const ThreatIntelligenceOverview: FC = () => { defaultMessage="Threat intelligence" /> ), - link: { - callback: goToThreatIntelligenceTab, - tooltip: ( - <FormattedMessage - id="xpack.securitySolution.flyout.right.insights.threatIntelligence.threatIntelligenceTooltip" - defaultMessage="Show all threat intelligence" - /> - ), - }, - iconType: 'arrowStart', + link, + iconType: !isPreviewMode ? 'arrowStart' : undefined, }} data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID} content={{ loading }} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx index 22e6df6d01fd..b327fccea3be 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { EuiFlyoutHeader } from '@elastic/eui'; import { EuiSpacer, EuiTab } from '@elastic/eui'; import type { FC } from 'react'; import React, { memo, useMemo } from 'react'; @@ -23,7 +24,7 @@ import { } from '../../../common/components/guided_onboarding_tour/tour_config'; import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step'; -export interface PanelHeaderProps { +export interface PanelHeaderProps extends React.ComponentProps<typeof EuiFlyoutHeader> { /** * Id of the tab selected in the parent component to display its content */ @@ -40,7 +41,7 @@ export interface PanelHeaderProps { } export const PanelHeader: FC<PanelHeaderProps> = memo( - ({ selectedTabId, setSelectedTabId, tabs }) => { + ({ selectedTabId, setSelectedTabId, tabs, ...flyoutHeaderProps }) => { const { dataFormattedForFieldBrowser } = useDocumentDetailsContext(); const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); const onSelectedTabChanged = (id: RightPanelPaths) => setSelectedTabId(id); @@ -88,7 +89,7 @@ export const PanelHeader: FC<PanelHeaderProps> = memo( ); return ( - <FlyoutHeader> + <FlyoutHeader {...flyoutHeaderProps}> {isAlert ? <AlertHeaderTitle /> : <EventHeaderTitle />} <EuiSpacer size="m" /> <FlyoutHeaderTabs>{renderTabs}</FlyoutHeaderTabs> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.test.tsx index 6db228e75cb8..b2c9b895bc0c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.test.tsx @@ -34,7 +34,7 @@ describe('<RulePreviewFooter />', () => { expect(getByTestId(RULE_OVERVIEW_FOOTER_TEST_ID)).toBeInTheDocument(); expect(getByTestId(RULE_OVERVIEW_NAVIGATE_TO_RULE_TEST_ID)).toBeInTheDocument(); expect(getByTestId(RULE_OVERVIEW_NAVIGATE_TO_RULE_TEST_ID)).toHaveTextContent( - 'Show rule details' + 'Show full rule details' ); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.tsx index d1af7096ef5f..ebc204f8cb92 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/components/footer.tsx @@ -30,7 +30,7 @@ export const RuleFooter = memo(() => { data-test-subj={RULE_OVERVIEW_NAVIGATE_TO_RULE_TEST_ID} > {i18n.translate('xpack.securitySolution.flyout.preview.rule.viewDetailsLabel', { - defaultMessage: 'Show rule details', + defaultMessage: 'Show full rule details', })} </EuiLink> </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/index.tsx index c9a1a62114f7..504be510a09f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/rule_overview/index.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; -import { EuiFlyoutBody } from '@elastic/eui'; +import { FlyoutBody } from '../../shared/components/flyout_body'; import type { DocumentDetailsRuleOverviewPanelKey } from '../shared/constants/panel_keys'; import { RuleOverview } from './components/rule_overview'; import { RuleFooter } from './components/footer'; @@ -25,11 +25,11 @@ export interface RuleOverviewPanelProps extends FlyoutPanelProps { export const RuleOverviewPanel: React.FC = memo(() => { return ( <> - <EuiFlyoutBody> + <FlyoutBody> <div style={{ marginTop: '-15px' }}> <RuleOverview /> </div> - </EuiFlyoutBody> + </FlyoutBody> <RuleFooter /> </> ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx index 388706e4bd0b..1197e39ad86c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx @@ -60,11 +60,18 @@ export interface DocumentDetailsContext { */ getFieldsData: GetFieldsData; /** - * Boolean to indicate whether it is a preview flyout + * Boolean to indicate whether flyout is opened in rule preview */ isPreview: boolean; + /** + * Boolean to indicate whether it is a preview panel + */ + isPreviewMode: boolean; } +/** + * A context provider shared by the right, left and preview panels in expandable document details flyout + */ export const DocumentDetailsContext = createContext<DocumentDetailsContext | undefined>(undefined); export type DocumentDetailsProviderProps = { @@ -75,7 +82,7 @@ export type DocumentDetailsProviderProps = { } & Partial<DocumentDetailsProps['params']>; export const DocumentDetailsProvider = memo( - ({ id, indexName, scopeId, children }: DocumentDetailsProviderProps) => { + ({ id, indexName, scopeId, isPreviewMode, children }: DocumentDetailsProviderProps) => { const { browserFields, dataAsNestedObject, @@ -109,6 +116,7 @@ export const DocumentDetailsProvider = memo( refetchFlyoutData, getFieldsData, isPreview: scopeId === TableId.rulePreview, + isPreviewMode: Boolean(isPreviewMode), } : undefined, [ @@ -122,6 +130,7 @@ export const DocumentDetailsProvider = memo( searchHit, refetchFlyoutData, getFieldsData, + isPreviewMode, ] ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_context.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_context.ts index 11148dc2e099..a7f702495216 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_context.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_context.ts @@ -27,4 +27,5 @@ export const mockContextValue: DocumentDetailsContext = { investigationFields: [], refetchFlyoutData: jest.fn(), isPreview: false, + isPreviewMode: false, }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/types.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/types.tsx index e72220ae02ac..00fb1da32449 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/types.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/types.tsx @@ -18,5 +18,6 @@ export interface DocumentDetailsProps extends FlyoutPanelProps { id: string; indexName: string; scopeId: string; + isPreviewMode?: boolean; }; } diff --git a/x-pack/plugins/security_solution/public/flyout/index.tsx b/x-pack/plugins/security_solution/public/flyout/index.tsx index b9e6b06196b2..f768b71e32ab 100644 --- a/x-pack/plugins/security_solution/public/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/index.tsx @@ -12,6 +12,7 @@ import { DocumentDetailsIsolateHostPanelKey, DocumentDetailsLeftPanelKey, DocumentDetailsRightPanelKey, + DocumentDetailsPreviewPanelKey, DocumentDetailsAlertReasonPanelKey, DocumentDetailsRuleOverviewPanelKey, } from './document_details/shared/constants/panel_keys'; @@ -22,6 +23,7 @@ import type { DocumentDetailsProps } from './document_details/shared/types'; import { DocumentDetailsProvider } from './document_details/shared/context'; import { RightPanel } from './document_details/right'; import { LeftPanel } from './document_details/left'; +import { PreviewPanel } from './document_details/preview'; import type { AlertReasonPanelProps } from './document_details/alert_reason'; import { AlertReasonPanel } from './document_details/alert_reason'; import { AlertReasonPanelProvider } from './document_details/alert_reason/context'; @@ -58,6 +60,14 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels'] </DocumentDetailsProvider> ), }, + { + key: DocumentDetailsPreviewPanelKey, + component: (props) => ( + <DocumentDetailsProvider {...(props as DocumentDetailsProps).params}> + <PreviewPanel path={props.path as DocumentDetailsProps['path']} /> + </DocumentDetailsProvider> + ), + }, { key: DocumentDetailsAlertReasonPanelKey, component: (props) => ( diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.tsx index 7974690663b5..116f7d514496 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.tsx @@ -8,6 +8,7 @@ import type { FC } from 'react'; import React, { memo } from 'react'; import { EuiFlyoutBody, EuiPanel } from '@elastic/eui'; +import { css } from '@emotion/react'; interface FlyoutBodyProps extends React.ComponentProps<typeof EuiFlyoutBody> { children: React.ReactNode; @@ -18,7 +19,16 @@ interface FlyoutBodyProps extends React.ComponentProps<typeof EuiFlyoutBody> { */ export const FlyoutBody: FC<FlyoutBodyProps> = memo(({ children, ...flyoutBodyProps }) => { return ( - <EuiFlyoutBody {...flyoutBodyProps}> + <EuiFlyoutBody + {...flyoutBodyProps} + css={css` + .euiFlyoutBody__overflow { + // fix a bug with red overlay when position was not set + // remove when changes in EUI are merged + transform: translateZ(0); + } + `} + > <EuiPanel hasShadow={false} color="transparent"> {children} </EuiPanel> diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx index cb6fcf255126..731c4076f75c 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx @@ -57,6 +57,7 @@ describe.each([ pageIndex: 0, }, 'data-test-subj': 'testGrid', + CardDecorator: undefined, ...props, }; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx index a1e6243a67a7..100a92f11b5e 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx @@ -5,10 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { memo } from 'react'; import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; -import type { ArtifactEntryCardProps } from './artifact_entry_card'; +import type { + ArtifactEntryCardDecoratorProps, + ArtifactEntryCardProps, +} from './artifact_entry_card'; import { ArtifactEntryCard } from './artifact_entry_card'; import { act, fireEvent, getByTestId } from '@testing-library/react'; import type { AnyArtifact } from './types'; @@ -268,5 +271,19 @@ describe.each([ expect(renderResult.getByText('policy-1').textContent).not.toBeNull(); }); + + it('should pass item to decorator function and display its result', () => { + let passedItem: ArtifactEntryCardDecoratorProps['item'] | null = null; + const MockDecorator = memo<ArtifactEntryCardDecoratorProps>(({ item: actualItem }) => { + passedItem = actualItem; + return <p>{'mock decorator'}</p>; + }); + MockDecorator.displayName = 'MockDecorator'; + + render({ Decorator: MockDecorator }); + + expect(renderResult.getByText('mock decorator')).toBeInTheDocument(); + expect(passedItem).toBe(item); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx index f0037b691cea..76b5142bf068 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx @@ -37,6 +37,12 @@ export interface CommonArtifactEntryCardProps extends CommonProps { */ policies?: MenuItemPropsByPolicyId; loadingPoliciesList?: boolean; + /** + * Artifact specific decorator component that receives the current artifact as a prop, and + * is displayed inside the card on the top of the card section, + * above the selected OS and the condition entries. + */ + Decorator?: React.ComponentType<ArtifactEntryCardDecoratorProps>; } export interface ArtifactEntryCardProps extends CommonArtifactEntryCardProps { @@ -46,6 +52,10 @@ export interface ArtifactEntryCardProps extends CommonArtifactEntryCardProps { hideComments?: boolean; } +export interface ArtifactEntryCardDecoratorProps extends CommonProps { + item: MaybeImmutable<AnyArtifact>; +} + /** * Display Artifact Items (ex. Trusted App, Event Filter, etc) as a card. * This component is a TS Generic that allows you to set what the Item type is @@ -58,6 +68,7 @@ export const ArtifactEntryCard = memo<ArtifactEntryCardProps>( actions, hideDescription = false, hideComments = false, + Decorator, 'data-test-subj': dataTestSubj, ...commonProps }) => { @@ -103,6 +114,8 @@ export const ArtifactEntryCard = memo<ArtifactEntryCardProps>( <EuiHorizontalRule margin="none" /> <CardSectionPanel className="bottom-section"> + {Decorator && <Decorator item={item} data-test-subj={getTestId('decorator')} />} + <CriteriaConditions os={artifact.os as CriteriaConditionsProps['os']} entries={artifact.entries} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx index 05b157b6f871..6163d3795545 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { memo } from 'react'; import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; import type { ArtifactEntryCardMinifiedProps } from './artifact_entry_card_minified'; @@ -13,6 +13,7 @@ import { ArtifactEntryCardMinified } from './artifact_entry_card_minified'; import { act, fireEvent } from '@testing-library/react'; import type { AnyArtifact } from './types'; import { getTrustedAppProviderMock, getExceptionProviderMock } from './test_utils'; +import type { ArtifactEntryCardDecoratorProps } from './artifact_entry_card'; describe.each([ ['trusted apps', getTrustedAppProviderMock], @@ -94,4 +95,23 @@ describe.each([ expect(onToggleSelectedArtifactMock).toHaveBeenCalledTimes(1); expect(onToggleSelectedArtifactMock).toHaveBeenCalledWith(false); }); + + it('should pass item to Decorator component and display the component', () => { + let passedItem: ArtifactEntryCardDecoratorProps['item'] | null = null; + const MockDecorator = memo<ArtifactEntryCardDecoratorProps>(({ item: actualItem }) => { + passedItem = actualItem; + return <p>{'mock decorator'}</p>; + }); + MockDecorator.displayName = 'MockDecorator'; + + render({ + item, + isSelected: false, + onToggleSelectedArtifact: onToggleSelectedArtifactMock, + Decorator: MockDecorator, + }); + + expect(renderResult.getByText('mock decorator')).toBeInTheDocument(); + expect(passedItem).toBe(item); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx index 0d17cfaf7e45..c1acc122eb2d 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx @@ -25,6 +25,7 @@ import { useNormalizedArtifact } from './hooks/use_normalized_artifact'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import { DESCRIPTION_LABEL } from './components/translations'; import { DescriptionField } from './components/description_field'; +import type { ArtifactEntryCardDecoratorProps } from './artifact_entry_card'; const CardContainerPanel = styled(EuiSplitPanel.Outer)` &.artifactEntryCardMinified + &.artifactEntryCardMinified { @@ -40,6 +41,12 @@ export interface ArtifactEntryCardMinifiedProps extends CommonProps { item: AnyArtifact; isSelected: boolean; onToggleSelectedArtifact: (selected: boolean) => void; + /** + * Artifact specific decorator component that receives the current artifact as a prop, and + * is displayed inside the card on the top of the card section, + * above the selected OS and the condition entries. + */ + Decorator?: React.ComponentType<ArtifactEntryCardDecoratorProps>; } /** @@ -52,6 +59,7 @@ export const ArtifactEntryCardMinified = memo( isSelected = false, onToggleSelectedArtifact, 'data-test-subj': dataTestSubj, + Decorator, ...commonProps }: ArtifactEntryCardMinifiedProps) => { const artifact = useNormalizedArtifact(item); @@ -126,6 +134,8 @@ export const ArtifactEntryCardMinified = memo( {getAccordionTitle()} </EuiButtonEmpty> <EuiAccordion id="showDetails" arrowDisplay="none" forceState={accordionTrigger}> + {Decorator && <Decorator item={item} data-test-subj={getTestId('decorator')} />} + <CriteriaConditions os={artifact.os as CriteriaConditionsProps['os']} entries={artifact.entries} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx index 8fd87ad9cc26..d4c6c56d9e9d 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { memo } from 'react'; import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; import { act, fireEvent } from '@testing-library/react'; @@ -13,6 +13,7 @@ import type { AnyArtifact } from './types'; import { getTrustedAppProviderMock, getExceptionProviderMock } from './test_utils'; import type { ArtifactEntryCollapsibleCardProps } from './artifact_entry_collapsible_card'; import { ArtifactEntryCollapsibleCard } from './artifact_entry_collapsible_card'; +import type { ArtifactEntryCardDecoratorProps } from './artifact_entry_card'; describe.each([ ['trusted apps', getTrustedAppProviderMock], @@ -119,4 +120,32 @@ describe.each([ expect(renderResult.getByTestId(testSubjId).classList.contains('eui-textTruncate')).toBe(false); }); + + it('should pass item to decorator function and display its result when expanded', () => { + let passedItem: ArtifactEntryCardDecoratorProps['item'] | null = null; + const MockDecorator = memo<ArtifactEntryCardDecoratorProps>(({ item: actualItem }) => { + passedItem = actualItem; + return <p>{'mock decorator'}</p>; + }); + MockDecorator.displayName = 'MockDecorator'; + + render({ Decorator: MockDecorator, expanded: true }); + + expect(renderResult.getByText('mock decorator')).toBeInTheDocument(); + expect(passedItem).toBe(item); + }); + + it('should not display decorator when collapsed', () => { + let passedItem: ArtifactEntryCardDecoratorProps['item'] | null = null; + const MockDecorator = memo<ArtifactEntryCardDecoratorProps>(({ item: actualItem }) => { + passedItem = actualItem; + return <p>{'mock decorator'}</p>; + }); + MockDecorator.displayName = 'MockDecorator'; + + render({ Decorator: MockDecorator, expanded: false }); + + expect(renderResult.queryByText('mock decorator')).not.toBeInTheDocument(); + expect(passedItem).toBe(null); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.tsx index 29d336d45aef..ecf99fac8343 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.tsx @@ -29,6 +29,7 @@ export const ArtifactEntryCollapsibleCard = memo<ArtifactEntryCollapsibleCardPro actions, expanded = false, 'data-test-subj': dataTestSubj, + Decorator, ...commonProps }) => { const artifact = useNormalizedArtifact(item); @@ -51,6 +52,8 @@ export const ArtifactEntryCollapsibleCard = memo<ArtifactEntryCollapsibleCardPro <EuiHorizontalRule margin="xs" /> <CardSectionPanel> + {Decorator && <Decorator item={item} data-test-subj={getTestId('decorator')} />} + <CriteriaConditions os={artifact.os as CriteriaConditionsProps['os']} entries={artifact.entries} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator.test.tsx new file mode 100644 index 000000000000..ce4c48a6863b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { EventFiltersProcessDescendantIndicator } from './event_filters_process_descendant_indicator'; +import type { AnyArtifact } from '../../types'; +import type { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../common/mock/endpoint'; +import { + FILTER_PROCESS_DESCENDANTS_TAG, + GLOBAL_ARTIFACT_TAG, +} from '../../../../../../common/endpoint/service/artifacts/constants'; +import type { ArtifactEntryCardDecoratorProps } from '../../artifact_entry_card'; + +describe('EventFiltersProcessDescendantIndicator', () => { + let appTestContext: AppContextTestRender; + let renderResult: ReturnType<AppContextTestRender['render']>; + let render: ( + props: ArtifactEntryCardDecoratorProps + ) => ReturnType<AppContextTestRender['render']>; + + const getStandardEventFilter: () => AnyArtifact = () => + ({ + tags: [GLOBAL_ARTIFACT_TAG], + } as Partial<AnyArtifact> as AnyArtifact); + + const getProcessDescendantEventFilter: () => AnyArtifact = () => + ({ + tags: [GLOBAL_ARTIFACT_TAG, FILTER_PROCESS_DESCENDANTS_TAG], + } as Partial<AnyArtifact> as AnyArtifact); + + beforeEach(() => { + appTestContext = createAppRootMockRenderer(); + render = (props) => { + renderResult = appTestContext.render( + <EventFiltersProcessDescendantIndicator data-test-subj="test" {...props} /> + ); + return renderResult; + }; + }); + + it('should not display anything if feature flag is disabled', () => { + appTestContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: false }); + + render({ item: getProcessDescendantEventFilter() }); + + expect(renderResult.queryByTestId('test-processDescendantIndication')).not.toBeInTheDocument(); + }); + + it('should not display anything if Event Filter is not for process descendants', () => { + appTestContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: true }); + + render({ item: getStandardEventFilter() }); + + expect(renderResult.queryByTestId('test-processDescendantIndication')).not.toBeInTheDocument(); + }); + + it('should display indication if Event Filter is for process descendants', () => { + appTestContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: true }); + + render({ item: getProcessDescendantEventFilter() }); + + expect(renderResult.getByTestId('test-processDescendantIndication')).toBeInTheDocument(); + }); + + it('should mention additional `event.category is process` entry in tooltip', async () => { + const prefix = 'test-processDescendantIndicationTooltip'; + appTestContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: true }); + render({ item: getProcessDescendantEventFilter() }); + + expect(renderResult.queryByTestId(`${prefix}-tooltipText`)).not.toBeInTheDocument(); + + userEvent.hover(renderResult.getByTestId(`${prefix}-tooltipIcon`)); + expect(await renderResult.findByTestId(`${prefix}-tooltipText`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator.tsx new file mode 100644 index 000000000000..93eaeb5fbc7e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { memo } from 'react'; +import { EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator'; +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import { isFilterProcessDescendantsEnabled } from '../../../../../../common/endpoint/service/artifacts/utils'; +import { ProcessDescendantsTooltip } from '../../../../pages/event_filters/view/components/process_descendant_tooltip'; +import type { ArtifactEntryCardDecoratorProps } from '../../artifact_entry_card'; + +export const EventFiltersProcessDescendantIndicator = memo<ArtifactEntryCardDecoratorProps>( + ({ item, 'data-test-subj': dataTestSubj, ...commonProps }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + const isProcessDescendantFeatureEnabled = useIsExperimentalFeatureEnabled( + 'filterProcessDescendantsForEventFiltersEnabled' + ); + + if ( + isProcessDescendantFeatureEnabled && + isFilterProcessDescendantsEnabled(item as ExceptionListItemSchema) + ) { + return ( + <> + <EuiText {...commonProps} data-test-subj={getTestId('processDescendantIndication')}> + <code> + <strong> + <FormattedMessage + defaultMessage="Filtering descendants of process" + id="xpack.securitySolution.eventFilters.filteringProcessDescendants" + />{' '} + <ProcessDescendantsTooltip + indicateExtraEntry + data-test-subj={getTestId('processDescendantIndicationTooltip')} + /> + </strong> + </code> + </EuiText> + <EuiSpacer size="m" /> + </> + ); + } + + return <></>; + } +); +EventFiltersProcessDescendantIndicator.displayName = 'EventFiltersProcessDescendantIndicator'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index abe52767c5d5..49755f88562f 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -18,6 +18,7 @@ import { AdministrationListPage } from '../administration_list_page'; import type { PaginatedContentProps } from '../paginated_content'; import { PaginatedContent } from '../paginated_content'; +import type { ArtifactEntryCardDecoratorProps } from '../artifact_entry_card'; import { ArtifactEntryCard } from '../artifact_entry_card'; import type { ArtifactListPageLabels } from './translations'; @@ -75,6 +76,7 @@ export interface ArtifactListPageProps { allowCardDeleteAction?: boolean; allowCardCreateAction?: boolean; secondaryPageInfo?: React.ReactNode; + CardDecorator?: React.ComponentType<ArtifactEntryCardDecoratorProps>; } export const ArtifactListPage = memo<ArtifactListPageProps>( @@ -90,6 +92,7 @@ export const ArtifactListPage = memo<ArtifactListPageProps>( allowCardEditAction = true, allowCardCreateAction = true, allowCardDeleteAction = true, + CardDecorator, }) => { const { state: routeState } = useLocation<ListPageRouteState | undefined>(); const getTestId = useTestIdGenerator(dataTestSubj); @@ -354,6 +357,7 @@ export const ArtifactListPage = memo<ArtifactListPageProps>( pagination={uiPagination} contentClassName="card-container" data-test-subj={getTestId('list')} + CardDecorator={CardDecorator} /> </> )} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx index 4ce4a80a00e9..f67e69422471 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React, { memo } from 'react'; import type { AppContextTestRender } from '../../../../common/mock/endpoint'; import type { trustedAppsAllHttpMocks } from '../../../mocks'; import type { ArtifactListPageProps } from '../artifact_list_page'; @@ -14,6 +15,7 @@ import type { ArtifactListPageRenderingSetup } from '../mocks'; import { getArtifactListPageRenderingSetup } from '../mocks'; import { getDeferred } from '../../../mocks/utils'; import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks'; +import type { ArtifactEntryCardDecoratorProps } from '../../artifact_entry_card'; jest.mock('../../../services/policies/hooks', () => ({ useGetEndpointSpecificPolicies: jest.fn(), @@ -144,6 +146,19 @@ describe('When using the ArtifactListPage component', () => { }); }); + it('should show per card decoration', async () => { + const MockCardDecorator = memo<ArtifactEntryCardDecoratorProps>(({ item: actualItem }) => { + return <p>{'mock decorator'}</p>; + }); + MockCardDecorator.displayName = 'MockCardDecorator'; + + const { getAllByText } = await renderWithListData({ + CardDecorator: MockCardDecorator, + }); + + expect(getAllByText('mock decorator')).toHaveLength(10); + }); + it('should call useGetEndpointSpecificPolicies hook with specific perPage value', () => { expect(mockUseGetEndpointSpecificPolicies).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx index 78f7580a7b16..c560f155e99f 100644 --- a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx @@ -60,6 +60,7 @@ describe('when using PaginatedContent', () => { totalItemCount: 10, }, 'data-test-subj': 'test', + CardDecorator: undefined, ...(additionalProps ?? {}), }; renderResult = mockedContext.render(<PaginatedContent<Foo, ItemComponentType> {...props} />); diff --git a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx index 11797c0544ae..edc5151ecfd1 100644 --- a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx +++ b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx @@ -29,6 +29,7 @@ import { v4 as generateUUI } from 'uuid'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import type { MaybeImmutable } from '../../../../common/endpoint/types'; import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../common/constants'; +import type { ArtifactEntryCardDecoratorProps } from '../artifact_entry_card'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ComponentWithAnyProps = ComponentType<any>; @@ -52,6 +53,8 @@ export interface PaginatedContentProps<T, C extends ComponentWithAnyProps> exten error?: ReactNode; /** Classname applied to the area that holds the content items */ contentClassName?: string; + // Artifact specific decorations to display in the cards + CardDecorator: React.ComponentType<ArtifactEntryCardDecoratorProps> | undefined; /** * Children can be used to define custom content if the default creation of items is not sufficient * to accommodate a use case. @@ -139,6 +142,7 @@ export const PaginatedContent = memo( 'data-test-subj': dataTestSubj, 'aria-label': ariaLabel, className, + CardDecorator, children, }: PaginatedContentProps<T, C>) => { const [itemKeys] = useState<WeakMap<T, string>>(new WeakMap()); @@ -223,21 +227,22 @@ export const PaginatedContent = memo( } } - return <Item {...itemComponentProps(item)} key={key} />; + return <Item {...itemComponentProps(item)} key={key} Decorator={CardDecorator} />; }); } if (!loading) return noItemsMessage || <DefaultNoItemsFound data-test-subj={getTestId('noResults')} />; }, [ - ItemComponent, error, + ItemComponent, + items, + loading, + noItemsMessage, getTestId, - itemComponentProps, itemId, + itemComponentProps, + CardDecorator, itemKeys, - items, - noItemsMessage, - loading, ]); return ( diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/responder.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/responder.cy.ts index e72b5a42eaf1..3a763fc0e8e2 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/responder.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/responder.cy.ts @@ -43,7 +43,8 @@ describe( login(); }); - describe('from Cases', () => { + // FLAKY: https://github.com/elastic/kibana/issues/169894 + describe.skip('from Cases', () => { let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>; let caseData: ReturnTypeFromChainable<typeof indexNewCase>; let alertData: ReturnTypeFromChainable<typeof indexEndpointRuleAlerts>; diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts index 57b2820921dd..21a7253109d4 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts @@ -16,7 +16,14 @@ describe( { tags: ['@serverless', '@skipInServerlessMKI'], env: { - ftrConfig: { productTypes: [{ product_line: 'security', product_tier: 'complete' }] }, + ftrConfig: { + productTypes: [{ product_line: 'security', product_tier: 'complete' }], + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'responseActionScanEnabled', + ])}`, + ], + }, }, }, () => { @@ -53,10 +60,9 @@ describe( } // No access to response actions (except `unisolate`) - // TODO: update tests when `scan` is included in PLIs for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - ).filter((apiName) => apiName !== 'unisolate')) { + (apiName) => apiName !== 'unisolate' + )) { it(`should not allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('none', actionName, username, password); }); @@ -79,10 +85,9 @@ describe( }); // No access to response actions (except `unisolate`) - // TODO: update tests when `scan` is included in PLIs for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - ).filter((apiName) => apiName !== 'unisolate')) { + (apiName) => apiName !== 'unisolate' + )) { it(`should not allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('none', actionName, username, password); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts index da17beb14d76..54d5b688aa1d 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts @@ -24,6 +24,11 @@ describe( { product_line: 'security', product_tier: 'complete' }, { product_line: 'endpoint', product_tier: 'complete' }, ], + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'responseActionScanEnabled', + ])}`, + ], }, }, }, @@ -47,10 +52,7 @@ describe( }); } - // TODO: update tests when `scan` is included in PLIs - for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - )) { + for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES) { it(`should allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('all', actionName, username, password); }); @@ -73,10 +75,7 @@ describe( }); }); - // TODO: update tests when `scan` is included in PLIs - for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - )) { + for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES) { it(`should allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('all', actionName, username, password); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts index e4388924f05f..7be22d7e7e5b 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts @@ -18,6 +18,11 @@ describe( env: { ftrConfig: { productTypes: [{ product_line: 'security', product_tier: 'essentials' }], + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'responseActionScanEnabled', + ])}`, + ], }, }, }, @@ -55,10 +60,9 @@ describe( } // No access to response actions (except `unisolate`) - // TODO: update tests when `scan` is included in PLIs for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - ).filter((apiName) => apiName !== 'unisolate')) { + (apiName) => apiName !== 'unisolate' + )) { it(`should not allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('none', actionName, username, password); }); @@ -81,10 +85,9 @@ describe( }); // No access to response actions (except `unisolate`) - // TODO: update tests when `scan` is included in PLIs for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - ).filter((apiName) => apiName !== 'unisolate')) { + (apiName) => apiName !== 'unisolate' + )) { it(`should not allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('none', actionName, username, password); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts index 4a37f1089e89..a57102e7a2b2 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts @@ -24,6 +24,11 @@ describe( { product_line: 'security', product_tier: 'essentials' }, { product_line: 'endpoint', product_tier: 'essentials' }, ], + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'responseActionScanEnabled', + ])}`, + ], }, }, }, @@ -62,10 +67,9 @@ describe( }); } - // TODO: update tests when `scan` is included in PLIs for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - ).filter((apiName) => apiName !== 'unisolate')) { + (apiName) => apiName !== 'unisolate' + )) { it(`should not allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('none', actionName, username, password); }); @@ -92,10 +96,9 @@ describe( }); }); - // TODO: update tests when `scan` is included in PLIs for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter( - (apiName) => apiName !== 'scan' - ).filter((apiName) => apiName !== 'unisolate')) { + (apiName) => apiName !== 'unisolate' + )) { it(`should not allow access to Response Action: ${actionName}`, () => { ensureResponseActionAuthzAccess('none', actionName, username, password); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/metering.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/metering.cy.ts index 7a0533e85c1e..f7c18433b07a 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/metering.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/metering.cy.ts @@ -32,6 +32,7 @@ describe( }, () => { const HEARTBEAT_COUNT = 2001; + const UNBILLED_COUNT = 5; let endpointData: ReturnTypeFromChainable<typeof indexEndpointHeartbeats> | undefined; @@ -40,6 +41,7 @@ describe( startTransparentApiProxy({ port: 3623 }); indexEndpointHeartbeats({ count: HEARTBEAT_COUNT, + unbilledCount: UNBILLED_COUNT, }).then((indexedHeartbeats) => { endpointData = indexedHeartbeats; }); @@ -53,8 +55,7 @@ describe( stopTransparentApiProxy(); }); - // FLAKY: https://github.com/elastic/kibana/issues/187083 - describe.skip('Usage Reporting Task', () => { + describe('Usage Reporting Task', () => { it('properly sends indexed heartbeats to the metering api', () => { const expectedChunks = Math.ceil(HEARTBEAT_COUNT / METERING_SERVICE_BATCH_SIZE); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts index 8d2b564e9dd1..a31ae854aa05 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts @@ -40,6 +40,11 @@ describe( { product_line: 'security', product_tier: 'complete' }, { product_line: 'endpoint', product_tier: 'complete' }, ], + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'responseActionScanEnabled', + ])}`, + ], }, }, }, @@ -118,7 +123,8 @@ describe( 'kill-process', 'suspend-process', 'get-file', - 'upload' + 'upload', + 'scan' ); const deniedResponseActions = pick(consoleHelpPanelResponseActionsTestSubj, 'execute'); diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/responder.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/responder.ts index 7e920772374c..19a86eb153bc 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/screens/responder.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/responder.ts @@ -14,9 +14,8 @@ const TEST_SUBJ = Object.freeze({ actionLogFlyout: 'responderActionLogFlyout', }); -// TODO: 8.15 Include `scan` in return type when responseActionsScanEnabled when `scan` is categorized in PLIs export const getConsoleHelpPanelResponseActionTestSubj = (): Record< - Exclude<ConsoleResponseActionCommands, 'scan'>, + ConsoleResponseActionCommands, string > => { return { @@ -28,8 +27,7 @@ export const getConsoleHelpPanelResponseActionTestSubj = (): Record< 'get-file': 'endpointResponseActionsConsole-commandList-Responseactions-get-file', execute: 'endpointResponseActionsConsole-commandList-Responseactions-execute', upload: 'endpointResponseActionsConsole-commandList-Responseactions-upload', - // TODO: 8.15 Include `scan` in return type when responseActionsScanEnabled when `scan` is categorized in PLIs - // scan: 'endpointResponseActionsConsole-commandList-Responseactions-scan', + scan: 'endpointResponseActionsConsole-commandList-Responseactions-scan', }; }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts index 2ce2f8140184..2ed45297fc53 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts @@ -231,9 +231,10 @@ export const dataLoaders = ( return deleteIndexedHostsAndAlerts(esClient, kbnClient, indexedData); }, - indexEndpointHeartbeats: async (options: { count?: number }) => { + indexEndpointHeartbeats: async (options: { count?: number; unbilledCount?: number }) => { const { esClient, log } = await setupStackServicesUsingCypressConfig(config); - return (await indexEndpointHeartbeats(esClient, log, options.count || 1)).data; + return (await indexEndpointHeartbeats(esClient, log, options.count, options.unbilledCount)) + .data; }, deleteIndexedEndpointHeartbeats: async ( diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_heartbeats.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_heartbeats.ts index 090b664a1222..985170c78ae2 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_heartbeats.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/index_endpoint_heartbeats.ts @@ -12,6 +12,7 @@ import type { export const indexEndpointHeartbeats = (options: { count?: number; + unbilledCount?: number; }): Cypress.Chainable< Pick<IndexedEndpointHeartbeats, 'data'> & { cleanup: () => Cypress.Chainable<DeletedEndpointHeartbeats>; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts index 488742ac945c..a55e385b4b1d 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts @@ -16,6 +16,7 @@ import { GET_PROCESSES_ROUTE, ISOLATE_HOST_ROUTE_V2, KILL_PROCESS_ROUTE, + SCAN_ROUTE, SUSPEND_PROCESS_ROUTE, UNISOLATE_HOST_ROUTE_V2, UPLOAD_ROUTE, @@ -243,6 +244,11 @@ export const ensureResponseActionAuthzAccess = ( } break; + case 'scan': + url = SCAN_ROUTE; + Object.assign(apiPayload, { parameters: { path: 'scan/two' } }); + break; + default: throw new Error(`Response action [${responseAction}] has no API payload defined`); } diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 06d47e293611..91bf4e958f6f 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -22,7 +22,6 @@ import { EVENT_FILTERS_PATH, HOST_ISOLATION_EXCEPTIONS_PATH, MANAGE_PATH, - NOTES_MANAGEMENT_PATH, POLICIES_PATH, RESPONSE_ACTIONS_HISTORY_PATH, SecurityPageName, @@ -40,7 +39,6 @@ import { TRUSTED_APPLICATIONS, ENTITY_ANALYTICS_RISK_SCORE, ASSET_CRITICALITY, - NOTES, } from '../app/translations'; import { licenseService } from '../common/hooks/use_license'; import type { LinkItem } from '../common/links/types'; @@ -87,12 +85,6 @@ const categories = [ }), linkIds: [SecurityPageName.cloudDefendPolicies], }, - { - label: i18n.translate('xpack.securitySolution.appLinks.category.investigations', { - defaultMessage: 'Investigations', - }), - linkIds: [SecurityPageName.notesManagement], - }, ]; export const links: LinkItem = { @@ -223,18 +215,6 @@ export const links: LinkItem = { hideTimeline: true, }, cloudDefendLink, - { - id: SecurityPageName.notesManagement, - title: NOTES, - description: i18n.translate('xpack.securitySolution.appLinks.notesManagementDescription', { - defaultMessage: 'Visualize and delete notes.', - }), - landingIcon: IconTool, // TODO get new icon - path: NOTES_MANAGEMENT_PATH, - skipUrlState: true, - hideTimeline: true, - experimentalKey: 'securitySolutionNotesEnabled', - }, ], }; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx index 9ea16a071a72..c355cc8bdea0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx @@ -532,8 +532,8 @@ describe('Event filter form', () => { }); it('should display a tooltip to the user', async () => { - const tooltipIconSelector = `${formPrefix}-filterProcessDescendants-tooltipIcon`; - const tooltipTextSelector = `${formPrefix}-filterProcessDescendants-tooltipText`; + const tooltipIconSelector = `${formPrefix}-filterProcessDescendantsTooltip-tooltipIcon`; + const tooltipTextSelector = `${formPrefix}-filterProcessDescendantsTooltip-tooltipText`; render(); expect(renderResult.getByTestId(tooltipIconSelector)).toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx index 2413007fd693..427550296184 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx @@ -14,8 +14,6 @@ import { EuiSpacer, EuiFlexGroup, EuiButtonGroup, - EuiToolTip, - EuiIcon, useEuiTheme, EuiForm, EuiFormRow, @@ -41,7 +39,10 @@ import type { OnChangeProps } from '@kbn/lists-plugin/public'; import type { ValueSuggestionsGetFn } from '@kbn/unified-search-plugin/public/autocomplete/providers/value_suggestion_provider'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { useGetUpdatedTags } from '../../../../hooks/artifacts'; -import { FILTER_PROCESS_DESCENDANTS_TAG } from '../../../../../../common/endpoint/service/artifacts/constants'; +import { + FILTER_PROCESS_DESCENDANTS_TAG, + PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY_TEXT, +} from '../../../../../../common/endpoint/service/artifacts/constants'; import { isFilterProcessDescendantsEnabled, isFilterProcessDescendantsTag, @@ -81,6 +82,7 @@ import { EffectedPolicySelect } from '../../../../components/effected_policy_sel import { ExceptionItemComments } from '../../../../../detection_engine/rule_exceptions/components/item_comments'; import { EventFiltersApiClient } from '../../service/api_client'; import { ShowValueListModal } from '../../../../../value_list/components/show_value_list_modal'; +import { ProcessDescendantsTooltip } from './process_descendant_tooltip'; const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ OperatingSystem.MAC, @@ -455,33 +457,6 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele ); const filterTypeOptions: EuiButtonGroupOptionProps[] = useMemo(() => { - const descendantsTooltip = ( - <EuiToolTip - content={ - <EuiText size="s"> - <p> - <FormattedMessage - id="xpack.securitySolution.eventFilters.filterProcessDescendants.tooltip" - defaultMessage="Filtering the descendants of a process means that events from the matched process are ingested, but events from its descendant processes are omitted." - /> - </p> - <p> - <FormattedMessage - id="xpack.securitySolution.eventFilters.filterProcessDescendants.tooltipVersionInfo" - defaultMessage="Process descendant filtering works only with Agents v8.15 and newer." - /> - </p> - </EuiText> - } - data-test-subj={getTestId('filterProcessDescendants-tooltipText')} - > - <EuiIcon - type="iInCircle" - data-test-subj={getTestId('filterProcessDescendants-tooltipIcon')} - /> - </EuiToolTip> - ); - return [ { id: 'events', @@ -506,7 +481,9 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele defaultMessage="Process Descendants" /> </EuiText> - {descendantsTooltip} + <ProcessDescendantsTooltip + data-test-subj={getTestId('filterProcessDescendantsTooltip')} + /> </EuiFlexGroup> ), iconType: isFilterProcessDescendantsSelected ? 'checkInCircleFilled' : 'empty', @@ -547,12 +524,7 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele defaultMessage="Additional condition added:" /> </EuiText> - <code> - <FormattedMessage - id="xpack.securitySolution.eventFilters.filterProcessDescendants.additionalCondition" - defaultMessage="event.category is process" - /> - </code> + <code>{PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY_TEXT}</code> <EuiSpacer size="m" /> </> )} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/process_descendant_tooltip.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/process_descendant_tooltip.tsx new file mode 100644 index 000000000000..f8709306d209 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/process_descendant_tooltip.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import type { CommonProps } from '@elastic/eui'; +import { EuiToolTip, EuiText, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator'; +import { PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY_TEXT } from '../../../../../../common/endpoint/service/artifacts/constants'; + +interface ProcessDescendantsTooltipProps extends CommonProps { + indicateExtraEntry?: boolean; +} + +export const ProcessDescendantsTooltip = memo<ProcessDescendantsTooltipProps>( + ({ + indicateExtraEntry = false, + 'data-test-subj': dataTestSubj, + ...commonProps + }: ProcessDescendantsTooltipProps) => { + const getTestId = useTestIdGenerator(dataTestSubj); + + return ( + <EuiToolTip + {...commonProps} + content={ + <EuiText size="s"> + <p> + <FormattedMessage + id="xpack.securitySolution.eventFilters.filterProcessDescendants.tooltip" + defaultMessage="Filtering the descendants of a process means that events from the matched process are ingested, but events from its descendant processes are omitted." + /> + </p> + {indicateExtraEntry && ( + <> + <p> + <FormattedMessage + id="xpack.securitySolution.eventFilters.filterProcessDescendants.tooltipExtraEntry" + defaultMessage="Note: the following additional condition is applied:" + /> + </p> + <p> + <code>{PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY_TEXT}</code> + </p> + </> + )} + <p> + <FormattedMessage + id="xpack.securitySolution.eventFilters.filterProcessDescendants.tooltipVersionInfo" + defaultMessage="Process descendant filtering works only with Agents v8.15 and newer." + /> + </p> + </EuiText> + } + data-test-subj={getTestId('tooltipText')} + > + <EuiIcon type="iInCircle" data-test-subj={getTestId('tooltipIcon')} /> + </EuiToolTip> + ); + } +); +ProcessDescendantsTooltip.displayName = 'ProcessDescendantsTooltip'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx index b2e2054ca0ed..87aae6a37673 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx @@ -18,6 +18,7 @@ import { ArtifactListPage } from '../../../components/artifact_list_page'; import { EventFiltersApiClient } from '../service/api_client'; import { EventFiltersForm } from './components/form'; import { SEARCHABLE_FIELDS } from '../constants'; +import { EventFiltersProcessDescendantIndicator } from '../../../components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator'; export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { defaultMessage: @@ -155,6 +156,7 @@ export const EventFiltersList = memo(() => { allowCardCreateAction={canWriteEventFilters} allowCardEditAction={canWriteEventFilters} allowCardDeleteAction={canWriteEventFilters} + CardDecorator={EventFiltersProcessDescendantIndicator} /> ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/integration_tests/event_filters_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/integration_tests/event_filters_list.test.tsx index c454cda4d49b..8311c111ac8b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/integration_tests/event_filters_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/integration_tests/event_filters_list.test.tsx @@ -17,6 +17,8 @@ import { SEARCHABLE_FIELDS } from '../../constants'; import { parseQueryFilterToKQL } from '../../../../common/utils'; import type { EndpointPrivileges } from '../../../../../../common/endpoint/types'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { ExceptionsListItemGenerator } from '../../../../../../common/endpoint/data_generators/exceptions_list_item_generator'; +import { FILTER_PROCESS_DESCENDANTS_TAG } from '../../../../../../common/endpoint/service/artifacts/constants'; jest.mock('../../../../../common/components/user_privileges'); const mockUserPrivileges = useUserPrivileges as jest.Mock; @@ -69,6 +71,80 @@ describe('When on the Event Filters list page', () => { ); }); + describe('filtering process descendants', () => { + let renderWithData: () => Promise<ReturnType<AppContextTestRender['render']>>; + + beforeEach(() => { + renderWithData = async () => { + const generator = new ExceptionsListItemGenerator(); + + apiMocks.responseProvider.exceptionsFind.mockReturnValue({ + data: [ + generator.generateEventFilter(), + generator.generateEventFilter({ tags: [FILTER_PROCESS_DESCENDANTS_TAG] }), + generator.generateEventFilter({ tags: [FILTER_PROCESS_DESCENDANTS_TAG] }), + ], + total: 3, + per_page: 3, + page: 1, + }); + + render(); + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('EventFiltersListPage-list')).toBeTruthy(); + }); + }); + + return renderResult; + }; + }); + + it('should not show indication if feature flag is disabled', async () => { + mockedContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: false }); + + await renderWithData(); + + expect(renderResult.getAllByTestId('EventFiltersListPage-card')).toHaveLength(3); + expect( + renderResult.queryAllByTestId( + 'EventFiltersListPage-card-decorator-processDescendantIndication' + ) + ).toHaveLength(0); + }); + + it('should indicate to user if event filter filters process descendants', async () => { + mockedContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: true }); + + await renderWithData(); + + expect(renderResult.getAllByTestId('EventFiltersListPage-card')).toHaveLength(3); + expect( + renderResult.getAllByTestId( + 'EventFiltersListPage-card-decorator-processDescendantIndication' + ) + ).toHaveLength(2); + }); + + it('should display additional `event.category is process` entry in tooltip', async () => { + mockedContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: true }); + const prefix = 'EventFiltersListPage-card-decorator-processDescendantIndicationTooltip'; + + await renderWithData(); + + expect(renderResult.getAllByTestId(`${prefix}-tooltipIcon`)).toHaveLength(2); + expect(renderResult.queryByTestId(`${prefix}-tooltipText`)).not.toBeInTheDocument(); + + userEvent.hover(renderResult.getAllByTestId(`${prefix}-tooltipIcon`)[0]); + + expect(await renderResult.findByTestId(`${prefix}-tooltipText`)).toBeInTheDocument(); + expect(renderResult.getByTestId(`${prefix}-tooltipText`).textContent).toContain( + 'event.category is process' + ); + }); + }); + describe('RBAC Event Filters', () => { describe('ALL privilege', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index 05ea215f65c5..b6333f949c76 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -1908,4 +1908,15 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ } ), }, + { + key: 'windows.advanced.events.registry.enforce_registry_filters', + first_supported_version: '8.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.events.registry.enforce_registry_filters', + { + defaultMessage: + 'Reduce data volume by filtering out registry events which are not relevant to behavioral protections. Default: true', + } + ), + }, ]; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx index c1f95b1f4d06..f86e4bfd10f4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx @@ -35,6 +35,7 @@ describe('Policy artifacts list', () => { selectedArtifactIds: [], isListLoading: true, selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + CardDecorator: undefined, }); expect(component.getByTestId('artifactsAssignableListLoader')).not.toBeNull(); @@ -47,6 +48,7 @@ describe('Policy artifacts list', () => { selectedArtifactIds: [], isListLoading: false, selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + CardDecorator: undefined, }); expect(component.queryByTestId('artifactsList')).toBeNull(); }); @@ -58,6 +60,7 @@ describe('Policy artifacts list', () => { selectedArtifactIds: [], isListLoading: false, selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + CardDecorator: undefined, }); expect(component.getByTestId('artifactsList')).not.toBeNull(); }); @@ -69,6 +72,7 @@ describe('Policy artifacts list', () => { selectedArtifactIds: [artifactsResponse.data[0].id], isListLoading: false, selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + CardDecorator: undefined, }); const tACardCheckbox = component.getByTestId(`${getMockListResponse().data[1].name}_checkbox`); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.tsx index 4be70c364e0d..d2f0ae02a2dd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.tsx @@ -12,7 +12,10 @@ import type { GetTrustedAppsListResponse, Immutable, } from '../../../../../../../common/endpoint/types'; -import type { AnyArtifact } from '../../../../../components/artifact_entry_card'; +import type { + AnyArtifact, + ArtifactEntryCardDecoratorProps, +} from '../../../../../components/artifact_entry_card'; import { ArtifactEntryCardMinified } from '../../../../../components/artifact_entry_card'; export interface PolicyArtifactsAssignableListProps { @@ -25,10 +28,11 @@ export interface PolicyArtifactsAssignableListProps { selectedArtifactIds: string[]; selectedArtifactsUpdated: (id: string, selected: boolean) => void; isListLoading: boolean; + CardDecorator: React.ComponentType<ArtifactEntryCardDecoratorProps> | undefined; } export const PolicyArtifactsAssignableList = React.memo<PolicyArtifactsAssignableListProps>( - ({ artifacts, isListLoading, selectedArtifactIds, selectedArtifactsUpdated }) => { + ({ artifacts, isListLoading, selectedArtifactIds, selectedArtifactsUpdated, CardDecorator }) => { const selectedArtifactIdsByKey = useMemo( () => selectedArtifactIds.reduce( @@ -51,11 +55,12 @@ export const PolicyArtifactsAssignableList = React.memo<PolicyArtifactsAssignabl onToggleSelectedArtifact={(selected) => selectedArtifactsUpdated(artifact.id, selected) } + Decorator={CardDecorator} /> ))} </div> ); - }, [artifacts, selectedArtifactIdsByKey, selectedArtifactsUpdated]); + }, [CardDecorator, artifacts, selectedArtifactIdsByKey, selectedArtifactsUpdated]); return ( <> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx index 48d133cd41f2..90438d7eb80d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx @@ -86,6 +86,7 @@ describe('Policy details artifacts flyout', () => { apiClient={EventFiltersApiClient.getInstance(mockedContext.coreStart.http)} onClose={onCloseMock} searchableFields={[...SEARCHABLE_FIELDS]} + CardDecorator={undefined} /> ); await waitFor(mockedApi.responseProvider.eventFiltersList); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.tsx index 7793061be4fe..9805c72a75ca 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.tsx @@ -24,6 +24,7 @@ import { EuiEmptyPrompt, useGeneratedHtmlId, } from '@elastic/eui'; +import type { ArtifactEntryCardDecoratorProps } from '../../../../../components/artifact_entry_card'; import { SearchExceptions } from '../../../../../components/search_exceptions'; import type { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; import { useToasts } from '../../../../../../common/lib/kibana'; @@ -38,12 +39,13 @@ interface PolicyArtifactsFlyoutProps { searchableFields: string[]; onClose: () => void; labels: typeof POLICY_ARTIFACT_FLYOUT_LABELS; + CardDecorator: React.ComponentType<ArtifactEntryCardDecoratorProps> | undefined; } export const MAX_ALLOWED_RESULTS = 100; export const PolicyArtifactsFlyout = React.memo<PolicyArtifactsFlyoutProps>( - ({ policyItem, apiClient, searchableFields, onClose, labels }) => { + ({ policyItem, apiClient, searchableFields, onClose, labels, CardDecorator }) => { const toasts = useToasts(); const queryClient = useQueryClient(); const [selectedArtifactIds, setSelectedArtifactIds] = useState<string[]>([]); @@ -210,6 +212,7 @@ export const PolicyArtifactsFlyout = React.memo<PolicyArtifactsFlyoutProps>( selectedArtifactIds={selectedArtifactIds} isListLoading={isLoadingArtifacts || isRefetchingArtifacts} selectedArtifactsUpdated={handleSelectArtifacts} + CardDecorator={CardDecorator} /> {noItemsMessage} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx index 70ac3ce16aab..75927ece7dfd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx @@ -17,6 +17,7 @@ import { EuiButton, EuiPageSection, } from '@elastic/eui'; +import type { ArtifactEntryCardDecoratorProps } from '../../../../../components/artifact_entry_card'; import { useAppUrl } from '../../../../../../common/lib/kibana'; import { APP_UI_ID } from '../../../../../../../common/constants'; import type { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; @@ -34,7 +35,7 @@ import { policyArtifactsPageLabels } from '../translations'; import { PolicyArtifactsDeleteModal } from '../delete_modal'; import type { ArtifactListPageUrlParams } from '../../../../../components/artifact_list_page'; -interface PolicyArtifactsLayoutProps { +export interface PolicyArtifactsLayoutProps { policyItem?: ImmutableObject<PolicyData> | undefined; /** A list of labels for the given policy artifact page. Not all have to be defined, only those that should override the defaults */ labels: PolicyArtifactsPageLabels; @@ -44,6 +45,8 @@ interface PolicyArtifactsLayoutProps { getPolicyArtifactsPath: (policyId: string) => string; /** A boolean to check if has write artifact privilege or not */ canWriteArtifact?: boolean; + // Artifact specific decorations to display in the cards + CardDecorator?: React.ComponentType<ArtifactEntryCardDecoratorProps>; } export const PolicyArtifactsLayout = React.memo<PolicyArtifactsLayoutProps>( ({ @@ -54,6 +57,7 @@ export const PolicyArtifactsLayout = React.memo<PolicyArtifactsLayoutProps>( getArtifactPath, getPolicyArtifactsPath, canWriteArtifact = false, + CardDecorator, }) => { const exceptionsListApiClient = useMemo( () => getExceptionsListApiClient(), @@ -154,6 +158,7 @@ export const PolicyArtifactsLayout = React.memo<PolicyArtifactsLayoutProps>( searchableFields={[...searchableFields]} onClose={handleOnCloseFlyout} labels={labels} + CardDecorator={CardDecorator} /> )} {allArtifacts && allArtifacts.total !== 0 ? ( @@ -205,6 +210,7 @@ export const PolicyArtifactsLayout = React.memo<PolicyArtifactsLayoutProps>( searchableFields={[...searchableFields]} onClose={handleOnCloseFlyout} labels={labels} + CardDecorator={CardDecorator} /> )} {exceptionItemToDelete && ( @@ -228,6 +234,7 @@ export const PolicyArtifactsLayout = React.memo<PolicyArtifactsLayoutProps>( canWriteArtifact={canWriteArtifact} getPolicyArtifactsPath={getPolicyArtifactsPath} getArtifactPath={getArtifactPath} + CardDecorator={CardDecorator} /> </EuiPageSection> </div> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx index 1ad26fd171c2..3d4469a4ec33 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx @@ -68,6 +68,7 @@ describe('Policy details artifacts list', () => { canWriteArtifact={canWriteArtifact} getPolicyArtifactsPath={getPolicyEventFiltersPath} getArtifactPath={getEventFiltersListPath} + CardDecorator={undefined} /> ); await waitFor(() => expect(mockedApi.responseProvider.eventFiltersList).toHaveBeenCalled()); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx index 497caf30d402..082012295b02 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import type { Pagination } from '@elastic/eui'; import { EuiSpacer, EuiText } from '@elastic/eui'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { ArtifactEntryCardDecoratorProps } from '../../../../../components/artifact_entry_card'; import { useAppUrl } from '../../../../../../common/lib/kibana'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SearchExceptions } from '../../../../../components/search_exceptions'; @@ -38,6 +39,7 @@ interface PolicyArtifactsListProps { labels: typeof POLICY_ARTIFACT_LIST_LABELS; onDeleteActionCallback: (item: ExceptionListItemSchema) => void; canWriteArtifact?: boolean; + CardDecorator: React.ComponentType<ArtifactEntryCardDecoratorProps> | undefined; } export const PolicyArtifactsList = React.memo<PolicyArtifactsListProps>( @@ -50,6 +52,7 @@ export const PolicyArtifactsList = React.memo<PolicyArtifactsListProps>( labels, onDeleteActionCallback, canWriteArtifact = false, + CardDecorator, }) => { useOldUrlSearchPaginationReplace(); const { getAppUrl } = useAppUrl(); @@ -192,6 +195,7 @@ export const PolicyArtifactsList = React.memo<PolicyArtifactsListProps>( pagination={artifacts ? pagination : undefined} loading={isLoadingArtifacts || isRefetchingArtifacts} data-test-subj={'artifacts-collapsed-list'} + CardDecorator={CardDecorator} /> </> ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index fd7e8793535e..cb480615d27a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { EventFiltersProcessDescendantIndicator } from '../../../../components/artifact_entry_card/components/card_decorators/event_filters_process_descendant_indicator'; import { UnsavedChangesConfirmModal } from './unsaved_changes_confirm_modal'; import { useLicense } from '../../../../../common/hooks/use_license'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; @@ -290,6 +291,7 @@ export const PolicyTabs = React.memo(() => { getArtifactPath={getEventFiltersListPath} getPolicyArtifactsPath={getPolicyEventFiltersPath} canWriteArtifact={canWriteEventFilters} + CardDecorator={EventFiltersProcessDescendantIndicator} /> </> ), diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index 91455d71a8d1..4c9542458c30 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -29,6 +29,38 @@ export const createNote = async ({ note }: { note: BareNote }) => { } }; +export const fetchNotes = async ({ + page, + perPage, + sortField, + sortOrder, + filter, + search, +}: { + page: number; + perPage: number; + sortField: string; + sortOrder: string; + filter: string; + search: string; +}) => { + const response = await KibanaServices.get().http.get<{ totalCount: number; notes: Note[] }>( + NOTE_URL, + { + query: { + page, + perPage, + sortField, + sortOrder, + filter, + search, + }, + version: '2023-10-31', + } + ); + return response; +}; + /** * Fetches all the notes for an array of document ids */ @@ -44,11 +76,11 @@ export const fetchNotesByDocumentIds = async (documentIds: string[]) => { }; /** - * Deletes a note + * Deletes multiple notes */ -export const deleteNote = async (noteId: string) => { +export const deleteNotes = async (noteIds: string[]) => { const response = await KibanaServices.get().http.delete<{ data: unknown }>(NOTE_URL, { - body: JSON.stringify({ noteId }), + body: JSON.stringify({ noteIds }), version: '2023-10-31', }); return response; 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 new file mode 100644 index 000000000000..cba7e81b0fb2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from './translations'; +import { + deleteNotes, + userClosedDeleteModal, + selectNotesTablePendingDeleteIds, + selectDeleteNotesStatus, + ReqStatus, +} from '..'; + +export const DeleteConfirmModal = React.memo(() => { + const dispatch = useDispatch(); + const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); + const deleteNotesStatus = useSelector(selectDeleteNotesStatus); + const deleteLoading = deleteNotesStatus === ReqStatus.Loading; + + const onCancel = useCallback(() => { + dispatch(userClosedDeleteModal()); + }, [dispatch]); + + const onConfirm = useCallback(() => { + dispatch(deleteNotes({ ids: pendingDeleteIds, refetch: true })); + }, [dispatch, pendingDeleteIds]); + + return ( + <EuiConfirmModal + aria-labelledby={'delete-notes-modal'} + title={i18n.DELETE_NOTES_MODAL_TITLE} + onCancel={onCancel} + onConfirm={onConfirm} + isLoading={deleteLoading} + cancelButtonText={i18n.DELETE_NOTES_CANCEL} + confirmButtonText={i18n.DELETE} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.DELETE_NOTES_CONFIRM(pendingDeleteIds.length)} + </EuiConfirmModal> + ); +}); + +DeleteConfirmModal.displayName = 'DeleteConfirmModal'; diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx new file mode 100644 index 000000000000..1e88a47b2e2d --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.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 { EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui'; +import React, { useMemo, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { userSearchedNotes, selectNotesTableSearch } from '..'; + +const SearchRowContainer = styled.div` + &:not(:last-child) { + margin-bottom: ${(props) => props.theme.eui.euiSizeL}; + } +`; + +SearchRowContainer.displayName = 'SearchRowContainer'; + +const SearchRowFlexGroup = styled(EuiFlexGroup)` + margin-bottom: ${(props) => props.theme.eui.euiSizeXS}; +`; + +SearchRowFlexGroup.displayName = 'SearchRowFlexGroup'; + +export const SearchRow = React.memo(() => { + const dispatch = useDispatch(); + const searchBox = useMemo( + () => ({ + placeholder: 'Search note contents', + incremental: false, + 'data-test-subj': 'notes-search-bar', + }), + [] + ); + + const notesSearch = useSelector(selectNotesTableSearch); + + const onQueryChange = useCallback( + ({ queryText }) => { + dispatch(userSearchedNotes(queryText.trim())); + }, + [dispatch] + ); + + return ( + <SearchRowContainer> + <SearchRowFlexGroup gutterSize="s"> + <EuiFlexItem> + <EuiSearchBar + box={searchBox} + onChange={onQueryChange} + query={notesSearch} + defaultQuery={''} + /> + </EuiFlexItem> + </SearchRowFlexGroup> + </SearchRowContainer> + ); +}); + +SearchRow.displayName = 'SearchRow'; diff --git a/x-pack/plugins/security_solution/public/notes/components/translations.ts b/x-pack/plugins/security_solution/public/notes/components/translations.ts new file mode 100644 index 000000000000..471c28cbc9d4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/translations.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; 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 CREATED_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.createdColumnTitle', + { + defaultMessage: 'Created', + } +); + +export const CREATED_BY_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.createdByColumnTitle', + { + defaultMessage: 'Created by', + } +); + +export const EVENT_ID_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.eventIdColumnTitle', + { + defaultMessage: 'Document ID', + } +); + +export const TIMELINE_ID_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.timelineIdColumnTitle', + { + defaultMessage: 'Timeline ID', + } +); + +export const NOTE_CONTENT_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.noteContentColumnTitle', + { + defaultMessage: 'Note content', + } +); + +export const DELETE = i18n.translate('xpack.securitySolution.notes.management.deleteAction', { + defaultMessage: 'Delete', +}); + +export const DELETE_SINGLE_NOTE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.notes.management.deleteDescription', + { + defaultMessage: 'Delete this note', + } +); + +export const NOTES_MANAGEMENT_TITLE = i18n.translate( + 'xpack.securitySolution.notes.management.pageTitle', + { + defaultMessage: 'Notes management', + } +); + +export const TABLE_ERROR = i18n.translate('xpack.securitySolution.notes.management.tableError', { + defaultMessage: 'Unable to load notes', +}); + +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', +}); 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 new file mode 100644 index 000000000000..0c09f6393f66 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; 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 { + UtilityBarGroup, + UtilityBarText, + UtilityBar, + UtilityBarSection, + UtilityBarAction, +} from '../../common/components/utility_bar'; +import { + selectNotesPagination, + selectNotesTableSort, + fetchNotes, + selectNotesTableSelectedIds, + selectNotesTableSearch, + userSelectedBulkDelete, +} from '..'; +import * as i18n from './translations'; + +export const NotesUtilityBar = React.memo(() => { + const dispatch = useDispatch(); + const pagination = useSelector(selectNotesPagination); + const sort = useSelector(selectNotesTableSort); + const selectedItems = useSelector(selectNotesTableSelectedIds); + const resultsCount = useMemo(() => { + const { perPage, page, total } = pagination; + const startOfCurrentPage = perPage * (page - 1) + 1; + const endOfCurrentPage = Math.min(perPage * page, total); + return perPage === 0 ? 'All' : `${startOfCurrentPage}-${endOfCurrentPage} of ${total}`; + }, [pagination]); + const deleteSelectedNotes = useCallback(() => { + dispatch(userSelectedBulkDelete()); + }, [dispatch]); + const notesSearch = useSelector(selectNotesTableSearch); + + const BulkActionPopoverContent = useCallback(() => { + return ( + <EuiContextMenuItem + data-test-subj="notes-management-delete-notes" + onClick={deleteSelectedNotes} + disabled={selectedItems.length === 0} + icon="trash" + key="DeleteItemKey" + > + {i18n.DELETE_SELECTED} + </EuiContextMenuItem> + ); + }, [deleteSelectedNotes, selectedItems.length]); + const refresh = useCallback(() => { + dispatch( + fetchNotes({ + page: pagination.page, + perPage: pagination.perPage, + sortField: sort.field, + sortOrder: sort.direction, + filter: '', + search: notesSearch, + }) + ); + }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + return ( + <UtilityBar border> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText data-test-subj="notes-management-pagination-count"> + {`Showing: ${resultsCount}`} + </UtilityBarText> + </UtilityBarGroup> + <UtilityBarGroup> + <UtilityBarText data-test-subj="notes-management-selected-count"> + {selectedItems.length > 0 ? `${selectedItems.length} selected` : ''} + </UtilityBarText> + <UtilityBarAction + dataTestSubj="notes-management-utility-bar-actions" + iconSide="right" + iconType="arrowDown" + popoverContent={BulkActionPopoverContent} + > + <span data-test-subj="notes-management-utility-bar-action-button"> + {i18n.BATCH_ACTIONS} + </span> + </UtilityBarAction> + <UtilityBarAction + dataTestSubj="notes-management-utility-bar-refresh-button" + iconSide="right" + iconType="refresh" + onClick={refresh} + > + {i18n.REFRESH} + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> + ); +}); + +NotesUtilityBar.displayName = 'NotesUtilityBar'; diff --git a/x-pack/plugins/security_solution/public/notes/index.ts b/x-pack/plugins/security_solution/public/notes/index.ts new file mode 100644 index 000000000000..2c8f3548cfb5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/index.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. + */ + +export { NoteManagementPage } from './pages/note_management_page'; +export * from './store/notes.slice'; 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 1964fa65fd96..1c39265a1b02 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 @@ -5,14 +5,206 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useMemo, useEffect } from 'react'; +import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +// TODO unify this type from the api with the one in public/common/lib/note +import type { Note } from '../../../common/api/timeline'; +import { FormattedRelativePreferenceDate } from '../../common/components/formatted_date'; +import { + userSelectedPage, + userSelectedPerPage, + userSelectedRow, + userSortedNotes, + selectAllNotes, + selectNotesPagination, + selectNotesTableSort, + fetchNotes, + selectNotesTableSearch, + selectFetchNotesStatus, + selectNotesTablePendingDeleteIds, + userSelectedRowForDeletion, + selectFetchNotesError, + ReqStatus, +} from '..'; +import type { NotesState } from '..'; +import { SearchRow } from '../components/search_row'; +import { NotesUtilityBar } from '../components/utility_bar'; +import { DeleteConfirmModal } from '../components/delete_confirm_modal'; +import * as i18n from '../components/translations'; + +const columns: Array<EuiBasicTableColumn<Note>> = [ + { + field: 'created', + name: i18n.CREATED_COLUMN, + sortable: true, + render: (created: Note['created']) => <FormattedRelativePreferenceDate value={created} />, + }, + { + field: 'createdBy', + name: i18n.CREATED_BY_COLUMN, + }, + { + field: 'eventId', + name: i18n.EVENT_ID_COLUMN, + sortable: true, + }, + { + field: 'timelineId', + name: i18n.TIMELINE_ID_COLUMN, + }, + { + field: 'note', + name: i18n.NOTE_CONTENT_COLUMN, + }, +]; + +const pageSizeOptions = [50, 25, 10, 0]; /** - * Page to allow users to manage notes. The page is accessible via the Investigations section within the Manage page. - * // TODO to be implemented + * Allows user to search and delete notes. + * This component uses the same slices of state as the notes functionality of the rest of the Security Solution applicaiton. + * Therefore, changes made in this page (like fetching or deleting notes) will have an impact everywhere. */ export const NoteManagementPage = () => { - return <></>; + const dispatch = useDispatch(); + const notes = useSelector(selectAllNotes); + const pagination = useSelector(selectNotesPagination); + const sort = useSelector(selectNotesTableSort); + const notesSearch = useSelector(selectNotesTableSearch); + const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); + const isDeleteModalVisible = pendingDeleteIds.length > 0; + const fetchNotesStatus = useSelector(selectFetchNotesStatus); + const fetchLoading = fetchNotesStatus === ReqStatus.Loading; + const fetchError = fetchNotesStatus === ReqStatus.Failed; + const fetchErrorData = useSelector(selectFetchNotesError); + + const fetchData = useCallback(() => { + dispatch( + fetchNotes({ + page: pagination.page, + perPage: pagination.perPage, + sortField: sort.field, + sortOrder: sort.direction, + filter: '', + search: notesSearch, + }) + ); + }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const onTableChange = useCallback( + ({ + page, + sort: newSort, + }: { + page?: { index: number; size: number }; + sort?: NotesState['sort']; + }) => { + if (page) { + dispatch(userSelectedPage(page.index + 1)); + dispatch(userSelectedPerPage(page.size)); + } + if (newSort) { + dispatch(userSortedNotes({ field: newSort.field, direction: newSort.direction })); + } + }, + [dispatch] + ); + + const selectRowForDeletion = useCallback( + (id: string) => { + dispatch(userSelectedRowForDeletion(id)); + }, + [dispatch] + ); + + const onSelectionChange = useCallback( + (selection: Note[]) => { + const rowIds = selection.map((item) => item.noteId); + dispatch(userSelectedRow(rowIds)); + }, + [dispatch] + ); + + const itemIdSelector = useCallback((item: Note) => { + return item.noteId; + }, []); + + const columnWithActions = useMemo(() => { + const actions: Array<DefaultItemAction<Note>> = [ + { + name: i18n.DELETE, + description: i18n.DELETE_SINGLE_NOTE_DESCRIPTION, + color: 'primary', + icon: 'trash', + type: 'icon', + onClick: (note: Note) => selectRowForDeletion(note.noteId), + }, + ]; + return [ + ...columns, + { + name: 'actions', + actions, + }, + ]; + }, [selectRowForDeletion]); + + const currentPagination = useMemo(() => { + return { + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: pagination.total, + pageSizeOptions, + }; + }, [pagination]); + + const selection = useMemo(() => { + return { + onSelectionChange, + selectable: () => true, + }; + }, [onSelectionChange]); + + const sorting: { sort: { field: keyof Note; direction: 'asc' | 'desc' } } = useMemo(() => { + return { + sort, + }; + }, [sort]); + + if (fetchError) { + return ( + <EuiEmptyPrompt + iconType="error" + color="danger" + title={<h2>{i18n.TABLE_ERROR}</h2>} + body={<p>{fetchErrorData}</p>} + /> + ); + } + + return ( + <> + <SearchRow /> + <NotesUtilityBar /> + <EuiBasicTable + items={notes} + pagination={currentPagination} + columns={columnWithActions} + onChange={onTableChange} + selection={selection} + sorting={sorting} + itemId={itemIdSelector} + loading={fetchLoading} + /> + {isDeleteModalVisible && <DeleteConfirmModal />} + </> + ); }; NoteManagementPage.displayName = 'NoteManagementPage'; diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 59f196b6a5af..ad0e3b198d0d 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -5,113 +5,137 @@ * 2.0. */ import * as uuid from 'uuid'; +import { miniSerializeError } from '@reduxjs/toolkit'; +import type { SerializedError } from '@reduxjs/toolkit'; import { createNote, - deleteNote, + deleteNotes, fetchNotesByDocumentIds, + fetchNotes, initialNotesState, notesReducer, ReqStatus, selectAllNotes, selectCreateNoteError, selectCreateNoteStatus, - selectDeleteNoteError, - selectDeleteNoteStatus, + selectDeleteNotesError, + selectDeleteNotesStatus, selectFetchNotesByDocumentIdsError, selectFetchNotesByDocumentIdsStatus, + selectFetchNotesError, + selectFetchNotesStatus, selectNoteById, selectNoteIds, selectNotesByDocumentId, + selectNotesPagination, + selectNotesTablePendingDeleteIds, + selectNotesTableSearch, + selectNotesTableSelectedIds, + selectNotesTableSort, + userClosedDeleteModal, + userFilteredNotes, + userSearchedNotes, + userSelectedBulkDelete, + userSelectedPage, + userSelectedPerPage, + userSelectedRow, + userSelectedRowForDeletion, + userSortedNotes, } from './notes.slice'; +import type { NotesState } from './notes.slice'; import { mockGlobalState } from '../../common/mock'; +import type { Note } from '../../../common/api/timeline'; const initalEmptyState = initialNotesState; -export const generateNoteMock = (documentIds: string[]) => - documentIds.map((documentId: string) => ({ - noteId: uuid.v4(), - version: 'WzU1MDEsMV0=', - timelineId: '', - eventId: documentId, - note: 'This is a mocked note', - created: new Date().getTime(), - createdBy: 'elastic', - updated: new Date().getTime(), - updatedBy: 'elastic', - })); - -const mockNote = { ...generateNoteMock(['1'])[0] }; +const generateNoteMock = (documentId: string): Note => ({ + noteId: uuid.v4(), + version: 'WzU1MDEsMV0=', + timelineId: '', + eventId: documentId, + note: 'This is a mocked note', + created: new Date().getTime(), + createdBy: 'elastic', + updated: new Date().getTime(), + updatedBy: 'elastic', +}); + +const mockNote1 = generateNoteMock('1'); +const mockNote2 = generateNoteMock('2'); + const initialNonEmptyState = { entities: { - [mockNote.noteId]: mockNote, + [mockNote1.noteId]: mockNote1, + [mockNote2.noteId]: mockNote2, }, - ids: [mockNote.noteId], + ids: [mockNote1.noteId, mockNote2.noteId], status: { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, + }, + error: { fetchNotesByDocumentIds: null, createNote: null, deleteNotes: null, fetchNotes: null }, + pagination: { + page: 1, + perPage: 10, + total: 0, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, + sort: { + field: 'created' as const, + direction: 'desc' as const, + }, + filter: '', + search: '', + selectedIds: [], + pendingDeleteIds: [], }; describe('notesSlice', () => { describe('notesReducer', () => { it('should handle an unknown action and return the initial state', () => { - expect(notesReducer(initalEmptyState, { type: 'unknown' })).toEqual({ - entities: {}, - ids: [], - status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, - }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, - }); + expect(notesReducer(initalEmptyState, { type: 'unknown' })).toEqual(initalEmptyState); }); describe('fetchNotesByDocumentIds', () => { - it('should set correct status state when fetching notes by document id', () => { + it('should set correct status state when fetching notes by document ids', () => { const action = { type: fetchNotesByDocumentIds.pending.type }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Loading, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct state when success on fetch notes by document id on an empty state', () => { + it('should set correct state when success on fetch notes by document ids on an empty state', () => { const action = { type: fetchNotesByDocumentIds.fulfilled.type, payload: { entities: { notes: { - [mockNote.noteId]: mockNote, + [mockNote1.noteId]: mockNote1, }, }, - result: [mockNote.noteId], + result: [mockNote1.noteId], }, }; expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, entities: action.payload.entities.notes, ids: action.payload.result, status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Succeeded, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should replace notes when success on fetch notes by document id on a non-empty state', () => { - const newMockNote = { ...mockNote, timelineId: 'timelineId' }; + it('should replace notes when success on fetch notes by document ids on a non-empty state', () => { + const newMockNote = { ...mockNote1, timelineId: 'timelineId' }; const action = { type: fetchNotesByDocumentIds.fulfilled.type, payload: { @@ -125,173 +149,336 @@ describe('notesSlice', () => { }; expect(notesReducer(initialNonEmptyState, action)).toEqual({ - entities: action.payload.entities.notes, - ids: action.payload.result, + ...initalEmptyState, + entities: { + [newMockNote.noteId]: newMockNote, + [mockNote2.noteId]: mockNote2, + }, + ids: [newMockNote.noteId, mockNote2.noteId], status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Succeeded, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct error state when failing to fetch notes by document id', () => { + it('should set correct error state when failing to fetch notes by document ids', () => { const action = { type: fetchNotesByDocumentIds.rejected.type, error: 'error' }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Failed, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, error: { + ...initalEmptyState.error, fetchNotesByDocumentIds: 'error', - createNote: null, - deleteNote: null, }, }); }); }); describe('createNote', () => { - it('should set correct status state when creating a note by document id', () => { + it('should set correct status state when creating a note', () => { const action = { type: createNote.pending.type }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, + ...initalEmptyState.status, createNote: ReqStatus.Loading, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct state when success on create a note by document id on an empty state', () => { + it('should set correct state when success on create a note on an empty state', () => { const action = { type: createNote.fulfilled.type, payload: { entities: { notes: { - [mockNote.noteId]: mockNote, + [mockNote1.noteId]: mockNote1, }, }, - result: mockNote.noteId, + result: mockNote1.noteId, }, }; expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, entities: action.payload.entities.notes, ids: [action.payload.result], status: { - fetchNotesByDocumentIds: ReqStatus.Idle, + ...initalEmptyState.status, createNote: ReqStatus.Succeeded, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct error state when failing to create a note by document id', () => { + it('should set correct error state when failing to create a note', () => { const action = { type: createNote.rejected.type, error: 'error' }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, + ...initalEmptyState.status, createNote: ReqStatus.Failed, - deleteNote: ReqStatus.Idle, }, error: { - fetchNotesByDocumentIds: null, + ...initalEmptyState.error, createNote: 'error', - deleteNote: null, }, }); }); }); - describe('deleteNote', () => { - it('should set correct status state when deleting a note', () => { - const action = { type: deleteNote.pending.type }; + describe('deleteNotes', () => { + it('should set correct status state when deleting notes', () => { + const action = { type: deleteNotes.pending.type }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Loading, + ...initalEmptyState.status, + deleteNotes: ReqStatus.Loading, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct state when success on deleting a note', () => { + it('should set correct state when success on deleting notes', () => { const action = { - type: deleteNote.fulfilled.type, - payload: mockNote.noteId, + type: deleteNotes.fulfilled.type, + payload: [mockNote1.noteId], + }; + const state = { + ...initialNonEmptyState, + pendingDeleteIds: [mockNote1.noteId], }; - expect(notesReducer(initialNonEmptyState, action)).toEqual({ - entities: {}, - ids: [], + expect(notesReducer(state, action)).toEqual({ + ...initialNonEmptyState, + entities: { + [mockNote2.noteId]: mockNote2, + }, + ids: [mockNote2.noteId], status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Succeeded, + ...initialNonEmptyState.status, + deleteNotes: ReqStatus.Succeeded, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, + pendingDeleteIds: [], }); }); - it('should set correct state when failing to create a note by document id', () => { - const action = { type: deleteNote.rejected.type, error: 'error' }; + it('should set correct state when failing to delete notes', () => { + const action = { type: deleteNotes.rejected.type, error: 'error' }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Failed, + ...initalEmptyState.status, + deleteNotes: ReqStatus.Failed, }, error: { - fetchNotesByDocumentIds: null, - createNote: null, - deleteNote: 'error', + ...initalEmptyState.error, + deleteNotes: 'error', + }, + }); + }); + + it('should set correct status when fetching notes', () => { + const action = { type: fetchNotes.pending.type }; + expect(notesReducer(initialNotesState, action)).toEqual({ + ...initialNotesState, + status: { + ...initialNotesState.status, + fetchNotes: ReqStatus.Loading, + }, + }); + }); + + it('should set notes and update pagination when fetch is successful', () => { + const action = { + type: fetchNotes.fulfilled.type, + payload: { + entities: { + notes: { [mockNote2.noteId]: mockNote2, '2': { ...mockNote2, noteId: '2' } }, + }, + totalCount: 2, + }, + }; + const state = notesReducer(initialNotesState, action); + expect(state.entities).toEqual(action.payload.entities.notes); + expect(state.ids).toHaveLength(2); + expect(state.pagination.total).toBe(2); + expect(state.status.fetchNotes).toBe(ReqStatus.Succeeded); + }); + + it('should set error when fetch fails', () => { + const action = { type: fetchNotes.rejected.type, error: { message: 'Failed to fetch' } }; + const state = notesReducer(initialNotesState, action); + expect(state.status.fetchNotes).toBe(ReqStatus.Failed); + expect(state.error.fetchNotes).toEqual({ message: 'Failed to fetch' }); + }); + + it('should set correct status when deleting multiple notes', () => { + const action = { type: deleteNotes.pending.type }; + expect(notesReducer(initialNotesState, action)).toEqual({ + ...initialNotesState, + status: { + ...initialNotesState.status, + deleteNotes: ReqStatus.Loading, + }, + }); + }); + + it('should remove multiple notes when delete is successful', () => { + const initialState = { + ...initialNotesState, + entities: { '1': mockNote1, '2': { ...mockNote2, noteId: '2' } }, + ids: ['1', '2'], + }; + const action = { type: deleteNotes.fulfilled.type, payload: ['1', '2'] }; + const state = notesReducer(initialState, action); + expect(state.entities).toEqual({}); + expect(state.ids).toHaveLength(0); + expect(state.status.deleteNotes).toBe(ReqStatus.Succeeded); + }); + + it('should set error when delete fails', () => { + const action = { type: deleteNotes.rejected.type, error: { message: 'Failed to delete' } }; + const state = notesReducer(initialNotesState, action); + expect(state.status.deleteNotes).toBe(ReqStatus.Failed); + expect(state.error.deleteNotes).toEqual({ message: 'Failed to delete' }); + }); + }); + + describe('userSelectedPage', () => { + it('should set correct value for the selected page', () => { + const action = { type: userSelectedPage.type, payload: 2 }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pagination: { + ...initalEmptyState.pagination, + page: 2, + }, + }); + }); + }); + + describe('userSelectedPerPage', () => { + it('should set correct value for number of notes per page', () => { + const action = { type: userSelectedPerPage.type, payload: 25 }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pagination: { + ...initalEmptyState.pagination, + perPage: 25, + }, + }); + }); + }); + + describe('userSortedNotes', () => { + it('should set correct value for sorting notes', () => { + const action = { type: userSortedNotes.type, payload: { field: 'note', direction: 'asc' } }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + sort: { + field: 'note', + direction: 'asc', }, }); }); }); + + describe('userFilteredNotes', () => { + it('should set correct value to filter notes', () => { + const action = { type: userFilteredNotes.type, payload: 'abc' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + filter: 'abc', + }); + }); + }); + + describe('userSearchedNotes', () => { + it('should set correct value to search notes', () => { + const action = { type: userSearchedNotes.type, payload: 'abc' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + search: 'abc', + }); + }); + }); + + describe('userSelectedRow', () => { + it('should set correct ids for selected rows', () => { + const action = { type: userSelectedRow.type, payload: ['1'] }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + selectedIds: ['1'], + }); + }); + }); + + describe('userClosedDeleteModal', () => { + it('should reset pendingDeleteIds when closing modal', () => { + const action = { type: userClosedDeleteModal.type }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pendingDeleteIds: [], + }); + }); + }); + + describe('userSelectedRowForDeletion', () => { + it('should set correct id when user selects a row', () => { + const action = { type: userSelectedRowForDeletion.type, payload: '1' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pendingDeleteIds: ['1'], + }); + }); + }); + + describe('userSelectedBulkDelete', () => { + it('should update pendingDeleteIds when user chooses bulk delete', () => { + const action = { type: userSelectedBulkDelete.type }; + const state = { + ...initalEmptyState, + selectedIds: ['1'], + }; + + expect(notesReducer(state, action)).toEqual({ + ...state, + pendingDeleteIds: ['1'], + }); + }); + }); }); describe('selectors', () => { it('should return all notes', () => { - const state = mockGlobalState; - state.notes.entities = initialNonEmptyState.entities; - state.notes.ids = initialNonEmptyState.ids; - expect(selectAllNotes(state)).toEqual([mockNote]); + expect(selectAllNotes(mockGlobalState)).toEqual( + Object.values(mockGlobalState.notes.entities) + ); }); it('should return note by id', () => { - const state = mockGlobalState; - state.notes.entities = initialNonEmptyState.entities; - state.notes.ids = initialNonEmptyState.ids; - expect(selectNoteById(state, mockNote.noteId)).toEqual(mockNote); + expect(selectNoteById(mockGlobalState, '1')).toEqual(mockGlobalState.notes.entities['1']); }); it('should return note ids', () => { - const state = mockGlobalState; - state.notes.entities = initialNonEmptyState.entities; - state.notes.ids = initialNonEmptyState.ids; - expect(selectNoteIds(state)).toEqual([mockNote.noteId]); + expect(selectNoteIds(mockGlobalState)).toEqual(['1']); }); it('should return fetch notes by document id status', () => { @@ -311,19 +498,110 @@ describe('notesSlice', () => { }); it('should return delete note status', () => { - expect(selectDeleteNoteStatus(mockGlobalState)).toEqual(ReqStatus.Idle); + expect(selectDeleteNotesStatus(mockGlobalState)).toEqual(ReqStatus.Idle); }); it('should return delete note error', () => { - expect(selectDeleteNoteError(mockGlobalState)).toEqual(null); + expect(selectDeleteNotesError(mockGlobalState)).toEqual(null); }); it('should return all notes for an existing document id', () => { - expect(selectNotesByDocumentId(mockGlobalState, '1')).toEqual([mockNote]); + expect(selectNotesByDocumentId(mockGlobalState, '1')).toEqual([ + mockGlobalState.notes.entities['1'], + ]); }); it('should return no notes if document id does not exist', () => { - expect(selectNotesByDocumentId(mockGlobalState, '2')).toHaveLength(0); + expect(selectNotesByDocumentId(mockGlobalState, 'wrong-document-id')).toHaveLength(0); + }); + + it('should select notes pagination', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, pagination: { page: 2, perPage: 20, total: 100 } }, + }; + expect(selectNotesPagination(state)).toEqual({ page: 2, perPage: 20, total: 100 }); + }); + + it('should select notes table sort', () => { + const notes: NotesState = { + ...initialNotesState, + sort: { field: 'created', direction: 'asc' }, + }; + const state = { + ...mockGlobalState, + notes, + }; + expect(selectNotesTableSort(state)).toEqual({ field: 'created', direction: 'asc' }); + }); + + it('should select notes table total items', () => { + const state = { + ...mockGlobalState, + notes: { + ...initialNotesState, + pagination: { ...initialNotesState.pagination, total: 100 }, + }, + }; + expect(selectNotesPagination(state).total).toBe(100); + }); + + it('should select notes table selected ids', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, selectedIds: ['1', '2'] }, + }; + expect(selectNotesTableSelectedIds(state)).toEqual(['1', '2']); + }); + + it('should select notes table search', () => { + const state = { ...mockGlobalState, notes: { ...initialNotesState, search: 'test search' } }; + expect(selectNotesTableSearch(state)).toBe('test search'); + }); + + it('should select notes table pending delete ids', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, pendingDeleteIds: ['1', '2'] }, + }; + expect(selectNotesTablePendingDeleteIds(state)).toEqual(['1', '2']); + }); + + it('should select delete notes status', () => { + const state = { + ...mockGlobalState, + notes: { + ...initialNotesState, + status: { ...initialNotesState.status, deleteNotes: ReqStatus.Loading }, + }, + }; + expect(selectDeleteNotesStatus(state)).toBe(ReqStatus.Loading); + }); + + it('should select fetch notes error', () => { + const error = new Error('Error fetching notes'); + const reudxToolKItError = miniSerializeError(error); + const notes: NotesState = { + ...initialNotesState, + error: { ...initialNotesState.error, fetchNotes: reudxToolKItError }, + }; + const state = { + ...mockGlobalState, + notes, + }; + const selectedError = selectFetchNotesError(state) as SerializedError; + expect(selectedError.message).toBe('Error fetching notes'); + }); + + it('should select fetch notes status', () => { + const state = { + ...mockGlobalState, + notes: { + ...initialNotesState, + status: { ...initialNotesState.status, fetchNotes: ReqStatus.Succeeded }, + }, + }; + expect(selectFetchNotesStatus(state)).toBe(ReqStatus.Succeeded); }); }); }); 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 303313d18030..3b59c8be957c 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 @@ -11,7 +11,8 @@ import { createSelector } from 'reselect'; import type { State } from '../../common/store'; import { createNote as createNoteApi, - deleteNote as deleteNoteApi, + deleteNotes as deleteNotesApi, + fetchNotes as fetchNotesApi, fetchNotesByDocumentIds as fetchNotesByDocumentIdsApi, } from '../api/api'; import type { NormalizedEntities, NormalizedEntity } from './normalize'; @@ -34,13 +35,28 @@ export interface NotesState extends EntityState<Note> { status: { fetchNotesByDocumentIds: ReqStatus; createNote: ReqStatus; - deleteNote: ReqStatus; + deleteNotes: ReqStatus; + fetchNotes: ReqStatus; }; error: { fetchNotesByDocumentIds: SerializedError | HttpError | null; createNote: SerializedError | HttpError | null; - deleteNote: SerializedError | HttpError | null; + deleteNotes: SerializedError | HttpError | null; + fetchNotes: SerializedError | HttpError | null; }; + pagination: { + page: number; + perPage: number; + total: number; + }; + sort: { + field: keyof Note; + direction: 'asc' | 'desc'; + }; + filter: string; + search: string; + selectedIds: string[]; + pendingDeleteIds: string[]; } const notesAdapter = createEntityAdapter<Note>({ @@ -51,13 +67,28 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ status: { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, createNote: null, - deleteNote: null, + deleteNotes: null, + fetchNotes: null, + }, + pagination: { + page: 1, + perPage: 10, + total: 0, }, + sort: { + field: 'created', + direction: 'desc', + }, + filter: '', + search: '', + selectedIds: [], + pendingDeleteIds: [], }); export const fetchNotesByDocumentIds = createAsyncThunk< @@ -70,6 +101,23 @@ export const fetchNotesByDocumentIds = createAsyncThunk< return normalizeEntities(res.notes); }); +export const fetchNotes = createAsyncThunk< + NormalizedEntities<Note> & { totalCount: number }, + { + page: number; + perPage: number; + sortField: string; + sortOrder: string; + filter: string; + search: string; + }, + {} +>('notes/fetchNotes', async (args) => { + const { page, perPage, sortField, sortOrder, filter, search } = args; + const res = await fetchNotesApi({ page, perPage, sortField, sortOrder, filter, search }); + return { ...normalizeEntities(res.notes), totalCount: res.totalCount }; +}); + export const createNote = createAsyncThunk<NormalizedEntity<Note>, { note: BareNote }, {}>( 'notes/createNote', async (args) => { @@ -79,19 +127,64 @@ export const createNote = createAsyncThunk<NormalizedEntity<Note>, { note: BareN } ); -export const deleteNote = createAsyncThunk<string, { id: string }, {}>( - 'notes/deleteNote', - async (args) => { - const { id } = args; - await deleteNoteApi(id); - return id; +export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?: boolean }, {}>( + 'notes/deleteNotes', + async (args, { dispatch, getState }) => { + const { ids, refetch } = args; + await deleteNotesApi(ids); + if (refetch) { + const state = getState() as State; + const { search, pagination, sort } = state.notes; + dispatch( + fetchNotes({ + page: pagination.page, + perPage: pagination.perPage, + sortField: sort.field, + sortOrder: sort.direction, + filter: '', + search, + }) + ); + } + return ids; } ); const notesSlice = createSlice({ name: 'notes', initialState: initialNotesState, - reducers: {}, + reducers: { + userSelectedPage: (state, action: { payload: number }) => { + state.pagination.page = action.payload; + }, + userSelectedPerPage: (state, action: { payload: number }) => { + state.pagination.perPage = action.payload; + }, + userSortedNotes: ( + state, + action: { payload: { field: keyof Note; direction: 'asc' | 'desc' } } + ) => { + state.sort = action.payload; + }, + userFilteredNotes: (state, action: { payload: string }) => { + state.filter = action.payload; + }, + userSearchedNotes: (state, action: { payload: string }) => { + state.search = action.payload; + }, + userSelectedRow: (state, action: { payload: string[] }) => { + state.selectedIds = action.payload; + }, + userClosedDeleteModal: (state) => { + state.pendingDeleteIds = []; + }, + userSelectedRowForDeletion: (state, action: { payload: string }) => { + state.pendingDeleteIds = [action.payload]; + }, + userSelectedBulkDelete: (state) => { + state.pendingDeleteIds = state.selectedIds; + }, + }, extraReducers(builder) { builder .addCase(fetchNotesByDocumentIds.pending, (state) => { @@ -116,16 +209,32 @@ const notesSlice = createSlice({ state.status.createNote = ReqStatus.Failed; state.error.createNote = action.payload ?? action.error; }) - .addCase(deleteNote.pending, (state) => { - state.status.deleteNote = ReqStatus.Loading; + .addCase(deleteNotes.pending, (state) => { + state.status.deleteNotes = ReqStatus.Loading; + }) + .addCase(deleteNotes.fulfilled, (state, action) => { + notesAdapter.removeMany(state, action.payload); + state.status.deleteNotes = ReqStatus.Succeeded; + state.pendingDeleteIds = state.pendingDeleteIds.filter( + (value) => !action.payload.includes(value) + ); }) - .addCase(deleteNote.fulfilled, (state, action) => { - notesAdapter.removeOne(state, action.payload); - state.status.deleteNote = ReqStatus.Succeeded; + .addCase(deleteNotes.rejected, (state, action) => { + state.status.deleteNotes = ReqStatus.Failed; + state.error.deleteNotes = action.payload ?? action.error; }) - .addCase(deleteNote.rejected, (state, action) => { - state.status.deleteNote = ReqStatus.Failed; - state.error.deleteNote = action.payload ?? action.error; + .addCase(fetchNotes.pending, (state) => { + state.status.fetchNotes = ReqStatus.Loading; + }) + .addCase(fetchNotes.fulfilled, (state, action) => { + notesAdapter.setAll(state, action.payload.entities.notes); + state.pagination.total = action.payload.totalCount; + state.status.fetchNotes = ReqStatus.Succeeded; + state.selectedIds = []; + }) + .addCase(fetchNotes.rejected, (state, action) => { + state.status.fetchNotes = ReqStatus.Failed; + state.error.fetchNotes = action.payload ?? action.error; }); }, }); @@ -148,11 +257,37 @@ export const selectCreateNoteStatus = (state: State) => state.notes.status.creat export const selectCreateNoteError = (state: State) => state.notes.error.createNote; -export const selectDeleteNoteStatus = (state: State) => state.notes.status.deleteNote; +export const selectDeleteNotesStatus = (state: State) => state.notes.status.deleteNotes; + +export const selectDeleteNotesError = (state: State) => state.notes.error.deleteNotes; + +export const selectNotesPagination = (state: State) => state.notes.pagination; + +export const selectNotesTableSort = (state: State) => state.notes.sort; + +export const selectNotesTableSelectedIds = (state: State) => state.notes.selectedIds; -export const selectDeleteNoteError = (state: State) => state.notes.error.deleteNote; +export const selectNotesTableSearch = (state: State) => state.notes.search; + +export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds; + +export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes; + +export const selectFetchNotesStatus = (state: State) => state.notes.status.fetchNotes; export const selectNotesByDocumentId = createSelector( [selectAllNotes, (state, documentId) => documentId], (notes, documentId) => notes.filter((note) => note.eventId === documentId) ); + +export const { + userSelectedPage, + userSelectedPerPage, + userSortedNotes, + userFilteredNotes, + userSearchedNotes, + userSelectedRow, + userClosedDeleteModal, + userSelectedRowForDeletion, + userSelectedBulkDelete, +} = notesSlice.actions; diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx index ecd2ba46560f..f215a790414d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx @@ -8,9 +8,10 @@ import { findIndex } from 'lodash/fp'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { FieldCategory } from '@kbn/timelines-plugin/common/search_strategy'; import { DataProviderType } from '../../../../common/api/timeline'; -import type { BrowserField, BrowserFields } from '../../../common/containers/source'; +import type { BrowserFields } from '../../../common/containers/source'; import { getAllFieldsByName } from '../../../common/containers/source'; import type { QueryOperator } from '../timeline/data_providers/data_provider'; import { @@ -46,7 +47,7 @@ export const operatorLabels: EuiComboBoxOptionOption[] = [ export const EMPTY_ARRAY_RESULT = []; /** Returns the names of fields in a category */ -export const getFieldNames = (category: Partial<BrowserField>): string[] => +export const getFieldNames = (category: FieldCategory): string[] => category.fields != null && Object.keys(category.fields).length > 0 ? Object.keys(category.fields) : EMPTY_ARRAY_RESULT; diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx index 097d2d256a2c..4b1824130c70 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx @@ -12,6 +12,9 @@ import { TimelineId } from '../../../../../common/types'; import { timelineActions } from '../../../store'; import { defaultHeaders } from '../../timeline/body/column_headers/default_headers'; import { TestProviders } from '../../../../common/mock'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { RowRendererId } from '../../../../../common/api/timeline'; +import { defaultUdtHeaders } from '../../timeline/unified_components/default_headers'; jest.mock('../../../../common/components/discover_in_timeline/use_discover_in_timeline_context'); jest.mock('../../../../common/hooks/use_selector'); @@ -70,6 +73,28 @@ describe('NewTimelineButton', () => { show: true, timelineType: 'default', updated: undefined, + excludedRowRendererIds: [], + }); + }); + + // enable unified components in timeline + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + + getByTestId('timeline-modal-new-timeline-dropdown-button').click(); + getByTestId('timeline-modal-new-timeline').click(); + + spy.mockClear(); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + columns: defaultUdtHeaders, + dataViewId, + id: TimelineId.test, + indexNames: selectedPatterns, + show: true, + timelineType: 'default', + updated: undefined, + excludedRowRendererIds: [...Object.keys(RowRendererId)], }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx index e1bc119235e2..7a7e3ce6061c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx @@ -64,6 +64,7 @@ describe('NewTimelineButton', () => { show: true, timelineType: TimelineType.default, updated: undefined, + excludedRowRendererIds: [], }); }); }); @@ -93,6 +94,7 @@ describe('NewTimelineButton', () => { show: true, timelineType: TimelineType.template, updated: undefined, + excludedRowRendererIds: [], }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx index 0a7ed36a8495..f5006589310c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx @@ -13,6 +13,7 @@ import { NoteCards } from '.'; import { TimelineStatus } from '../../../../../common/api/timeline'; import { TestProviders } from '../../../../common/mock'; import type { TimelineResultNote } from '../../open_timeline/types'; +import { TimelineId } from '../../../../../common/types'; const getNotesByIds = () => ({ abc: { @@ -60,6 +61,7 @@ describe('NoteCards', () => { status: TimelineStatus.active, toggleShowAddNote: jest.fn(), updateNote: jest.fn(), + timelineId: TimelineId.test, }; test('it renders the notes column when notes are specified', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 3616e4352cd8..656c80384fe8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -41,24 +41,37 @@ const NotesContainer = styled(EuiFlexGroup)` `; NotesContainer.displayName = 'NotesContainer'; -interface Props { +export interface NoteCardsProps { ariaRowindex: number; associateNote: AssociateNote; className?: string; notes: TimelineResultNote[]; showAddNote: boolean; - toggleShowAddNote: (eventId?: string) => void; + toggleShowAddNote?: (eventId?: string) => void; eventId?: string; + timelineId: string; + onCancel?: () => void; } /** A view for entering and reviewing notes */ -export const NoteCards = React.memo<Props>( - ({ ariaRowindex, associateNote, className, notes, showAddNote, toggleShowAddNote, eventId }) => { +export const NoteCards = React.memo<NoteCardsProps>( + ({ + ariaRowindex, + associateNote, + className, + notes, + showAddNote, + toggleShowAddNote, + eventId, + timelineId, + onCancel, + }) => { const [newNote, setNewNote] = useState(''); const associateNoteAndToggleShow = useCallback( (noteId: string) => { associateNote(noteId); + if (!toggleShowAddNote) return; if (eventId != null) { toggleShowAddNote(eventId); } else { @@ -69,12 +82,14 @@ export const NoteCards = React.memo<Props>( ); const onCancelAddNote = useCallback(() => { + onCancel?.(); + if (!toggleShowAddNote) return; if (eventId != null) { toggleShowAddNote(eventId); } else { toggleShowAddNote(); } - }, [eventId, toggleShowAddNote]); + }, [eventId, toggleShowAddNote, onCancel]); return ( <NoteCardsCompContainer @@ -94,7 +109,7 @@ export const NoteCards = React.memo<Props>( <EuiScreenReaderOnly data-test-subj="screenReaderOnly"> <p>{i18n.YOU_ARE_VIEWING_NOTES(ariaRowindex)}</p> </EuiScreenReaderOnly> - <NotePreviews notes={notes} /> + <NotePreviews timelineId={timelineId} notes={notes} /> </NotesContainer> </NotePreviewsContainer> ) : null} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 67d0c5a9e459..073e9c486ac6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -25,7 +25,7 @@ export const useEditTimelineBatchActions = ({ }: { deleteTimelines?: DeleteTimelines; selectedItems?: OpenTimelineResult[]; - tableRef: React.MutableRefObject<EuiBasicTable<OpenTimelineResult> | undefined>; + tableRef: React.MutableRefObject<EuiBasicTable<OpenTimelineResult> | null>; timelineType: TimelineType | null; }) => { const { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 835862c04ced..56d3937c301d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -358,6 +358,9 @@ export const useQueryTimelineById = () => { show: openTimeline, initialized: true, savedSearchId: savedSearchId ?? null, + excludedRowRendererIds: unifiedComponentsInTimelineEnabled + ? timelineDefaults.excludedRowRendererIds + : [], }, }); resetDiscoverAppState(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index aa80df5a33ba..8bab2e7dbe7a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -57,6 +57,7 @@ import { useSourcererDataView } from '../../../sourcerer/containers'; import { useStartTransaction } from '../../../common/lib/apm/use_start_transaction'; import { TIMELINE_ACTIONS } from '../../../common/lib/apm/user_actions'; import { defaultUdtHeaders } from '../timeline/unified_components/default_headers'; +import { timelineDefaults } from '../../store/defaults'; interface OwnProps<TCache = object> { /** Displays open timeline in modal */ @@ -69,7 +70,7 @@ interface OwnProps<TCache = object> { export type OpenTimelineOwnProps = OwnProps & Pick< OpenTimelineProps, - 'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' + 'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' | 'tabName' >; /** Returns a collection of selected timeline ids */ @@ -130,6 +131,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>( importDataModalToggle, onOpenTimeline, setImportDataModalToggle, + tabName, title, }) => { const dispatch = useDispatch(); @@ -254,6 +256,9 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>( dataViewId, indexNames: selectedPatterns, show: false, + excludedRowRendererIds: unifiedComponentsInTimelineEnabled + ? timelineDefaults.excludedRowRendererIds + : [], }) ); } @@ -305,12 +310,16 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>( /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ const onTableChange: OnTableChange = useCallback(({ page, sort }: OnTableChangeParams) => { - const { index, size } = page; - const { field, direction } = sort; - setPageIndex(index); - setPageSize(size); - setSortDirection(direction); - setSortField(field); + if (page != null) { + const { index, size } = page; + setPageIndex(index); + setPageSize(size); + } + if (sort != null) { + const { field, direction } = sort; + setSortDirection(direction); + setSortField(field); + } }, []); /** Invoked when the user toggles the option to only view favorite timelines */ @@ -414,6 +423,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} + tabName={tabName} templateTimelineFilter={templateTimelineFilter} timelineType={timelineType} timelineStatus={timelineStatus} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 28e42b3aa202..3dc686229e4f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { EuiBasicTable } from '@elastic/eui'; import React, { useCallback, useMemo, useRef } from 'react'; +import type { EuiBasicTable } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import styled from 'styled-components'; @@ -29,7 +29,8 @@ import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import * as i18n from './translations'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import type { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types'; +import type { OpenTimelineProps, ActionTimelineToShow, OpenTimelineResult } from './types'; +import { NoteManagementPage } from '../../../notes'; const QueryText = styled.span` white-space: normal; @@ -63,13 +64,13 @@ export const OpenTimeline = React.memo<OpenTimelineProps>( sortDirection, setImportDataModalToggle, sortField, + tabName, timelineType = TimelineType.default, timelineStatus, timelineFilter, templateTimelineFilter, totalSearchResultsCount, }) => { - const tableRef = useRef<EuiBasicTable<OpenTimelineResult>>(); const { actionItem, enableExportTimelineDownloader, @@ -78,7 +79,7 @@ export const OpenTimeline = React.memo<OpenTimelineProps>( onOpenDeleteTimelineModal, onCompleteEditTimelineAction, } = useEditTimelineActions(); - + const tableRef = useRef<EuiBasicTable<OpenTimelineResult> | null>(null); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const { getBatchItemsPopoverContent } = useEditTimelineBatchActions({ deleteTimelines: kibanaSecuritySolutionsPrivileges.crud ? deleteTimelines : undefined, @@ -227,84 +228,92 @@ export const OpenTimeline = React.memo<OpenTimelineProps>( <div data-test-subj="timelines-page-container" className={OPEN_TIMELINE_CLASS_NAME}> {!!timelineFilter && timelineFilter} - <SearchRow - data-test-subj="search-row" - favoriteCount={favoriteCount} - onlyFavorites={onlyFavorites} - onQueryChange={onQueryChange} - onToggleOnlyFavorites={onToggleOnlyFavorites} - query={query} - timelineType={timelineType} - > - {SearchRowContent} - </SearchRow> + {tabName !== 'notes' ? ( + <> + <SearchRow + data-test-subj="search-row" + favoriteCount={favoriteCount} + onlyFavorites={onlyFavorites} + onQueryChange={onQueryChange} + onToggleOnlyFavorites={onToggleOnlyFavorites} + query={query} + timelineType={timelineType} + > + {SearchRowContent} + </SearchRow> - <UtilityBar border> - <UtilityBarSection> - <UtilityBarGroup> - <UtilityBarText data-test-subj="query-message"> - <> - {i18n.SHOWING}{' '} - {timelineType === TimelineType.template ? nTemplates : nTimelines} - </> - </UtilityBarText> - </UtilityBarGroup> - <UtilityBarGroup> - {timelineStatus !== TimelineStatus.immutable && ( - <> - <UtilityBarText data-test-subj="selected-count"> - {timelineType === TimelineType.template - ? i18n.SELECTED_TEMPLATES(selectedItems.length) - : i18n.SELECTED_TIMELINES(selectedItems.length)} + <UtilityBar border> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText data-test-subj="query-message"> + <> + {i18n.SHOWING}{' '} + {timelineType === TimelineType.template ? nTemplates : nTimelines} + </> </UtilityBarText> + </UtilityBarGroup> + <UtilityBarGroup> + {timelineStatus !== TimelineStatus.immutable && ( + <> + <UtilityBarText data-test-subj="selected-count"> + {timelineType === TimelineType.template + ? i18n.SELECTED_TEMPLATES(selectedItems.length) + : i18n.SELECTED_TIMELINES(selectedItems.length)} + </UtilityBarText> + <UtilityBarAction + dataTestSubj="batchActions" + iconSide="right" + iconType="arrowDown" + popoverContent={getBatchItemsPopoverContent} + data-test-subj="utility-bar-action" + > + <span data-test-subj="utility-bar-action-button"> + {i18n.BATCH_ACTIONS} + </span> + </UtilityBarAction> + </> + )} <UtilityBarAction - dataTestSubj="batchActions" + dataTestSubj="refreshButton" iconSide="right" - iconType="arrowDown" - popoverContent={getBatchItemsPopoverContent} - data-test-subj="utility-bar-action" + iconType="refresh" + onClick={onRefreshBtnClick} > - <span data-test-subj="utility-bar-action-button">{i18n.BATCH_ACTIONS}</span> + {i18n.REFRESH} </UtilityBarAction> - </> - )} - <UtilityBarAction - dataTestSubj="refreshButton" - iconSide="right" - iconType="refresh" - onClick={onRefreshBtnClick} - > - {i18n.REFRESH} - </UtilityBarAction> - </UtilityBarGroup> - </UtilityBarSection> - </UtilityBar> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> - <TimelinesTable - actionTimelineToShow={actionTimelineToShow} - data-test-subj="timelines-table" - deleteTimelines={deleteTimelines} - defaultPageSize={defaultPageSize} - loading={isLoading} - itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} - enableExportTimelineDownloader={enableExportTimelineDownloader} - onCreateRule={onCreateRule} - onCreateRuleFromEql={onCreateRuleFromEql} - onOpenDeleteTimelineModal={onOpenDeleteTimelineModal} - onOpenTimeline={onOpenTimeline} - onSelectionChange={onSelectionChange} - onTableChange={onTableChange} - onToggleShowNotes={onToggleShowNotes} - pageIndex={pageIndex} - pageSize={pageSize} - searchResults={searchResults} - showExtendedColumns={true} - sortDirection={sortDirection} - sortField={sortField} - timelineType={timelineType} - tableRef={tableRef} - totalSearchResultsCount={totalSearchResultsCount} - /> + <TimelinesTable + actionTimelineToShow={actionTimelineToShow} + data-test-subj="timelines-table" + deleteTimelines={deleteTimelines} + defaultPageSize={defaultPageSize} + loading={isLoading} + itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + enableExportTimelineDownloader={enableExportTimelineDownloader} + onCreateRule={onCreateRule} + onCreateRuleFromEql={onCreateRuleFromEql} + onOpenDeleteTimelineModal={onOpenDeleteTimelineModal} + onOpenTimeline={onOpenTimeline} + onSelectionChange={onSelectionChange} + onTableChange={onTableChange} + onToggleShowNotes={onToggleShowNotes} + pageIndex={pageIndex} + pageSize={pageSize} + searchResults={searchResults} + showExtendedColumns={true} + sortDirection={sortDirection} + sortField={sortField} + timelineType={timelineType} + totalSearchResultsCount={totalSearchResultsCount} + tableRef={tableRef} + /> + </> + ) : ( + <NoteManagementPage /> + )} </div> </> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index 6e012c65478c..7eb3c65f427a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -6,10 +6,11 @@ */ import { EuiModalBody, EuiModalHeader, EuiSpacer } from '@elastic/eui'; -import React, { Fragment, memo, useMemo } from 'react'; +import type { EuiBasicTable } from '@elastic/eui'; +import React, { Fragment, memo, useMemo, useRef } from 'react'; import styled from 'styled-components'; -import type { OpenTimelineProps, ActionTimelineToShow } from '../types'; +import type { OpenTimelineProps, ActionTimelineToShow, OpenTimelineResult } from '../types'; import { SearchRow } from '../search_row'; import { TimelinesTable } from '../timelines_table'; import { TitleRow } from '../title_row'; @@ -49,6 +50,8 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>( title, totalSearchResultsCount, }) => { + const tableRef = useRef<EuiBasicTable<OpenTimelineResult> | null>(null); + const actionsToShow = useMemo(() => { const actions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; @@ -118,6 +121,7 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>( sortField={sortField} timelineType={timelineType} totalSearchResultsCount={totalSearchResultsCount} + tableRef={tableRef} /> </> </EuiModalBody> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx index 9c58b30fbfc5..a5eab754a729 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { ICON_TYPES, EuiTableActionsColumnType } from '@elastic/eui'; import type { ActionTimelineToShow, DeleteTimelines, @@ -13,10 +14,11 @@ import type { OnOpenTimeline, OpenTimelineResult, OnOpenDeleteTimelineModal, - TimelineActionsOverflowColumns, } from '../types'; import * as i18n from '../translations'; import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; + +type Action = EuiTableActionsColumnType<object>['actions'][number]; /** * Returns the action columns (e.g. delete, open duplicate timeline) */ @@ -38,10 +40,10 @@ export const getActionsColumns = ({ onCreateRule?: OnCreateRuleFromTimeline; onCreateRuleFromEql?: OnCreateRuleFromTimeline; hasCrudAccess: boolean; -}): [TimelineActionsOverflowColumns] => { +}): Array<EuiTableActionsColumnType<object>> => { const createTimelineFromTemplate = { name: i18n.CREATE_TIMELINE_FROM_TEMPLATE, - icon: 'timeline', + icon: 'timeline' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -56,11 +58,11 @@ export const getActionsColumns = ({ 'data-test-subj': 'create-from-template', available: (item: OpenTimelineResult) => item.timelineType === TimelineType.template && actionTimelineToShow.includes('createFrom'), - }; + } as Action; const createTemplateFromTimeline = { name: i18n.CREATE_TEMPLATE_FROM_TIMELINE, - icon: 'visText', + icon: 'visText' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -75,11 +77,11 @@ export const getActionsColumns = ({ 'data-test-subj': 'create-template-from-timeline', available: (item: OpenTimelineResult) => item.timelineType !== TimelineType.template && actionTimelineToShow.includes('createFrom'), - }; + } as Action; const openAsDuplicateColumn = { name: i18n.OPEN_AS_DUPLICATE, - icon: 'copy', + icon: 'copy' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -92,11 +94,11 @@ export const getActionsColumns = ({ 'data-test-subj': 'open-duplicate', available: (item: OpenTimelineResult) => item.timelineType !== TimelineType.template && actionTimelineToShow.includes('duplicate'), - }; + } as Action; const openAsDuplicateTemplateColumn = { name: i18n.OPEN_AS_DUPLICATE_TEMPLATE, - icon: 'copy', + icon: 'copy' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -109,11 +111,12 @@ export const getActionsColumns = ({ 'data-test-subj': 'open-duplicate-template', available: (item: OpenTimelineResult) => item.timelineType === TimelineType.template && actionTimelineToShow.includes('duplicate'), - }; + } as Action; const exportTimelineAction = { name: i18n.EXPORT_SELECTED, - icon: 'exportAction', + icon: 'exportAction' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); }, @@ -123,11 +126,12 @@ export const getActionsColumns = ({ description: i18n.EXPORT_SELECTED, 'data-test-subj': 'export-timeline', available: () => actionTimelineToShow.includes('export'), - }; + } as Action; const deleteTimelineColumn = { name: i18n.DELETE_SELECTED, - icon: 'trash', + icon: 'trash' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); }, @@ -136,11 +140,12 @@ export const getActionsColumns = ({ description: i18n.DELETE_SELECTED, 'data-test-subj': 'delete-timeline', available: () => actionTimelineToShow.includes('delete') && deleteTimelines != null, - }; + } as Action; const createRuleFromTimeline = { name: i18n.CREATE_RULE_FROM_TIMELINE, - icon: 'indexEdit', + icon: 'indexEdit' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (onCreateRule != null && selectedTimeline.savedObjectId) onCreateRule(selectedTimeline.savedObjectId); @@ -156,11 +161,12 @@ export const getActionsColumns = ({ onCreateRule != null && queryType != null && queryType.hasQuery, - }; + } as Action; const createRuleFromTimelineCorrelation = { name: i18n.CREATE_RULE_FROM_TIMELINE_CORRELATION, - icon: 'indexEdit', + icon: 'indexEdit' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (onCreateRuleFromEql != null && selectedTimeline.savedObjectId) onCreateRuleFromEql(selectedTimeline.savedObjectId); @@ -176,7 +182,7 @@ export const getActionsColumns = ({ onCreateRuleFromEql != null && queryType != null && queryType.hasEql, - }; + } as Action; return [ { width: hasCrudAccess ? '80px' : '150px', diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx index a040434f0a7d..7c1e0a419683 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx @@ -6,6 +6,7 @@ */ import { EuiButtonIcon, EuiLink } from '@elastic/eui'; +import type { EuiBasicTableColumn, EuiTableDataType } from '@elastic/eui'; import { omit } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; @@ -42,8 +43,9 @@ export const getCommonColumns = ({ onToggleShowNotes: OnToggleShowNotes; itemIdToExpandedNotesRowMap: Record<string, JSX.Element>; timelineType: TimelineType | null; -}) => [ +}): Array<EuiBasicTableColumn<object>> => [ { + dataType: 'auto' as EuiTableDataType, isExpander: true, render: ({ notes, savedObjectId }: OpenTimelineResult) => notes != null && notes.length > 0 && savedObjectId != null ? ( @@ -64,7 +66,7 @@ export const getCommonColumns = ({ width: ACTION_COLUMN_WIDTH, }, { - dataType: 'string', + dataType: 'string' as EuiTableDataType, field: 'title', name: timelineType === TimelineType.default ? i18n.TIMELINE_NAME : i18n.TIMELINE_TEMPLATE_NAME, render: (title: string, timelineResult: OpenTimelineResult) => @@ -92,7 +94,7 @@ export const getCommonColumns = ({ sortable: false, }, { - dataType: 'string', + dataType: 'string' as EuiTableDataType, field: 'description', name: i18n.DESCRIPTION, render: (description: string) => ( @@ -103,7 +105,7 @@ export const getCommonColumns = ({ sortable: false, }, { - dataType: 'date', + dataType: 'date' as EuiTableDataType, field: 'updated', name: i18n.LAST_MODIFIED, render: (date: number, timelineResult: OpenTimelineResult) => ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx index 454ecce7bf2a..3451d260da4f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; - +import type { EuiTableDataType } from '@elastic/eui'; import { defaultToEmptyTag } from '../../../../common/components/empty_value'; import * as i18n from '../translations'; @@ -21,7 +21,7 @@ export const getExtendedColumns = (showExtendedColumns: boolean) => { return [ { - dataType: 'string', + dataType: 'string' as EuiTableDataType, field: 'updatedBy', name: i18n.MODIFIED_BY, render: (updatedBy: OpenTimelineResult['updatedBy']) => ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx index f43a713315d1..412ccd72c815 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -6,6 +6,7 @@ */ import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import type { EuiTableFieldDataColumnType, HorizontalAlignment } from '@elastic/eui'; import React from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; @@ -22,10 +23,10 @@ export const getIconHeaderColumns = ({ timelineType, }: { timelineType: TimelineTypeLiteralWithNull; -}) => { +}): Array<EuiTableFieldDataColumnType<object>> => { const columns = { note: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'eventIdToNoteIds', name: ( <EuiToolTip content={i18n.NOTES}> @@ -40,7 +41,7 @@ export const getIconHeaderColumns = ({ width: ACTION_COLUMN_WIDTH, }, pinnedEvent: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'pinnedEventIds', name: ( <EuiToolTip content={i18n.PINNED_EVENTS}> @@ -57,7 +58,7 @@ export const getIconHeaderColumns = ({ width: ACTION_COLUMN_WIDTH, }, favorite: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'favorite', name: ( <EuiToolTip content={i18n.FAVORITES}> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index b4841b68810f..1e49028326b5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui'; +import { EuiBasicTable } from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; import React, { useMemo } from 'react'; -import styled from 'styled-components'; +import { css } from '@emotion/react'; import * as i18n from '../translations'; import type { @@ -29,19 +30,6 @@ import { getIconHeaderColumns } from './icon_header_columns'; import type { TimelineTypeLiteralWithNull } from '../../../../../common/api/timeline'; import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; -// there are a number of type mismatches across this file -const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any - -const BasicTable = styled(EuiBasicTable)` - .euiTableCellContent { - animation: none; /* Prevents applying max-height from animation */ - } - - .euiTableRow-isExpandedRow .euiTableCellContent__text { - width: 100%; /* Fixes collapsing nested flex content in IE11 */ - } -`; -BasicTable.displayName = 'BasicTable'; /** * Returns the column definitions (passed as the `columns` prop to @@ -77,7 +65,7 @@ export const getTimelinesTableColumns = ({ showExtendedColumns: boolean; timelineType: TimelineTypeLiteralWithNull; hasCrudAccess: boolean; -}) => { +}): Array<EuiBasicTableColumn<object>> => { return [ ...getCommonColumns({ itemIdToExpandedNotesRowMap, @@ -123,8 +111,7 @@ export interface TimelinesTableProps { sortDirection: 'asc' | 'desc'; sortField: string; timelineType: TimelineTypeLiteralWithNull; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tableRef?: React.MutableRefObject<_EuiBasicTable<any> | undefined>; + tableRef: React.MutableRefObject<EuiBasicTable<OpenTimelineResult> | null>; totalSearchResultsCount: number; } @@ -157,33 +144,39 @@ export const TimelinesTable = React.memo<TimelinesTableProps>( timelineType, totalSearchResultsCount, }) => { - const pagination = { - showPerPageOptions: showExtendedColumns, - pageIndex, - pageSize, - pageSizeOptions: [ - Math.floor(Math.max(defaultPageSize, 1) / 2), - defaultPageSize, - defaultPageSize * 2, - ], - totalItemCount: totalSearchResultsCount, - }; + const pagination = useMemo(() => { + return { + showPerPageOptions: showExtendedColumns, + pageIndex, + pageSize, + pageSizeOptions: [ + Math.floor(Math.max(defaultPageSize, 1) / 2), + defaultPageSize, + defaultPageSize * 2, + ], + totalItemCount: totalSearchResultsCount, + }; + }, [defaultPageSize, pageIndex, pageSize, showExtendedColumns, totalSearchResultsCount]); - const sorting = { - sort: { - field: sortField as keyof OpenTimelineResult, - direction: sortDirection, - }, - }; + const sorting = useMemo(() => { + return { + sort: { + field: sortField as keyof OpenTimelineResult, + direction: sortDirection, + }, + }; + }, [sortField, sortDirection]); - const selection = { - selectable: (timelineResult: OpenTimelineResult) => - timelineResult.savedObjectId != null && timelineResult.status !== TimelineStatus.immutable, - selectableMessage: (selectable: boolean) => - !selectable ? i18n.MISSING_SAVED_OBJECT_ID : undefined, - onSelectionChange, - }; - const basicTableProps = tableRef != null ? { ref: tableRef } : {}; + const selection = useMemo(() => { + return { + selectable: (timelineResult: OpenTimelineResult) => + timelineResult.savedObjectId != null && + timelineResult.status !== TimelineStatus.immutable, + selectableMessage: (selectable: boolean) => + !selectable ? i18n.MISSING_SAVED_OBJECT_ID : '', + onSelectionChange, + }; + }, [onSelectionChange]); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const columns = useMemo( () => @@ -227,7 +220,7 @@ export const TimelinesTable = React.memo<TimelinesTableProps>( : i18n.ZERO_TIMELINES_MATCH; return ( - <BasicTable + <EuiBasicTable columns={columns} data-test-subj="timelines-table" itemId="savedObjectId" @@ -239,7 +232,16 @@ export const TimelinesTable = React.memo<TimelinesTableProps>( pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} - {...basicTableProps} + css={css` + .euiTableCellContent { + animation: none; /* Prevents applying max-height from animation */ + } + + .euiTableRow-isExpandedRow .euiTableCellContent__text { + width: 100%; /* Fixes collapsing nested flex content in IE11 */ + } + `} + ref={tableRef} /> ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts index 804d1625df84..075f4aca49f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts @@ -33,4 +33,5 @@ export const getMockTimelinesTableProps = ( sortField: DEFAULT_SORT_FIELD, timelineType: TimelineType.default, totalSearchResultsCount: mockOpenTimelineResults.length, + tableRef: { current: null }, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 1373870c0b8a..fd0fca18adc7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -6,6 +6,7 @@ */ import type React from 'react'; +import type { IconType } from '@elastic/eui'; import type { TimelineModel } from '../../store/model'; import type { RowRendererId, @@ -39,11 +40,11 @@ export interface TimelineActionsOverflowColumns { width: string; actions: Array<{ name: string; - icon?: string; + icon: IconType; onClick?: (timeline: OpenTimelineResult) => void; description: string; render?: (timeline: OpenTimelineResult) => JSX.Element; - } | null>; + }>; } /** The results of the query run by the OpenTimeline component */ @@ -117,11 +118,11 @@ export type OnToggleShowNotes = (itemIdToExpandedNotesRowMap: Record<string, JSX /** Parameters to the OnTableChange callback */ export interface OnTableChangeParams { - page: { + page?: { index: number; size: number; }; - sort: { + sort?: { field: string; direction: 'asc' | 'desc'; }; @@ -207,6 +208,7 @@ export interface OpenTimelineProps { totalSearchResultsCount: number; /** Hide action on timeline if needed it */ hideActions?: ActionTimelineToShow[]; + tabName?: string; } export interface ResolveTimelineConfig { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx index b7bb49c67bb6..e04ee0f43458 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx @@ -10,9 +10,12 @@ import { fireEvent, render } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react-hooks'; import type { UseTimelineTypesArgs, UseTimelineTypesResult } from './use_timeline_types'; import { useTimelineTypes } from './use_timeline_types'; +import { TestProviders } from '../../../common/mock'; jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); return { + ...original, useParams: jest.fn().mockReturnValue('default'), useHistory: jest.fn().mockReturnValue([]), }; @@ -50,7 +53,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(result.current).toEqual({ timelineType: 'default', @@ -66,7 +71,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(result.current.timelineTabs); @@ -84,7 +91,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(result.current.timelineTabs); @@ -110,7 +119,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(result.current.timelineTabs); @@ -138,7 +149,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(<>{result.current.timelineFilters}</>); @@ -156,7 +169,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(<>{result.current.timelineFilters}</>); @@ -182,7 +197,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(<>{result.current.timelineFilters}</>); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx index 5eefa23b0750..d8943b0f674e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx @@ -18,6 +18,7 @@ import * as i18n from './translations'; import type { TimelineTab } from './types'; import { TimelineTabsStyle } from './types'; import { useKibana } from '../../../common/lib/kibana'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; export interface UseTimelineTypesArgs { defaultTimelineCount?: number | null; templateTimelineCount?: number | null; @@ -42,8 +43,18 @@ export const useTimelineTypes = ({ : TimelineType.default ); - const timelineUrl = formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)); - const templateUrl = formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)); + const notesEnabled = useIsExperimentalFeatureEnabled('securitySolutionNotesEnabled'); + + const timelineUrl = useMemo(() => { + return formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)); + }, [formatUrl, urlSearch]); + const templateUrl = useMemo(() => { + return formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)); + }, [formatUrl, urlSearch]); + + const notesUrl = useMemo(() => { + return formatUrl(getTimelineTabsUrl('notes', urlSearch)); + }, [formatUrl, urlSearch]); const goToTimeline = useCallback( (ev) => { @@ -60,6 +71,15 @@ export const useTimelineTypes = ({ }, [navigateToUrl, templateUrl] ); + + const goToNotes = useCallback( + (ev) => { + ev.preventDefault(); + navigateToUrl(notesUrl); + }, + [navigateToUrl, notesUrl] + ); + const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback( (timelineTabsStyle: TimelineTabsStyle) => [ { @@ -113,6 +133,17 @@ export const useTimelineTypes = ({ {tab.name} </EuiTab> ))} + {notesEnabled && ( + <EuiTab + data-test-subj="timeline-notes" + isSelected={tabName === 'notes'} + key="timeline-notes" + href={notesUrl} + onClick={goToNotes} + > + {'Notes'} + </EuiTab> + )} </EuiTabs> <EuiSpacer size="m" /> </> diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/index.test.tsx new file mode 100644 index 000000000000..3978c06f2784 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/index.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ComponentProps } from 'react'; +import React from 'react'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../common/mock'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { RowRendererSwitch } from '.'; +import { TimelineId } from '../../../../common/types'; +import { RowRendererId } from '../../../../common/api/timeline'; + +const localState = structuredClone(mockGlobalState); + +// exclude all row renderers by default +localState.timeline.timelineById[TimelineId.test].excludedRowRendererIds = + Object.values(RowRendererId); + +const renderTestComponent = (props?: ComponentProps<typeof TestProviders>) => { + const store = props?.store ?? createMockStore(localState); + return render( + <TestProviders {...props} store={store}> + <RowRendererSwitch timelineId={TimelineId.test} /> + </TestProviders> + ); +}; + +describe('Row Renderer Switch', () => { + it('should render correctly', () => { + const { getByTestId } = renderTestComponent(); + + expect(getByTestId('row-renderer-switch')).toBeVisible(); + expect(getByTestId('row-renderer-switch')).toHaveAttribute('aria-checked', 'false'); + }); + + it('should successfully enable all row renderers', async () => { + const localStore = createMockStore(localState); + const { getByTestId } = renderTestComponent({ store: localStore }); + + fireEvent.click(getByTestId('row-renderer-switch')); + + await waitFor(() => { + expect(getByTestId('row-renderer-switch')).toHaveAttribute('aria-checked', 'true'); + + expect( + localStore.getState().timeline.timelineById[TimelineId.test].excludedRowRendererIds + ).toMatchObject([]); + }); + }); + + it('should successfully disable all row renderers', async () => { + const localStore = createMockStore(localState); + const { getByTestId } = renderTestComponent({ store: localStore }); + + // enable all row renderers + fireEvent.click(getByTestId('row-renderer-switch')); + + await waitFor(() => { + expect(getByTestId('row-renderer-switch')).toHaveAttribute('aria-checked', 'true'); + }); + + // disable all row renderers + fireEvent.click(getByTestId('row-renderer-switch')); + + await waitFor(() => { + expect(getByTestId('row-renderer-switch')).toHaveAttribute('aria-checked', 'false'); + expect( + localStore.getState().timeline.timelineById[TimelineId.test].excludedRowRendererIds + ).toMatchObject(Object.values(RowRendererId)); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/index.tsx new file mode 100644 index 000000000000..12a6127a3905 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/index.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiSwitchEvent } from '@elastic/eui'; +import { EuiToolTip, EuiSwitch, EuiFormRow, useGeneratedHtmlId } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { RowRendererId } from '../../../../common/api/timeline'; +import type { State } from '../../../common/store'; +import { setExcludedRowRendererIds } from '../../store/actions'; +import { selectExcludedRowRendererIds } from '../../store/selectors'; +import * as i18n from './translations'; + +interface RowRendererSwitchProps { + timelineId: string; +} + +const CustomFormRow = styled(EuiFormRow)` + .euiFormRow__label { + font-weight: 400; + } +`; + +export const RowRendererSwitch = React.memo(function RowRendererSwitch( + props: RowRendererSwitchProps +) { + const toggleTextSwitchId = useGeneratedHtmlId({ prefix: 'rowRendererSwitch' }); + + const { timelineId } = props; + + const dispatch = useDispatch(); + + const excludedRowRendererIds = useSelector((state: State) => + selectExcludedRowRendererIds(state, timelineId) + ); + + const isAnyRowRendererEnabled = useMemo( + () => Object.values(RowRendererId).some((id) => !excludedRowRendererIds.includes(id)), + [excludedRowRendererIds] + ); + + const handleDisableAll = useCallback(() => { + dispatch( + setExcludedRowRendererIds({ + id: timelineId, + excludedRowRendererIds: Object.values(RowRendererId), + }) + ); + }, [dispatch, timelineId]); + + const handleEnableAll = useCallback(() => { + dispatch(setExcludedRowRendererIds({ id: timelineId, excludedRowRendererIds: [] })); + }, [dispatch, timelineId]); + + const onChange = useCallback( + (e: EuiSwitchEvent) => { + if (e.target.checked) { + handleEnableAll(); + } else { + handleDisableAll(); + } + }, + [handleDisableAll, handleEnableAll] + ); + + const rowRendererLabel = useMemo( + () => <span id={toggleTextSwitchId}>{i18n.EVENT_RENDERERS_SWITCH}</span>, + [toggleTextSwitchId] + ); + + return ( + <EuiToolTip position="top" content={i18n.EVENT_RENDERERS_SWITCH_WARNING}> + <CustomFormRow display="columnCompressedSwitch" label={rowRendererLabel}> + <EuiSwitch + data-test-subj="row-renderer-switch" + label="" + checked={isAnyRowRendererEnabled} + onChange={onChange} + compressed + /> + </CustomFormRow> + </EuiToolTip> + ); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/translations.ts new file mode 100644 index 000000000000..7ddd65972325 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderer_switch/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const EVENT_RENDERERS_SWITCH = i18n.translate( + 'xpack.securitySolution.timeline.eventRenderersSwitch.title', + { + defaultMessage: 'Event renderers', + } +); + +export const EVENT_RENDERERS_SWITCH_WARNING = i18n.translate( + 'xpack.securitySolution.timeline.eventRenderersSwitch.warning', + { + defaultMessage: 'Enabling event renderers might impact table performance.', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx index eb195feee885..45f709013952 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx @@ -22,6 +22,10 @@ interface RowRenderersBrowserProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` .euiTable { + tr:has(.isNotSelected) { + background-color: ${(props) => props.theme.eui.euiColorLightestShade}; + } + tr > *:last-child { display: none; } @@ -105,6 +109,7 @@ const RowRenderersBrowserComponent = ({ <EuiCheckbox id={item.id} onChange={handleNameClick(item)} + className={`${!excludedRowRendererIds.includes(item.id) ? 'isSelected' : 'isNotSelected'}`} checked={!excludedRowRendererIds.includes(item.id)} /> ), diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/translations.ts index feb06de9eeba..3af2ff0fecdd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/translations.ts @@ -10,14 +10,14 @@ import { i18n } from '@kbn/i18n'; export const EVENT_RENDERERS_TITLE = i18n.translate( 'xpack.securitySolution.customizeEventRenderers.eventRenderersTitle', { - defaultMessage: 'Event Renderers', + defaultMessage: 'Event renderers', } ); export const CUSTOMIZE_EVENT_RENDERERS_TITLE = i18n.translate( 'xpack.securitySolution.customizeEventRenderers.customizeEventRenderersTitle', { - defaultMessage: 'Customize Event Renderers', + defaultMessage: 'Customize event renderers', } ); @@ -25,7 +25,7 @@ export const CUSTOMIZE_EVENT_RENDERERS_DESCRIPTION = i18n.translate( 'xpack.securitySolution.customizeEventRenderers.customizeEventRenderersDescription', { defaultMessage: - 'Event Renderers automatically convey the most relevant details in an event to reveal its story', + 'Event renderers automatically convey the most relevant details in an event to reveal its story', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 34578db7e5a1..11cc6032242e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -20,12 +20,14 @@ import { getDefaultControlColumn } from '../control_columns'; import { testLeadingControlColumn } from '../../../../../common/mock/mock_timeline_control_columns'; import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin'; import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; -import { - NOTES_DISABLE_TOOLTIP, - NOTES_TOOLTIP, -} from '../../../../../common/components/header_actions/translations'; import { getActionsColumnWidth } from '../../../../../common/components/header_actions'; +jest.mock('../../../../../common/components/header_actions/add_note_icon_item', () => { + return { + AddEventNoteAction: jest.fn(() => <div data-test-subj="add-note-button-mock" />), + }; +}); + jest.mock('../../../../../common/hooks/use_experimental_features'); const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; jest.mock('../../../../../common/hooks/use_selector', () => ({ @@ -125,36 +127,15 @@ describe('EventColumnView', () => { wrappingComponent: TestProviders, }); - expect(wrapper.find('[data-test-subj="timeline-notes-button-small"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="add-note-button-mock"]').exists()).toBe(false); }); - test('it invokes toggleShowNotes when the button for adding notes is clicked', () => { - const wrapper = mount(<EventColumnView {...props} />, { wrappingComponent: TestProviders }); - - expect(props.toggleShowNotes).not.toHaveBeenCalled(); - - wrapper.find('[data-test-subj="timeline-notes-button-small"]').first().simulate('click'); - - expect(props.toggleShowNotes).toHaveBeenCalled(); - }); - - test('it renders correct tooltip for NotesButton - timeline', () => { - const wrapper = mount(<EventColumnView {...props} />, { wrappingComponent: TestProviders }); - - expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual(NOTES_TOOLTIP); - }); - - test('it renders correct tooltip for NotesButton - timeline template', () => { - (useShallowEqualSelector as jest.Mock).mockReturnValue({ - timelineType: TimelineType.template, + test('it does NOT render a notes button when showNotes is false', () => { + const wrapper = mount(<EventColumnView {...props} showNotes={false} />, { + wrappingComponent: TestProviders, }); - const wrapper = mount(<EventColumnView {...props} />, { wrappingComponent: TestProviders }); - - expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual( - NOTES_DISABLE_TOOLTIP - ); - (useShallowEqualSelector as jest.Mock).mockReturnValue({ timelineType: TimelineType.default }); + expect(wrapper.find('[data-test-subj="add-note-button-mock"]').exists()).toBe(false); }); test('it does NOT render a pin button when isEventViewer is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index d3d27ba083b7..e184e27d428e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -48,7 +48,7 @@ interface Props { showNotes: boolean; tabType?: TimelineTabs; timelineId: string; - toggleShowNotes: () => void; + toggleShowNotes: (eventId?: string) => void; leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; setEventsLoading: SetEventsLoading; @@ -149,6 +149,7 @@ export const EventColumnView = React.memo<Props>( toggleShowNotes={toggleShowNotes} setEventsLoading={setEventsLoading} setEventsDeleted={setEventsDeleted} + disablePinAction={false} /> )} </EventsTdGroupActions> @@ -173,12 +174,12 @@ export const EventColumnView = React.memo<Props>( refetch, selectedEventIds, showCheckboxes, - showNotes, tabType, timelineId, toggleShowNotes, setEventsLoading, setEventsDeleted, + showNotes, ] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx index 18bfd74ab951..76c28f24b14d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx @@ -49,6 +49,7 @@ interface Props { tabType?: TimelineTabs; leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; + onToggleShowNotes?: (eventId?: string) => void; } const EventsComponent: React.FC<Props> = ({ @@ -72,6 +73,7 @@ const EventsComponent: React.FC<Props> = ({ tabType, leadingControlColumns, trailingControlColumns, + onToggleShowNotes, }) => ( <EventsTbody data-test-subj="events"> {data.map((event, i) => ( @@ -100,6 +102,7 @@ const EventsComponent: React.FC<Props> = ({ timelineId={id} leadingControlColumns={leadingControlColumns} trailingControlColumns={trailingControlColumns} + onToggleShowNotes={onToggleShowNotes} /> ))} </EventsTbody> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 0e100d9a25bc..000837ff8350 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -11,13 +11,10 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { isEventBuildingBlockType } from '@kbn/securitysolution-data-table'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { LeftPanelNotesTab } from '../../../../../flyout/document_details/left'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; -import { - DocumentDetailsLeftPanelKey, - DocumentDetailsRightPanelKey, -} from '../../../../../flyout/document_details/shared/constants/panel_keys'; +import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/shared/constants/panel_keys'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { useKibana } from '../../../../../common/lib/kibana'; import type { ColumnHeaderOptions, CellValueElementProps, @@ -32,7 +29,6 @@ import type { OnRowSelected } from '../../events'; import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers'; import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '../../styles'; import { getEventType, isEvenEqlSequence } from '../helpers'; -import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; import type { inputsModel } from '../../../../../common/store'; @@ -74,6 +70,7 @@ interface Props { timelineId: string; leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; + onToggleShowNotes?: (eventId?: string) => void; } const emptyNotes: string[] = []; @@ -109,15 +106,14 @@ const StatefulEventComponent: React.FC<Props> = ({ timelineId, leadingControlColumns, trailingControlColumns, + onToggleShowNotes, }) => { + const { telemetry } = useKibana().services; const trGroupRef = useRef<HTMLDivElement | null>(null); const dispatch = useDispatch(); const expandableFlyoutDisabled = useIsExperimentalFeatureEnabled('expandableFlyoutDisabled'); const { openFlyout } = useExpandableFlyoutApi(); - const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( - 'securitySolutionNotesEnabled' - ); // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created const [activeStatefulEventContext] = useState({ @@ -127,7 +123,8 @@ const StatefulEventComponent: React.FC<Props> = ({ tabType, }); - const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); + const [, setFocusedNotes] = useState<{ [eventId: string]: boolean }>({}); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedDetail = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail ?? {} @@ -188,31 +185,10 @@ const StatefulEventComponent: React.FC<Props> = ({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const indexName = event._index!; - const onToggleShowNotes = useCallback(() => { - if (!expandableFlyoutDisabled && securitySolutionNotesEnabled) { - openFlyout({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: eventId, - indexName, - scopeId: timelineId, - }, - }, - left: { - id: DocumentDetailsLeftPanelKey, - path: { - tab: LeftPanelNotesTab, - }, - params: { - id: eventId, - indexName, - scopeId: timelineId, - }, - }, - }); - } else { - setShowNotes((prevShowNotes) => { + const onToggleShowNotesHandler = useCallback( + (currentEventId?: string) => { + onToggleShowNotes?.(currentEventId); + setFocusedNotes((prevShowNotes) => { if (prevShowNotes[eventId]) { // notes are closing, so focus the notes button on the next tick, after escaping the EuiFocusTrap setTimeout(() => { @@ -225,15 +201,9 @@ const StatefulEventComponent: React.FC<Props> = ({ return { ...prevShowNotes, [eventId]: !prevShowNotes[eventId] }; }); - } - }, [ - eventId, - expandableFlyoutDisabled, - indexName, - securitySolutionNotesEnabled, - openFlyout, - timelineId, - ]); + }, + [onToggleShowNotes, eventId] + ); const handleOnEventDetailPanelOpened = useCallback(() => { const updatedExpandedDetail: ExpandedDetailType = { @@ -256,6 +226,10 @@ const StatefulEventComponent: React.FC<Props> = ({ }, }, }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'right', + }); } else { // opens the panel when clicking on the table row action dispatch( @@ -273,23 +247,11 @@ const StatefulEventComponent: React.FC<Props> = ({ expandableFlyoutDisabled, openFlyout, timelineId, + telemetry, dispatch, tabType, ]); - const associateNote = useCallback( - (noteId: string) => { - dispatch( - timelineActions.addNoteToEvent({ - eventId, - id: timelineId, - noteId, - }) - ); - }, - [dispatch, eventId, timelineId] - ); - const setEventsLoading = useCallback<SetEventsLoading>( ({ eventIds, isLoading }) => { dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); @@ -337,10 +299,10 @@ const StatefulEventComponent: React.FC<Props> = ({ onRuleChange={onRuleChange} selectedEventIds={selectedEventIds} showCheckboxes={showCheckboxes} - showNotes={!!showNotes[eventId]} + showNotes={true} tabType={tabType} timelineId={timelineId} - toggleShowNotes={onToggleShowNotes} + toggleShowNotes={onToggleShowNotesHandler} leadingControlColumns={leadingControlColumns} trailingControlColumns={trailingControlColumns} setEventsLoading={setEventsLoading} @@ -348,21 +310,6 @@ const StatefulEventComponent: React.FC<Props> = ({ /> <EventsTrSupplementContainerWrapper> - <EventsTrSupplement - className="siemEventsTable__trSupplement--notes" - data-test-subj="event-notes-flex-item" - $display="block" - > - <NoteCards - ariaRowindex={ariaRowindex} - associateNote={associateNote} - data-test-subj="note-cards" - notes={notes} - showAddNote={!!showNotes[eventId]} - toggleShowAddNote={onToggleShowNotes} - /> - </EventsTrSupplement> - <EuiFlexGroup gutterSize="none" justifyContent="center"> <EuiFlexItem grow={false}> <EventsTrSupplement> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts index 8d496e607cd9..0fbe03302e90 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts @@ -238,7 +238,7 @@ describe('helpers', () => { ).toEqual('Unpin alert'); }); - test('it indicates the event is NOT pinned when `isPinned` is `false` and the event has notes', () => { + test('it indicates the event is pinned when `isPinned` is `false` and the event has notes', () => { expect( getPinTooltip({ isAlert: false, @@ -246,10 +246,10 @@ describe('helpers', () => { eventHasNotes: true, timelineType: TimelineType.default, }) - ).toEqual('Pin event'); + ).toEqual('This event cannot be unpinned because it has notes'); }); - test('it indicates the alert is NOT pinned when `isPinned` is `false` and the alert has notes', () => { + test('it indicates the alert is pinned when `isPinned` is `false` and the alert has notes', () => { expect( getPinTooltip({ isAlert: true, @@ -257,7 +257,7 @@ describe('helpers', () => { eventHasNotes: true, timelineType: TimelineType.default, }) - ).toEqual('Pin alert'); + ).toEqual('This alert cannot be unpinned because it has notes'); }); test('it indicates the event is NOT pinned when `isPinned` is `false` and the event does NOT have notes', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index a578a7c2fff7..709ee375ad04 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -39,7 +39,7 @@ export const getPinTooltip = ({ }) => { if (timelineType === TimelineType.template) { return i18n.DISABLE_PIN(isAlert); - } else if (isPinned && eventHasNotes) { + } else if (eventHasNotes) { return i18n.PINNED_WITH_NOTES(isAlert); } else if (isPinned) { return i18n.PINNED(isAlert); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 77bc18fe0580..98d3ea8f507b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -27,7 +27,6 @@ import type { Props } from '.'; import { StatefulBody } from '.'; import type { Sort } from './sort'; import { getDefaultControlColumn } from './control_columns'; -import { timelineActions } from '../../../store'; import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; import { defaultRowRenderers } from './renderers'; import type { State } from '../../../../common/store'; @@ -39,6 +38,7 @@ import type { DroppableStateSnapshot, } from '@hello-pangea/dnd'; import { DocumentDetailsRightPanelKey } from '../../../../flyout/document_details/shared/constants/panel_keys'; +import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/components/guided_onboarding_tour/tour_step'); @@ -105,6 +105,8 @@ jest.mock('@kbn/expandable-flyout', () => { }; }); +const mockedTelemetry = createTelemetryServiceMock(); + jest.mock('../../../../common/components/link_to', () => { const originalModule = jest.requireActual('../../../../common/components/link_to'); return { @@ -256,6 +258,7 @@ describe('Body', () => { savedObjects: { client: {}, }, + telemetry: mockedTelemetry, timelines: { getLastUpdated: jest.fn(), getLoadingPanel: jest.fn(), @@ -338,86 +341,6 @@ describe('Body', () => { }); }); }); - describe('action on event', () => { - const addaNoteToEvent = (wrapper: ReturnType<typeof mount>, note: string) => { - wrapper.find('[data-test-subj="add-note"]').first().find('button').simulate('click'); - wrapper.update(); - wrapper - .find('[data-test-subj="new-note-tabs"] textarea') - .simulate('change', { target: { value: note } }); - wrapper.update(); - wrapper.find('button[data-test-subj="add-note"]').first().simulate('click'); - wrapper.update(); - }; - - beforeEach(() => { - mockDispatch.mockClear(); - }); - - test('Add a note to an event', async () => { - const wrapper = await getWrapper(<StatefulBody {...props} />); - - addaNoteToEvent(wrapper, 'hello world'); - wrapper.update(); - expect(mockDispatch).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - payload: { - eventId: '1', - id: 'timeline-test', - noteId: expect.anything(), - }, - type: timelineActions.addNoteToEvent({ - eventId: '1', - id: 'timeline-test', - noteId: '11', - }).type, - }) - ); - }); - - test('Add two notes to an event', async () => { - const state: State = { - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.test]: { - ...mockGlobalState.timeline.timelineById[TimelineId.test], - id: 'timeline-test', - pinnedEventIds: { 1: true }, - }, - }, - }, - }; - - const store = createMockStore(state); - - const Proxy = (proxyProps: Props) => <StatefulBody {...proxyProps} />; - - const wrapper = await getWrapper(<Proxy {...props} />, { store }); - - addaNoteToEvent(wrapper, 'hello world'); - mockDispatch.mockClear(); - addaNoteToEvent(wrapper, 'new hello world'); - expect(mockDispatch).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - payload: { - eventId: '1', - id: 'timeline-test', - noteId: expect.anything(), - }, - type: timelineActions.addNoteToEvent({ - eventId: '1', - id: 'timeline-test', - noteId: '11', - }).type, - }) - ); - }); - }); describe('event details', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 2a45d3b5f233..ab60e061fcdf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -51,6 +51,7 @@ export interface Props { tabType: TimelineTabs; totalPages: number; onRuleChange?: () => void; + onToggleShowNotes?: (eventId?: string) => void; } /** @@ -73,6 +74,7 @@ export const StatefulBody = React.memo<Props>( totalPages, leadingControlColumns = [], trailingControlColumns = [], + onToggleShowNotes, }) => { const dispatch = useDispatch(); const containerRef = useRef<HTMLDivElement | null>(null); @@ -256,6 +258,7 @@ export const StatefulBody = React.memo<Props>( leadingControlColumns={leadingControlColumns} trailingControlColumns={trailingControlColumns} tabType={tabType} + onToggleShowNotes={onToggleShowNotes} /> </EventsTable> </TimelineBody> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index 36233fcc3a39..61bff652c807 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -71,7 +71,7 @@ const FormattedFieldValueComponent: React.FC<{ isObjectArray?: boolean; isUnifiedDataTable?: boolean; fieldFormat?: string; - fieldFromBrowserField?: BrowserField; + fieldFromBrowserField?: Partial<BrowserField>; fieldName: string; fieldType?: string; isButton?: boolean; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx index 21a923653237..401fe8763ada 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx @@ -46,8 +46,6 @@ const defaultProps: UnifiedTimelineBodyProps = { activePage: 0, querySize: 0, }, - eventIdToNoteIds: {} as Record<string, string[]>, - pinnedEventIds: {} as Record<string, boolean>, }; const renderTestComponents = (props?: UnifiedTimelineBodyProps) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx index 6f9868267842..576812016dee 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx @@ -42,8 +42,6 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => { updatedAt, trailingControlColumns, leadingControlColumns, - pinnedEventIds, - eventIdToNoteIds, } = props; const [pageRows, setPageRows] = useState<TimelineItem[][]>([]); @@ -91,8 +89,6 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => { isTextBasedQuery={false} trailingControlColumns={trailingControlColumns} leadingControlColumns={leadingControlColumns} - pinnedEventIds={pinnedEventIds} - eventIdToNoteIds={eventIdToNoteIds} /> </RootDragDropProvider> </StyledTableFlexItem> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index f749fea36c4f..977d3e51c982 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -133,6 +133,9 @@ const StatefulTimelineComponent: React.FC<Props> = ({ dataViewId: selectedDataViewIdSourcerer, indexNames: selectedPatternsSourcerer, show: false, + excludedRowRendererIds: unifiedComponentsInTimelineEnabled + ? timelineDefaults.excludedRowRendererIds + : [], }) ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx new file mode 100644 index 000000000000..e8508aaf0b4c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ComponentProps } from 'react'; +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import { NotesButton } from './helpers'; +import { TimelineType } from '../../../../../common/api/timeline'; +import { ThemeProvider } from 'styled-components'; + +const toggleShowNotesMock = jest.fn(); + +const defaultProps: ComponentProps<typeof NotesButton> = { + ariaLabel: 'Sample Notes', + isDisabled: false, + toggleShowNotes: toggleShowNotesMock, + eventId: 'event-id', + notesCount: 1, + timelineType: TimelineType.default, + toolTip: 'Sample Tooltip', +}; + +const TestWrapper: React.FC = ({ children }) => { + return <ThemeProvider theme={{ eui: { euiColorDanger: 'red' } }}>{children}</ThemeProvider>; +}; + +const renderTestComponent = (props?: Partial<ComponentProps<typeof NotesButton>>) => { + const localProps = { + ...defaultProps, + ...props, + }; + + render(<NotesButton {...localProps} />, { wrapper: TestWrapper }); +}; + +describe('helpers', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + test('should show the notes button correctly', () => { + renderTestComponent(); + + expect(screen.getByTestId('timeline-notes-button-small')).toBeVisible(); + }); + + test('should show the notification dot correctly when notes are available', () => { + renderTestComponent(); + + expect(screen.getByTestId('timeline-notes-button-small')).toBeVisible(); + expect(screen.getByTestId('timeline-notes-notification-dot')).toBeVisible(); + }); + + test('should not show the notification dot where there are no notes available', () => { + renderTestComponent({ + notesCount: 0, + }); + + expect(screen.getByTestId('timeline-notes-button-small')).toBeVisible(); + expect(screen.queryByTestId('timeline-notes-notification-dot')).not.toBeInTheDocument(); + }); + + test('should call the toggleShowNotes function when the button is clicked', () => { + renderTestComponent(); + + const button = screen.getByTestId('timeline-notes-button-small'); + + fireEvent.click(button); + + expect(toggleShowNotesMock).toHaveBeenCalledTimes(1); + expect(toggleShowNotesMock).toHaveBeenCalledWith('event-id'); + }); + + test('should call the toggleShowNotes correctly when the button is clicked and eventId is not available', () => { + renderTestComponent({ + eventId: undefined, + }); + + const button = screen.getByTestId('timeline-notes-button-small'); + + fireEvent.click(button); + + expect(toggleShowNotesMock).toHaveBeenCalledTimes(1); + expect(toggleShowNotesMock).toHaveBeenCalledWith(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 1a217abd674e..739e07dca199 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiBadge, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; @@ -14,12 +14,6 @@ import { TimelineType } from '../../../../../common/api/timeline'; import * as i18n from './translations'; -const NotesCountBadge = styled(EuiBadge)` - margin-left: 5px; -` as unknown as typeof EuiBadge; - -NotesCountBadge.displayName = 'NotesCountBadge'; - export const NotificationDot = styled.span` position: absolute; display: block; @@ -31,16 +25,10 @@ export const NotificationDot = styled.span` left: 52%; `; -const NotesButtonContainer = styled(EuiFlexGroup)` - position: relative; -`; - -export const NOTES_BUTTON_CLASS_NAME = 'notes-button'; - interface SmallNotesButtonProps { ariaLabel?: string; isDisabled?: boolean; - toggleShowNotes: (eventId?: string) => void; + toggleShowNotes?: (eventId?: string) => void; timelineType: TimelineTypeLiteral; eventId?: string; /** @@ -49,21 +37,32 @@ interface SmallNotesButtonProps { notesCount: number; } +export const NOTES_BUTTON_CLASS_NAME = 'notes-button'; + +const NotesButtonContainer = styled(EuiFlexGroup)` + position: relative; +`; + const SmallNotesButton = React.memo<SmallNotesButtonProps>( ({ ariaLabel = i18n.NOTES, isDisabled, toggleShowNotes, timelineType, eventId, notesCount }) => { const isTemplate = timelineType === TimelineType.template; const onClick = useCallback(() => { if (eventId != null) { - toggleShowNotes(eventId); + toggleShowNotes?.(eventId); } else { - toggleShowNotes(); + toggleShowNotes?.(); } }, [toggleShowNotes, eventId]); return ( <NotesButtonContainer> <EuiFlexItem grow={false}> - {notesCount > 0 ? <NotificationDot /> : null} + {notesCount > 0 ? ( + <NotificationDot + className="timeline-notes-notification-dot" + data-test-subj="timeline-notes-notification-dot" + /> + ) : null} <EuiButtonIcon aria-label={ariaLabel} className={NOTES_BUTTON_CLASS_NAME} @@ -84,49 +83,29 @@ SmallNotesButton.displayName = 'SmallNotesButton'; interface NotesButtonProps { ariaLabel?: string; isDisabled?: boolean; - showNotes: boolean; - toggleShowNotes: () => void | ((eventId: string) => void); - toolTip?: string; + toggleShowNotes?: () => void | ((eventId: string) => void); + toolTip: string; timelineType: TimelineTypeLiteral; eventId?: string; /** - * Number of notes associated with the event. - * Defaults to 0 + * Number of notes. If > 0, then a red dot is shown in the top right corner of the icon. */ notesCount?: number; } export const NotesButton = React.memo<NotesButtonProps>( - ({ - ariaLabel, - isDisabled, - showNotes, - timelineType, - toggleShowNotes, - toolTip, - eventId, - notesCount = 0, - }) => - showNotes ? ( + ({ ariaLabel, isDisabled, timelineType, toggleShowNotes, toolTip, eventId, notesCount }) => ( + <EuiToolTip content={toolTip} data-test-subj="timeline-notes-tool-tip"> <SmallNotesButton ariaLabel={ariaLabel} isDisabled={isDisabled} toggleShowNotes={toggleShowNotes} timelineType={timelineType} eventId={eventId} - notesCount={notesCount} + notesCount={notesCount ?? 0} /> - ) : ( - <EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip"> - <SmallNotesButton - ariaLabel={ariaLabel} - isDisabled={isDisabled} - toggleShowNotes={toggleShowNotes} - timelineType={timelineType} - eventId={eventId} - notesCount={notesCount} - /> - </EuiToolTip> - ) + </EuiToolTip> + ) ); + NotesButton.displayName = 'NotesButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/notes_flyout.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/notes_flyout.test.tsx new file mode 100644 index 000000000000..33836289d5e2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/notes_flyout.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TimelineId } from '../../../../../common/types'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { NoteCards } from '../../notes/note_cards'; +import type { TimelineResultNote } from '../../open_timeline/types'; +import { NotesFlyout } from './notes_flyout'; + +const onClose = jest.fn(); +const toggleShowAddNote = jest.fn(); +const associateNote = jest.fn(); +const eventId = 'sample_event_id'; +const notes = [] as TimelineResultNote[]; + +jest.mock('../../notes/note_cards', () => ({ + NoteCards: jest.fn(), +})); + +const renderTestComponent = (props?: Partial<ComponentProps<typeof NotesFlyout>>) => { + return render( + <NotesFlyout + show={true} + eventId={eventId} + onClose={onClose} + toggleShowAddNote={toggleShowAddNote} + associateNote={associateNote} + notes={notes} + timelineId={TimelineId.test} + {...props} + />, + { + wrapper: ({ children }) => ( + <ThemeProvider + theme={{ + eui: { + euiZFlyout: 1000, + }, + }} + > + {children} + </ThemeProvider> + ), + } + ); +}; + +describe('Notes Flyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (NoteCards as unknown as jest.Mock).mockImplementation( + jest.fn().mockReturnValue(<div>{`NoteCards`}</div>) + ); + }); + + it('should respond to visibility prop correctly', () => { + renderTestComponent({ + show: false, + }); + + expect(screen.queryByTestId('timeline-notes-flyout')).not.toBeInTheDocument(); + }); + + it('should display notes correctly', () => { + renderTestComponent({ + show: true, + }); + + expect(screen.getByText('NoteCards')).toBeVisible(); + }); + + it('should trigger onClose correctly', () => { + renderTestComponent({ + show: true, + }); + + fireEvent.click(screen.getByTestId('euiFlyoutCloseButton')); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/notes_flyout.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/notes_flyout.tsx new file mode 100644 index 000000000000..438e04283e74 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/notes_flyout.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import styled from 'styled-components'; +import type { EuiTheme } from '@kbn/react-kibana-context-styled'; +import type { NoteCardsProps } from '../../notes/note_cards'; +import { NoteCards } from '../../notes/note_cards'; +import * as i18n from './translations'; + +export type NotesFlyoutProps = { + show: boolean; + onClose: () => void; + eventId?: string; +} & Pick< + NoteCardsProps, + 'notes' | 'associateNote' | 'toggleShowAddNote' | 'timelineId' | 'onCancel' +>; + +/* + * z-index override is needed because otherwise NotesFlyout appears below + * Timeline Modal as they both have same z-index of 1000 + */ +const NotesFlyoutContainer = styled(EuiFlyout)` + /* + * We want the width of flyout to be less than 50% of screen because + * otherwise it interferes with the delete notes modal + * */ + width: 30%; + z-index: ${(props) => + ((props.theme as EuiTheme).eui.euiZFlyout.toFixed() ?? 1000) + 2} !important; +`; + +export const NotesFlyout = React.memo(function NotesFlyout(props: NotesFlyoutProps) { + const { eventId, toggleShowAddNote, show, onClose, associateNote, notes, timelineId, onCancel } = + props; + + const notesFlyoutTitleId = useGeneratedHtmlId({ + prefix: 'notesFlyoutTitle', + }); + + if (!show || !eventId) { + return null; + } + + return ( + <NotesFlyoutContainer + ownFocus={false} + className="timeline-notes-flyout" + data-test-subj="timeline-notes-flyout" + onClose={onClose} + aria-labelledby={notesFlyoutTitleId} + maxWidth={750} + > + <EuiFlyoutHeader hasBorder> + <EuiTitle size="m"> + <h2>{i18n.NOTES}</h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <NoteCards + ariaRowindex={0} + associateNote={associateNote} + className="notes-in-flyout" + data-test-subj="note-cards" + notes={notes} + showAddNote={true} + toggleShowAddNote={toggleShowAddNote} + eventId={eventId} + timelineId={timelineId} + onCancel={onCancel} + /> + </EuiFlyoutBody> + </NotesFlyoutContainer> + ); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.test.tsx new file mode 100644 index 000000000000..d40bf849dc32 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.test.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { TimelineId } from '../../../../../common/types'; +import { renderHook, act } from '@testing-library/react-hooks/dom'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; +import { useNotesInFlyout } from './use_notes_in_flyout'; +import { waitFor } from '@testing-library/react'; +import { useDispatch } from 'react-redux'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +const mockEventIdToNoteIds = { + 'event-1': ['note-1', 'note-3'], + 'event-2': ['note-2'], +}; + +const note1 = { + created: new Date('2024-06-25T13:34:35.669Z'), + id: 'note-1', + lastEdit: new Date('2024-06-25T13:34:35.669Z'), + note: 'First Comment', + user: 'elastic', + saveObjectId: '7402b6fc-34a8-42bd-b590-389df3011c6b', + version: 'WzU0OTcsMV0=', + eventId: 'event-1', + timelineId: '35937e12-b600-4bdd-a79e-5431aa39ab4b', +}; + +const note2 = { + created: new Date('2024-06-25T11:57:22.031Z'), + id: 'note-2', + lastEdit: new Date('2024-06-25T11:57:22.031Z'), + note: 'Some Note', + user: 'elastic', + saveObjectId: 'fafdfe3e-82b6-4c09-b116-fcba4a5390de', + version: 'WzU0OTUsMV0=', + eventId: 'event-2', + timelineId: '35937e12-b600-4bdd-a79e-5431aa39ab4b', +}; + +const note3 = { + ...note1, + id: 'note-3', + eventId: 'event-1', + note: 'Third Comment', + saveObjectId: 'note-3', +}; + +const mockState = structuredClone(mockGlobalState); + +const mockLocalState = { + ...mockState, + timeline: { + ...mockState.timeline, + timelineById: { + [TimelineId.test]: { + ...mockState.timeline.timelineById[TimelineId.test], + eventIdToNoteIds: { + ...mockEventIdToNoteIds, + }, + }, + }, + }, + app: { + ...mockState.app, + notesById: { + 'note-1': note1, + 'note-2': note2, + 'note-3': note3, + }, + }, +}; + +const dispatchMock = jest.fn(); +const refetchMock = jest.fn(); + +const renderTestHook = () => { + return renderHook( + () => + useNotesInFlyout({ + eventIdToNoteIds: mockEventIdToNoteIds, + timelineId: TimelineId.test, + refetch: refetchMock, + }), + { + wrapper: ({ children }) => ( + <TestProviders store={createMockStore(mockLocalState)}>{children}</TestProviders> + ), + } + ); +}; + +describe('useNotesInFlyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useDispatch as jest.Mock).mockReturnValue(dispatchMock); + }); + it('should return correct array of notes based on Events', async () => { + const { result } = renderTestHook(); + + expect(result.current.notes).toEqual([]); + + act(() => { + result.current.setNotesEventId('event-1'); + }); + + await waitFor(() => { + expect(result.current.notes).toMatchObject( + [note1, note3].map((note) => ({ + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })) + ); + }); + + act(() => { + result.current.setNotesEventId('event-2'); + }); + + await waitFor(() => { + expect(result.current.notes).toMatchObject( + [note2].map((note) => ({ + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })) + ); + }); + }); + + it('should show flyout when eventId is not undefined', async () => { + const { result } = renderTestHook(); + + expect(result.current.eventId).toBeUndefined(); + expect(result.current.notes).toEqual([]); + + act(() => { + result.current.setNotesEventId('event-1'); + }); + + await waitFor(() => { + expect(result.current.eventId).toBe('event-1'); + }); + + act(() => { + result.current.showNotesFlyout(); + }); + + expect(result.current.isNotesFlyoutVisible).toBe(true); + }); + + it('should return correct instance of associate Note', () => { + const { result } = renderTestHook(); + + act(() => { + result.current.setNotesEventId('event-1'); + }); + + const { associateNote } = result.current; + + dispatchMock.mockClear(); + associateNote('some-noteId'); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(refetchMock).toHaveBeenCalledTimes(1); + }); + + it('should close flyout correctly', () => { + const { result } = renderTestHook(); + + act(() => { + result.current.setNotesEventId('event-1'); + }); + + act(() => { + result.current.showNotesFlyout(); + }); + + expect(result.current.isNotesFlyoutVisible).toBe(true); + + act(() => { + result.current.closeNotesFlyout(); + }); + + expect(result.current.isNotesFlyoutVisible).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.ts new file mode 100644 index 000000000000..99a2dfe3953c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.ts @@ -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 { useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { appSelectors } from '../../../../common/store'; +import { timelineActions } from '../../../store'; + +interface UseNotesInFlyoutArgs { + eventIdToNoteIds: Record<string, string[]>; + refetch?: () => void; + timelineId: string; +} + +const EMPTY_STRING_ARRAY: string[] = []; + +function isNoteNotNull<T>(note: T | null): note is T { + return note !== null; +} + +export const useNotesInFlyout = (args: UseNotesInFlyoutArgs) => { + const [isNotesFlyoutVisible, setIsNotesFlyoutVisible] = useState(false); + + const [eventId, setNotesEventId] = useState<string>(); + + const closeNotesFlyout = useCallback(() => { + setIsNotesFlyoutVisible(false); + }, []); + + const showNotesFlyout = useCallback(() => { + setIsNotesFlyoutVisible(true); + }, []); + + const { eventIdToNoteIds, refetch, timelineId } = args; + + const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); + + const notesById = useDeepEqualSelector(getNotesByIds); + + const dispatch = useDispatch(); + + const noteIds: string[] = useMemo( + () => (eventId && eventIdToNoteIds?.[eventId]) || EMPTY_STRING_ARRAY, + [eventIdToNoteIds, eventId] + ); + + const associateNote = useCallback( + (currentNoteId: string) => { + if (!eventId) return; + dispatch( + timelineActions.addNoteToEvent({ + eventId, + id: timelineId, + noteId: currentNoteId, + }) + ); + if (refetch) { + refetch(); + } + }, + [dispatch, eventId, refetch, timelineId] + ); + + const notes = useMemo( + () => + noteIds + .map((currentNoteId) => { + const note = notesById[currentNoteId]; + if (note) { + return { + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + }; + } else { + return null; + } + }) + .filter(isNoteNotNull), + [noteIds, notesById] + ); + + return { + associateNote, + notes, + isNotesFlyoutVisible, + closeNotesFlyout, + showNotesFlyout, + eventId, + setNotesEventId, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index 5068a80fb101..97762de6bcb9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -352,7 +352,6 @@ export const EventsTdContent = styled.div.attrs(({ className }) => ({ font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; min-width: 0; - padding: ${({ theme }) => theme.eui.euiSizeXS}; text-align: ${({ textAlign }) => textAlign}; width: ${({ width }) => width != null diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx index 38d2ade2d985..0d6332ffe805 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx @@ -17,6 +17,7 @@ import type { EuiDataGridControlColumn } from '@elastic/eui'; import { DataLoadingState } from '@kbn/unified-data-table'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useKibana } from '../../../../../common/lib/kibana'; import { DocumentDetailsLeftPanelKey, DocumentDetailsRightPanelKey, @@ -63,6 +64,8 @@ import { EqlTabHeader } from './header'; import { useTimelineColumns } from '../shared/use_timeline_columns'; import { useTimelineControlColumn } from '../shared/use_timeline_control_columns'; import { LeftPanelNotesTab } from '../../../../../flyout/document_details/left'; +import { useNotesInFlyout } from '../../properties/use_notes_in_flyout'; +import { NotesFlyout } from '../../properties/notes_flyout'; export type Props = TimelineTabCommonProps & PropsFromRedux; @@ -85,6 +88,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({ pinnedEventIds, eventIdToNoteIds, }) => { + const { telemetry } = useKibana().services; const dispatch = useDispatch(); const { query: eqlQuery = '', ...restEqlOption } = eqlOptions; const { portalNode: eqlEventsCountPortalNode } = useEqlEventsCountPortal(); @@ -146,6 +150,21 @@ export const EqlTabContentComponent: React.FC<Props> = ({ const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( 'securitySolutionNotesEnabled' ); + + const { + associateNote, + notes, + isNotesFlyoutVisible, + closeNotesFlyout, + showNotesFlyout, + eventId: noteEventId, + setNotesEventId, + } = useNotesInFlyout({ + eventIdToNoteIds, + refetch, + timelineId, + }); + const onToggleShowNotes = useCallback( (eventId?: string) => { const indexName = selectedPatterns.join(','); @@ -171,6 +190,18 @@ export const EqlTabContentComponent: React.FC<Props> = ({ }, }, }); + telemetry.reportOpenNoteInExpandableFlyoutClicked({ + location: timelineId, + }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'left', + }); + } else { + if (eventId) { + setNotesEventId(eventId); + showNotesFlyout(); + } } }, [ @@ -178,7 +209,10 @@ export const EqlTabContentComponent: React.FC<Props> = ({ openFlyout, securitySolutionNotesEnabled, selectedPatterns, + telemetry, timelineId, + setNotesEventId, + showNotesFlyout, ] ); @@ -188,6 +222,9 @@ export const EqlTabContentComponent: React.FC<Props> = ({ timelineId, activeTab: TimelineTabs.eql, refetch, + events, + pinnedEventIds, + eventIdToNoteIds, onToggleShowNotes, }); @@ -225,6 +262,20 @@ export const EqlTabContentComponent: React.FC<Props> = ({ [activeTab, setTimelineFullScreen, timelineFullScreen, timelineId] ); + const NotesFlyoutMemo = useMemo(() => { + return ( + <NotesFlyout + associateNote={associateNote} + eventId={noteEventId} + show={isNotesFlyoutVisible} + notes={notes} + onClose={closeNotesFlyout} + onCancel={closeNotesFlyout} + timelineId={timelineId} + /> + ); + }, [associateNote, closeNotesFlyout, isNotesFlyoutVisible, noteEventId, notes, timelineId]); + return ( <> {unifiedComponentsInTimelineEnabled ? ( @@ -232,6 +283,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({ <InPortal node={eqlEventsCountPortalNode}> {totalCount >= 0 ? <EventsCountBadge>{totalCount}</EventsCountBadge> : null} </InPortal> + {NotesFlyoutMemo} <FullWidthFlexGroup> <ScrollableFlexItem grow={2}> <UnifiedTimelineBody @@ -256,8 +308,6 @@ export const EqlTabContentComponent: React.FC<Props> = ({ isTextBasedQuery={false} pageInfo={pageInfo} leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]} - pinnedEventIds={pinnedEventIds} - eventIdToNoteIds={eventIdToNoteIds} /> </ScrollableFlexItem> </FullWidthFlexGroup> @@ -267,6 +317,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({ <InPortal node={eqlEventsCountPortalNode}> {totalCount >= 0 ? <EventsCountBadge>{totalCount}</EventsCountBadge> : null} </InPortal> + {NotesFlyoutMemo} <TimelineRefetch id={`${timelineId}-${TimelineTabs.eql}`} inputId={InputsModelId.timeline} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx index a49b63a5e57f..76db7bcde9af 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx @@ -20,6 +20,7 @@ import { DocumentDetailsRightPanelKey, } from '../../../../../flyout/document_details/shared/constants/panel_keys'; import type { ControlColumnProps } from '../../../../../../common/types'; +import { useKibana } from '../../../../../common/lib/kibana'; import { timelineActions, timelineSelectors } from '../../../../store'; import type { Direction } from '../../../../../../common/search_strategy'; import { useTimelineEvents } from '../../../../containers'; @@ -52,6 +53,8 @@ import type { TimelineTabCommonProps } from '../shared/types'; import { useTimelineColumns } from '../shared/use_timeline_columns'; import { useTimelineControlColumn } from '../shared/use_timeline_control_columns'; import { LeftPanelNotesTab } from '../../../../../flyout/document_details/left'; +import { useNotesInFlyout } from '../../properties/use_notes_in_flyout'; +import { NotesFlyout } from '../../properties/notes_flyout'; const ExitFullScreenContainer = styled.div` width: 180px; @@ -92,6 +95,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({ expandedDetail, eventIdToNoteIds, }) => { + const { telemetry } = useKibana().services; const { browserFields, dataViewId, @@ -182,6 +186,21 @@ export const PinnedTabContentComponent: React.FC<Props> = ({ const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( 'securitySolutionNotesEnabled' ); + + const { + associateNote, + notes, + isNotesFlyoutVisible, + closeNotesFlyout, + showNotesFlyout, + eventId: noteEventId, + setNotesEventId, + } = useNotesInFlyout({ + eventIdToNoteIds, + refetch, + timelineId, + }); + const onToggleShowNotes = useCallback( (eventId?: string) => { const indexName = selectedPatterns.join(','); @@ -207,6 +226,18 @@ export const PinnedTabContentComponent: React.FC<Props> = ({ }, }, }); + telemetry.reportOpenNoteInExpandableFlyoutClicked({ + location: timelineId, + }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'left', + }); + } else { + if (eventId) { + setNotesEventId(eventId); + showNotesFlyout(); + } } }, [ @@ -214,7 +245,10 @@ export const PinnedTabContentComponent: React.FC<Props> = ({ openFlyout, securitySolutionNotesEnabled, selectedPatterns, + telemetry, timelineId, + setNotesEventId, + showNotesFlyout, ] ); @@ -224,6 +258,9 @@ export const PinnedTabContentComponent: React.FC<Props> = ({ timelineId, activeTab: TimelineTabs.pinned, refetch, + events, + pinnedEventIds, + eventIdToNoteIds, onToggleShowNotes, }); @@ -236,38 +273,54 @@ export const PinnedTabContentComponent: React.FC<Props> = ({ onEventClosed({ tabType: TimelineTabs.pinned, id: timelineId }); }, [timelineId, onEventClosed]); - if (unifiedComponentsInTimelineEnabled) { + const NotesFlyoutMemo = useMemo(() => { return ( - <UnifiedTimelineBody - header={<></>} - columns={augmentedColumnHeaders} - rowRenderers={rowRenderers} + <NotesFlyout + associateNote={associateNote} + eventId={noteEventId} + show={isNotesFlyoutVisible} + notes={notes} + onClose={closeNotesFlyout} + onCancel={closeNotesFlyout} timelineId={timelineId} - itemsPerPage={itemsPerPage} - itemsPerPageOptions={itemsPerPageOptions} - sort={sort} - events={events} - refetch={refetch} - dataLoadingState={queryLoadingState} - pinnedEventIds={pinnedEventIds} - totalCount={events.length} - onEventClosed={onEventClosed} - expandedDetail={expandedDetail} - eventIdToNoteIds={eventIdToNoteIds} - showExpandedDetails={showExpandedDetails} - onChangePage={loadPage} - activeTab={TimelineTabs.pinned} - updatedAt={refreshedAt} - isTextBasedQuery={false} - pageInfo={pageInfo} - leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]} - trailingControlColumns={rowDetailColumn} /> ); + }, [associateNote, closeNotesFlyout, isNotesFlyoutVisible, noteEventId, notes, timelineId]); + + if (unifiedComponentsInTimelineEnabled) { + return ( + <> + {NotesFlyoutMemo} + <UnifiedTimelineBody + header={<></>} + columns={augmentedColumnHeaders} + rowRenderers={rowRenderers} + timelineId={timelineId} + itemsPerPage={itemsPerPage} + itemsPerPageOptions={itemsPerPageOptions} + sort={sort} + events={events} + refetch={refetch} + dataLoadingState={queryLoadingState} + totalCount={events.length} + onEventClosed={onEventClosed} + expandedDetail={expandedDetail} + showExpandedDetails={showExpandedDetails} + onChangePage={loadPage} + activeTab={TimelineTabs.pinned} + updatedAt={refreshedAt} + isTextBasedQuery={false} + pageInfo={pageInfo} + leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]} + trailingControlColumns={rowDetailColumn} + /> + </> + ); } return ( <> + {NotesFlyoutMemo} <FullWidthFlexGroup data-test-subj={`${TimelineTabs.pinned}-tab`}> <ScrollableFlexItem grow={2}> {timelineFullScreen && setTimelineFullScreen != null && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx index 32c7a525f525..c326105f3ce8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx @@ -59,6 +59,8 @@ jest.mock('../../../../containers/use_timeline_data_filters', () => ({ useTimelineDataFilters: jest.fn().mockReturnValue({ from: 'now-15m', to: 'now' }), })); +jest.mock('../../../../../common/hooks/use_experimental_features'); + describe('Timeline', () => { let props = {} as QueryTabContentComponentProps; const sort: Sort[] = [ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx index 443b290a5302..017031e1ffba 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx @@ -67,6 +67,8 @@ import { import type { TimelineTabCommonProps } from '../shared/types'; import { useTimelineColumns } from '../shared/use_timeline_columns'; import { useTimelineControlColumn } from '../shared/use_timeline_control_columns'; +import { NotesFlyout } from '../../properties/notes_flyout'; +import { useNotesInFlyout } from '../../properties/use_notes_in_flyout'; const compareQueryProps = (prevProps: Props, nextProps: Props) => prevProps.kqlMode === nextProps.kqlMode && @@ -114,7 +116,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({ selectedPatterns, } = useSourcererDataView(SourcererScopeName.timeline); - const { uiSettings, timelineDataService } = useKibana().services; + const { uiSettings, telemetry, timelineDataService } = useKibana().services; const { query: { filterManager: timelineFilterManager }, } = timelineDataService; @@ -214,6 +216,21 @@ export const QueryTabContentComponent: React.FC<Props> = ({ const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( 'securitySolutionNotesEnabled' ); + + const { + associateNote, + notes, + isNotesFlyoutVisible, + closeNotesFlyout, + showNotesFlyout, + eventId: noteEventId, + setNotesEventId, + } = useNotesInFlyout({ + eventIdToNoteIds, + refetch, + timelineId, + }); + const onToggleShowNotes = useCallback( (eventId?: string) => { const indexName = selectedPatterns.join(','); @@ -239,6 +256,18 @@ export const QueryTabContentComponent: React.FC<Props> = ({ }, }, }); + telemetry.reportOpenNoteInExpandableFlyoutClicked({ + location: timelineId, + }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'left', + }); + } else { + if (eventId) { + setNotesEventId(eventId); + showNotesFlyout(); + } } }, [ @@ -246,7 +275,10 @@ export const QueryTabContentComponent: React.FC<Props> = ({ openFlyout, securitySolutionNotesEnabled, selectedPatterns, + telemetry, timelineId, + showNotesFlyout, + setNotesEventId, ] ); @@ -256,6 +288,9 @@ export const QueryTabContentComponent: React.FC<Props> = ({ timelineId, activeTab: TimelineTabs.query, refetch, + events, + pinnedEventIds, + eventIdToNoteIds, onToggleShowNotes, }); @@ -311,6 +346,20 @@ export const QueryTabContentComponent: React.FC<Props> = ({ }, [timelineDataService, combinedQueries, kqlQueryLanguage]); // </Synchronisation of the timeline data service> + const NotesFlyoutMemo = useMemo(() => { + return ( + <NotesFlyout + associateNote={associateNote} + eventId={noteEventId} + show={isNotesFlyoutVisible} + notes={notes} + onClose={closeNotesFlyout} + onCancel={closeNotesFlyout} + timelineId={timelineId} + /> + ); + }, [associateNote, closeNotesFlyout, isNotesFlyoutVisible, noteEventId, notes, timelineId]); + if (unifiedComponentsInTimelineEnabled) { return ( <> @@ -322,6 +371,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({ refetch={refetch} skip={!canQueryTimeline} /> + {NotesFlyoutMemo} <UnifiedTimelineBody header={ @@ -350,8 +400,6 @@ export const QueryTabContentComponent: React.FC<Props> = ({ expandedDetail={expandedDetail} showExpandedDetails={showExpandedDetails} leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]} - eventIdToNoteIds={eventIdToNoteIds} - pinnedEventIds={pinnedEventIds} onChangePage={loadPage} activeTab={activeTab} updatedAt={refreshedAt} @@ -372,6 +420,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({ refetch={refetch} skip={!canQueryTimeline} /> + {NotesFlyoutMemo} <FullWidthFlexGroup gutterSize="none"> <ScrollableFlexItem grow={2}> <QueryTabHeader @@ -405,6 +454,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({ })} leadingControlColumns={leadingControlColumns as ControlColumnProps[]} trailingControlColumns={timelineEmptyTrailingControlColumns} + onToggleShowNotes={onToggleShowNotes} /> </StyledEuiFlyoutBody> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx index 13a7e9045f20..9a712a8fbeaf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx @@ -774,95 +774,427 @@ describe('query tab with unified timeline', () => { ); }); - describe('row leading actions', () => { - // fix this with the new EUI flyout implementation for notes - it.skip( - 'should be able to add notes using EuiFlyout', - async () => { - (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( - jest.fn((feature: keyof ExperimentalFeatures) => { - if (feature === 'unifiedComponentsInTimelineEnabled') { - return true; - } - return allowedExperimentalValues[feature]; - }) + describe('Leading actions - notes', () => { + describe('securitySolutionNotesEnabled = true', () => { + describe('expandableFlyoutDisabled = false', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( + jest.fn((feature: keyof ExperimentalFeatures) => { + if (feature === 'unifiedComponentsInTimelineEnabled') { + return true; + } + if (feature === 'securitySolutionNotesEnabled') { + return true; + } + return allowedExperimentalValues[feature]; + }) + ); + }); + + it( + 'should have the notification dot & correct tooltip', + async () => { + renderTestComponents(); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getAllByTestId('timeline-notes-button-small')).toHaveLength(1); + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + + expect(screen.getByTestId('timeline-notes-notification-dot')).toBeVisible(); + + fireEvent.mouseOver(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-tool-tip')).toBeVisible(); + expect(screen.getByTestId('timeline-notes-tool-tip')).toHaveTextContent( + '1 Note available. Click to view it & add more.' + ); + }); + }, + SPECIAL_TEST_TIMEOUT ); + it( + 'should be able to add notes through expandable flyout', + async () => { + renderTestComponents(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); - renderTestComponents(); - expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + }); - await waitFor(() => { - expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(mockOpenFlyout).toHaveBeenCalled(); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + }); + + describe('expandableFlyoutDisabled = true', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( + jest.fn((feature: keyof ExperimentalFeatures) => { + if (feature === 'unifiedComponentsInTimelineEnabled') { + return true; + } + if (feature === 'expandableFlyoutDisabled') { + return true; + } + if (feature === 'securitySolutionNotesEnabled') { + return true; + } + return allowedExperimentalValues[feature]; + }) + ); }); - fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + it( + 'should have the notification dot & correct tooltip', + async () => { + renderTestComponents(); - await waitFor(() => { - expect(screen.getByTestId('add-note-container')).toBeVisible(); - }); - }, - SPECIAL_TEST_TIMEOUT - ); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); - it( - 'should be able to add notes through expandable flyout', - async () => { - (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( - jest.fn((feature: keyof ExperimentalFeatures) => { - if (feature === 'unifiedComponentsInTimelineEnabled') { - return true; - } - if (feature === 'securitySolutionNotesEnabled') { - return true; - } - return allowedExperimentalValues[feature]; - }) + expect(screen.getAllByTestId('timeline-notes-button-small')).toHaveLength(1); + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + + expect(screen.getByTestId('timeline-notes-notification-dot')).toBeVisible(); + + fireEvent.mouseOver(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-tool-tip')).toBeVisible(); + expect(screen.getByTestId('timeline-notes-tool-tip')).toHaveTextContent( + '1 Note available. Click to view it & add more.' + ); + }); + }, + SPECIAL_TEST_TIMEOUT ); + it( + 'should be able to add notes using EuiFlyout', + async () => { + renderTestComponents(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); - renderTestComponents(); - expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + }); - await waitFor(() => { - expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); - }); + fireEvent.click(screen.getByTestId('timeline-notes-button-small')); - fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + await waitFor(() => { + expect(screen.getByTestId('add-note-container')).toBeVisible(); + }); + }, + SPECIAL_TEST_TIMEOUT + ); - await waitFor(() => { - expect(mockOpenFlyout).toHaveBeenCalled(); - }); - }, - SPECIAL_TEST_TIMEOUT - ); + it( + 'should be cancel adding notes', + async () => { + renderTestComponents(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); - // once the new EUI flyout for notes is implemented this test should be removed - it.skip( - 'should be cancel adding notes', - async () => { - renderTestComponents(); - expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + }); - await waitFor(() => { - expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); - }); + fireEvent.click(screen.getByTestId('timeline-notes-button-small')); - fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + await waitFor(() => { + expect(screen.getByTestId('add-note-container')).toBeVisible(); + }); - await waitFor(() => { - expect(screen.getByTestId('add-note-container')).toBeVisible(); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), 'Test Note 1'); + + expect(screen.getByTestId('cancel')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('add-note-container')).not.toBeInTheDocument(); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + }); + }); + + describe('securitySolutionNotesEnabled = false', () => { + describe('expandableFlyoutDisabled = false', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( + jest.fn((feature: keyof ExperimentalFeatures) => { + if (feature === 'unifiedComponentsInTimelineEnabled') { + return true; + } + if (feature === 'securitySolutionNotesEnabled') { + return false; + } + return allowedExperimentalValues[feature]; + }) + ); }); - userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), 'Test Note 1'); + it( + 'should have the notification dot & correct tooltip', + async () => { + renderTestComponents(); - expect(screen.getByTestId('cancel')).not.toBeDisabled(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); - fireEvent.click(screen.getByTestId('cancel')); + expect(screen.getAllByTestId('timeline-notes-button-small')).toHaveLength(1); + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); - await waitFor(() => { - expect(screen.queryByTestId('add-note-container')).not.toBeInTheDocument(); + expect(screen.getByTestId('timeline-notes-notification-dot')).toBeVisible(); + + fireEvent.mouseOver(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-tool-tip')).toBeVisible(); + expect(screen.getByTestId('timeline-notes-tool-tip')).toHaveTextContent( + '1 Note available. Click to view it & add more.' + ); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + it( + 'should be able to add notes using EuiFlyout', + async () => { + renderTestComponents(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('add-note-container')).toBeVisible(); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + + it( + 'should be cancel adding notes', + async () => { + renderTestComponents(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('add-note-container')).toBeVisible(); + }); + + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), 'Test Note 1'); + + expect(screen.getByTestId('cancel')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('add-note-container')).not.toBeInTheDocument(); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + }); + + describe('expandableFlyoutDisabled = true', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( + jest.fn((feature: keyof ExperimentalFeatures) => { + if (feature === 'unifiedComponentsInTimelineEnabled') { + return true; + } + if (feature === 'expandableFlyoutDisabled') { + return true; + } + if (feature === 'securitySolutionNotesEnabled') { + return true; + } + return allowedExperimentalValues[feature]; + }) + ); }); - }, - SPECIAL_TEST_TIMEOUT - ); + + it( + 'should have the notification dot & correct tooltip', + async () => { + renderTestComponents(); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getAllByTestId('timeline-notes-button-small')).toHaveLength(1); + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + + expect(screen.getByTestId('timeline-notes-notification-dot')).toBeVisible(); + + fireEvent.mouseOver(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-tool-tip')).toBeVisible(); + expect(screen.getByTestId('timeline-notes-tool-tip')).toHaveTextContent( + '1 Note available. Click to view it & add more.' + ); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + it( + 'should be able to add notes using EuiFlyout', + async () => { + renderTestComponents(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('add-note-container')).toBeVisible(); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + + it( + 'should be cancel adding notes', + async () => { + renderTestComponents(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByTestId('timeline-notes-button-small')); + + await waitFor(() => { + expect(screen.getByTestId('add-note-container')).toBeVisible(); + }); + + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), 'Test Note 1'); + + expect(screen.getByTestId('cancel')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('add-note-container')).not.toBeInTheDocument(); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + }); + }); + }); + + describe('Leading actions - pin', () => { + describe('securitySolutionNotesEnabled = true', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( + jest.fn((feature: keyof ExperimentalFeatures) => { + if (feature === 'unifiedComponentsInTimelineEnabled') { + return true; + } + if (feature === 'securitySolutionNotesEnabled') { + return true; + } + return allowedExperimentalValues[feature]; + }) + ); + }); + it( + 'should have the pin button with correct tooltip', + async () => { + renderTestComponents(); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getAllByTestId('pin')).toHaveLength(1); + // disabled because it is already pinned + expect(screen.getByTestId('pin')).toBeDisabled(); + + fireEvent.mouseOver(screen.getByTestId('pin')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-action-pin-tool-tip')).toBeVisible(); + expect(screen.getByTestId('timeline-action-pin-tool-tip')).toHaveTextContent( + 'This event cannot be unpinned because it has notes' + ); + /* + * Above event is alert and not an event but `getEventType` in + *x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx + * returns it has event and not an alert even though, it has event.kind as signal. + * Need to see if it is okay + * + * */ + }); + }, + SPECIAL_TEST_TIMEOUT + ); + }); + + describe('securitySolutionNotesEnabled = false', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation( + jest.fn((feature: keyof ExperimentalFeatures) => { + if (feature === 'unifiedComponentsInTimelineEnabled') { + return true; + } + if (feature === 'securitySolutionNotesEnabled') { + return false; + } + return allowedExperimentalValues[feature]; + }) + ); + }); + + it( + 'should have the pin button with correct tooltip', + async () => { + renderTestComponents(); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getAllByTestId('pin')).toHaveLength(1); + // disabled because it is already pinned + expect(screen.getByTestId('pin')).toBeDisabled(); + + fireEvent.mouseOver(screen.getByTestId('pin')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-action-pin-tool-tip')).toBeVisible(); + expect(screen.getByTestId('timeline-action-pin-tool-tip')).toHaveTextContent( + 'This event cannot be unpinned because it has notes' + ); + /* + * Above event is alert and not an event but `getEventType` in + * x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx + * returns it has event and not an alert even though, it has event.kind as signal. + * Need to see if it is okay + * + * */ + }); + }, + SPECIAL_TEST_TIMEOUT + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx index ea6490bf18dd..f7d58b1c04a2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx @@ -51,6 +51,9 @@ describe('useTimelineColumns', () => { timelineId: TimelineId.test, activeTab: TimelineTabs.query, refetch: refetchMock, + events: [], + pinnedEventIds: {}, + eventIdToNoteIds: {}, onToggleShowNotes: jest.fn(), }), { @@ -71,6 +74,9 @@ describe('useTimelineColumns', () => { timelineId: TimelineId.test, activeTab: TimelineTabs.query, refetch: refetchMock, + events: [], + pinnedEventIds: {}, + eventIdToNoteIds: {}, onToggleShowNotes: jest.fn(), }), { @@ -92,6 +98,9 @@ describe('useTimelineColumns', () => { timelineId: TimelineId.test, activeTab: TimelineTabs.query, refetch: refetchMock, + events: [], + pinnedEventIds: {}, + eventIdToNoteIds: {}, onToggleShowNotes: jest.fn(), }), { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx index f6d583572c93..a32338a9dc03 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx @@ -8,6 +8,7 @@ import React, { useMemo } from 'react'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import type { SortColumnTable } from '@kbn/securitysolution-data-table'; +import type { TimelineItem } from '@kbn/timelines-plugin/common'; import { useLicense } from '../../../../../common/hooks/use_license'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; @@ -16,10 +17,10 @@ import { getDefaultControlColumn } from '../../body/control_columns'; import type { UnifiedActionProps } from '../../unified_components/data_table/control_column_cell_render'; import type { TimelineTabs } from '../../../../../../common/types/timeline'; import { HeaderActions } from '../../../../../common/components/header_actions/header_actions'; -import { ControlColumnCellRender } from '../../unified_components/data_table/control_column_cell_render'; +import { TimelineControlColumnCellRender } from '../../unified_components/data_table/control_column_cell_render'; import type { ColumnHeaderOptions } from '../../../../../../common/types'; import { useTimelineColumns } from './use_timeline_columns'; -import type { TimelineDataGridCellContext } from '../../types'; +import type { UnifiedTimelineDataGridCellContext } from '../../types'; interface UseTimelineControlColumnArgs { columns: ColumnHeaderOptions[]; @@ -27,6 +28,9 @@ interface UseTimelineControlColumnArgs { timelineId: string; activeTab: TimelineTabs; refetch: () => void; + events: TimelineItem[]; + pinnedEventIds: Record<string, boolean>; + eventIdToNoteIds: Record<string, string[]>; onToggleShowNotes: (eventId?: string) => void; } @@ -40,6 +44,9 @@ export const useTimelineControlColumn = ({ timelineId, activeTab, refetch, + events, + pinnedEventIds, + eventIdToNoteIds, onToggleShowNotes, }: UseTimelineControlColumnArgs) => { const { browserFields } = useSourcererDataView(SourcererScopeName.timeline); @@ -54,7 +61,6 @@ export const useTimelineControlColumn = ({ // We need one less when the unified components are enabled because the document expand is provided by the unified data table const UNIFIED_COMPONENTS_ACTION_BUTTON_COUNT = ACTION_BUTTON_COUNT - 1; - return useMemo(() => { if (unifiedComponentsInTimelineEnabled) { return getDefaultControlColumn(UNIFIED_COMPONENTS_ACTION_BUTTON_COUNT).map((x) => ({ @@ -78,18 +84,36 @@ export const useTimelineControlColumn = ({ /> ); }, - rowCellRender: (props: EuiDataGridCellValueElementProps & TimelineDataGridCellContext) => { + rowCellRender: ( + props: EuiDataGridCellValueElementProps & UnifiedTimelineDataGridCellContext + ) => { + /* + * In some cases, when number of events is updated + * but new table is not yet rendered it can result + * in the mismatch between the number of events v/s + * the number of rows in the table currently rendered. + * + * */ + if (props.rowIndex >= events.length) return <></>; + props.setCellProps({ + className: + props.expandedEventId === events[props.rowIndex]?._id + ? 'unifiedDataTable__cell--expanded' + : '', + }); + return ( - <ControlColumnCellRender - {...props} + <TimelineControlColumnCellRender + rowIndex={props.rowIndex} + columnId={props.columnId} timelineId={timelineId} ariaRowindex={props.rowIndex} checked={false} columnValues="" - data={props.events[props.rowIndex].data} - ecsData={props.events[props.rowIndex].ecs} + data={events[props.rowIndex].data} + ecsData={events[props.rowIndex].ecs} loadingEventIds={EMPTY_STRING_ARRAY} - eventId={props.events[props.rowIndex]?._id} + eventId={events[props.rowIndex]?._id} index={props.rowIndex} onEventDetailsPanelOpened={noOp} onRowSelected={noOp} @@ -97,7 +121,9 @@ export const useTimelineControlColumn = ({ showCheckboxes={false} setEventsLoading={noOp} setEventsDeleted={noOp} - onToggleShowNotes={onToggleShowNotes} + pinnedEventIds={pinnedEventIds} + eventIdToNoteIds={eventIdToNoteIds} + toggleShowNotes={onToggleShowNotes} /> ); }, @@ -117,6 +143,9 @@ export const useTimelineControlColumn = ({ activeTab, timelineId, refetch, + events, + pinnedEventIds, + eventIdToNoteIds, onToggleShowNotes, ACTION_BUTTON_COUNT, ]); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/types.ts index e7dedcfa9aad..3ce0d3e876e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/types.ts @@ -5,14 +5,6 @@ * 2.0. */ -import type { TimelineItem } from '@kbn/timelines-plugin/common'; -import type { TimelineModel } from '../../store/model'; - -export interface TimelineDataGridCellContext { - events: TimelineItem[]; - pinnedEventIds: TimelineModel['pinnedEventIds']; - eventIdsAddingNotes: Set<string>; - onToggleShowNotes: (eventId?: string) => void; - eventIdToNoteIds: Record<string, string[]>; - refetch: () => void; +export interface UnifiedTimelineDataGridCellContext { + expandedEventId?: string; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap index d11a5f23cbda..4e220a4d2db5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap @@ -1,24 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = ` -.c3 { - padding-top: 8px; -} - -.c2 { - border: none; - background-color: transparent; - box-shadow: none; -} - -.c2.euiPanel--plain { - background-color: transparent; -} - -.c4 { - margin-bottom: 5px; -} - .c0 { width: -webkit-fit-content; width: -moz-fit-content; @@ -110,140 +92,6 @@ exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = ` Cell-0-2 </div> </div> - <div - class="euiPanel euiPanel--plain c2 udt--customRow emotion-euiPanel-grow-m-plain" - data-test-subj="note-cards" - > - <section - class="c3" - data-test-subj="note-previews-container" - > - <div - class="euiFlexGroup c4 notes-container-0 emotion-euiFlexGroup-none-flexStart-stretch-column" - data-test-subj="notes" - > - <p - class="emotion-euiScreenReaderOnly" - > - You are viewing notes for the event in row 0. Press the up arrow key when finished to return to the event. - </p> - <ol - class="euiTimeline euiCommentList emotion-euiTimeline-l" - data-test-subj="note-comment-list" - role="list" - > - <li - class="euiComment emotion-euiTimelineItem-top" - data-test-subj="note-preview-id" - > - <div - class="emotion-euiTimelineItemIcon-top" - > - <div - class="emotion-euiTimelineItemIcon__content" - > - <div - aria-label="test" - class="euiAvatar euiAvatar--l euiAvatar--user emotion-euiAvatar-user-l-uppercase" - data-test-subj="avatar" - role="img" - style="background-color: rgb(228, 166, 199); color: rgb(0, 0, 0);" - title="test" - > - <span - aria-hidden="true" - > - t - </span> - </div> - </div> - </div> - <div - class="emotion-euiTimelineItemEvent-top" - > - <figure - class="euiCommentEvent emotion-euiCommentEvent-border-subdued" - data-type="regular" - > - <figcaption - class="euiCommentEvent__header emotion-euiCommentEvent__header-border-subdued" - > - <div - class="euiPanel euiPanel--subdued euiPanel--paddingSmall emotion-euiPanel-grow-none-s-subdued" - > - <div - class="euiCommentEvent__headerMain emotion-euiCommentEvent__headerMain" - > - <div - class="euiCommentEvent__headerData emotion-euiCommentEvent__headerData" - > - <div - class="euiCommentEvent__headerUsername emotion-euiCommentEvent__headerUsername" - > - test - </div> - <div - class="euiCommentEvent__headerEvent emotion-euiCommentEvent__headerEvent" - > - added a note - </div> - <div - class="euiCommentEvent__headerTimestamp" - > - <time> - now - </time> - </div> - </div> - <div - class="euiCommentEvent__headerActions emotion-euiCommentEvent__headerActions" - > - <button - aria-label="Delete Note" - class="euiButtonIcon emotion-euiButtonIcon-xs-empty-text" - data-test-subj="delete-note" - title="Delete Note" - type="button" - > - <span - aria-hidden="true" - class="euiButtonIcon__icon" - color="inherit" - data-euiicon-type="trash" - /> - </button> - </div> - </div> - </div> - </figcaption> - <div - class="euiCommentEvent__body emotion-euiCommentEvent__body-regular" - > - <div - class="note-content" - tabindex="0" - > - <p - class="emotion-euiScreenReaderOnly" - > - test added a note - </p> - <div - class="euiText euiMarkdownFormat emotion-euiText-m-euiTextColor-default-euiMarkdownFormat-m-default" - > - <p> - note - </p> - </div> - </div> - </div> - </figure> - </div> - </li> - </ol> - </div> - </section> - </div> <div> Cell-0-3 </div> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/control_column_cell_render.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/control_column_cell_render.tsx index 1dcd3ccd9db2..ca9a1b0c06d5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/control_column_cell_render.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/control_column_cell_render.tsx @@ -6,7 +6,6 @@ */ import React, { memo, useMemo } from 'react'; -import type { TimelineItem } from '@kbn/timelines-plugin/common'; import { eventIsPinned } from '../../body/helpers'; import { Actions } from '../../../../../common/components/header_actions'; import type { TimelineModel } from '../../../../store/model'; @@ -14,37 +13,46 @@ import type { ActionProps } from '../../../../../../common/types'; const noOp = () => {}; export interface UnifiedActionProps extends ActionProps { - onToggleShowNotes: (eventId?: string) => void; - events: TimelineItem[]; pinnedEventIds: TimelineModel['pinnedEventIds']; } -export const ControlColumnCellRender = memo(function ControlColumnCellRender( +export const TimelineControlColumnCellRender = memo(function TimelineControlColumnCellRender( props: UnifiedActionProps ) { - const { rowIndex, events, pinnedEventIds, onToggleShowNotes, eventIdToNoteIds, timelineId } = - props; + const { rowIndex, pinnedEventIds } = props; - const event = useMemo(() => events && events[rowIndex], [events, rowIndex]); const isPinned = useMemo( - () => eventIsPinned({ eventId: event?._id, pinnedEventIds }), - [event?._id, pinnedEventIds] + () => eventIsPinned({ eventId: props.eventId, pinnedEventIds }), + [props.eventId, pinnedEventIds] ); return ( <Actions - {...props} - ariaRowindex={rowIndex} + action={props.action} + columnId={props.columnId} columnValues="columnValues" - disableExpandAction - eventIdToNoteIds={eventIdToNoteIds} + data={props.data} + ecsData={props.ecsData} + eventId={props.eventId} + eventIdToNoteIds={props.eventIdToNoteIds} + index={rowIndex} isEventPinned={isPinned} isEventViewer={false} + refetch={props.refetch} + rowIndex={rowIndex} + setEventsDeleted={noOp} + setEventsLoading={noOp} onEventDetailsPanelOpened={noOp} + onRowSelected={noOp} onRuleChange={noOp} + showCheckboxes={false} showNotes={true} - timelineId={timelineId} - toggleShowNotes={onToggleShowNotes} - rowIndex={rowIndex} + timelineId={props.timelineId} + ariaRowindex={rowIndex} + checked={false} + loadingEventIds={props.loadingEventIds} + toggleShowNotes={props.toggleShowNotes} + disableExpandAction + disablePinAction={false} /> ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx index 779a1c5bed05..e93b9014785f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx @@ -16,7 +16,6 @@ import { defaultUdtHeaders } from '../default_headers'; import type { EuiDataGridColumn } from '@elastic/eui'; import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; -import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; jest.mock('../../../../../common/hooks/use_selector'); @@ -40,8 +39,6 @@ const mockVisibleColumns = ['@timestamp', 'message', 'user.name'] .map((id) => defaultUdtHeaders.find((h) => h.id === id) as EuiDataGridColumn) .concat(additionalTrailingColumn); -const mockEventIdsAddingNotes = new Set<string>(); - const defaultProps: CustomTimelineDataGridBodyProps = { Cell: MockCellComponent, visibleRowData: { startRow: 0, endRow: 2, visibleRowCount: 2 }, @@ -49,34 +46,6 @@ const defaultProps: CustomTimelineDataGridBodyProps = { enabledRowRenderers: [], setCustomGridBodyProps: jest.fn(), visibleColumns: mockVisibleColumns, - eventIdsAddingNotes: mockEventIdsAddingNotes, - eventIdToNoteIds: { - event1: ['noteId1', 'noteId2'], - event2: ['noteId3'], - }, - events: [ - { - _id: 'event1', - _index: 'logs-*', - data: [], - ecs: { _id: 'event1', _index: 'logs-*' }, - }, - { - _id: 'event2', - _index: 'logs-*', - data: [], - ecs: { _id: 'event2', _index: 'logs-*' }, - }, - ], - onToggleShowNotes: (eventId?: string) => { - if (eventId) { - if (mockEventIdsAddingNotes.has(eventId)) { - mockEventIdsAddingNotes.delete(eventId); - } else { - mockEventIdsAddingNotes.add(eventId); - } - } - }, }; const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => { @@ -91,34 +60,9 @@ const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => { describe('CustomTimelineDataGridBody', () => { beforeEach(() => { - const now = new Date(); (useStatefulRowRenderer as jest.Mock).mockReturnValue({ canShowRowRenderer: true, }); - (useDeepEqualSelector as jest.Mock).mockReturnValue({ - noteId1: { - created: now, - eventId: 'event1', - id: 'test', - lastEdit: now, - note: 'note', - user: 'test', - saveObjectId: 'id', - timelineId: 'timeline-1', - version: '', - }, - noteId2: { - created: now, - eventId: 'event1', - id: 'test', - lastEdit: now, - note: 'note', - user: 'test', - saveObjectId: 'id', - timelineId: 'timeline-1', - version: '', - }, - }); }); afterEach(() => { @@ -145,18 +89,4 @@ describe('CustomTimelineDataGridBody', () => { expect(queryByText('Cell-0-3')).toBeFalsy(); expect(getByText('Cell-1-3')).toBeInTheDocument(); }); - - it('should render a note when notes are present', () => { - const { getByText } = renderTestComponents(); - expect(getByText('note')).toBeInTheDocument(); - }); - - it('should render the note creation form when the set of eventIds adding a note includes the eventId', () => { - const { getByTestId } = renderTestComponents({ - ...defaultProps, - eventIdsAddingNotes: new Set(['event1']), - }); - - expect(getByTestId('new-note-tabs')).toBeInTheDocument(); - }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx index fecfb56f87b1..fd8d3a9011f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx @@ -10,16 +10,9 @@ import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiTheme } from '@kbn/react-kibana-context-styled'; import type { TimelineItem } from '@kbn/timelines-plugin/common'; import type { FC } from 'react'; -import React, { memo, useMemo, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; import type { RowRenderer } from '../../../../../../common/types'; -import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; -import { appSelectors } from '../../../../../common/store'; -import { TimelineId } from '../../../../../../common/types/timeline'; -import { timelineActions } from '../../../../store'; -import { NoteCards } from '../../../notes/note_cards'; -import type { TimelineResultNote } from '../../../open_timeline/types'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer'; import { useGetEventTypeRowClassName } from './use_get_event_type_row_classname'; @@ -27,16 +20,10 @@ import { useGetEventTypeRowClassName } from './use_get_event_type_row_classname' export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & { rows: Array<DataTableRecord & TimelineItem> | undefined; enabledRowRenderers: RowRenderer[]; - events: TimelineItem[]; - eventIdToNoteIds?: Record<string, string[]> | null; - eventIdsAddingNotes?: Set<string>; - onToggleShowNotes: (eventId?: string) => void; rowHeight?: number; refetch?: () => void; }; -const emptyNotes: string[] = []; - // THE DataGrid Row default is 34px, but we make ours 40 to account for our row actions const DEFAULT_UDT_ROW_HEIGHT = 40; @@ -55,48 +42,17 @@ const DEFAULT_UDT_ROW_HEIGHT = 40; * */ export const CustomTimelineDataGridBody: FC<CustomTimelineDataGridBodyProps> = memo( function CustomTimelineDataGridBody(props) { - const { - Cell, - visibleColumns, - visibleRowData, - rows, - rowHeight, - enabledRowRenderers, - events = [], - eventIdToNoteIds = {}, - eventIdsAddingNotes = new Set<string>(), - onToggleShowNotes, - refetch, - } = props; - const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); - const notesById = useDeepEqualSelector(getNotesByIds); + const { Cell, visibleColumns, visibleRowData, rows, rowHeight, enabledRowRenderers, refetch } = + props; + const visibleRows = useMemo( () => (rows ?? []).slice(visibleRowData.startRow, visibleRowData.endRow), [rows, visibleRowData] ); - const eventIds = useMemo(() => events.map((event) => event._id), [events]); return ( <> {visibleRows.map((row, rowIndex) => { - const eventId = eventIds[rowIndex]; - const noteIds: string[] = (eventIdToNoteIds && eventIdToNoteIds[eventId]) || emptyNotes; - const notes = noteIds - .map((noteId) => { - const note = notesById[noteId]; - if (note) { - return { - savedObjectId: note.saveObjectId, - note: note.note, - noteId: note.id, - updated: (note.lastEdit ?? note.created).getTime(), - updatedBy: note.user, - }; - } else { - return null; - } - }) - .filter((note) => note !== null) as TimelineResultNote[]; return ( <CustomDataGridSingleRow rowData={row} @@ -106,10 +62,6 @@ export const CustomTimelineDataGridBody: FC<CustomTimelineDataGridBodyProps> = m rowHeight={rowHeight} Cell={Cell} enabledRowRenderers={enabledRowRenderers} - notes={notes} - eventIdsAddingNotes={eventIdsAddingNotes} - eventId={eventId} - onToggleShowNotes={onToggleShowNotes} refetch={refetch} /> ); @@ -188,10 +140,6 @@ const CustomGridRowCellWrapper = styled.div.attrs<{ type CustomTimelineDataGridSingleRowProps = { rowData: DataTableRecord & TimelineItem; rowIndex: number; - notes?: TimelineResultNote[] | null; - eventId?: string; - eventIdsAddingNotes?: Set<string>; - onToggleShowNotes: (eventId?: string) => void; } & Pick< CustomTimelineDataGridBodyProps, 'visibleColumns' | 'Cell' | 'enabledRowRenderers' | 'refetch' | 'rowHeight' @@ -221,14 +169,8 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow( enabledRowRenderers, visibleColumns, Cell, - notes, - eventIdsAddingNotes, - eventId = '', - onToggleShowNotes, - refetch, rowHeight: rowHeightMultiple = 0, } = props; - const dispatch = useDispatch(); const { canShowRowRenderer } = useStatefulRowRenderer({ data: rowData.ecs, rowRenderers: enabledRowRenderers, @@ -251,26 +193,6 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow( ); const eventTypeRowClassName = useGetEventTypeRowClassName(rowData.ecs); - const associateNote = useCallback( - (noteId: string) => { - dispatch( - timelineActions.addNoteToEvent({ - eventId, - id: TimelineId.active, - noteId, - }) - ); - if (refetch) { - refetch(); - } - }, - [dispatch, eventId, refetch] - ); - - const renderNotesContainer = useMemo(() => { - return ((notes && notes.length > 0) || eventIdsAddingNotes?.has(eventId)) ?? false; - }, [notes, eventIdsAddingNotes, eventId]); - return ( <CustomGridRow className={`${rowIndex % 2 === 0 ? 'euiDataGridRow--striped' : ''}`} @@ -293,18 +215,6 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow( return null; })} </CustomGridRowCellWrapper> - {renderNotesContainer && ( - <NoteCards - ariaRowindex={rowIndex} - associateNote={associateNote} - className="udt--customRow" - data-test-subj="note-cards" - notes={notes ?? []} - showAddNote={eventIdsAddingNotes?.has(eventId) ?? false} - toggleShowAddNote={onToggleShowNotes} - eventId={eventId} - /> - )} {/* Timeline Expanded Row */} {canShowRowRenderer ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx index f512fcbe04a0..e1ecabcc06c9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx @@ -49,6 +49,7 @@ import { TimelineEventDetailRow } from './timeline_event_detail_row'; import { CustomTimelineDataGridBody } from './custom_timeline_data_grid_body'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; import { useUnifiedTableExpandableFlyout } from '../hooks/use_unified_timeline_expandable_flyout'; +import type { UnifiedTimelineDataGridCellContext } from '../../types'; export const SAMPLE_SIZE_SETTING = 500; const DataGridMemoized = React.memo(UnifiedDataTable); @@ -73,8 +74,6 @@ type CommonDataTableProps = { updatedAt: number; isTextBasedQuery?: boolean; leadingControlColumns: EuiDataGridProps['leadingControlColumns']; - cellContext?: EuiDataGridProps['cellContext']; - eventIdToNoteIds?: Record<string, string[]>; } & Pick< UnifiedDataTableProps, | 'onSort' @@ -117,8 +116,6 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo( onSort, onFilter, leadingControlColumns, - cellContext, - eventIdToNoteIds, }) { const dispatch = useDispatch(); @@ -137,6 +134,7 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo( storage, dataViewFieldEditor, notifications: { toasts: toastsService }, + telemetry, theme, data: dataPluginContract, }, @@ -190,6 +188,10 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo( }, }, }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'right', + }); } else { dispatch( timelineActions.toggleDetailPanel({ @@ -202,7 +204,7 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo( activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); }, - [activeTab, dispatch, refetch, timelineId, isExpandableFlyoutDisabled, openFlyout] + [refetch, isExpandableFlyoutDisabled, openFlyout, timelineId, telemetry, dispatch, activeTab] ); const onTimelineLegacyFlyoutClose = useCallback(() => { @@ -389,28 +391,28 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo( Cell={Cell} visibleColumns={visibleColumns} visibleRowData={visibleRowData} - eventIdToNoteIds={eventIdToNoteIds} - rowHeight={rowHeight} setCustomGridBodyProps={setCustomGridBodyProps} - events={events} enabledRowRenderers={enabledRowRenderers} - eventIdsAddingNotes={cellContext?.eventIdsAddingNotes} - onToggleShowNotes={cellContext?.onToggleShowNotes} refetch={refetch} /> ), - [ - tableRows, - enabledRowRenderers, - events, - eventIdToNoteIds, - cellContext?.eventIdsAddingNotes, - cellContext?.onToggleShowNotes, - rowHeight, - refetch, - ] + [tableRows, enabledRowRenderers, refetch] ); + const cellContext: UnifiedTimelineDataGridCellContext = useMemo(() => { + return { + expandedEventId: expandedDoc?.id, + }; + }, [expandedDoc]); + + const finalRenderCustomBodyCallback = useMemo(() => { + return enabledRowRenderers.length > 0 ? renderCustomBodyCallback : undefined; + }, [enabledRowRenderers.length, renderCustomBodyCallback]); + + const finalTrailControlColumns = useMemo(() => { + return enabledRowRenderers.length > 0 ? trailingControlColumns : undefined; + }, [enabledRowRenderers.length, trailingControlColumns]); + return ( <StatefulEventContext.Provider value={activeStatefulEventContext}> <StyledTimelineUnifiedDataTable> @@ -460,8 +462,8 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo( showMultiFields={true} cellActionsMetadata={cellActionsMetadata} externalAdditionalControls={additionalControls} - renderCustomGridBody={renderCustomBodyCallback} - trailingControlColumns={trailingControlColumns} + renderCustomGridBody={finalRenderCustomBodyCallback} + trailingControlColumns={finalTrailControlColumns} externalControlColumns={leadingControlColumns} cellContext={cellContext} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx index 8e24d7fcbcc5..5897c1aec401 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx @@ -17,6 +17,7 @@ import { StatefulRowRenderersBrowser } from '../../../row_renderers_browser'; import * as i18n from './translations'; import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../../../common/components/exit_full_screen'; import { LastUpdatedContainer } from '../../footer/last_updated'; +import { RowRendererSwitch } from '../../../row_renderer_switch'; export const isFullScreen = ({ globalFullScreen, @@ -68,6 +69,7 @@ export const ToolbarAdditionalControlsComponent: React.FC<Props> = ({ timelineId return ( <> + <RowRendererSwitch timelineId={timelineId} /> <StatefulRowRenderersBrowser timelineId={timelineId} /> <LastUpdatedContainer updatedAt={updatedAt} /> <span className="rightPosition"> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx index 4cb56cdeba01..881a129c90a8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx @@ -118,8 +118,6 @@ const TestComponent = (props: Partial<ComponentProps<typeof UnifiedTimeline>>) = dataLoadingState: DataLoadingState.loaded, updatedAt: Date.now(), isTextBasedQuery: false, - eventIdToNoteIds: {} as Record<string, string[]>, - pinnedEventIds: {} as Record<string, boolean>, }; const dispatch = useDispatch(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx index eaa85e635e4c..b4e67b373c84 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx @@ -47,10 +47,8 @@ import { DRAG_DROP_FIELD } from './data_table/translations'; import { TimelineResizableLayout } from './resizable_layout'; import TimelineDataTable from './data_table'; import { timelineActions } from '../../../store'; -import type { TimelineModel } from '../../../store/model'; import { getFieldsListCreationOptions } from './get_fields_list_creation_options'; import { defaultUdtHeaders } from './default_headers'; -import type { TimelineDataGridCellContext } from '../types'; const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({ className: `${className}`, @@ -120,8 +118,6 @@ interface Props { dataView: DataView; trailingControlColumns?: EuiDataGridProps['trailingControlColumns']; leadingControlColumns?: EuiDataGridProps['leadingControlColumns']; - pinnedEventIds: TimelineModel['pinnedEventIds']; - eventIdToNoteIds: TimelineModel['eventIdToNoteIds']; } const UnifiedTimelineComponent: React.FC<Props> = ({ @@ -146,8 +142,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({ dataView, trailingControlColumns, leadingControlColumns, - pinnedEventIds, - eventIdToNoteIds, }) => { const dispatch = useDispatch(); const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null); @@ -170,22 +164,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({ query: { filterManager: timelineFilterManager }, } = timelineDataService; - const [eventIdsAddingNotes, setEventIdsAddingNotes] = useState<Set<string>>(new Set()); - - const onToggleShowNotes = useCallback( - (eventId?: string) => { - if (!eventId) return; - const newSet = new Set(eventIdsAddingNotes); - if (newSet.has(eventId)) { - newSet.delete(eventId); - setEventIdsAddingNotes(newSet); - } else { - setEventIdsAddingNotes(newSet.add(eventId)); - } - }, - [eventIdsAddingNotes] - ); - const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo( () => ({ fieldFormats, @@ -373,17 +351,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({ onFieldEdited(); }, [onFieldEdited]); - const cellContext: TimelineDataGridCellContext = useMemo(() => { - return { - events, - pinnedEventIds, - eventIdsAddingNotes, - onToggleShowNotes, - eventIdToNoteIds, - refetch, - }; - }, [events, pinnedEventIds, eventIdsAddingNotes, onToggleShowNotes, eventIdToNoteIds, refetch]); - return ( <TimelineBodyContainer className="timelineBodyContainer" ref={setSidebarContainer}> <TimelineResizableLayout @@ -460,8 +427,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({ onFilter={onAddFilter as DocViewFilterFn} trailingControlColumns={trailingControlColumns} leadingControlColumns={leadingControlColumns} - cellContext={cellContext} - eventIdToNoteIds={eventIdToNoteIds} /> </EventDetailsWidthProvider> </DropOverlayWrapper> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx index 30001b2ed3a3..78ab042155e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx @@ -56,6 +56,12 @@ export const StyledMainEuiPanel = styled(EuiPanel).attrs(({ className = '' }) => height: 100%; `; +export const leadingActionsColumnStyles = ` + .udtTimeline .euiDataGridRowCell--controlColumn:nth-child(3) .euiDataGridRowCell__content { + padding: 0; + } +`; + export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = '' }) => ({ className: `unifiedDataTable ${className}`, role: 'rowgroup', @@ -167,6 +173,8 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = '' display: flex; align-items: baseline; } + + ${leadingActionsColumnStyles} `; export const UnifiedTimelineGlobalStyles = createGlobalStyle` diff --git a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx index 8c30f2485ae0..03f3eba0ab23 100644 --- a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx @@ -22,6 +22,7 @@ import type { TimeRange } from '../../common/store/inputs/model'; import { useDiscoverInTimelineContext } from '../../common/components/discover_in_timeline/use_discover_in_timeline_context'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers'; +import { timelineDefaults } from '../store/defaults'; export interface UseCreateTimelineParams { /** @@ -75,6 +76,7 @@ export const useCreateTimeline = ({ selectedPatterns, }) ); + dispatch( timelineActions.createTimeline({ columns: unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders, @@ -84,6 +86,9 @@ export const useCreateTimeline = ({ show, timelineType, updated: undefined, + excludedRowRendererIds: unifiedComponentsInTimelineEnabled + ? timelineDefaults.excludedRowRendererIds + : [], }) ); diff --git a/x-pack/plugins/security_solution/public/timelines/links.ts b/x-pack/plugins/security_solution/public/timelines/links.ts index 9315417d9764..97667c0ce8aa 100644 --- a/x-pack/plugins/security_solution/public/timelines/links.ts +++ b/x-pack/plugins/security_solution/public/timelines/links.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { SecurityPageName, SERVER_APP_ID, TIMELINES_PATH } from '../../common/constants'; -import { TIMELINES } from '../app/translations'; +import { TIMELINES, NOTES } from '../app/translations'; import type { LinkItem } from '../common/links/types'; export const links: LinkItem = { @@ -30,5 +30,16 @@ export const links: LinkItem = { path: `${TIMELINES_PATH}/template`, sideNavDisabled: true, }, + { + id: SecurityPageName.notesManagement, + title: NOTES, + description: i18n.translate('xpack.securitySolution.appLinks.notesManagementDescription', { + defaultMessage: 'Visualize and delete notes.', + }), + path: `${TIMELINES_PATH}/notes`, + skipUrlState: true, + hideTimeline: true, + experimentalKey: 'securitySolutionNotesEnabled', + }, ], }; diff --git a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx index 384a0b86ff62..0fc2c87246a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx @@ -17,7 +17,7 @@ import { appendSearch } from '../../common/components/link_to/helpers'; import { TIMELINES_PATH } from '../../../common/constants'; -const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineType.default}|${TimelineType.template})`; +const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineType.default}|${TimelineType.template}|notes)`; const timelinesDefaultPath = `${TIMELINES_PATH}/${TimelineType.default}`; export const Timelines = React.memo(() => ( diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 547bedf1caea..459c37a4133f 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -41,7 +41,7 @@ export const TimelinesPage = React.memo(() => { {indicesExist ? ( <SecuritySolutionPageWrapper> <HeaderPage title={i18n.PAGE_TITLE}> - {capabilitiesCanUserCRUD && ( + {capabilitiesCanUserCRUD && tabName !== 'notes' ? ( <EuiFlexGroup gutterSize="s" alignItems="center"> <EuiFlexItem> <EuiButton @@ -56,7 +56,7 @@ export const TimelinesPage = React.memo(() => { <NewTimelineButton type={timelineType} /> </EuiFlexItem> </EuiFlexGroup> - )} + ) : null} </HeaderPage> <StatefulOpenTimeline @@ -66,6 +66,7 @@ export const TimelinesPage = React.memo(() => { setImportDataModalToggle={setImportDataModal} title={i18n.ALL_TIMELINES_PANEL_TITLE} data-test-subj="stateful-open-timeline" + tabName={tabName} /> </SecuritySolutionPageWrapper> ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts index 55155a30dbbb..27ca80320ff0 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts @@ -6,7 +6,7 @@ */ import { TimelineTabs } from '../../../common/types/timeline'; -import { TimelineType, TimelineStatus } from '../../../common/api/timeline'; +import { TimelineType, TimelineStatus, RowRendererId } from '../../../common/api/timeline'; import { defaultHeaders } from '../components/timeline/body/column_headers/default_headers'; import { normalizeTimeRange } from '../../common/utils/normalize_time_range'; @@ -35,7 +35,26 @@ export const timelineDefaults: SubsetTimelineModel & }, eventType: 'all', eventIdToNoteIds: {}, - excludedRowRendererIds: [], + excludedRowRendererIds: [ + RowRendererId.alert, + RowRendererId.alerts, + RowRendererId.auditd, + RowRendererId.auditd_file, + RowRendererId.library, + RowRendererId.netflow, + RowRendererId.plain, + RowRendererId.registry, + RowRendererId.suricata, + RowRendererId.system, + RowRendererId.system_dns, + RowRendererId.system_endgame_process, + RowRendererId.system_file, + RowRendererId.system_fim, + RowRendererId.system_security_event, + RowRendererId.system_socket, + RowRendererId.threat_match, + RowRendererId.zeek, + ], expandedDetail: {}, highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts index 624ae513a418..9fb058560104 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts @@ -102,7 +102,10 @@ describe('Timeline note middleware', () => { }, }, }); - expect(selectTimelineById(store.getState(), TimelineId.test).eventIdToNoteIds).toEqual({}); + expect(selectTimelineById(store.getState(), TimelineId.test).eventIdToNoteIds).toEqual({ + // existing note + '1': ['1'], + }); await store.dispatch(updateNote({ note: testNote })); await store.dispatch( addNoteToEvent({ eventId: testEventId, id: TimelineId.test, noteId: testNote.id }) diff --git a/x-pack/plugins/security_solution/public/timelines/store/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/selectors.ts index d639a8f25674..19281ba06883 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/selectors.ts @@ -178,3 +178,8 @@ export const selectDataInTimeline = createSelector( return !isEmpty(dataProviders) || !isEmpty(get('filterQuery.kuery.expression', kqlQuery)); } ); + +export const selectExcludedRowRendererIds = createSelector( + selectTimelineById, + (timeline) => timeline?.excludedRowRendererIds +); diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 1eb944143c0f..129f3e7728f9 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -60,6 +60,7 @@ import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; import type { PluginStartContract } from '@kbn/alerting-plugin/public/plugin'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; +import type { IntegrationAssistantPluginStart } from '@kbn/integration-assistant-plugin/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { Detections } from './detections'; @@ -152,6 +153,7 @@ export interface StartPlugins { savedSearch: SavedSearchPublicPluginStart; alerting: PluginStartContract; core: CoreStart; + integrationAssistant?: IntegrationAssistantPluginStart; } export interface StartPluginsDependencies extends StartPlugins { diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml index 3bc3320b9602..12214295817a 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml @@ -53,6 +53,7 @@ viewer: - ".fleet-actions*" - "risk-score.risk-score-*" - ".asset-criticality.asset-criticality-*" + - ".ml-anomalies-*" privileges: - read applications: @@ -119,6 +120,10 @@ editor: - "read" - "write" allow_restricted_indices: false + - names: + - ".ml-anomalies-*" + privileges: + - read applications: - application: "kibana-.kibana" privileges: @@ -174,6 +179,7 @@ t1_analyst: - ".fleet-actions*" - risk-score.risk-score-* - .asset-criticality.asset-criticality-* + - ".ml-anomalies-*" privileges: - read applications: @@ -222,6 +228,7 @@ t2_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read - names: @@ -284,6 +291,7 @@ t3_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -303,6 +311,7 @@ t3_analyst: - feature_siem.process_operations_all - feature_siem.actions_log_management_all # Response actions history - feature_siem.file_operations_all + - feature_siem.scan_operations_all - feature_securitySolutionCases.all - feature_securitySolutionAssistant.all - feature_actions.read @@ -349,6 +358,7 @@ threat_intelligence_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -408,6 +418,7 @@ rule_author: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -473,6 +484,7 @@ soc_manager: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -534,6 +546,7 @@ detections_admin: - metrics-endpoint.metadata_current_* - .fleet-agents* - .fleet-actions* + - ".ml-anomalies-*" privileges: - read - names: @@ -592,6 +605,10 @@ platform_engineer: privileges: - read - write + - names: + - ".ml-anomalies-*" + privileges: + - read applications: - application: "kibana-.kibana" privileges: @@ -643,6 +660,7 @@ endpoint_operations_analyst: - .lists* - .items* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read - names: @@ -711,6 +729,7 @@ endpoint_policy_manager: - packetbeat-* - winlogbeat-* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read - names: diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts index 304c4e6d744e..872cb1c352fd 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts @@ -31,6 +31,7 @@ export const getT3Analyst: () => Omit<Role, 'name'> = () => { 'process_operations_all', 'actions_log_management_all', 'file_operations_all', + 'scan_operations_all', ], }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index bb46bc2da92b..be85d8b83210 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -19,22 +19,28 @@ import { import type { TranslatedEntry, TranslatedExceptionListItem } from '../../schemas/artifacts'; import { ArtifactConstants } from './common'; import { + ENDPOINT_ARTIFACT_LISTS, ENDPOINT_BLOCKLISTS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; +import { FILTER_PROCESS_DESCENDANTS_TAG } from '../../../../common/endpoint/service/artifacts/constants'; +import type { ExperimentalFeatures } from '../../../../common'; +import { allowedExperimentalValues } from '../../../../common'; describe('artifacts lists', () => { let mockExceptionClient: ExceptionListClient; + let defaultFeatures: ExperimentalFeatures; beforeEach(() => { jest.clearAllMocks(); mockExceptionClient = listMock.getExceptionListClient(); + defaultFeatures = allowedExperimentalValues; }); - describe('getFilteredEndpointExceptionListRaw', () => { + describe('getFilteredEndpointExceptionListRaw + convertExceptionsToEndpointFormat', () => { const TEST_FILTER = 'exception-list-agnostic.attributes.os_types:"linux"'; test('it should get convert the exception lists response to the proper endpoint format', async () => { @@ -69,7 +75,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -115,7 +121,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -166,7 +172,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -219,7 +225,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -271,7 +277,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -314,7 +320,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -357,7 +363,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -386,7 +392,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); // Expect 1 exceptions, the first two calls returned the same exception list items expect(translated.entries.length).toEqual(1); @@ -402,7 +408,7 @@ describe('artifacts lists', () => { filter: TEST_FILTER, listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated.entries.length).toEqual(0); }); @@ -516,6 +522,215 @@ describe('artifacts lists', () => { ); expect(artifact1.decodedSha256).toEqual(artifact2.decodedSha256); }); + + describe('`descendant_of` operator', () => { + let enabledProcessDescendant: ExperimentalFeatures; + + beforeEach(() => { + enabledProcessDescendant = { + ...defaultFeatures, + filterProcessDescendantsForEventFiltersEnabled: true, + }; + }); + + test('when feature flag is disabled, it should not convert `descendant_of`', async () => { + const expectedEndpointExceptions: TranslatedExceptionListItem = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'exact_caseless', + value: 'C:\\Windows\\System32\\ping.exe', + }, + ], + }; + + const inputEntry: EntriesArray = [ + { + field: 'process.executable.text', + operator: 'included', + type: 'match', + value: 'C:\\Windows\\System32\\ping.exe', + }, + ]; + + const exceptionMock = getFoundExceptionListItemSchemaMock(); + exceptionMock.data[0].tags.push(FILTER_PROCESS_DESCENDANTS_TAG); + exceptionMock.data[0].list_id = ENDPOINT_ARTIFACT_LISTS.eventFilters.id; + exceptionMock.data[0].entries = inputEntry; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionMock); + + const resp = await getFilteredEndpointExceptionListRaw({ + elClient: mockExceptionClient, + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', { + filterProcessDescendantsForEventFiltersEnabled: false, + } as ExperimentalFeatures); + + expect(translated).toEqual({ entries: [expectedEndpointExceptions] }); + }); + + test.each([ + ENDPOINT_ARTIFACT_LISTS.blocklists.id, + ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id, + ENDPOINT_ARTIFACT_LISTS.trustedApps.id, + ])('when %s, it should not convert `descendant_of`', async (listId) => { + const expectedEndpointExceptions: TranslatedExceptionListItem = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'exact_caseless', + value: 'C:\\Windows\\System32\\ping.exe', + }, + ], + }; + + const inputEntry: EntriesArray = [ + { + field: 'process.executable.text', + operator: 'included', + type: 'match', + value: 'C:\\Windows\\System32\\ping.exe', + }, + ]; + + const exceptionMock = getFoundExceptionListItemSchemaMock(); + exceptionMock.data[0].tags.push(FILTER_PROCESS_DESCENDANTS_TAG); + exceptionMock.data[0].list_id = listId; + exceptionMock.data[0].entries = inputEntry; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionMock); + + const resp = await getFilteredEndpointExceptionListRaw({ + elClient: mockExceptionClient, + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', enabledProcessDescendant); + + expect(translated).toEqual({ entries: [expectedEndpointExceptions] }); + }); + + test('it should convert `descendant_of` to the expected format', async () => { + const expectedEndpointExceptions: TranslatedExceptionListItem = { + type: 'simple', + entries: [ + { + operator: 'included', + type: 'descendent_of', + value: { + entries: [ + { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'exact_caseless', + value: 'C:\\Windows\\System32\\ping.exe', + }, + { + field: 'event.category', + operator: 'included', + type: 'exact_cased', + value: 'process', + }, + ], + }, + ], + }, + }, + ], + }; + + const inputEntry: EntriesArray = [ + { + field: 'process.executable.text', + operator: 'included', + type: 'match', + value: 'C:\\Windows\\System32\\ping.exe', + }, + ]; + + const exceptionMock = getFoundExceptionListItemSchemaMock(); + exceptionMock.data[0].tags.push(FILTER_PROCESS_DESCENDANTS_TAG); + exceptionMock.data[0].list_id = ENDPOINT_ARTIFACT_LISTS.eventFilters.id; + exceptionMock.data[0].entries = inputEntry; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionMock); + + const resp = await getFilteredEndpointExceptionListRaw({ + elClient: mockExceptionClient, + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', enabledProcessDescendant); + + expect(translated).toEqual({ entries: [expectedEndpointExceptions] }); + }); + + test('it should handle nested entries properly', async () => { + const expectedEndpointExceptions: TranslatedExceptionListItem = { + type: 'simple', + entries: [ + { + operator: 'included', + type: 'descendent_of', + value: { + entries: [ + { + type: 'simple', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + { + field: 'event.category', + operator: 'included', + type: 'exact_cased', + value: 'process', + }, + ], + }, + ], + }, + }, + ], + }; + + const exceptionMock = getFoundExceptionListItemSchemaMock(); + exceptionMock.data[0].tags.push(FILTER_PROCESS_DESCENDANTS_TAG); + exceptionMock.data[0].list_id = ENDPOINT_ARTIFACT_LISTS.eventFilters.id; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionMock); + + const resp = await getFilteredEndpointExceptionListRaw({ + elClient: mockExceptionClient, + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', enabledProcessDescendant); + + expect(translated).toEqual({ entries: [expectedEndpointExceptions] }); + }); + }); }); describe('Endpoint Artifacts', () => { @@ -562,7 +777,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -607,7 +822,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -646,7 +861,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -710,7 +925,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], @@ -750,7 +965,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -815,7 +1030,7 @@ describe('artifacts lists', () => { listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -864,7 +1079,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -911,7 +1126,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -952,7 +1167,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -1019,7 +1234,7 @@ describe('artifacts lists', () => { listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -1059,7 +1274,7 @@ describe('artifacts lists', () => { filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -1126,7 +1341,7 @@ describe('artifacts lists', () => { listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual({ entries: [expectedEndpointExceptions], }); @@ -1174,7 +1389,7 @@ describe('artifacts lists', () => { listId: ENDPOINT_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual(TEST_EXCEPTION_LIST_ITEM); expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ @@ -1198,7 +1413,7 @@ describe('artifacts lists', () => { os: 'macos', listId: ENDPOINT_TRUSTED_APPS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual(TEST_EXCEPTION_LIST_ITEM); @@ -1224,7 +1439,7 @@ describe('artifacts lists', () => { listId: ENDPOINT_EVENT_FILTERS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual(TEST_EXCEPTION_LIST_ITEM); expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ @@ -1249,7 +1464,7 @@ describe('artifacts lists', () => { listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual(TEST_EXCEPTION_LIST_ITEM); expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ @@ -1274,7 +1489,7 @@ describe('artifacts lists', () => { listId: ENDPOINT_BLOCKLISTS_LIST_ID, }); - const translated = convertExceptionsToEndpointFormat(resp, 'v1'); + const translated = convertExceptionsToEndpointFormat(resp, 'v1', defaultFeatures); expect(translated).toEqual(TEST_EXCEPTION_LIST_ITEM); expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 3b9690c902fc..8040a4f59698 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -26,6 +26,9 @@ import { } from '@kbn/securitysolution-list-constants'; import type { ExceptionListClient } from '@kbn/lists-plugin/server'; import { validate } from '@kbn/securitysolution-io-ts-utils'; +import { PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY } from '../../../../common/endpoint/service/artifacts/constants'; +import type { ExperimentalFeatures } from '../../../../common'; +import { isFilterProcessDescendantsEnabled } from '../../../../common/endpoint/service/artifacts/utils'; import type { InternalArtifactCompleteSchema, TranslatedEntry, @@ -36,6 +39,7 @@ import type { TranslatedEntryNestedEntry, TranslatedExceptionListItem, WrappedTranslatedExceptionList, + TranslatedEntriesOfDescendantOf, } from '../../schemas'; import { translatedPerformantEntries as translatedPerformantEntriesType, @@ -78,10 +82,11 @@ export type ArtifactListId = export function convertExceptionsToEndpointFormat( exceptions: ExceptionListItemSchema[], - schemaVersion: string + schemaVersion: string, + experimentalFeatures: ExperimentalFeatures ) { const translatedExceptions = { - entries: translateToEndpointExceptions(exceptions, schemaVersion), + entries: translateToEndpointExceptions(exceptions, schemaVersion, experimentalFeatures), }; const [validated, errors] = validate(translatedExceptions, wrappedTranslatedExceptionList); if (errors != null) { @@ -151,13 +156,24 @@ export async function getAllItemsFromEndpointExceptionList({ * Translates Exception list items to Exceptions the endpoint can understand * @param exceptions * @param schemaVersion + * @param experimentalFeatures */ export function translateToEndpointExceptions( exceptions: ExceptionListItemSchema[], - schemaVersion: string + schemaVersion: string, + experimentalFeatures: ExperimentalFeatures ): TranslatedExceptionListItem[] { const entrySet = new Set(); - const entriesFiltered: TranslatedExceptionListItem[] = []; + const uniqueItems: TranslatedExceptionListItem[] = []; + const storeUniqueItem = (item: TranslatedExceptionListItem) => { + const entryHash = createHash('sha256').update(JSON.stringify(item)).digest('hex'); + + if (!entrySet.has(entryHash)) { + uniqueItems.push(item); + entrySet.add(entryHash); + } + }; + if (schemaVersion === 'v1') { exceptions.forEach((entry) => { // For Blocklist, we create a single entry for each blocklist entry item @@ -172,30 +188,51 @@ export function translateToEndpointExceptions( ...entry, entries: [blocklistSingleEntry], }); - const entryHash = createHash('sha256') - .update(JSON.stringify(translatedItem)) - .digest('hex'); - if (!entrySet.has(entryHash)) { - entriesFiltered.push(translatedItem); - entrySet.add(entryHash); - } + + storeUniqueItem(translatedItem); }); + } else if ( + experimentalFeatures.filterProcessDescendantsForEventFiltersEnabled && + entry.list_id === ENDPOINT_ARTIFACT_LISTS.eventFilters.id && + isFilterProcessDescendantsEnabled(entry) + ) { + const translatedItem = translateProcessDescendantEventFilter(schemaVersion, entry); + storeUniqueItem(translatedItem); } else { const translatedItem = translateItem(schemaVersion, entry); - const entryHash = createHash('sha256').update(JSON.stringify(translatedItem)).digest('hex'); - if (!entrySet.has(entryHash)) { - entriesFiltered.push(translatedItem); - entrySet.add(entryHash); - } + storeUniqueItem(translatedItem); } }); - return entriesFiltered; + return uniqueItems; } else { throw new Error('unsupported schemaVersion'); } } +function translateProcessDescendantEventFilter( + schemaVersion: string, + entry: ExceptionListItemSchema +): TranslatedExceptionListItem { + const translatedEntries: TranslatedEntriesOfDescendantOf = translateItem(schemaVersion, { + ...entry, + entries: [...entry.entries, PROCESS_DESCENDANT_EVENT_FILTER_EXTRA_ENTRY], + }) as TranslatedEntriesOfDescendantOf; + + return { + type: entry.type, + entries: [ + { + operator: 'included', + type: 'descendent_of', + value: { + entries: [translatedEntries], + }, + }, + ], + }; +} + function getMatcherFunction({ field, matchAny, @@ -246,8 +283,9 @@ function translateItem( item: ExceptionListItemSchema ): TranslatedExceptionListItem { const itemSet = new Set(); - const getEntries = (): TranslatedExceptionListItem['entries'] => { - return item.entries.reduce<TranslatedEntry[]>((translatedEntries, entry) => { + + const entries: TranslatedExceptionListItem['entries'] = item.entries.reduce<TranslatedEntry[]>( + (translatedEntries, entry) => { const translatedEntry = translateEntry(schemaVersion, item.entries, entry, item.os_types[0]); if (translatedEntry !== undefined) { @@ -272,12 +310,13 @@ function translateItem( } return translatedEntries; - }, []); - }; + }, + [] + ); return { type: item.type, - entries: getEntries(), + entries, }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts index 3abbbe129288..262ead3493df 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts @@ -84,11 +84,36 @@ export const translatedEntryNested = t.exact( }) ); +const translatedEntriesOfDescendantOf = t.type({ + type: t.string, + entries: t.array( + t.union([ + translatedEntryNested, + translatedEntryMatch, + translatedEntryMatchWildcard, + translatedEntryMatchAny, + ]) + ), +}); +export type TranslatedEntriesOfDescendantOf = t.TypeOf<typeof translatedEntriesOfDescendantOf>; + +export const translatedEntryDescendantOf = t.exact( + t.type({ + operator, + type: t.keyof({ descendent_of: null }), + value: t.type({ + entries: t.array(translatedEntriesOfDescendantOf), + }), + }) +); +export type TranslatedEntryDescendantOf = t.TypeOf<typeof translatedEntryDescendantOf>; + export const translatedEntry = t.union([ translatedEntryNested, translatedEntryMatch, translatedEntryMatchWildcard, translatedEntryMatchAny, + translatedEntryDescendantOf, ]); export type TranslatedEntry = t.TypeOf<typeof translatedEntry>; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts index 858a1f53a10d..1fa046496f23 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts @@ -27,7 +27,7 @@ describe('EndpointActionsClient', () => { 'endpoint_ids' | 'case_ids' > => { return { - endpoint_ids: ['1-2-3', 'invalid-id'], + endpoint_ids: ['1-2-3', 'invalid-id', '1-2-3'], case_ids: ['case-a'], }; }; @@ -42,8 +42,8 @@ describe('EndpointActionsClient', () => { responseActionsClientMock.createIsolateOptions(getCommonResponseActionOptions()) ); - expect(classConstructorOptions.endpointService.createLogger().debug).toHaveBeenCalledWith( - 'The following agent ids are not valid: ["invalid-id"]' + expect(classConstructorOptions.endpointService.createLogger().warn).toHaveBeenCalledWith( + 'The following agent ids are not valid: ["invalid-id"] and will not be included in action request' ); }); @@ -85,11 +85,87 @@ describe('EndpointActionsClient', () => { }); }); - it('should write action request document', async () => { + it('should write action request document to endpoint action request index with given set of valid/invalid agent ids', async () => { await endpointActionsClient.isolate( responseActionsClientMock.createIsolateOptions(getCommonResponseActionOptions()) ); + expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith( + { + index: ENDPOINT_ACTIONS_INDEX, + document: { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: expect.any(String), + data: { + command: 'isolate', + comment: + 'test comment. (WARNING: The following agent ids are not valid: ["invalid-id"] and will not be included in action request)', + parameters: undefined, + }, + expiration: expect.any(String), + input_type: 'endpoint', + type: 'INPUT_ACTION', + }, + agent: { + id: ['1-2-3'], + }, + user: { + id: 'foo', + }, + }, + refresh: 'wait_for', + }, + expect.anything() + ); + }); + + it('should write correct comment when invalid agent ids', async () => { + await endpointActionsClient.isolate( + responseActionsClientMock.createIsolateOptions({ + ...getCommonResponseActionOptions(), + comment: '', + }) + ); + + expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith( + { + index: ENDPOINT_ACTIONS_INDEX, + document: { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: expect.any(String), + data: { + command: 'isolate', + comment: + '(WARNING: The following agent ids are not valid: ["invalid-id"] and will not be included in action request)', + parameters: undefined, + }, + expiration: expect.any(String), + input_type: 'endpoint', + type: 'INPUT_ACTION', + }, + agent: { + id: ['1-2-3'], + }, + user: { + id: 'foo', + }, + }, + refresh: 'wait_for', + }, + expect.anything() + ); + }); + + it('should write action request document to endpoint action request index with given valid agent ids', async () => { + await endpointActionsClient.isolate( + responseActionsClientMock.createIsolateOptions({ + endpoint_ids: ['1-2-3'], + case_ids: ['case-a'], + }) + ); + expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith( { index: ENDPOINT_ACTIONS_INDEX, @@ -107,7 +183,7 @@ describe('EndpointActionsClient', () => { type: 'INPUT_ACTION', }, agent: { - id: ['1-2-3', 'invalid-id'], + id: ['1-2-3'], }, user: { id: 'foo', @@ -119,9 +195,12 @@ describe('EndpointActionsClient', () => { ); }); - it('should update cases', async () => { + it('should update cases for valid agent ids', async () => { await endpointActionsClient.isolate( - responseActionsClientMock.createIsolateOptions(getCommonResponseActionOptions()) + responseActionsClientMock.createIsolateOptions({ + endpoint_ids: ['1-2-3'], + case_ids: ['case-a'], + }) ); expect(classConstructorOptions.casesClient?.attachments.bulkCreate).toHaveBeenCalledWith({ @@ -138,10 +217,38 @@ describe('EndpointActionsClient', () => { endpointId: '1-2-3', hostname: 'Host-ku5jy6j0pw', }, + ], + }, + externalReferenceStorage: { + type: 'elasticSearchDoc', + }, + owner: 'securitySolution', + type: 'externalReference', + }, + ], + caseId: 'case-a', + }); + }); + + it('should update cases for valid/invalid agent ids', async () => { + await endpointActionsClient.isolate( + responseActionsClientMock.createIsolateOptions(getCommonResponseActionOptions()) + ); + + expect(classConstructorOptions.casesClient?.attachments.bulkCreate).toHaveBeenCalledWith({ + attachments: [ + { + externalReferenceAttachmentTypeId: 'endpoint', + externalReferenceId: expect.any(String), + externalReferenceMetadata: { + command: 'isolate', + comment: + 'test comment. (WARNING: The following agent ids are not valid: ["invalid-id"] and will not be included in action request)', + targets: [ { agentType: 'endpoint', - endpointId: 'invalid-id', - hostname: '', + endpointId: '1-2-3', + hostname: 'Host-ku5jy6j0pw', }, ], }, @@ -175,6 +282,7 @@ describe('EndpointActionsClient', () => { { meta: true } ); }); + it('should create an action with error when agents are invalid', async () => { // @ts-expect-error mocking this for testing purposes endpointActionsClient.checkAgentIds = jest.fn().mockResolvedValueOnce({ diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts index eb7921e4ca42..2c908bd1a3f3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts @@ -52,6 +52,13 @@ import type { } from '../lib/types'; import { DEFAULT_EXECUTE_ACTION_TIMEOUT } from '../../../../../../common/endpoint/service/response_actions/constants'; +const getInvalidAgentsWarning = (invalidAgents: string[]) => + invalidAgents.length + ? `The following agent ids are not valid: ${JSON.stringify( + invalidAgents + )} and will not be included in action request` + : ''; + export class EndpointActionsClient extends ResponseActionsClientImpl { protected readonly agentType: ResponseActionAgentType = 'endpoint'; @@ -61,14 +68,15 @@ export class EndpointActionsClient extends ResponseActionsClientImpl { allValid: boolean; hosts: HostMetadata[]; }> { + const uniqueIds = [...new Set(ids)]; const foundEndpointHosts = await this.options.endpointService .getEndpointMetadataService() - .getMetadataForEndpoints(this.options.esClient, [...new Set(ids)]); + .getMetadataForEndpoints(this.options.esClient, uniqueIds); const validIds = foundEndpointHosts.map((endpoint: HostMetadata) => endpoint.elastic.agent.id); const invalidIds = ids.filter((id) => !validIds.includes(id)); if (invalidIds.length) { - this.log.debug(`The following agent ids are not valid: ${JSON.stringify(invalidIds)}`); + this.log.warn(getInvalidAgentsWarning(invalidIds)); } return { @@ -88,12 +96,12 @@ export class EndpointActionsClient extends ResponseActionsClientImpl { actionReq: TOptions, options?: TMethodOptions ): Promise<TResponse> { - const agentIds = await this.checkAgentIds(actionReq.endpoint_ids); + const validatedAgents = await this.checkAgentIds(actionReq.endpoint_ids); const actionId = uuidv4(); const { error: validationError } = await this.validateRequest({ ...actionReq, command, - endpoint_ids: agentIds.valid || [], + endpoint_ids: validatedAgents.valid || [], }); const { hosts, ruleName, ruleId, error } = this.getMethodOptions<TMethodOptions>(options); @@ -104,7 +112,7 @@ export class EndpointActionsClient extends ResponseActionsClientImpl { try { await this.dispatchActionViaFleet({ actionId, - agents: agentIds.valid, + agents: validatedAgents.valid, data: { command, comment: actionReq.comment, @@ -122,29 +130,40 @@ export class EndpointActionsClient extends ResponseActionsClientImpl { } } + // Append warning message to comment if there are invalid agents + const commentMessage = actionReq.comment ? actionReq.comment : ''; + const warningMessage = `(WARNING: ${getInvalidAgentsWarning(validatedAgents.invalid)})`; + const comment = validatedAgents.invalid.length + ? commentMessage + ? `${commentMessage}. ${warningMessage}` + : warningMessage + : actionReq.comment; + // Write action to endpoint index await this.writeActionRequestToEndpointIndex({ ...actionReq, + endpoint_ids: validatedAgents.valid, error: actionError, ruleId, ruleName, hosts, actionId, command, + comment, }); // Update cases await this.updateCases({ command, actionId, - comment: actionReq.comment, + comment, caseIds: actionReq.case_ids, alertIds: actionReq.alert_ids, - hosts: actionReq.endpoint_ids.map((hostId) => { + hosts: validatedAgents.valid.map((hostId) => { return { hostId, hostname: - agentIds.hosts.find((host) => host.agent.id === hostId)?.host.hostname ?? + validatedAgents.hosts.find((host) => host.agent.id === hostId)?.host.hostname ?? hosts?.[hostId].name ?? '', }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index d6e82322ffa9..bb2ea455675c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -36,7 +36,6 @@ import { import type { ManifestManagerContext } from './manifest_manager'; import { ManifestManager } from './manifest_manager'; import type { EndpointArtifactClientInterface } from '../artifact_client'; -import { InvalidInternalManifestError } from '../errors'; import { EndpointError } from '../../../../../common/endpoint/errors'; import type { Artifact } from '@kbn/fleet-plugin/server'; import { ProductFeatureSecurityKey } from '@kbn/security-solution-features/keys'; @@ -45,6 +44,8 @@ import { createFetchAllArtifactsIterableMock, generateArtifactMock, } from '@kbn/fleet-plugin/server/services/artifacts/mocks'; +import type { ExperimentalFeatures } from '../../../../../common'; +import { allowedExperimentalValues } from '../../../../../common'; const getArtifactObject = (artifact: InternalArtifactSchema) => JSON.parse(Buffer.from(artifact.body!, 'base64').toString()); @@ -94,6 +95,8 @@ describe('ManifestManager', () => { let ARTIFACT_TRUSTED_APPS_MACOS: InternalArtifactCompleteSchema; let ARTIFACT_TRUSTED_APPS_WINDOWS: InternalArtifactCompleteSchema; + let defaultFeatures: ExperimentalFeatures; + beforeAll(async () => { ARTIFACTS = await getMockArtifacts(); ARTIFACTS_BY_ID = { @@ -107,6 +110,7 @@ describe('ManifestManager', () => { ARTIFACT_EXCEPTIONS_WINDOWS = ARTIFACTS[1]; ARTIFACT_TRUSTED_APPS_MACOS = ARTIFACTS[3]; ARTIFACT_TRUSTED_APPS_WINDOWS = ARTIFACTS[4]; + defaultFeatures = allowedExperimentalValues; }); describe('getLastComputedManifest from Unified Manifest SO', () => { @@ -147,7 +151,6 @@ describe('ManifestManager', () => { const manifestManager = new ManifestManager( buildManifestManagerContextMock({ savedObjectsClient, - experimentalFeatures: ['unifiedManifestEnabled'], }) ); @@ -167,7 +170,6 @@ describe('ManifestManager', () => { const manifestManager = new ManifestManager( buildManifestManagerContextMock({ savedObjectsClient, - experimentalFeatures: ['unifiedManifestEnabled'], }) ); @@ -239,7 +241,6 @@ describe('ManifestManager', () => { const savedObjectsClient = savedObjectsClientMock.create(); const manifestManagerContext = buildManifestManagerContextMock({ savedObjectsClient, - experimentalFeatures: ['unifiedManifestEnabled'], }); const manifestManager = new ManifestManager(manifestManagerContext); @@ -271,186 +272,9 @@ describe('ManifestManager', () => { }); }); - describe('getLastComputedManifest', () => { - test('Returns null when saved object not found', async () => { - const savedObjectsClient = savedObjectsClientMock.create(); - const manifestManager = new ManifestManager( - buildManifestManagerContextMock({ savedObjectsClient }) - ); - - savedObjectsClient.get = jest.fn().mockRejectedValue({ output: { statusCode: 404 } }); - - expect(await manifestManager.getLastComputedManifest()).toBe(null); - }); - - test('Throws error when saved object client responds with 500', async () => { - const savedObjectsClient = savedObjectsClientMock.create(); - const manifestManager = new ManifestManager( - buildManifestManagerContextMock({ savedObjectsClient }) - ); - const error = { message: 'bad request', output: { statusCode: 500 } }; - - savedObjectsClient.get = jest.fn().mockRejectedValue(error); - - await expect(manifestManager.getLastComputedManifest()).rejects.toThrow( - new EndpointError('bad request', error) - ); - }); - - test('Throws error when no version on the manifest', async () => { - const savedObjectsClient = savedObjectsClientMock.create(); - const manifestManager = new ManifestManager( - buildManifestManagerContextMock({ savedObjectsClient }) - ); - - savedObjectsClient.get = jest.fn().mockResolvedValue({}); - - await expect(manifestManager.getLastComputedManifest()).rejects.toStrictEqual( - new InvalidInternalManifestError('Internal Manifest map SavedObject is missing version') - ); - }); - - test('Retrieves empty manifest successfully', async () => { - const savedObjectsClient = savedObjectsClientMock.create(); - const manifestManager = new ManifestManager( - buildManifestManagerContextMock({ savedObjectsClient }) - ); - - savedObjectsClient.get = jest.fn().mockResolvedValue({ - attributes: { - created: '20-01-2020 10:00:00.000Z', - schemaVersion: 'v2', - semanticVersion: '1.0.0', - artifacts: [], - }, - version: '2.0.0', - }); - - const manifest = await manifestManager.getLastComputedManifest(); - - expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); - expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); - expect(manifest?.getSavedObjectVersion()).toStrictEqual('2.0.0'); - expect(manifest?.getAllArtifacts()).toStrictEqual([]); - }); - - test('Retrieves non empty manifest successfully', async () => { - const savedObjectsClient = savedObjectsClientMock.create(); - const manifestManagerContext = buildManifestManagerContextMock({ savedObjectsClient }); - const manifestManager = new ManifestManager(manifestManagerContext); - - savedObjectsClient.get = jest.fn().mockImplementation(async (objectType: string) => { - if (objectType === ManifestConstants.SAVED_OBJECT_TYPE) { - return { - attributes: { - created: '20-01-2020 10:00:00.000Z', - schemaVersion: 'v2', - semanticVersion: '1.0.0', - artifacts: [ - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_LINUX, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, - ], - }, - version: '2.0.0', - }; - } else { - return null; - } - }); - - ( - manifestManagerContext.artifactClient as jest.Mocked<EndpointArtifactClientInterface> - ).fetchAll.mockReturnValue(createFetchAllArtifactsIterableMock([ARTIFACTS as Artifact[]])); - - const manifest = await manifestManager.getLastComputedManifest(); - - expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); - expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); - expect(manifest?.getSavedObjectVersion()).toStrictEqual('2.0.0'); - expect(manifest?.getAllArtifacts()).toStrictEqual(ARTIFACTS.slice(0, 5)); - expect(manifest?.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(true); - expect(manifest?.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( - new Set() - ); - expect(manifest?.isDefaultArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)).toBe(true); - expect(manifest?.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_WINDOWS)).toStrictEqual( - new Set([TEST_POLICY_ID_1]) - ); - expect(manifest?.isDefaultArtifact(ARTIFACT_TRUSTED_APPS_MACOS)).toBe(false); - expect(manifest?.getArtifactTargetPolicies(ARTIFACT_TRUSTED_APPS_MACOS)).toStrictEqual( - new Set([TEST_POLICY_ID_1]) - ); - expect(manifest?.isDefaultArtifact(ARTIFACT_TRUSTED_APPS_WINDOWS)).toBe(false); - expect(manifest?.getArtifactTargetPolicies(ARTIFACT_TRUSTED_APPS_WINDOWS)).toStrictEqual( - new Set([TEST_POLICY_ID_1, TEST_POLICY_ID_2]) - ); - }); - - test("Retrieve non empty manifest and skips over artifacts that can't be found", async () => { - const savedObjectsClient = savedObjectsClientMock.create(); - const manifestManagerContext = buildManifestManagerContextMock({ savedObjectsClient }); - const manifestManager = new ManifestManager(manifestManagerContext); - - savedObjectsClient.get = jest.fn().mockImplementation(async (objectType: string) => { - if (objectType === ManifestConstants.SAVED_OBJECT_TYPE) { - return { - attributes: { - created: '20-01-2020 10:00:00.000Z', - schemaVersion: 'v2', - semanticVersion: '1.0.0', - artifacts: [ - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_LINUX, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, - ], - }, - version: '2.0.0', - }; - } else { - return null; - } - }); - - ( - manifestManagerContext.artifactClient as jest.Mocked<EndpointArtifactClientInterface> - ).fetchAll.mockReturnValue( - createFetchAllArtifactsIterableMock([ - // report the MACOS Exceptions artifact as not found - [ - ARTIFACT_TRUSTED_APPS_MACOS, - ARTIFACT_EXCEPTIONS_WINDOWS, - ARTIFACT_TRUSTED_APPS_WINDOWS, - ARTIFACTS_BY_ID[ARTIFACT_ID_EXCEPTIONS_LINUX], - ] as Artifact[], - ]) - ); - - const manifest = await manifestManager.getLastComputedManifest(); - - expect(manifest?.getAllArtifacts()).toStrictEqual(ARTIFACTS.slice(1, 5)); - - expect(manifestManagerContext.logger.warn).toHaveBeenCalledWith( - "Missing artifacts detected! Internal artifact manifest (SavedObject version [2.0.0]) references [1] artifact IDs that don't exist.\n" + - "First 10 below (run with logging set to 'debug' to see all):\n" + - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3' - ); - }); - }); - describe('commit unified manifest', () => { test('Correctly updates, creates and deletes unified manifest so', async () => { - const context = buildManifestManagerContextMock({ - experimentalFeatures: ['unifiedManifestEnabled'], - }); + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const manifest = ManifestManager.createDefaultManifest(); @@ -536,107 +360,7 @@ describe('ManifestManager', () => { }); }); - describe('commit', () => { - test('Creates new saved object if no saved object version', async () => { - const context = buildManifestManagerContextMock({}); - const manifestManager = new ManifestManager(context); - const manifest = ManifestManager.createDefaultManifest(); - - manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); - manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); - manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); - manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); - manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); - - context.savedObjectsClient.create = jest - .fn() - .mockImplementation((_type: string, object: InternalManifestSchema) => object); - - await expect(manifestManager.commit(manifest)).resolves.toBeUndefined(); - - expect(context.savedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(context.savedObjectsClient.create).toHaveBeenNthCalledWith( - 1, - ManifestConstants.SAVED_OBJECT_TYPE, - { - artifacts: [ - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_2 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_2 }, - ], - schemaVersion: 'v1', - semanticVersion: '1.0.0', - created: expect.anything(), - }, - { id: 'endpoint-manifest-v1' } - ); - }); - - test('Updates existing saved object if has saved object version', async () => { - const context = buildManifestManagerContextMock({}); - const manifestManager = new ManifestManager(context); - const manifest = new Manifest({ soVersion: '1.0.0' }); - - manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); - manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); - manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); - manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); - manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); - - context.savedObjectsClient.update = jest - .fn() - .mockImplementation((_type: string, _id: string, object: InternalManifestSchema) => object); - - await expect(manifestManager.commit(manifest)).resolves.toBeUndefined(); - - expect(context.savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(context.savedObjectsClient.update).toHaveBeenNthCalledWith( - 1, - ManifestConstants.SAVED_OBJECT_TYPE, - 'endpoint-manifest-v1', - { - artifacts: [ - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_2 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_2 }, - ], - schemaVersion: 'v1', - semanticVersion: '1.0.0', - }, - { version: '1.0.0' } - ); - }); - - test('Throws error when saved objects client fails', async () => { - const context = buildManifestManagerContextMock({}); - const manifestManager = new ManifestManager(context); - const manifest = new Manifest({ soVersion: '1.0.0' }); - const error = new Error(); - - context.savedObjectsClient.update = jest.fn().mockRejectedValue(error); - - await expect(manifestManager.commit(manifest)).rejects.toBe(error); - - expect(context.savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(context.savedObjectsClient.update).toHaveBeenNthCalledWith( - 1, - ManifestConstants.SAVED_OBJECT_TYPE, - 'endpoint-manifest-v1', - { - artifacts: [], - schemaVersion: 'v1', - semanticVersion: '1.0.0', - }, - { version: '1.0.0' } - ); - }); - }); - - describe.each([true, false])('buildNewManifest', (unifiedManifestSO) => { + describe('buildNewManifest', () => { const SUPPORTED_ARTIFACT_NAMES = [ ARTIFACT_NAME_EXCEPTIONS_MACOS, ARTIFACT_NAME_EXCEPTIONS_WINDOWS, @@ -659,10 +383,8 @@ describe('ManifestManager', () => { ...new Set(artifacts.map((artifact) => artifact.identifier)).values(), ]; - test(`Fails when exception list client fails when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Fails when exception list client fails`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = jest.fn().mockRejectedValue(new Error()); @@ -670,10 +392,8 @@ describe('ManifestManager', () => { await expect(manifestManager.buildNewManifest()).rejects.toThrow(); }); - test(`Builds fully new manifest if no baseline parameter passed and no exception list items when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Builds fully new manifest if no baseline parameter passed and no exception list items`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({}); @@ -701,7 +421,7 @@ describe('ManifestManager', () => { } }); - test(`Builds fully new manifest if no baseline parameter passed and present exception list items when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { + test(`Builds fully new manifest if no baseline parameter passed and present exception list items`, async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'], @@ -719,9 +439,7 @@ describe('ManifestManager', () => { os_types: ['macos'], tags: ['policy:all'], }); - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ @@ -750,29 +468,33 @@ describe('ManifestManager', () => { expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ - entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + entries: translateToEndpointExceptions([exceptionListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[1])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[2])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[3])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[4])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[5])).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + entries: translateToEndpointExceptions([trustedAppListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[8])).toStrictEqual({ - entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[11])).toStrictEqual({ - entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), + entries: translateToEndpointExceptions( + [hostIsolationExceptionsItem], + 'v1', + defaultFeatures + ), }); expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[14])).toStrictEqual({ - entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + entries: translateToEndpointExceptions([blocklistsListItem], 'v1', defaultFeatures), }); for (const artifact of artifacts) { @@ -783,7 +505,7 @@ describe('ManifestManager', () => { } }); - test(`Reuses artifacts when baseline parameter passed and present exception list items when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { + test(`Reuses artifacts when baseline parameter passed and present exception list items`, async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'], @@ -801,9 +523,7 @@ describe('ManifestManager', () => { os_types: ['macos'], tags: ['policy:all'], }); - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ @@ -842,22 +562,26 @@ describe('ManifestManager', () => { expect(getArtifactObject(artifacts[3])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[4])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[5])).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + entries: translateToEndpointExceptions([trustedAppListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[8])).toStrictEqual({ - entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[11])).toStrictEqual({ - entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), + entries: translateToEndpointExceptions( + [hostIsolationExceptionsItem], + 'v1', + defaultFeatures + ), }); expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[14])).toStrictEqual({ - entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + entries: translateToEndpointExceptions([blocklistsListItem], 'v1', defaultFeatures), }); for (const artifact of artifacts) { @@ -868,7 +592,7 @@ describe('ManifestManager', () => { } }); - test(`Builds fully new manifest with single entries when they are duplicated when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { + test(`Builds fully new manifest with single entries when they are duplicated`, async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'], @@ -886,9 +610,7 @@ describe('ManifestManager', () => { os_types: ['macos'], tags: ['policy:all'], }); - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const duplicatedEventFilterInDifferentPolicy = { @@ -938,16 +660,20 @@ describe('ManifestManager', () => { expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ - entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + entries: translateToEndpointExceptions([exceptionListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[1])).toStrictEqual({ - entries: translateToEndpointExceptions([duplicatedEndpointExceptionInDifferentOS], 'v1'), + entries: translateToEndpointExceptions( + [duplicatedEndpointExceptionInDifferentOS], + 'v1', + defaultFeatures + ), }); expect(getArtifactObject(artifacts[2])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[3])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[4])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[5])).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + entries: translateToEndpointExceptions([trustedAppListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); @@ -955,16 +681,21 @@ describe('ManifestManager', () => { expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: translateToEndpointExceptions( [eventFiltersListItem, duplicatedEventFilterInDifferentPolicy], - 'v1' + 'v1', + defaultFeatures ), }); expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[11])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[12])).toStrictEqual({ - entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), + entries: translateToEndpointExceptions( + [hostIsolationExceptionsItem], + 'v1', + defaultFeatures + ), }); expect(getArtifactObject(artifacts[13])).toStrictEqual({ - entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + entries: translateToEndpointExceptions([blocklistsListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[14])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[15])).toStrictEqual({ entries: [] }); @@ -1002,7 +733,7 @@ describe('ManifestManager', () => { } }); - test(`Builds manifest with policy specific exception list items for trusted apps when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { + test(`Builds manifest with policy specific exception list items for trusted apps`, async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'], @@ -1015,9 +746,7 @@ describe('ManifestManager', () => { ], tags: [`policy:${TEST_POLICY_ID_2}`], }); - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ @@ -1043,19 +772,20 @@ describe('ManifestManager', () => { expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ - entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + entries: translateToEndpointExceptions([exceptionListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[1])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[2])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[3])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[4])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[5])).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + entries: translateToEndpointExceptions([trustedAppListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: translateToEndpointExceptions( [trustedAppListItem, trustedAppListItemPolicy2], - 'v1' + 'v1', + defaultFeatures ), }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); @@ -1076,7 +806,7 @@ describe('ManifestManager', () => { }); }); - describe.each([true, false])('buildNewManifest when using app features', (unifiedManifestSO) => { + describe('buildNewManifest when using app features', () => { const SUPPORTED_ARTIFACT_NAMES = [ ARTIFACT_NAME_EXCEPTIONS_MACOS, ARTIFACT_NAME_EXCEPTIONS_WINDOWS, @@ -1099,7 +829,7 @@ describe('ManifestManager', () => { ...new Set(artifacts.map((artifact) => artifact.identifier)).values(), ]; - test(`when it has endpoint artifact management app feature it should not generate host isolation exceptions when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { + test(`when it has endpoint artifact management app feature it should not generate host isolation exceptions`, async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'], @@ -1117,10 +847,9 @@ describe('ManifestManager', () => { os_types: ['macos'], tags: ['policy:all'], }); - const context = buildManifestManagerContextMock( - { ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}) }, - [ProductFeatureSecurityKey.endpointArtifactManagement] - ); + const context = buildManifestManagerContextMock({}, [ + ProductFeatureSecurityKey.endpointArtifactManagement, + ]); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ @@ -1149,19 +878,19 @@ describe('ManifestManager', () => { expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ - entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + entries: translateToEndpointExceptions([exceptionListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[1])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[2])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[3])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[4])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[5])).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + entries: translateToEndpointExceptions([trustedAppListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[8])).toStrictEqual({ - entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); @@ -1169,7 +898,7 @@ describe('ManifestManager', () => { expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[14])).toStrictEqual({ - entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + entries: translateToEndpointExceptions([blocklistsListItem], 'v1', defaultFeatures), }); for (const artifact of artifacts) { @@ -1180,7 +909,7 @@ describe('ManifestManager', () => { } }); - test(`when it has endpoint artifact management and response actions app features it should generate all exceptions when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { + test(`when it has endpoint artifact management and response actions app features it should generate all exceptions`, async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'], @@ -1198,13 +927,10 @@ describe('ManifestManager', () => { os_types: ['macos'], tags: ['policy:all'], }); - const context = buildManifestManagerContextMock( - { ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}) }, - [ - ProductFeatureSecurityKey.endpointArtifactManagement, - ProductFeatureSecurityKey.endpointResponseActions, - ] - ); + const context = buildManifestManagerContextMock({}, [ + ProductFeatureSecurityKey.endpointArtifactManagement, + ProductFeatureSecurityKey.endpointResponseActions, + ]); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ @@ -1233,29 +959,33 @@ describe('ManifestManager', () => { expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ - entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + entries: translateToEndpointExceptions([exceptionListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[1])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[2])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[3])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[4])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[5])).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + entries: translateToEndpointExceptions([trustedAppListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[8])).toStrictEqual({ - entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1', defaultFeatures), }); expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[11])).toStrictEqual({ - entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), + entries: translateToEndpointExceptions( + [hostIsolationExceptionsItem], + 'v1', + defaultFeatures + ), }); expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[14])).toStrictEqual({ - entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + entries: translateToEndpointExceptions([blocklistsListItem], 'v1', defaultFeatures), }); for (const artifact of artifacts) { @@ -1266,7 +996,7 @@ describe('ManifestManager', () => { } }); - test(`when does not have right app features, should not generate any exception when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { + test(`when does not have right app features, should not generate any exception`, async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'], @@ -1284,10 +1014,7 @@ describe('ManifestManager', () => { os_types: ['macos'], tags: ['policy:all'], }); - const context = buildManifestManagerContextMock( - { ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}) }, - [] - ); + const context = buildManifestManagerContextMock({}, []); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ @@ -1340,102 +1067,93 @@ describe('ManifestManager', () => { }); }); - describe.each([true, false])( - 'buildNewManifest when Endpoint Exceptions contain `matches`', - (unifiedManifestSO) => { - test(`when contains only \`wildcard\`, \`event.module=endpoint\` is added when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const exceptionListItem = getExceptionListItemSchemaMock({ - os_types: ['macos'], - entries: [ - { type: 'wildcard', operator: 'included', field: 'path', value: '*match_me*' }, - { type: 'wildcard', operator: 'excluded', field: 'not_path', value: '*dont_match_me*' }, - ], - }); - const expectedExceptionListItem = getExceptionListItemSchemaMock({ - os_types: ['macos'], - entries: [ - ...exceptionListItem.entries, - { type: 'match', operator: 'included', field: 'event.module', value: 'endpoint' }, - ], - }); + describe('buildNewManifest when Endpoint Exceptions contain `matches`', () => { + test(`when contains only \`wildcard\`, \`event.module=endpoint\` is added `, async () => { + const exceptionListItem = getExceptionListItemSchemaMock({ + os_types: ['macos'], + entries: [ + { type: 'wildcard', operator: 'included', field: 'path', value: '*match_me*' }, + { type: 'wildcard', operator: 'excluded', field: 'not_path', value: '*dont_match_me*' }, + ], + }); + const expectedExceptionListItem = getExceptionListItemSchemaMock({ + os_types: ['macos'], + entries: [ + ...exceptionListItem.entries, + { type: 'match', operator: 'included', field: 'event.module', value: 'endpoint' }, + ], + }); - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); - const manifestManager = new ManifestManager(context); + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); - context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ - [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, - }); + context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ + [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, + }); - context.packagePolicyService.fetchAllItemIds = getMockPolicyFetchAllItemIds([ - TEST_POLICY_ID_1, - ]); + context.packagePolicyService.fetchAllItemIds = getMockPolicyFetchAllItemIds([ + TEST_POLICY_ID_1, + ]); - const manifest = await manifestManager.buildNewManifest(); + const manifest = await manifestManager.buildNewManifest(); - expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); - expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); - expect(manifest?.getSavedObjectVersion()).toBeUndefined(); + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toBeUndefined(); - const artifacts = manifest.getAllArtifacts(); + const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(15); + expect(artifacts.length).toBe(15); - expect(getArtifactObject(artifacts[0])).toStrictEqual({ - entries: translateToEndpointExceptions([expectedExceptionListItem], 'v1'), - }); + expect(getArtifactObject(artifacts[0])).toStrictEqual({ + entries: translateToEndpointExceptions([expectedExceptionListItem], 'v1', defaultFeatures), }); + }); - test(`when contains anything next to \`wildcard\`, nothing is added when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const exceptionListItem = getExceptionListItemSchemaMock({ - os_types: ['macos'], - entries: [ - { type: 'wildcard', operator: 'included', field: 'path', value: '*match_me*' }, - { type: 'wildcard', operator: 'excluded', field: 'path', value: '*dont_match_me*' }, - { type: 'match', operator: 'included', field: 'path', value: 'something' }, - ], - }); - const expectedExceptionListItem = getExceptionListItemSchemaMock({ - os_types: ['macos'], - entries: [...exceptionListItem.entries], - }); + test(`when contains anything next to \`wildcard\`, nothing is added `, async () => { + const exceptionListItem = getExceptionListItemSchemaMock({ + os_types: ['macos'], + entries: [ + { type: 'wildcard', operator: 'included', field: 'path', value: '*match_me*' }, + { type: 'wildcard', operator: 'excluded', field: 'path', value: '*dont_match_me*' }, + { type: 'match', operator: 'included', field: 'path', value: 'something' }, + ], + }); + const expectedExceptionListItem = getExceptionListItemSchemaMock({ + os_types: ['macos'], + entries: [...exceptionListItem.entries], + }); - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); - const manifestManager = new ManifestManager(context); + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); - context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ - [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, - }); + context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ + [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, + }); - context.packagePolicyService.fetchAllItemIds = getMockPolicyFetchAllItemIds([ - TEST_POLICY_ID_1, - ]); + context.packagePolicyService.fetchAllItemIds = getMockPolicyFetchAllItemIds([ + TEST_POLICY_ID_1, + ]); - const manifest = await manifestManager.buildNewManifest(); + const manifest = await manifestManager.buildNewManifest(); - expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); - expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); - expect(manifest?.getSavedObjectVersion()).toBeUndefined(); + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toBeUndefined(); - const artifacts = manifest.getAllArtifacts(); + const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(15); + expect(artifacts.length).toBe(15); - expect(getArtifactObject(artifacts[0])).toStrictEqual({ - entries: translateToEndpointExceptions([expectedExceptionListItem], 'v1'), - }); + expect(getArtifactObject(artifacts[0])).toStrictEqual({ + entries: translateToEndpointExceptions([expectedExceptionListItem], 'v1', defaultFeatures), }); - } - ); + }); + }); - describe.each([true, false])('deleteArtifacts', (unifiedManifestSO) => { - test(`Successfully invokes saved objects client when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + describe('deleteArtifacts', () => { + test(`Successfully invokes saved objects client`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); await expect( @@ -1451,10 +1169,8 @@ describe('ManifestManager', () => { ]); }); - test(`Returns errors for partial failures when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Returns errors for partial failures`, async () => { + const context = buildManifestManagerContextMock({}); const artifactClient = context.artifactClient as jest.Mocked<EndpointArtifactClientInterface>; const manifestManager = new ManifestManager(context); const error = new Error(); @@ -1481,11 +1197,9 @@ describe('ManifestManager', () => { }); }); - describe.each([true, false])('pushArtifacts', (unifiedManifestSO) => { - test(`Successfully invokes artifactClient when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + describe('pushArtifacts', () => { + test(`Successfully invokes artifactClient `, async () => { + const context = buildManifestManagerContextMock({}); const artifactClient = context.artifactClient as jest.Mocked<EndpointArtifactClientInterface>; const manifestManager = new ManifestManager(context); const newManifest = ManifestManager.createDefaultManifest(); @@ -1507,10 +1221,8 @@ describe('ManifestManager', () => { ]); }); - test(`Returns errors for partial failures when unifiedManifestEnabled feature flag is set to: ${unifiedManifestSO}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedManifestSO ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Returns errors for partial failures`, async () => { + const context = buildManifestManagerContextMock({}); const artifactClient = context.artifactClient as jest.Mocked<EndpointArtifactClientInterface>; const manifestManager = new ManifestManager(context); const newManifest = ManifestManager.createDefaultManifest(); @@ -1551,16 +1263,14 @@ describe('ManifestManager', () => { }); }); - describe.each([true, false])('tryDispatch', (unifiedSavedObject) => { + describe('tryDispatch', () => { const getMockPolicyFetchAllItems = (items: PackagePolicy[]) => jest.fn(async function* () { yield items; }); - test(`Should not dispatch if no policies when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Should not dispatch if no policies`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const manifest = new Manifest({ soVersion: '1.0.0' }); @@ -1572,10 +1282,8 @@ describe('ManifestManager', () => { expect(context.packagePolicyService.bulkUpdate).toHaveBeenCalledTimes(0); }); - test(`Should return errors if invalid config for package policy when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Should return errors if invalid config for package policy`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const manifest = new Manifest({ soVersion: '1.0.0' }); @@ -1592,10 +1300,8 @@ describe('ManifestManager', () => { expect(context.packagePolicyService.bulkUpdate).toHaveBeenCalledTimes(0); }); - test(`Should not dispatch if semantic version has not changed when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Should not dispatch if semantic version has not changed`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const manifest = new Manifest({ soVersion: '1.0.0' }); @@ -1623,10 +1329,8 @@ describe('ManifestManager', () => { expect(context.packagePolicyService.bulkUpdate).toHaveBeenCalledTimes(0); }); - test(`Should dispatch to only policies where list of artifacts changed when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Should dispatch to only policies where list of artifacts changed`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const manifest = new Manifest({ soVersion: '1.0.0', semanticVersion: '1.0.1' }); @@ -1694,10 +1398,8 @@ describe('ManifestManager', () => { ); }); - test(`Should dispatch to only policies where artifact content changed when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Should dispatch to only policies where artifact content changed`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const manifest = new Manifest({ soVersion: '1.0.0', semanticVersion: '1.0.1' }); @@ -1767,10 +1469,8 @@ describe('ManifestManager', () => { ); }); - test(`Should return partial errors when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`Should return partial errors`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); const error = new Error(); @@ -1813,11 +1513,9 @@ describe('ManifestManager', () => { }); }); - describe.each([true, false])('cleanup artifacts', (unifiedSavedObject) => { - test(`Successfully removes orphan artifacts when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + describe('cleanup artifacts', () => { + test(`Successfully removes orphan artifacts`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); (context.artifactClient.fetchAll as jest.Mock).mockReturnValue( @@ -1845,10 +1543,8 @@ describe('ManifestManager', () => { ]); }); - test(`When there is no artifact to be removed when unifiedManifestEnabled feature flag is set to: ${unifiedSavedObject}`, async () => { - const context = buildManifestManagerContextMock({ - ...(unifiedSavedObject ? { experimentalFeatures: ['unifiedManifestEnabled'] } : {}), - }); + test(`When there is no artifact to be removed`, async () => { + const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 3a6cfc5be280..f10dbb1ab3a5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -189,7 +189,7 @@ export class ManifestManager { const exceptions: ExceptionListItemSchema[] = listId === ENDPOINT_LIST_ID ? allExceptionsByListId : allExceptionsByListId.filter(filter); - return convertExceptionsToEndpointFormat(exceptions, schemaVersion); + return convertExceptionsToEndpointFormat(exceptions, schemaVersion, this.experimentalFeatures); } /** diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/index.ts index 57dd3553cb4c..f2ffb98ad543 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/index.ts @@ -7,4 +7,5 @@ export { numberDiffAlgorithm } from './number_diff_algorithm'; export { singleLineStringDiffAlgorithm } from './single_line_string_diff_algorithm'; +export { scalarArrayDiffAlgorithm } from './scalar_array_diff_algorithm'; export { simpleDiffAlgorithm } from './simple_diff_algorithm'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/number_diff_algorithm.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/number_diff_algorithm.test.ts index 43f6c9ed97e9..ddefb27a397d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/number_diff_algorithm.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/number_diff_algorithm.test.ts @@ -14,7 +14,7 @@ import { import { numberDiffAlgorithm } from './number_diff_algorithm'; describe('numberDiffAlgorithm', () => { - it('returns current_version as merged output if there is no update', () => { + it('returns current_version as merged output if there is no update - scenario AAA', () => { const mockVersions: ThreeVersionsOf<number> = { base_version: 1, current_version: 1, @@ -33,7 +33,7 @@ describe('numberDiffAlgorithm', () => { ); }); - it('returns current_version as merged output if current_version is different and there is no update', () => { + it('returns current_version as merged output if current_version is different and there is no update - scenario ABA', () => { const mockVersions: ThreeVersionsOf<number> = { base_version: 1, current_version: 2, @@ -52,7 +52,7 @@ describe('numberDiffAlgorithm', () => { ); }); - it('returns target_version as merged output if current_version is the same and there is an update', () => { + it('returns target_version as merged output if current_version is the same and there is an update - scenario AAB', () => { const mockVersions: ThreeVersionsOf<number> = { base_version: 1, current_version: 1, @@ -71,7 +71,7 @@ describe('numberDiffAlgorithm', () => { ); }); - it('returns current_version as merged output if current version is different but it matches the update', () => { + it('returns current_version as merged output if current version is different but it matches the update - scenario ABB', () => { const mockVersions: ThreeVersionsOf<number> = { base_version: 1, current_version: 2, @@ -90,7 +90,7 @@ describe('numberDiffAlgorithm', () => { ); }); - it('returns current_version as merged output if all three versions are different', () => { + it('returns current_version as merged output if all three versions are different - scenario ABC', () => { const mockVersions: ThreeVersionsOf<number> = { base_version: 1, current_version: 2, @@ -110,7 +110,7 @@ describe('numberDiffAlgorithm', () => { }); describe('if base_version is missing', () => { - it('returns current_version as merged output if current_version and target_version are the same', () => { + it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => { const mockVersions: ThreeVersionsOf<number> = { base_version: MissingVersion, current_version: 1, @@ -129,7 +129,7 @@ describe('numberDiffAlgorithm', () => { ); }); - it('returns target_version as merged output if current_version and target_version are different', () => { + it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => { const mockVersions: ThreeVersionsOf<number> = { base_version: MissingVersion, current_version: 1, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/scalar_array_diff_algorithm.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/scalar_array_diff_algorithm.test.ts new file mode 100644 index 000000000000..81f3c0272ac6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/scalar_array_diff_algorithm.test.ts @@ -0,0 +1,333 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ThreeVersionsOf } from '../../../../../../../../common/api/detection_engine'; +import { + ThreeWayDiffOutcome, + ThreeWayMergeOutcome, + MissingVersion, +} from '../../../../../../../../common/api/detection_engine'; +import { scalarArrayDiffAlgorithm } from './scalar_array_diff_algorithm'; + +describe('scalarArrayDiffAlgorithm', () => { + it('returns current_version as merged output if there is no update - scenario AAA', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'three'], + current_version: ['one', 'two', 'three'], + target_version: ['one', 'two', 'three'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.current_version, + diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('returns current_version as merged output if current_version is different and there is no update - scenario ABA', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'three'], + current_version: ['one', 'three', 'four'], + target_version: ['one', 'two', 'three'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.current_version, + diff_outcome: ThreeWayDiffOutcome.CustomizedValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('returns target_version as merged output if current_version is the same and there is an update - scenario AAB', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'three'], + current_version: ['one', 'two', 'three'], + target_version: ['one', 'four', 'three'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.target_version, + diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate, + merge_outcome: ThreeWayMergeOutcome.Target, + has_conflict: false, + }) + ); + }); + + it('returns current_version as merged output if current version is different but it matches the update - scenario ABB', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'three'], + current_version: ['one', 'three', 'four'], + target_version: ['one', 'four', 'three'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.current_version, + diff_outcome: ThreeWayDiffOutcome.CustomizedValueSameUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('returns custom merged version as merged output if all three versions are different - scenario ABC', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'three'], + current_version: ['two', 'three', 'four', 'five'], + target_version: ['one', 'three', 'four', 'six'], + }; + const expectedMergedVersion = ['three', 'four', 'five', 'six']; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: expectedMergedVersion, + diff_outcome: ThreeWayDiffOutcome.CustomizedValueCanUpdate, + merge_outcome: ThreeWayMergeOutcome.Merged, + has_conflict: false, + }) + ); + }); + + describe('if base_version is missing', () => { + it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: MissingVersion, + current_version: ['one', 'two', 'three'], + target_version: ['one', 'two', 'three'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.current_version, + diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: MissingVersion, + current_version: ['one', 'two', 'three'], + target_version: ['one', 'four', 'three'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.target_version, + diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate, + merge_outcome: ThreeWayMergeOutcome.Target, + has_conflict: false, + }) + ); + }); + }); + + describe('edge cases', () => { + it('compares arrays agnostic of order', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'three'], + current_version: ['one', 'three', 'two'], + target_version: ['three', 'one', 'two'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.current_version, + diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + describe('compares arrays deduplicated', () => { + it('when values duplicated in base version', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'two'], + current_version: ['one', 'two'], + target_version: ['one', 'two'], + }; + const expectedMergedVersion = ['one', 'two']; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: expectedMergedVersion, + diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('when values are duplicated in current version', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two'], + current_version: ['one', 'two', 'two'], + target_version: ['one', 'two'], + }; + const expectedMergedVersion = ['one', 'two']; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: expectedMergedVersion, + diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('when values are duplicated in target version', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two'], + current_version: ['one', 'two'], + target_version: ['one', 'two', 'two'], + }; + const expectedMergedVersion = ['one', 'two']; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: expectedMergedVersion, + diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('when values are duplicated in all versions', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two', 'two'], + current_version: ['two', 'two', 'three'], + target_version: ['one', 'one', 'three', 'three'], + }; + const expectedMergedVersion = ['three']; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: expectedMergedVersion, + diff_outcome: ThreeWayDiffOutcome.CustomizedValueCanUpdate, + merge_outcome: ThreeWayMergeOutcome.Merged, + has_conflict: false, + }) + ); + }); + }); + + describe('compares empty arrays', () => { + it('when base version is empty', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: [], + current_version: ['one', 'two'], + target_version: ['one', 'two'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.current_version, + diff_outcome: ThreeWayDiffOutcome.CustomizedValueSameUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('when current version is empty', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two'], + current_version: [], + target_version: ['one', 'two'], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.current_version, + diff_outcome: ThreeWayDiffOutcome.CustomizedValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + + it('when target version is empty', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: ['one', 'two'], + current_version: ['one', 'two'], + target_version: [], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: mockVersions.target_version, + diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate, + merge_outcome: ThreeWayMergeOutcome.Target, + has_conflict: false, + }) + ); + }); + + it('when all versions are empty', () => { + const mockVersions: ThreeVersionsOf<string[]> = { + base_version: [], + current_version: [], + target_version: [], + }; + + const result = scalarArrayDiffAlgorithm(mockVersions); + + expect(result).toEqual( + expect.objectContaining({ + merged_version: [], + diff_outcome: ThreeWayDiffOutcome.StockValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + has_conflict: false, + }) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/scalar_array_diff_algorithm.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/scalar_array_diff_algorithm.ts new file mode 100644 index 000000000000..18cf7f4f8b2c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/scalar_array_diff_algorithm.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { difference, union, uniq } from 'lodash'; +import { assertUnreachable } from '../../../../../../../../common/utility_types'; +import type { + ThreeVersionsOf, + ThreeWayDiff, +} from '../../../../../../../../common/api/detection_engine/prebuilt_rules'; +import { + determineOrderAgnosticDiffOutcome, + determineIfValueCanUpdate, + ThreeWayDiffOutcome, + ThreeWayMergeOutcome, + MissingVersion, +} from '../../../../../../../../common/api/detection_engine/prebuilt_rules'; + +/** + * Diff algorithm used for arrays of scalar values (eg. numbers, strings, booleans, etc.) + * + * NOTE: Diffing logic will be agnostic to array order + */ +export const scalarArrayDiffAlgorithm = <TValue>( + versions: ThreeVersionsOf<TValue[]> +): ThreeWayDiff<TValue[]> => { + const { + base_version: baseVersion, + current_version: currentVersion, + target_version: targetVersion, + } = versions; + + const diffOutcome = determineOrderAgnosticDiffOutcome(baseVersion, currentVersion, targetVersion); + const valueCanUpdate = determineIfValueCanUpdate(diffOutcome); + + const { mergeOutcome, mergedVersion } = mergeVersions({ + baseVersion, + currentVersion, + targetVersion, + diffOutcome, + }); + + return { + base_version: baseVersion, + current_version: currentVersion, + target_version: targetVersion, + merged_version: mergedVersion, + + diff_outcome: diffOutcome, + merge_outcome: mergeOutcome, + has_update: valueCanUpdate, + has_conflict: mergeOutcome === ThreeWayMergeOutcome.Conflict, + }; +}; + +interface MergeResult<TValue> { + mergeOutcome: ThreeWayMergeOutcome; + mergedVersion: TValue[]; +} + +interface MergeArgs<TValue> { + baseVersion: TValue[] | MissingVersion; + currentVersion: TValue[]; + targetVersion: TValue[]; + diffOutcome: ThreeWayDiffOutcome; +} + +const mergeVersions = <TValue>({ + baseVersion, + currentVersion, + targetVersion, + diffOutcome, +}: MergeArgs<TValue>): MergeResult<TValue> => { + const dedupedBaseVersion = baseVersion !== MissingVersion ? uniq(baseVersion) : MissingVersion; + const dedupedCurrentVersion = uniq(currentVersion); + const dedupedTargetVersion = uniq(targetVersion); + + switch (diffOutcome) { + case ThreeWayDiffOutcome.StockValueNoUpdate: + case ThreeWayDiffOutcome.CustomizedValueNoUpdate: + case ThreeWayDiffOutcome.CustomizedValueSameUpdate: { + return { + mergeOutcome: ThreeWayMergeOutcome.Current, + mergedVersion: dedupedCurrentVersion, + }; + } + case ThreeWayDiffOutcome.StockValueCanUpdate: { + return { + mergeOutcome: ThreeWayMergeOutcome.Target, + mergedVersion: dedupedTargetVersion, + }; + } + case ThreeWayDiffOutcome.CustomizedValueCanUpdate: { + if (dedupedBaseVersion === MissingVersion) { + return { + mergeOutcome: ThreeWayMergeOutcome.Merged, + mergedVersion: union(currentVersion, targetVersion), + }; + } + + const addedCurrent = difference(dedupedCurrentVersion, dedupedBaseVersion); + const removedCurrent = difference(dedupedBaseVersion, dedupedCurrentVersion); + + const addedTarget = difference(dedupedTargetVersion, dedupedBaseVersion); + const removedTarget = difference(dedupedBaseVersion, dedupedTargetVersion); + + const bothAdded = union(addedCurrent, addedTarget); + const bothRemoved = union(removedCurrent, removedTarget); + + const merged = difference(union(dedupedBaseVersion, bothAdded), bothRemoved); + + return { + mergeOutcome: ThreeWayMergeOutcome.Merged, + mergedVersion: merged, + }; + } + default: + return assertUnreachable(diffOutcome); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/single_line_string_diff_algorithm.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/single_line_string_diff_algorithm.test.ts index a4f5197979db..427b592985e0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/single_line_string_diff_algorithm.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/algorithms/single_line_string_diff_algorithm.test.ts @@ -14,7 +14,7 @@ import { import { singleLineStringDiffAlgorithm } from './single_line_string_diff_algorithm'; describe('singleLineStringDiffAlgorithm', () => { - it('returns current_version as merged output if there is no update', () => { + it('returns current_version as merged output if there is no update - scenario AAA', () => { const mockVersions: ThreeVersionsOf<string> = { base_version: 'A', current_version: 'A', @@ -33,7 +33,7 @@ describe('singleLineStringDiffAlgorithm', () => { ); }); - it('returns current_version as merged output if current_version is different and there is no update', () => { + it('returns current_version as merged output if current_version is different and there is no update - scenario ABA', () => { const mockVersions: ThreeVersionsOf<string> = { base_version: 'A', current_version: 'B', @@ -52,7 +52,7 @@ describe('singleLineStringDiffAlgorithm', () => { ); }); - it('returns target_version as merged output if current_version is the same and there is an update', () => { + it('returns target_version as merged output if current_version is the same and there is an update - scenario AAB', () => { const mockVersions: ThreeVersionsOf<string> = { base_version: 'A', current_version: 'A', @@ -71,7 +71,7 @@ describe('singleLineStringDiffAlgorithm', () => { ); }); - it('returns current_version as merged output if current version is different but it matches the update', () => { + it('returns current_version as merged output if current version is different but it matches the update - scenario ABB', () => { const mockVersions: ThreeVersionsOf<string> = { base_version: 'A', current_version: 'B', @@ -90,7 +90,7 @@ describe('singleLineStringDiffAlgorithm', () => { ); }); - it('returns current_version as merged output if all three versions are different', () => { + it('returns current_version as merged output if all three versions are different - scenario ABC', () => { const mockVersions: ThreeVersionsOf<string> = { base_version: 'A', current_version: 'B', @@ -110,7 +110,7 @@ describe('singleLineStringDiffAlgorithm', () => { }); describe('if base_version is missing', () => { - it('returns current_version as merged output if current_version and target_version are the same', () => { + it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => { const mockVersions: ThreeVersionsOf<string> = { base_version: MissingVersion, current_version: 'A', @@ -129,7 +129,7 @@ describe('singleLineStringDiffAlgorithm', () => { ); }); - it('returns target_version as merged output if current_version and target_version are different', () => { + it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => { const mockVersions: ThreeVersionsOf<string> = { base_version: MissingVersion, current_version: 'A', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.test.ts index 537d7b6abaf8..5df02371befa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.test.ts @@ -110,6 +110,51 @@ describe('rule_converters', () => { }); }); + describe('machine learning rules', () => { + test('should accept machine learning params when existing rule type is machine learning', () => { + const patchParams = { + anomaly_threshold: 5, + }; + const rule = getMlRuleParams(); + const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); + expect(patchedParams).toEqual( + expect.objectContaining({ + anomalyThreshold: 5, + }) + ); + }); + + test('should reject invalid machine learning params when existing rule type is machine learning', () => { + const patchParams = { + anomaly_threshold: 'invalid', + } as PatchRuleRequestBody; + const rule = getMlRuleParams(); + expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( + 'anomaly_threshold: Expected number, received string' + ); + }); + + it('accepts suppression params', () => { + const patchParams = { + alert_suppression: { + group_by: ['agent.name'], + missing_fields_strategy: 'suppress' as const, + }, + }; + const rule = getMlRuleParams(); + const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); + + expect(patchedParams).toEqual( + expect.objectContaining({ + alertSuppression: { + groupBy: ['agent.name'], + missingFieldsStrategy: 'suppress', + }, + }) + ); + }); + }); + test('should accept threat match params when existing rule type is threat match', () => { const patchParams = { threat_indicator_path: 'my.indicator', @@ -298,29 +343,6 @@ describe('rule_converters', () => { ); }); - test('should accept machine learning params when existing rule type is machine learning', () => { - const patchParams = { - anomaly_threshold: 5, - }; - const rule = getMlRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - anomalyThreshold: 5, - }) - ); - }); - - test('should reject invalid machine learning params when existing rule type is machine learning', () => { - const patchParams = { - anomaly_threshold: 'invalid', - } as PatchRuleRequestBody; - const rule = getMlRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - 'anomaly_threshold: Expected number, received string' - ); - }); - test('should accept new terms params when existing rule type is new terms', () => { const patchParams = { new_terms_fields: ['event.new_field'], @@ -344,6 +366,7 @@ describe('rule_converters', () => { ); }); }); + describe('typeSpecificCamelToSnake', () => { describe('EQL', () => { test('should accept EQL params when existing rule type is EQL', () => { @@ -396,6 +419,54 @@ describe('rule_converters', () => { ); }); }); + + describe('machine learning rules', () => { + it('accepts normal params', () => { + const params = { + anomalyThreshold: 74, + machineLearningJobId: ['job-1'], + }; + const ruleParams = { ...getMlRuleParams(), ...params }; + const transformedParams = typeSpecificCamelToSnake(ruleParams); + expect(transformedParams).toEqual( + expect.objectContaining({ + anomaly_threshold: 74, + machine_learning_job_id: ['job-1'], + }) + ); + }); + + it('accepts suppression params', () => { + const params = { + anomalyThreshold: 74, + machineLearningJobId: ['job-1'], + alertSuppression: { + groupBy: ['event.type'], + duration: { + value: 10, + unit: 'm', + } as AlertSuppressionDuration, + missingFieldsStrategy: 'suppress' as AlertSuppressionMissingFieldsStrategy, + }, + }; + const ruleParams = { ...getMlRuleParams(), ...params }; + const transformedParams = typeSpecificCamelToSnake(ruleParams); + expect(transformedParams).toEqual( + expect.objectContaining({ + anomaly_threshold: 74, + machine_learning_job_id: ['job-1'], + alert_suppression: { + group_by: ['event.type'], + duration: { + value: 10, + unit: 'm', + }, + missing_fields_strategy: 'suppress', + }, + }) + ); + }); + }); }); describe('commonParamsCamelToSnake', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts index 7aac52dfe52c..db815f32fb5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts @@ -191,6 +191,7 @@ export const typeSpecificSnakeToCamel = ( type: params.type, anomalyThreshold: params.anomaly_threshold, machineLearningJobId: normalizeMachineLearningJobIds(params.machine_learning_job_id), + alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), }; } case 'new_terms': { @@ -338,6 +339,8 @@ const patchMachineLearningParams = ( machineLearningJobId: params.machine_learning_job_id ? normalizeMachineLearningJobIds(params.machine_learning_job_id) : existingRule.machineLearningJobId, + alertSuppression: + convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, }; }; @@ -706,6 +709,7 @@ export const typeSpecificCamelToSnake = ( type: params.type, anomaly_threshold: params.anomalyThreshold, machine_learning_job_id: params.machineLearningJobId, + alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), }; } case 'new_terms': { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts index 48637e898dda..b3000edf895d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts @@ -268,6 +268,7 @@ export const MachineLearningSpecificRuleParams = z.object({ type: z.literal('machine_learning'), anomalyThreshold: AnomalyThreshold, machineLearningJobId: z.array(z.string()), + alertSuppression: AlertSuppressionCamel.optional(), }); export type MachineLearningRuleParams = BaseRuleParams & MachineLearningSpecificRuleParams; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts index ca0edac6fca4..2d38b16e94b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts @@ -11,13 +11,15 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { SERVER_APP_ID } from '../../../../../common/constants'; import { MachineLearningRuleParams } from '../../rule_schema'; +import { getIsAlertSuppressionActive } from '../utils/get_is_alert_suppression_active'; import { mlExecutor } from './ml'; -import type { CreateRuleOptions, SecurityAlertType } from '../types'; +import type { CreateRuleOptions, SecurityAlertType, WrapSuppressedHits } from '../types'; +import { wrapSuppressedAlerts } from '../utils/wrap_suppressed_alerts'; export const createMlAlertType = ( createOptions: CreateRuleOptions ): SecurityAlertType<MachineLearningRuleParams, {}, {}, 'default'> => { - const { ml } = createOptions; + const { experimentalFeatures, ml, licensing } = createOptions; return { id: ML_RULE_TYPE_ID, name: 'Machine Learning Rule', @@ -56,11 +58,39 @@ export const createMlAlertType = ( wrapHits, exceptionFilter, unprocessedExceptions, + mergeStrategy, + alertTimestampOverride, + publicBaseUrl, + alertWithSuppression, + primaryTimestamp, + secondaryTimestamp, }, services, + spaceId, state, } = execOptions; + const isAlertSuppressionActive = await getIsAlertSuppressionActive({ + alertSuppression: completeRule.ruleParams.alertSuppression, + isFeatureDisabled: !experimentalFeatures.alertSuppressionForMachineLearningRuleEnabled, + licensing, + }); + + const wrapSuppressedHits: WrapSuppressedHits = (events, buildReasonMessage) => + wrapSuppressedAlerts({ + events, + spaceId, + completeRule, + mergeStrategy, + indicesToQuery: [], + buildReasonMessage, + alertTimestampOverride, + ruleExecutionLogger, + publicBaseUrl, + primaryTimestamp, + secondaryTimestamp, + }); + const result = await mlExecutor({ completeRule, tuple, @@ -72,6 +102,11 @@ export const createMlAlertType = ( wrapHits, exceptionFilter, unprocessedExceptions, + wrapSuppressedHits, + alertTimestampOverride, + alertWithSuppression, + isAlertSuppressionActive, + experimentalFeatures, }); return { ...result, state }; }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts index c357a7e077bb..59a0204ef954 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts @@ -9,6 +9,7 @@ import dateMath from '@kbn/datemath'; import type { RuleExecutorServicesMock } from '@kbn/alerting-plugin/server/mocks'; import { alertsMock } from '@kbn/alerting-plugin/server/mocks'; import { mlExecutor } from './ml'; +import type { ExperimentalFeatures } from '../../../../../common'; import { getCompleteRuleMock, getMlRuleParams } from '../../rule_schema/mocks'; import { getListClientMock } from '@kbn/lists-plugin/server/services/lists/list_client.mock'; import { findMlSignals } from './find_ml_signals'; @@ -21,6 +22,7 @@ jest.mock('./find_ml_signals'); jest.mock('./bulk_create_ml_signals'); describe('ml_executor', () => { + let mockExperimentalFeatures: jest.Mocked<ExperimentalFeatures>; let jobsSummaryMock: jest.Mock; let forceStartDatafeedsMock: jest.Mock; let stopDatafeedsMock: jest.Mock; @@ -37,6 +39,7 @@ describe('ml_executor', () => { const listClient = getListClientMock(); beforeEach(() => { + mockExperimentalFeatures = {} as jest.Mocked<ExperimentalFeatures>; jobsSummaryMock = jest.fn(); mlMock = mlPluginServerMock.createSetupContract(); mlMock.jobServiceProvider.mockReturnValue({ @@ -59,7 +62,7 @@ describe('ml_executor', () => { }); (bulkCreateMlSignals as jest.Mock).mockResolvedValue({ success: true, - bulkCreateDuration: 0, + bulkCreateDuration: 21, createdItemsCount: 0, errors: [], createdItems: [], @@ -80,6 +83,11 @@ describe('ml_executor', () => { wrapHits: jest.fn(), exceptionFilter: undefined, unprocessedExceptions: [], + wrapSuppressedHits: jest.fn(), + alertTimestampOverride: undefined, + alertWithSuppression: jest.fn(), + isAlertSuppressionActive: true, + experimentalFeatures: mockExperimentalFeatures, }) ).rejects.toThrow('ML plugin unavailable during rule execution'); }); @@ -97,6 +105,11 @@ describe('ml_executor', () => { wrapHits: jest.fn(), exceptionFilter: undefined, unprocessedExceptions: [], + wrapSuppressedHits: jest.fn(), + alertTimestampOverride: undefined, + alertWithSuppression: jest.fn(), + isAlertSuppressionActive: true, + experimentalFeatures: mockExperimentalFeatures, }); expect(ruleExecutionLogger.warn).toHaveBeenCalled(); expect(ruleExecutionLogger.warn.mock.calls[0][0]).toContain( @@ -125,6 +138,11 @@ describe('ml_executor', () => { wrapHits: jest.fn(), exceptionFilter: undefined, unprocessedExceptions: [], + wrapSuppressedHits: jest.fn(), + alertTimestampOverride: undefined, + alertWithSuppression: jest.fn(), + isAlertSuppressionActive: true, + experimentalFeatures: mockExperimentalFeatures, }); expect(ruleExecutionLogger.warn).toHaveBeenCalled(); expect(ruleExecutionLogger.warn.mock.calls[0][0]).toContain( @@ -149,9 +167,49 @@ describe('ml_executor', () => { wrapHits: jest.fn(), exceptionFilter: undefined, unprocessedExceptions: [], + wrapSuppressedHits: jest.fn(), + alertTimestampOverride: undefined, + alertWithSuppression: jest.fn(), + isAlertSuppressionActive: true, + experimentalFeatures: mockExperimentalFeatures, }); expect(result.userError).toEqual(true); expect(result.success).toEqual(false); expect(result.errors).toEqual(['my_test_job_name missing']); }); + + it('returns some timing information as part of the result', async () => { + // ensure our mock corresponds to the job that the rule uses + jobsSummaryMock.mockResolvedValue( + mlCompleteRule.ruleParams.machineLearningJobId.map((jobId) => ({ + id: jobId, + jobState: 'opened', + datafeedState: 'started', + })) + ); + + const result = await mlExecutor({ + completeRule: mlCompleteRule, + tuple, + ml: mlMock, + services: alertServices, + ruleExecutionLogger, + listClient, + bulkCreate: jest.fn(), + wrapHits: jest.fn(), + exceptionFilter: undefined, + unprocessedExceptions: [], + wrapSuppressedHits: jest.fn(), + alertTimestampOverride: undefined, + alertWithSuppression: jest.fn(), + isAlertSuppressionActive: true, + experimentalFeatures: mockExperimentalFeatures, + }); + + expect(result).toEqual( + expect.objectContaining({ + bulkCreateTimes: expect.arrayContaining([expect.any(Number)]), + }) + ); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts index 641a9dab05cb..4b7de9b27a66 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts @@ -8,6 +8,7 @@ /* eslint require-atomic-updates: ["error", { "allowProperties": true }] */ import type { KibanaRequest } from '@kbn/core/server'; +import type { SuppressedAlertService } from '@kbn/rule-registry-plugin/server'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import type { AlertInstanceContext, @@ -17,11 +18,12 @@ import type { import type { ListClient } from '@kbn/lists-plugin/server'; import type { Filter } from '@kbn/es-query'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; +import type { ExperimentalFeatures } from '../../../../../common/experimental_features'; import type { CompleteRule, MachineLearningRuleParams } from '../../rule_schema'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; import { filterEventsAgainstList } from '../utils/large_list_filters/filter_events_against_list'; import { findMlSignals } from './find_ml_signals'; -import type { BulkCreate, RuleRangeTuple, WrapHits } from '../types'; +import type { BulkCreate, RuleRangeTuple, WrapHits, WrapSuppressedHits } from '../types'; import { addToSearchAfterReturn, createErrorsFromShard, @@ -33,6 +35,26 @@ import type { SetupPlugins } from '../../../../plugin'; import { withSecuritySpan } from '../../../../utils/with_security_span'; import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring'; import type { AnomalyResults } from '../../../machine_learning'; +import { bulkCreateSuppressedAlertsInMemory } from '../utils/bulk_create_suppressed_alerts_in_memory'; +import { buildReasonMessageForMlAlert } from '../utils/reason_formatters'; + +interface MachineLearningRuleExecutorParams { + completeRule: CompleteRule<MachineLearningRuleParams>; + tuple: RuleRangeTuple; + ml: SetupPlugins['ml']; + listClient: ListClient; + services: RuleExecutorServices<AlertInstanceState, AlertInstanceContext, 'default'>; + ruleExecutionLogger: IRuleExecutionLogForExecutors; + bulkCreate: BulkCreate; + wrapHits: WrapHits; + exceptionFilter: Filter | undefined; + unprocessedExceptions: ExceptionListItemSchema[]; + wrapSuppressedHits: WrapSuppressedHits; + alertTimestampOverride: Date | undefined; + alertWithSuppression: SuppressedAlertService; + isAlertSuppressionActive: boolean; + experimentalFeatures: ExperimentalFeatures; +} export const mlExecutor = async ({ completeRule, @@ -45,18 +67,12 @@ export const mlExecutor = async ({ wrapHits, exceptionFilter, unprocessedExceptions, -}: { - completeRule: CompleteRule<MachineLearningRuleParams>; - tuple: RuleRangeTuple; - ml: SetupPlugins['ml']; - listClient: ListClient; - services: RuleExecutorServices<AlertInstanceState, AlertInstanceContext, 'default'>; - ruleExecutionLogger: IRuleExecutionLogForExecutors; - bulkCreate: BulkCreate; - wrapHits: WrapHits; - exceptionFilter: Filter | undefined; - unprocessedExceptions: ExceptionListItemSchema[]; -}) => { + isAlertSuppressionActive, + wrapSuppressedHits, + alertTimestampOverride, + alertWithSuppression, + experimentalFeatures, +}: MachineLearningRuleExecutorParams) => { const result = createSearchAfterReturnType(); const ruleParams = completeRule.ruleParams; @@ -120,6 +136,7 @@ export const mlExecutor = async ({ return result; } + // TODO we add the max_signals warning _before_ filtering the anomalies against the exceptions list. Is that correct? if ( anomalyResults.hits.total && typeof anomalyResults.hits.total !== 'number' && @@ -140,17 +157,36 @@ export const mlExecutor = async ({ ruleExecutionLogger.debug(`Found ${anomalyCount} signals from ML anomalies`); } - const createResult = await bulkCreateMlSignals({ - anomalyHits: filteredAnomalyHits, - completeRule, - services, - ruleExecutionLogger, - id: completeRule.alertId, - signalsIndex: ruleParams.outputIndex, - bulkCreate, - wrapHits, - }); - addToSearchAfterReturn({ current: result, next: createResult }); + if (anomalyCount && isAlertSuppressionActive) { + await bulkCreateSuppressedAlertsInMemory({ + enrichedEvents: filteredAnomalyHits, + toReturn: result, + wrapHits, + bulkCreate, + services, + buildReasonMessage: buildReasonMessageForMlAlert, + ruleExecutionLogger, + tuple, + alertSuppression: completeRule.ruleParams.alertSuppression, + wrapSuppressedHits, + alertTimestampOverride, + alertWithSuppression, + experimentalFeatures, + }); + } else { + const createResult = await bulkCreateMlSignals({ + anomalyHits: filteredAnomalyHits, + completeRule, + services, + ruleExecutionLogger, + id: completeRule.alertId, + signalsIndex: ruleParams.outputIndex, + bulkCreate, + wrapHits, + }); + addToSearchAfterReturn({ current: result, next: createResult }); + } + const shardFailures = anomalyResults._shards.failures ?? []; const searchErrors = createErrorsFromShard({ errors: shardFailures, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index 31aa1797234b..8f7a50b195e4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -37,7 +37,7 @@ import type { LicensingPluginSetup } from '@kbn/licensing-plugin/server'; import type { RuleResponseAction } from '../../../../common/api/detection_engine/model/rule_response_actions'; import type { ConfigType } from '../../../config'; import type { SetupPlugins } from '../../../plugin'; -import type { CompleteRule, EqlRuleParams, RuleParams, ThreatRuleParams } from '../rule_schema'; +import type { CompleteRule, RuleParams } from '../rule_schema'; import type { ExperimentalFeatures } from '../../../../common/experimental_features'; import type { ITelemetryEventsSender } from '../../telemetry/sender'; import type { IRuleExecutionLogForExecutors, IRuleMonitoringService } from '../rule_monitoring'; @@ -401,5 +401,3 @@ export interface OverrideBodyQuery { _source?: estypes.SearchSourceConfig; fields?: estypes.Fields; } - -export type RuleWithInMemorySuppression = ThreatRuleParams | EqlRuleParams; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/wrap_suppressed_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/wrap_suppressed_alerts.ts index 89328f176567..70fee20116fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/wrap_suppressed_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/wrap_suppressed_alerts.ts @@ -9,14 +9,19 @@ import objectHash from 'object-hash'; import { TIMESTAMP } from '@kbn/rule-data-utils'; import type { SuppressionFieldsLatest } from '@kbn/rule-registry-plugin/common/schemas'; -import type { RuleWithInMemorySuppression, SignalSourceHit } from '../types'; +import type { SignalSourceHit } from '../types'; import type { BaseFieldsLatest, WrappedFieldsLatest, } from '../../../../../common/api/detection_engine/model/alerts'; import type { ConfigType } from '../../../../config'; -import type { CompleteRule } from '../../rule_schema'; +import type { + CompleteRule, + EqlRuleParams, + MachineLearningRuleParams, + ThreatRuleParams, +} from '../../rule_schema'; import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring'; import { buildBulkBody } from '../factories/utils/build_bulk_body'; import { getSuppressionAlertFields, getSuppressionTerms } from './suppression_utils'; @@ -24,6 +29,8 @@ import { generateId } from './utils'; import type { BuildReasonMessage } from './reason_formatters'; +type RuleWithInMemorySuppression = ThreatRuleParams | EqlRuleParams | MachineLearningRuleParams; + /** * wraps suppressed alerts * creates instanceId hash, which is used to search on time interval alerts diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts index 27ef27b80070..a26b1eb4b4f1 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts @@ -244,6 +244,7 @@ export const calculateRiskScores = async ({ size: 0, _source: false, index, + ignore_unavailable: true, runtime_mappings: runtimeMappings, query: { function_score: { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts index 848335f26e58..27c2db7d21d9 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts @@ -80,6 +80,8 @@ const allowlistBaseEventFields: AllowlistFields = { mtime: true, directory: true, hash: true, + origin_referrer_url: true, + origin_url: true, pe: true, Ext: { bytes_compressed: true, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts index f9476a14c2a0..6cab82872b92 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts @@ -48,22 +48,22 @@ export const deleteNoteRoute = ( const noteId = request.body?.noteId ?? ''; const noteIds = request.body?.noteIds ?? null; if (noteIds != null) { - const res = await deleteNote({ + await deleteNote({ request: frameworkRequest, noteIds, }); return response.ok({ - body: { data: { persistNote: res } }, + body: { data: {} }, }); } else { - const res = await deleteNote({ + await deleteNote({ request: frameworkRequest, noteIds: [noteId], }); return response.ok({ - body: { data: { persistNote: res } }, + body: { data: {} }, }); } } catch (err) { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index f1320dc3205b..400efb61ae0a 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -209,5 +209,6 @@ "@kbn/core-analytics-browser", "@kbn/core-i18n-browser", "@kbn/core-theme-browser", + "@kbn/integration-assistant-plugin", ] } diff --git a/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts b/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts index f4365787b918..9a6eb9ab743c 100644 --- a/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts +++ b/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts @@ -27,6 +27,7 @@ export const PLI_PRODUCT_FEATURES: PliProductFeatures = { ProductFeatureKey.threatIntelligence, ProductFeatureKey.casesConnectors, ProductFeatureKey.externalRuleActions, + ProductFeatureKey.integrationAssistant, ], }, endpoint: { diff --git a/x-pack/plugins/security_solution_serverless/kibana.jsonc b/x-pack/plugins/security_solution_serverless/kibana.jsonc index 76fb90ec236c..1829503bfe98 100644 --- a/x-pack/plugins/security_solution_serverless/kibana.jsonc +++ b/x-pack/plugins/security_solution_serverless/kibana.jsonc @@ -24,7 +24,8 @@ "discover" ], "optionalPlugins": [ - "securitySolutionEss" + "securitySolutionEss", + "integrationAssistant" ], } } diff --git a/x-pack/plugins/security_solution_serverless/public/plugin.ts b/x-pack/plugins/security_solution_serverless/public/plugin.ts index 679884a0c140..8ea73d406cb3 100644 --- a/x-pack/plugins/security_solution_serverless/public/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/public/plugin.ts @@ -64,10 +64,9 @@ export class SecuritySolutionServerlessPlugin ): SecuritySolutionServerlessPluginStart { const { securitySolution } = startDeps; const { productTypes } = this.config; - const services = createServices(core, startDeps, this.experimentalFeatures); - registerUpsellings(securitySolution.getUpselling(), productTypes, services); + registerUpsellings(productTypes, services); securitySolution.setComponents({ DashboardsLandingCallout: getDashboardsLandingCallout(services), diff --git a/x-pack/plugins/security_solution_serverless/public/types.ts b/x-pack/plugins/security_solution_serverless/public/types.ts index 11ce38c77642..0e47917f8122 100644 --- a/x-pack/plugins/security_solution_serverless/public/types.ts +++ b/x-pack/plugins/security_solution_serverless/public/types.ts @@ -14,6 +14,7 @@ import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverle import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { DiscoverSetup } from '@kbn/discover-plugin/public'; +import type { IntegrationAssistantPluginStart } from '@kbn/integration-assistant-plugin/public'; import type { ServerlessSecurityConfigSchema } from '../common/config'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -36,6 +37,7 @@ export interface SecuritySolutionServerlessPluginStartDeps { serverless: ServerlessPluginStart; management: ManagementStart; cloud: CloudStart; + integrationAssistant?: IntegrationAssistantPluginStart; } export type ServerlessSecurityPublicConfig = Pick< diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/hooks/use_product_type_by_pli.ts b/x-pack/plugins/security_solution_serverless/public/upselling/hooks/use_product_type_by_pli.ts index 46d09cbae6c2..da98c3003c2a 100644 --- a/x-pack/plugins/security_solution_serverless/public/upselling/hooks/use_product_type_by_pli.ts +++ b/x-pack/plugins/security_solution_serverless/public/upselling/hooks/use_product_type_by_pli.ts @@ -6,6 +6,7 @@ */ import type { ProductFeatureKeyType } from '@kbn/security-solution-features'; +import { useMemo } from 'react'; import { PLI_PRODUCT_FEATURES } from '../../../common/pli/pli_config'; export const getProductTypeByPLI = (requiredPLI: ProductFeatureKeyType): string | null => { @@ -29,3 +30,7 @@ export const getProductTypeByPLI = (requiredPLI: ProductFeatureKeyType): string } return null; }; + +export const useProductTypeByPLI = (requiredPLI: ProductFeatureKeyType): string => { + return useMemo(() => getProductTypeByPLI(requiredPLI) ?? '', [requiredPLI]); +}; diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.test.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.test.tsx index 542ab4cd11fb..9eeafe816f09 100644 --- a/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.test.tsx +++ b/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.test.tsx @@ -5,23 +5,31 @@ * 2.0. */ -import { - registerUpsellings, - upsellingMessages, - upsellingPages, - upsellingSections, -} from './register_upsellings'; +import { registerUpsellings } from './register_upsellings'; +import { upsellingMessages, upsellingPages, upsellingSections } from './upsellings'; import { ProductLine, ProductTier } from '../../common/product'; import type { SecurityProductTypes } from '../../common/config'; import { ALL_PRODUCT_FEATURE_KEYS } from '@kbn/security-solution-features/keys'; import type { UpsellingService } from '@kbn/security-solution-upselling/service'; import { mockServices } from '../common/services/__mocks__/services.mock'; +import { of } from 'rxjs'; const mockGetProductProductFeatures = jest.fn(); jest.mock('../../common/pli/pli_features', () => ({ getProductProductFeatures: () => mockGetProductProductFeatures(), })); +const setPages = jest.fn(); +const setSections = jest.fn(); +const setMessages = jest.fn(); +const upselling = { + setPages, + setSections, + setMessages, + sections$: of([]), +} as unknown as UpsellingService; +mockServices.securitySolution.getUpselling = jest.fn(() => upselling); + const allProductTypes: SecurityProductTypes = [ { product_line: ProductLine.security, product_tier: ProductTier.complete }, { product_line: ProductLine.endpoint, product_tier: ProductTier.complete }, @@ -29,19 +37,14 @@ const allProductTypes: SecurityProductTypes = [ ]; describe('registerUpsellings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should not register anything when all PLIs features are enabled', () => { mockGetProductProductFeatures.mockReturnValue(ALL_PRODUCT_FEATURE_KEYS); - const setPages = jest.fn(); - const setSections = jest.fn(); - const setMessages = jest.fn(); - const upselling = { - setPages, - setSections, - setMessages, - } as unknown as UpsellingService; - - registerUpsellings(upselling, allProductTypes, mockServices); + registerUpsellings(allProductTypes, mockServices); expect(setPages).toHaveBeenCalledTimes(1); expect(setPages).toHaveBeenCalledWith({}); @@ -56,17 +59,7 @@ describe('registerUpsellings', () => { it('should register all upsellings pages, sections and messages when PLIs features are disabled', () => { mockGetProductProductFeatures.mockReturnValue([]); - const setPages = jest.fn(); - const setSections = jest.fn(); - const setMessages = jest.fn(); - - const upselling = { - setPages, - setSections, - setMessages, - } as unknown as UpsellingService; - - registerUpsellings(upselling, allProductTypes, mockServices); + registerUpsellings(allProductTypes, mockServices); const expectedPagesObject = Object.fromEntries( upsellingPages.map(({ pageName }) => [pageName, expect.anything()]) diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx index ef4574424b42..851bf6010cb4 100644 --- a/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx +++ b/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx @@ -4,63 +4,32 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { ProductFeatureKeyType } from '@kbn/security-solution-features'; -import { ProductFeatureKey } from '@kbn/security-solution-features/keys'; -import { SecurityPageName } from '@kbn/security-solution-plugin/common'; -import { - UPGRADE_INVESTIGATION_GUIDE, - UPGRADE_INVESTIGATION_GUIDE_INTERACTIONS, -} from '@kbn/security-solution-upselling/messages'; import type { UpsellingService } from '@kbn/security-solution-upselling/service'; import type { MessageUpsellings, PageUpsellings, SectionUpsellings, - UpsellingMessageId, - UpsellingSectionId, } from '@kbn/security-solution-upselling/service/types'; -import React from 'react'; -import { CloudSecurityPostureIntegrationPliBlockLazy } from './sections/cloud_security_posture'; -import { - EndpointAgentTamperProtectionLazy, - EndpointPolicyProtectionsLazy, - EndpointProtectionUpdatesLazy, - RuleDetailsEndpointExceptionsLazy, -} from './sections/endpoint_management'; import type { SecurityProductTypes } from '../../common/config'; import { getProductProductFeatures } from '../../common/pli/pli_features'; import type { Services } from '../common/services'; import { withServicesProvider } from '../common/services'; -import { getProductTypeByPLI } from './hooks/use_product_type_by_pli'; -import { - EndpointExceptionsDetailsUpsellingLazy, - EntityAnalyticsUpsellingPageLazy, - EntityAnalyticsUpsellingSectionLazy, - OsqueryResponseActionsUpsellingSectionLazy, - ThreatIntelligencePaywallLazy, -} from './lazy_upselling'; -import * as i18n from './translations'; +import { upsellingPages, upsellingSections, upsellingMessages } from './upsellings'; -interface UpsellingsConfig { - pli: ProductFeatureKeyType; - component: React.ComponentType; -} - -interface UpsellingsMessageConfig { - pli: ProductFeatureKeyType; - message: string; - id: UpsellingMessageId; -} - -type UpsellingPages = Array<UpsellingsConfig & { pageName: SecurityPageName }>; -type UpsellingSections = Array<UpsellingsConfig & { id: UpsellingSectionId }>; -type UpsellingMessages = UpsellingsMessageConfig[]; +export const registerUpsellings = (productTypes: SecurityProductTypes, services: Services) => { + const upsellingService = registerSecuritySolutionUpsellings(productTypes, services); + configurePluginsUpsellings(upsellingService, services); +}; -export const registerUpsellings = ( - upselling: UpsellingService, +/** + * Registers the upsellings for the security solution. + */ +const registerSecuritySolutionUpsellings = ( productTypes: SecurityProductTypes, services: Services -) => { +): UpsellingService => { + const upsellingService = services.securitySolution.getUpselling(); + const enabledPLIsSet = new Set(getProductProductFeatures(productTypes)); const upsellingPagesToRegister = upsellingPages.reduce<PageUpsellings>( @@ -93,105 +62,20 @@ export const registerUpsellings = ( {} ); - upselling.setPages(upsellingPagesToRegister); - upselling.setSections(upsellingSectionsToRegister); - upselling.setMessages(upsellingMessagesToRegister); -}; - -// Upselling for entire pages, linked to a SecurityPageName -export const upsellingPages: UpsellingPages = [ - // It is highly advisable to make use of lazy loaded components to minimize bundle size. - { - pageName: SecurityPageName.entityAnalytics, - pli: ProductFeatureKey.advancedInsights, - component: () => ( - <EntityAnalyticsUpsellingPageLazy - upgradeToLabel={entityAnalyticsProductType} - upgradeMessage={i18n.UPGRADE_PRODUCT_MESSAGE(entityAnalyticsProductType)} - /> - ), - }, - { - pageName: SecurityPageName.threatIntelligence, - pli: ProductFeatureKey.threatIntelligence, - component: () => ( - <ThreatIntelligencePaywallLazy requiredPLI={ProductFeatureKey.threatIntelligence} /> - ), - }, - { - pageName: SecurityPageName.exceptions, - pli: ProductFeatureKey.endpointExceptions, - component: () => ( - <EndpointExceptionsDetailsUpsellingLazy requiredPLI={ProductFeatureKey.endpointExceptions} /> - ), - }, -]; + upsellingService.setPages(upsellingPagesToRegister); + upsellingService.setSections(upsellingSectionsToRegister); + upsellingService.setMessages(upsellingMessagesToRegister); -const entityAnalyticsProductType = getProductTypeByPLI(ProductFeatureKey.advancedInsights) ?? ''; + return upsellingService; +}; -// Upselling for sections, linked by arbitrary ids -export const upsellingSections: UpsellingSections = [ - // It is highly advisable to make use of lazy loaded components to minimize bundle size. - { - id: 'osquery_automated_response_actions', - pli: ProductFeatureKey.osqueryAutomatedResponseActions, - component: () => ( - <OsqueryResponseActionsUpsellingSectionLazy - requiredPLI={ProductFeatureKey.osqueryAutomatedResponseActions} - /> - ), - }, - { - id: 'endpoint_agent_tamper_protection', - pli: ProductFeatureKey.endpointAgentTamperProtection, - component: EndpointAgentTamperProtectionLazy, - }, - { - id: 'endpointPolicyProtections', - pli: ProductFeatureKey.endpointPolicyProtections, - component: EndpointPolicyProtectionsLazy, - }, - { - id: 'ruleDetailsEndpointExceptions', - pli: ProductFeatureKey.endpointExceptions, - component: RuleDetailsEndpointExceptionsLazy, - }, - { - id: 'endpoint_protection_updates', - pli: ProductFeatureKey.endpointProtectionUpdates, - component: EndpointProtectionUpdatesLazy, - }, - { - id: 'cloud_security_posture_integration_installation', - pli: ProductFeatureKey.cloudSecurityPosture, - component: CloudSecurityPostureIntegrationPliBlockLazy, - }, - { - id: 'entity_analytics_panel', - pli: ProductFeatureKey.advancedInsights, - component: () => ( - <EntityAnalyticsUpsellingSectionLazy - upgradeToLabel={entityAnalyticsProductType} - upgradeMessage={i18n.UPGRADE_PRODUCT_MESSAGE(entityAnalyticsProductType)} - /> - ), - }, -]; +/** + * Configures the upsellings for other plugins. + */ +const configurePluginsUpsellings = (upsellingService: UpsellingService, services: Services) => { + const { integrationAssistant } = services; -// Upselling for sections, linked by arbitrary ids -export const upsellingMessages: UpsellingMessages = [ - { - id: 'investigation_guide', - pli: ProductFeatureKey.investigationGuide, - message: UPGRADE_INVESTIGATION_GUIDE( - getProductTypeByPLI(ProductFeatureKey.investigationGuide) ?? '' - ), - }, - { - id: 'investigation_guide_interactions', - pli: ProductFeatureKey.investigationGuideInteractions, - message: UPGRADE_INVESTIGATION_GUIDE_INTERACTIONS( - getProductTypeByPLI(ProductFeatureKey.investigationGuideInteractions) ?? '' - ), - }, -]; + upsellingService.sections$.subscribe((sections) => { + integrationAssistant?.renderUpselling(sections.get('integration_assistant')); + }); +}; diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/sections/integration_assistant/index.ts b/x-pack/plugins/security_solution_serverless/public/upselling/sections/integration_assistant/index.ts new file mode 100644 index 000000000000..320ed8be7ffc --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/upselling/sections/integration_assistant/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; + +export const IntegrationsAssistantLazy = lazy(() => + import('./integration_assistant').then(({ IntegrationsAssistant }) => ({ + default: IntegrationsAssistant, + })) +); diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/sections/integration_assistant/integration_assistant.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/sections/integration_assistant/integration_assistant.tsx new file mode 100644 index 000000000000..7d1d797c1eca --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/upselling/sections/integration_assistant/integration_assistant.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { + EuiCard, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTextColor, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ProductFeatureKeyType } from '@kbn/security-solution-features'; +import { useProductTypeByPLI } from '../../hooks/use_product_type_by_pli'; + +export const UPGRADE_PRODUCT_MESSAGE = (requiredProductType: string) => + i18n.translate( + 'xpack.securitySolutionServerless.upselling.integrationAssistant.upgradeProductMessage', + { + defaultMessage: + 'To turn on the Integration Assistant feature, you must upgrade the product tier to {requiredProductType}', + values: { + requiredProductType, + }, + } + ); +export const TIER_REQUIRED = (requiredProductType: string) => + i18n.translate('xpack.securitySolutionServerless.upselling.integrationAssistant.tierRequired', { + defaultMessage: '{requiredProductType} tier required', + values: { + requiredProductType, + }, + }); +export const CONTACT_ADMINISTRATOR = i18n.translate( + 'xpack.securitySolutionServerless.upselling.integrationAssistant.contactAdministrator', + { + defaultMessage: 'Contact your administrator for assistance.', + } +); + +export interface IntegrationsAssistantProps { + requiredPLI: ProductFeatureKeyType; +} +export const IntegrationsAssistant = React.memo<IntegrationsAssistantProps>(({ requiredPLI }) => { + const requiredProductType = useProductTypeByPLI(requiredPLI); + return ( + <> + <EuiSpacer size="m" /> + <EuiCard + data-test-subj={'EnterpriseLicenseRequiredCard'} + betaBadgeProps={{ + label: requiredProductType, + }} + isDisabled={true} + icon={<EuiIcon size="xl" type="lock" />} + title={ + <h3> + <strong>{TIER_REQUIRED(requiredProductType)}</strong> + </h3> + } + description={false} + > + <EuiFlexGroup className="lockedCardDescription" direction="column" justifyContent="center"> + <EuiFlexItem> + <EuiSpacer size="s" /> + <EuiText> + <h4> + <EuiTextColor color="subdued"> + {UPGRADE_PRODUCT_MESSAGE(requiredProductType)} + </EuiTextColor> + </h4> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <EuiText>{CONTACT_ADMINISTRATOR}</EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiCard> + </> + ); +}); +IntegrationsAssistant.displayName = 'IntegrationsAssistant'; diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/upsellings.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/upsellings.tsx new file mode 100644 index 000000000000..cb0e1514b1df --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/upselling/upsellings.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { ProductFeatureKeyType } from '@kbn/security-solution-features'; +import { ProductFeatureKey } from '@kbn/security-solution-features/keys'; +import { SecurityPageName } from '@kbn/security-solution-plugin/common'; +import { + UPGRADE_INVESTIGATION_GUIDE, + UPGRADE_INVESTIGATION_GUIDE_INTERACTIONS, +} from '@kbn/security-solution-upselling/messages'; +import type { + UpsellingMessageId, + UpsellingSectionId, +} from '@kbn/security-solution-upselling/service/types'; +import React from 'react'; +import { CloudSecurityPostureIntegrationPliBlockLazy } from './sections/cloud_security_posture'; +import { + EndpointAgentTamperProtectionLazy, + EndpointPolicyProtectionsLazy, + EndpointProtectionUpdatesLazy, + RuleDetailsEndpointExceptionsLazy, +} from './sections/endpoint_management'; +import { getProductTypeByPLI } from './hooks/use_product_type_by_pli'; +import { + EndpointExceptionsDetailsUpsellingLazy, + EntityAnalyticsUpsellingPageLazy, + EntityAnalyticsUpsellingSectionLazy, + OsqueryResponseActionsUpsellingSectionLazy, + ThreatIntelligencePaywallLazy, +} from './lazy_upselling'; +import * as i18n from './translations'; +import { IntegrationsAssistantLazy } from './sections/integration_assistant'; + +interface UpsellingsConfig { + pli: ProductFeatureKeyType; + component: React.ComponentType; +} + +interface UpsellingsMessageConfig { + pli: ProductFeatureKeyType; + message: string; + id: UpsellingMessageId; +} + +type UpsellingPages = Array<UpsellingsConfig & { pageName: SecurityPageName }>; +type UpsellingSections = Array<UpsellingsConfig & { id: UpsellingSectionId }>; +type UpsellingMessages = UpsellingsMessageConfig[]; + +// Upselling for entire pages, linked to a SecurityPageName +export const upsellingPages: UpsellingPages = [ + // It is highly advisable to make use of lazy loaded components to minimize bundle size. + { + pageName: SecurityPageName.entityAnalytics, + pli: ProductFeatureKey.advancedInsights, + component: () => ( + <EntityAnalyticsUpsellingPageLazy + upgradeToLabel={entityAnalyticsProductType} + upgradeMessage={i18n.UPGRADE_PRODUCT_MESSAGE(entityAnalyticsProductType)} + /> + ), + }, + { + pageName: SecurityPageName.threatIntelligence, + pli: ProductFeatureKey.threatIntelligence, + component: () => ( + <ThreatIntelligencePaywallLazy requiredPLI={ProductFeatureKey.threatIntelligence} /> + ), + }, + { + pageName: SecurityPageName.exceptions, + pli: ProductFeatureKey.endpointExceptions, + component: () => ( + <EndpointExceptionsDetailsUpsellingLazy requiredPLI={ProductFeatureKey.endpointExceptions} /> + ), + }, +]; + +const entityAnalyticsProductType = getProductTypeByPLI(ProductFeatureKey.advancedInsights) ?? ''; + +// Upselling for sections, linked by arbitrary ids +export const upsellingSections: UpsellingSections = [ + // It is highly advisable to make use of lazy loaded components to minimize bundle size. + { + id: 'osquery_automated_response_actions', + pli: ProductFeatureKey.osqueryAutomatedResponseActions, + component: () => ( + <OsqueryResponseActionsUpsellingSectionLazy + requiredPLI={ProductFeatureKey.osqueryAutomatedResponseActions} + /> + ), + }, + { + id: 'endpoint_agent_tamper_protection', + pli: ProductFeatureKey.endpointAgentTamperProtection, + component: EndpointAgentTamperProtectionLazy, + }, + { + id: 'endpointPolicyProtections', + pli: ProductFeatureKey.endpointPolicyProtections, + component: EndpointPolicyProtectionsLazy, + }, + { + id: 'ruleDetailsEndpointExceptions', + pli: ProductFeatureKey.endpointExceptions, + component: RuleDetailsEndpointExceptionsLazy, + }, + { + id: 'endpoint_protection_updates', + pli: ProductFeatureKey.endpointProtectionUpdates, + component: EndpointProtectionUpdatesLazy, + }, + { + id: 'cloud_security_posture_integration_installation', + pli: ProductFeatureKey.cloudSecurityPosture, + component: CloudSecurityPostureIntegrationPliBlockLazy, + }, + { + id: 'entity_analytics_panel', + pli: ProductFeatureKey.advancedInsights, + component: () => ( + <EntityAnalyticsUpsellingSectionLazy + upgradeToLabel={entityAnalyticsProductType} + upgradeMessage={i18n.UPGRADE_PRODUCT_MESSAGE(entityAnalyticsProductType)} + /> + ), + }, + { + id: 'integration_assistant', + pli: ProductFeatureKey.integrationAssistant, + component: () => ( + <IntegrationsAssistantLazy requiredPLI={ProductFeatureKey.integrationAssistant} /> + ), + }, +]; + +// Upselling for sections, linked by arbitrary ids +export const upsellingMessages: UpsellingMessages = [ + { + id: 'investigation_guide', + pli: ProductFeatureKey.investigationGuide, + message: UPGRADE_INVESTIGATION_GUIDE( + getProductTypeByPLI(ProductFeatureKey.investigationGuide) ?? '' + ), + }, + { + id: 'investigation_guide_interactions', + pli: ProductFeatureKey.investigationGuideInteractions, + message: UPGRADE_INVESTIGATION_GUIDE_INTERACTIONS( + getProductTypeByPLI(ProductFeatureKey.investigationGuideInteractions) ?? '' + ), + }, +]; diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts index 72568fbd4c8a..1a5fb8e64a26 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts @@ -21,6 +21,15 @@ export const cloudSecurityMetringCallback = async ({ lastSuccessfulReport, config, }: MeteringCallbackInput): Promise<MeteringCallBackResponse> => { + const projectHasCloudProductLine = config.productTypes.some( + (product) => product.product_line === ProductLine.cloud + ); + + if (!projectHasCloudProductLine) { + logger.info('No cloud product line found in the project'); + return { records: [] }; + } + const projectId = cloudSetup?.serverless?.projectId || 'missing_project_id'; const tier: Tier = getCloudProductTier(config, logger); @@ -30,8 +39,8 @@ export const cloudSecurityMetringCallback = async ({ const promiseResults = await Promise.allSettled( cloudSecuritySolutions.map((cloudSecuritySolution) => { - if (cloudSecuritySolution === CLOUD_DEFEND) { - return getCloudDefendUsageRecords({ + if (cloudSecuritySolution !== CLOUD_DEFEND) { + return getCloudSecurityUsageRecord({ esClient, projectId, logger, @@ -41,7 +50,9 @@ export const cloudSecurityMetringCallback = async ({ tier, }); } - return getCloudSecurityUsageRecord({ + + // since lastSuccessfulReport is not used by getCloudSecurityUsageRecord, we want to verify if it is used by getCloudDefendUsageRecords before getCloudSecurityUsageRecord. + return getCloudDefendUsageRecords({ esClient, projectId, logger, diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts index f775b91406c4..6d55e5246928 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts @@ -14,16 +14,23 @@ import { } from './cloud_security_metering_task'; import type { ServerlessSecurityConfig } from '../config'; -import type { CloudSecuritySolutions } from './types'; + import type { ProductTier } from '../../common/product'; -import { CLOUD_SECURITY_TASK_TYPE, CSPM, KSPM, CNVM, CLOUD_DEFEND } from './constants'; +import { + CLOUD_SECURITY_TASK_TYPE, + CSPM, + KSPM, + CNVM, + CLOUD_DEFEND, + BILLABLE_ASSETS_CONFIG, +} from './constants'; import { getCloudDefendUsageRecords } from './defend_for_containers_metering'; const mockEsClient = elasticsearchServiceMock.createStart().client.asInternalUser; const logger: ReturnType<typeof loggingSystemMock.createLogger> = loggingSystemMock.createLogger(); const chance = new Chance(); -const cloudSecuritySolutions: CloudSecuritySolutions[] = [CSPM, KSPM, CNVM]; +const cloudSecuritySolutions: Array<typeof CSPM | typeof KSPM> = [CSPM, KSPM]; describe('getCloudSecurityUsageRecord', () => { beforeEach(() => { @@ -54,18 +61,33 @@ describe('getCloudSecurityUsageRecord', () => { }); test.each(cloudSecuritySolutions)( - 'should return usageRecords with correct values for cspm, kspm, and cnvm when Elasticsearch response has aggregations', + 'should return usageRecords with correct values for cspm and kspm when Elasticsearch response has aggregations', async (cloudSecuritySolution) => { // @ts-ignore mockEsClient.search.mockResolvedValueOnce({ hits: { hits: [{ _id: 'someRecord', _index: 'mockIndex' }] }, // mocking for indexHasDataInDateRange }); + const randomIndex = Math.floor( + Math.random() * BILLABLE_ASSETS_CONFIG[cloudSecuritySolution].values.length + ); + const randomBillableAsset = BILLABLE_ASSETS_CONFIG[cloudSecuritySolution].values[randomIndex]; // @ts-ignore mockEsClient.search.mockResolvedValueOnce({ aggregations: { - unique_assets: { - value: 10, + resource_sub_type: { + buckets: [ + { + key: randomBillableAsset, + doc_count: 100, + unique_assets: { value: 10 }, + }, + { + key: 'not_billable_asset', + doc_count: 50, + unique_assets: { value: 11 }, + }, + ], }, min_timestamp: { value_as_string: '2023-07-30T15:11:41.738Z', @@ -100,6 +122,10 @@ describe('getCloudSecurityUsageRecord', () => { sub_type: cloudSecuritySolution, quantity: 10, period_seconds: expect.any(Number), + metadata: { + [randomBillableAsset]: '10', + not_billable_asset: '11', + }, }, source: { id: taskId, @@ -113,6 +139,62 @@ describe('getCloudSecurityUsageRecord', () => { } ); + it('should return usageRecords with correct values for cnvm when Elasticsearch response has aggregations', async () => { + const cloudSecuritySolution = CNVM; + + // @ts-ignore + mockEsClient.search.mockResolvedValueOnce({ + hits: { hits: [{ _id: 'someRecord', _index: 'mockIndex' }] }, // mocking for indexHasDataInDateRange + }); + + // @ts-ignore + mockEsClient.search.mockResolvedValueOnce({ + aggregations: { + unique_assets: { + value: 10, + }, + min_timestamp: { + value_as_string: '2023-07-30T15:11:41.738Z', + }, + }, + }); + + const projectId = chance.guid(); + const taskId = chance.guid(); + + const tier = 'essentials' as ProductTier; + const result = await getCloudSecurityUsageRecord({ + esClient: mockEsClient, + projectId, + logger, + taskId, + lastSuccessfulReport: new Date(), + cloudSecuritySolution, + tier, + }); + + expect(result).toEqual([ + { + id: expect.stringContaining(`${CLOUD_SECURITY_TASK_TYPE}_cnvm_${projectId}`), + usage_timestamp: '2023-07-30T15:11:41.738Z', + creation_timestamp: expect.any(String), // Expect a valid ISO string + usage: { + type: CLOUD_SECURITY_TASK_TYPE, + sub_type: CNVM, + quantity: 10, + period_seconds: expect.any(Number), + }, + source: { + id: taskId, + instance_group_id: projectId, + metadata: { + tier: 'essentials', + }, + }, + }, + ]); + }); + it('should return undefined when Elasticsearch response does not have aggregations', async () => { // @ts-ignore mockEsClient.search.mockResolvedValue({}); @@ -162,9 +244,7 @@ describe('getCloudSecurityUsageRecord', () => { describe('getSearchQueryByCloudSecuritySolution', () => { it('should return the correct search query for CSPM', () => { - const searchFrom = new Date('2023-07-30T15:11:41.738Z'); - - const result = getSearchQueryByCloudSecuritySolution('cspm', searchFrom); + const result = getSearchQueryByCloudSecuritySolution('cspm'); expect(result).toEqual({ bool: { @@ -181,39 +261,13 @@ describe('getSearchQueryByCloudSecuritySolution', () => { 'rule.benchmark.posture_type': 'cspm', }, }, - { - terms: { - 'resource.sub_type': [ - // 'aws-ebs', we can't include EBS volumes until https://github.com/elastic/security-team/issues/9283 is resolved - // 'aws-ec2', we can't include EC2 instances until https://github.com/elastic/security-team/issues/9254 is resolved - 'aws-s3', - 'aws-rds', - 'azure-disk', - 'azure-document-db-database-account', - 'azure-flexible-mysql-server-db', - 'azure-flexible-postgresql-server-db', - 'azure-mysql-server-db', - 'azure-postgresql-server-db', - 'azure-sql-server', - 'azure-storage-account', - 'azure-vm', - 'gcp-bigquery-dataset', - 'gcp-compute-disk', - 'gcp-compute-instance', - 'gcp-sqladmin-instance', - 'gcp-storage-bucket', - ], - }, - }, ], }, }); }); it('should return the correct search query for KSPM', () => { - const searchFrom = new Date('2023-07-30T15:11:41.738Z'); - - const result = getSearchQueryByCloudSecuritySolution('kspm', searchFrom); + const result = getSearchQueryByCloudSecuritySolution('kspm'); expect(result).toEqual({ bool: { @@ -230,20 +284,13 @@ describe('getSearchQueryByCloudSecuritySolution', () => { 'rule.benchmark.posture_type': 'kspm', }, }, - { - terms: { - 'resource.sub_type': ['Node', 'node'], - }, - }, ], }, }); }); it('should return the correct search query for CNVM', () => { - const searchFrom = new Date('2023-07-30T15:11:41.738Z'); - - const result = getSearchQueryByCloudSecuritySolution(CNVM, searchFrom); + const result = getSearchQueryByCloudSecuritySolution(CNVM); expect(result).toEqual({ bool: { diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts index 7827c5d25ebe..6687c3dfed48 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts @@ -15,72 +15,112 @@ import { CSPM, KSPM, METERING_CONFIGS, - THRESHOLD_MINUTES, BILLABLE_ASSETS_CONFIG, } from './constants'; -import type { Tier, UsageRecord } from '../types'; +import type { ResourceSubtypeCounter, Tier, UsageRecord } from '../types'; import type { CloudSecurityMeteringCallbackInput, CloudSecuritySolutions, AssetCountAggregation, + ResourceSubtypeAggregationBucket, } from './types'; export const getUsageRecords = ( - assetCountAggregations: AssetCountAggregation[], + assetCountAggregation: AssetCountAggregation, cloudSecuritySolution: CloudSecuritySolutions, taskId: string, tier: Tier, projectId: string, periodSeconds: number, logger: Logger -): UsageRecord[] => { - const usageRecords = assetCountAggregations.map((assetCountAggregation) => { - const assetCount = assetCountAggregation.unique_assets.value; - - if (assetCount > AGGREGATION_PRECISION_THRESHOLD) { - logger.warn( - `The number of unique resources for {${cloudSecuritySolution}} is ${assetCount}, which is higher than the AGGREGATION_PRECISION_THRESHOLD of ${AGGREGATION_PRECISION_THRESHOLD}.` - ); - } - - const minTimestamp = new Date( - assetCountAggregation.min_timestamp.value_as_string - ).toISOString(); - - const creationTimestamp = new Date(); - const minutes = creationTimestamp.getMinutes(); - if (minutes >= 30) { - creationTimestamp.setMinutes(30, 0, 0); - } else { - creationTimestamp.setMinutes(0, 0, 0); - } - const roundedCreationTimestamp = creationTimestamp.toISOString(); - - const usageRecord: UsageRecord = { - id: `${CLOUD_SECURITY_TASK_TYPE}_${cloudSecuritySolution}_${projectId}_${roundedCreationTimestamp}`, - usage_timestamp: minTimestamp, - creation_timestamp: creationTimestamp.toISOString(), - usage: { - type: CLOUD_SECURITY_TASK_TYPE, - sub_type: cloudSecuritySolution, - quantity: assetCount, - period_seconds: periodSeconds, - }, - source: { - id: taskId, - instance_group_id: projectId, - metadata: { tier }, +): UsageRecord => { + let assetCount; + let resourceSubtypeCounterMap; + + if (cloudSecuritySolution === CSPM || cloudSecuritySolution === KSPM) { + const resourceSubtypeBuckets: ResourceSubtypeAggregationBucket[] = + assetCountAggregation.resource_sub_type.buckets; + + const billableAssets = BILLABLE_ASSETS_CONFIG[cloudSecuritySolution].values; + assetCount = resourceSubtypeBuckets + .filter((bucket) => billableAssets.includes(bucket.key)) + .reduce((acc, bucket) => acc + bucket.unique_assets.value, 0); + + resourceSubtypeCounterMap = assetCountAggregation.resource_sub_type.buckets.reduce( + (resourceMap, item) => { + // By the usage spec, the resource subtype counter should be a string // https://github.com/elastic/usage-api/blob/main/api/user-v1-spec.yml + resourceMap[item.key] = String(item.unique_assets.value); + return resourceMap; }, - }; + {} as ResourceSubtypeCounter + ); + } else { + assetCount = assetCountAggregation.unique_assets.value; + } + + if (assetCount > AGGREGATION_PRECISION_THRESHOLD) { + logger.warn( + `The number of unique resources for {${cloudSecuritySolution}} is ${assetCount}, which is higher than the AGGREGATION_PRECISION_THRESHOLD of ${AGGREGATION_PRECISION_THRESHOLD}.` + ); + } + + const minTimestamp = new Date(assetCountAggregation.min_timestamp.value_as_string).toISOString(); + + const creationTimestamp = new Date(); + const minutes = creationTimestamp.getMinutes(); + if (minutes >= 30) { + creationTimestamp.setMinutes(30, 0, 0); + } else { + creationTimestamp.setMinutes(0, 0, 0); + } + const roundedCreationTimestamp = creationTimestamp.toISOString(); + + const usageRecord: UsageRecord = { + id: `${CLOUD_SECURITY_TASK_TYPE}_${cloudSecuritySolution}_${projectId}_${roundedCreationTimestamp}`, + usage_timestamp: minTimestamp, + creation_timestamp: creationTimestamp.toISOString(), + usage: { + type: CLOUD_SECURITY_TASK_TYPE, + sub_type: cloudSecuritySolution, + quantity: assetCount, + period_seconds: periodSeconds, + ...(resourceSubtypeCounterMap && { metadata: resourceSubtypeCounterMap }), + }, + source: { + id: taskId, + instance_group_id: projectId, + metadata: { tier }, + }, + }; - return usageRecord; - }); - return usageRecords; + return usageRecord; }; export const getAggregationByCloudSecuritySolution = ( cloudSecuritySolution: CloudSecuritySolutions ) => { + if (cloudSecuritySolution === CSPM || cloudSecuritySolution === KSPM) + return { + resource_sub_type: { + terms: { + field: BILLABLE_ASSETS_CONFIG[cloudSecuritySolution].filter_attribute, + }, + aggs: { + unique_assets: { + cardinality: { + field: METERING_CONFIGS[cloudSecuritySolution].assets_identifier, + precision_threshold: AGGREGATION_PRECISION_THRESHOLD, + }, + }, + }, + }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, + }; + return { unique_assets: { cardinality: { @@ -97,8 +137,7 @@ export const getAggregationByCloudSecuritySolution = ( }; export const getSearchQueryByCloudSecuritySolution = ( - cloudSecuritySolution: CloudSecuritySolutions, - searchFrom: Date + cloudSecuritySolution: CloudSecuritySolutions ) => { const mustFilters = []; @@ -117,20 +156,11 @@ export const getSearchQueryByCloudSecuritySolution = ( } if (cloudSecuritySolution === CSPM || cloudSecuritySolution === KSPM) { - const billableAssetsConfig = BILLABLE_ASSETS_CONFIG[cloudSecuritySolution]; - mustFilters.push({ term: { 'rule.benchmark.posture_type': cloudSecuritySolution, }, }); - - // filter in only billable assets - mustFilters.push({ - terms: { - [billableAssetsConfig.filter_attribute]: billableAssetsConfig.values, - }, - }); } return { @@ -141,10 +171,9 @@ export const getSearchQueryByCloudSecuritySolution = ( }; export const getAssetAggQueryByCloudSecuritySolution = ( - cloudSecuritySolution: CloudSecuritySolutions, - searchFrom: Date + cloudSecuritySolution: CloudSecuritySolutions ) => { - const query = getSearchQueryByCloudSecuritySolution(cloudSecuritySolution, searchFrom); + const query = getSearchQueryByCloudSecuritySolution(cloudSecuritySolution); const aggs = getAggregationByCloudSecuritySolution(cloudSecuritySolution); return { @@ -157,28 +186,27 @@ export const getAssetAggQueryByCloudSecuritySolution = ( export const getAssetAggByCloudSecuritySolution = async ( esClient: ElasticsearchClient, - cloudSecuritySolution: CloudSecuritySolutions, - searchFrom: Date -): Promise<AssetCountAggregation[]> => { - const assetsAggQuery = getAssetAggQueryByCloudSecuritySolution(cloudSecuritySolution, searchFrom); + cloudSecuritySolution: CloudSecuritySolutions +): Promise<AssetCountAggregation | undefined> => { + const assetsAggQuery = getAssetAggQueryByCloudSecuritySolution(cloudSecuritySolution); const response = await esClient.search<unknown, AssetCountAggregation>(assetsAggQuery); - if (!response.aggregations) return []; - return [response.aggregations]; + if (!response.aggregations) return; + + return response.aggregations; }; const indexHasDataInDateRange = async ( esClient: ElasticsearchClient, - cloudSecuritySolution: CloudSecuritySolutions, - searchFrom: Date + cloudSecuritySolution: CloudSecuritySolutions ) => { const response = await esClient.search( { index: METERING_CONFIGS[cloudSecuritySolution].index, size: 1, _source: false, - query: getSearchQueryByCloudSecuritySolution(cloudSecuritySolution, searchFrom), + query: getSearchQueryByCloudSecuritySolution(cloudSecuritySolution), }, { ignore: [404] } ); @@ -186,47 +214,29 @@ const indexHasDataInDateRange = async ( return response.hits?.hits.length > 0; }; -const getSearchStartDate = (lastSuccessfulReport: Date): Date => { - const initialDate = new Date(); - const thresholdDate = new Date(initialDate.getTime() - THRESHOLD_MINUTES * 60 * 1000); - - if (lastSuccessfulReport) { - const lastSuccessfulReportDate = new Date(lastSuccessfulReport); - - const searchFrom = - lastSuccessfulReport && lastSuccessfulReportDate > thresholdDate - ? lastSuccessfulReportDate - : thresholdDate; - return searchFrom; - } - return thresholdDate; -}; - export const getCloudSecurityUsageRecord = async ({ esClient, projectId, taskId, - lastSuccessfulReport, cloudSecuritySolution, tier, logger, }: CloudSecurityMeteringCallbackInput): Promise<UsageRecord[] | undefined> => { try { - const searchFrom = getSearchStartDate(lastSuccessfulReport); - - if (!(await indexHasDataInDateRange(esClient, cloudSecuritySolution, searchFrom))) return; + if (!(await indexHasDataInDateRange(esClient, cloudSecuritySolution))) return; // const periodSeconds = Math.floor((new Date().getTime() - searchFrom.getTime()) / 1000); const periodSeconds = 1800; // Workaround to prevent overbilling by charging for a constant time window. The issue should be resolved in https://github.com/elastic/security-team/issues/9424. - const assetCountAggregations = await getAssetAggByCloudSecuritySolution( + const assetCountAggregation = await getAssetAggByCloudSecuritySolution( esClient, - cloudSecuritySolution, - searchFrom + cloudSecuritySolution ); + if (!assetCountAggregation) return []; + const usageRecords = await getUsageRecords( - assetCountAggregations, + assetCountAggregation, cloudSecuritySolution, taskId, tier, @@ -235,7 +245,7 @@ export const getCloudSecurityUsageRecord = async ({ logger ); - return usageRecords; + return [usageRecords]; } catch (err) { logger.error(`Failed to fetch ${cloudSecuritySolution} metering data ${err}`); } diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts index 62ded11d5ad1..0ca9a7b5b943 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts @@ -14,12 +14,24 @@ export interface CloudDefendAssetCountAggregation { export interface AssetCountAggregationBucket { buckets: AssetCountAggregation[]; } + +export interface ResourceSubtypeAggregationBucket { + key: string; + doc_count: number; + unique_assets: { + value: number; + }; +} + export interface AssetCountAggregation { key_as_string: string; min_timestamp: MinTimestamp; unique_assets: { value: number; }; + resource_sub_type: { + buckets: ResourceSubtypeAggregationBucket[]; + }; } export interface MinTimestamp { diff --git a/x-pack/plugins/security_solution_serverless/server/plugin.ts b/x-pack/plugins/security_solution_serverless/server/plugin.ts index a7783ad37d53..d63152f16949 100644 --- a/x-pack/plugins/security_solution_serverless/server/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/server/plugin.ts @@ -26,13 +26,12 @@ import type { } from './types'; import { SecurityUsageReportingTask } from './task_manager/usage_reporting_task'; import { cloudSecurityMetringTaskProperties } from './cloud_security/cloud_security_metering_task_config'; -import { getProductProductFeaturesConfigurator, getSecurityProductTier } from './product_features'; +import { registerProductFeatures, getSecurityProductTier } from './product_features'; import { METERING_TASK as ENDPOINT_METERING_TASK } from './endpoint/constants/metering'; import { endpointMeteringService, setEndpointPackagePolicyServerlessBillingFlags, } from './endpoint/services'; -import { enableRuleActions } from './rules/enable_rule_actions'; import { NLPCleanupTask } from './task_manager/nlp_cleanup_task/nlp_cleanup_task'; import { telemetryEvents } from './telemetry/event_based_telemetry'; @@ -54,34 +53,25 @@ export class SecuritySolutionServerlessPlugin constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get<ServerlessSecurityConfig>(); this.logger = this.initializerContext.logger.get(); + + const productTypesStr = JSON.stringify(this.config.productTypes, null, 2); + this.logger.info(`Security Solution running with product types:\n${productTypesStr}`); } public setup(coreSetup: CoreSetup, pluginsSetup: SecuritySolutionServerlessPluginSetupDeps) { this.config = createConfig(this.initializerContext, pluginsSetup.securitySolution); - const enabledProductFeatures = getProductProductFeatures(this.config.productTypes); - // securitySolutionEss plugin should always be disabled when securitySolutionServerless is enabled. - // This check is an additional layer of security to prevent double registrations when - // `plugins.forceEnableAllPlugins` flag is enabled. Should never happen in real scenarios. - const shouldRegister = pluginsSetup.securitySolutionEss == null; - if (shouldRegister) { - const productTypesStr = JSON.stringify(this.config.productTypes, null, 2); - this.logger.info(`Security Solution running with product types:\n${productTypesStr}`); - const productFeaturesConfigurator = getProductProductFeaturesConfigurator( - enabledProductFeatures, - this.config - ); - pluginsSetup.securitySolution.setProductFeaturesConfigurator(productFeaturesConfigurator); - } + // Register product features + const enabledProductFeatures = getProductProductFeatures(this.config.productTypes); + registerProductFeatures(pluginsSetup, enabledProductFeatures, this.config); // Register telemetry events telemetryEvents.forEach((eventConfig) => coreSetup.analytics.registerEventType(eventConfig)); - enableRuleActions({ - actions: pluginsSetup.actions, - productFeatureKeys: enabledProductFeatures, - }); + // Setup project uiSettings whitelisting + pluginsSetup.serverless.setupProjectSettings(SECURITY_PROJECT_SETTINGS); + // Tasks this.cloudSecurityUsageReportingTask = new SecurityUsageReportingTask({ core: coreSetup, logFactory: this.initializerContext.logger, @@ -113,8 +103,6 @@ export class SecuritySolutionServerlessPlugin taskManager: pluginsSetup.taskManager, }); - pluginsSetup.serverless.setupProjectSettings(SECURITY_PROJECT_SETTINGS); - return {}; } diff --git a/x-pack/plugins/security_solution_serverless/server/product_features/index.ts b/x-pack/plugins/security_solution_serverless/server/product_features/index.ts index 40ae2a4253cd..6c0b2b9091c6 100644 --- a/x-pack/plugins/security_solution_serverless/server/product_features/index.ts +++ b/x-pack/plugins/security_solution_serverless/server/product_features/index.ts @@ -5,30 +5,56 @@ * 2.0. */ -import type { ProductFeatureKeys } from '@kbn/security-solution-features'; -import type { ProductFeaturesConfigurator } from '@kbn/security-solution-plugin/server/lib/product_features_service/types'; import type { Logger } from '@kbn/logging'; -import type { ServerlessSecurityConfig } from '../config'; + +import { ProductFeatureKey } from '@kbn/security-solution-features/keys'; +import type { ProductFeatureKeys } from '@kbn/security-solution-features'; import { getCasesProductFeaturesConfigurator } from './cases_product_features_config'; import { getSecurityProductFeaturesConfigurator } from './security_product_features_config'; import { getSecurityAssistantProductFeaturesConfigurator } from './assistant_product_features_config'; -import type { Tier } from '../types'; +import { enableRuleActions } from '../rules/enable_rule_actions'; +import type { ServerlessSecurityConfig } from '../config'; +import type { Tier, SecuritySolutionServerlessPluginSetupDeps } from '../types'; import { ProductLine } from '../../common/product'; -export const getProductProductFeaturesConfigurator = ( +export const registerProductFeatures = ( + pluginsSetup: SecuritySolutionServerlessPluginSetupDeps, enabledProductFeatureKeys: ProductFeatureKeys, config: ServerlessSecurityConfig -): ProductFeaturesConfigurator => { - return { +): void => { + // securitySolutionEss plugin should always be disabled when securitySolutionServerless is enabled. + // This check is an additional layer of security to prevent double registrations when + // `plugins.forceEnableAllPlugins` flag is enabled. Should never happen in real scenarios. + const shouldRegister = pluginsSetup.securitySolutionEss == null; + if (!shouldRegister) { + return; + } + + // register product features for the main security solution product features service + pluginsSetup.securitySolution.setProductFeaturesConfigurator({ security: getSecurityProductFeaturesConfigurator( enabledProductFeatureKeys, config.experimentalFeatures ), cases: getCasesProductFeaturesConfigurator(enabledProductFeatureKeys), securityAssistant: getSecurityAssistantProductFeaturesConfigurator(enabledProductFeatureKeys), - }; + }); + + // enable rule actions based on the enabled product features + enableRuleActions({ + actions: pluginsSetup.actions, + productFeatureKeys: enabledProductFeatureKeys, + }); + + // set availability for the integration assistant plugin based on the product features + pluginsSetup.integrationAssistant?.setIsAvailable( + enabledProductFeatureKeys.includes(ProductFeatureKey.integrationAssistant) + ); }; +/** + * Get the security product tier from the security product type in the config + */ export const getSecurityProductTier = (config: ServerlessSecurityConfig, logger: Logger): Tier => { const securityProductType = config.productTypes.find( (productType) => productType.product_line === ProductLine.security diff --git a/x-pack/plugins/security_solution_serverless/server/types.ts b/x-pack/plugins/security_solution_serverless/server/types.ts index 7e84b418cf4c..8f8d4b36041a 100644 --- a/x-pack/plugins/security_solution_serverless/server/types.ts +++ b/x-pack/plugins/security_solution_serverless/server/types.ts @@ -21,6 +21,7 @@ import type { FleetStartContract } from '@kbn/fleet-plugin/server'; import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { ServerlessPluginSetup } from '@kbn/serverless/server'; +import type { IntegrationAssistantPluginSetup } from '@kbn/integration-assistant-plugin/server'; import type { ProductTier } from '../common/product'; import type { ServerlessSecurityConfig } from './config'; @@ -39,6 +40,7 @@ export interface SecuritySolutionServerlessPluginSetupDeps { taskManager: TaskManagerSetupContract; cloud: CloudSetup; actions: ActionsPluginSetupContract; + integrationAssistant?: IntegrationAssistantPluginSetup; } export interface SecuritySolutionServerlessPluginStartDeps { @@ -63,17 +65,13 @@ export interface UsageMetrics { quantity: number; period_seconds?: number; cause?: string; - metadata?: unknown; + metadata?: ResourceSubtypeCounter; } export interface UsageSource { id: string; instance_group_id: string; - metadata?: UsageSourceMetadata; -} - -export interface UsageSourceMetadata { - tier?: Tier; + metadata?: { tier?: Tier }; } export type Tier = ProductTier | 'none'; @@ -123,3 +121,6 @@ export interface MetringTaskProperties { periodSeconds: number; version: string; } +export interface ResourceSubtypeCounter { + [key: string]: string; +} diff --git a/x-pack/plugins/security_solution_serverless/tsconfig.json b/x-pack/plugins/security_solution_serverless/tsconfig.json index a1c6cefd396c..b6bfbea1cc7b 100644 --- a/x-pack/plugins/security_solution_serverless/tsconfig.json +++ b/x-pack/plugins/security_solution_serverless/tsconfig.json @@ -44,5 +44,6 @@ "@kbn/management-cards-navigation", "@kbn/discover-plugin", "@kbn/logging", + "@kbn/integration-assistant-plugin", ] } diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index e72e1a457507..7953474a099b 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -101,11 +101,10 @@ export class ServerlessSearchPlugin async mount({ element, history }: AppMountParameters) { const { renderApp } = await import('./application/elasticsearch'); const [coreStart, services] = await core.getStartServices(); - const { security } = services; docLinks.setDocLinks(coreStart.docLinks.links); let user: AuthenticatedUser | undefined; try { - const response = await security.authc.getCurrentUser(); + const response = await coreStart.security.authc.getCurrentUser(); user = response; } catch { user = undefined; diff --git a/x-pack/plugins/stack_connectors/public/common/auth/auth_config.tsx b/x-pack/plugins/stack_connectors/public/common/auth/auth_config.tsx index e4477be874ec..8a4bfe652088 100644 --- a/x-pack/plugins/stack_connectors/public/common/auth/auth_config.tsx +++ b/x-pack/plugins/stack_connectors/public/common/auth/auth_config.tsx @@ -39,14 +39,13 @@ import * as i18n from './translations'; interface Props { readOnly: boolean; - hideSSL?: boolean; } const { emptyField } = fieldValidators; const VERIFICATION_MODE_DEFAULT = 'full'; -export const AuthConfig: FunctionComponent<Props> = ({ readOnly, hideSSL }) => { +export const AuthConfig: FunctionComponent<Props> = ({ readOnly }) => { const { setFieldValue, getFieldDefaultValue } = useFormContext(); const [{ config, __internal__ }] = useFormData({ watch: [ @@ -77,37 +76,6 @@ export const AuthConfig: FunctionComponent<Props> = ({ readOnly, hideSSL }) => { useEffect(() => setFieldValue('config.hasAuth', Boolean(authType)), [authType, setFieldValue]); - const hideSSLFields = hideSSL && authType !== AuthType.SSL; - - const authOptions = [ - { - value: null, - label: i18n.AUTHENTICATION_NONE, - 'data-test-subj': 'authNone', - }, - { - value: AuthType.Basic, - label: i18n.AUTHENTICATION_BASIC, - children: authType === AuthType.Basic && <BasicAuthFields readOnly={readOnly} />, - 'data-test-subj': 'authBasic', - }, - ]; - - if (!hideSSLFields) { - authOptions.push({ - value: AuthType.SSL, - label: i18n.AUTHENTICATION_SSL, - children: authType === AuthType.SSL && ( - <SSLCertFields - readOnly={readOnly} - certTypeDefaultValue={certTypeDefaultValue} - certType={certType} - /> - ), - 'data-test-subj': 'authSSL', - }); - } - return ( <> <EuiFlexGroup> @@ -124,7 +92,31 @@ export const AuthConfig: FunctionComponent<Props> = ({ readOnly, hideSSL }) => { defaultValue={authTypeDefaultValue} component={CardRadioGroupField} componentProps={{ - options: authOptions, + options: [ + { + value: null, + label: i18n.AUTHENTICATION_NONE, + 'data-test-subj': 'authNone', + }, + { + value: AuthType.Basic, + label: i18n.AUTHENTICATION_BASIC, + children: authType === AuthType.Basic && <BasicAuthFields readOnly={readOnly} />, + 'data-test-subj': 'authBasic', + }, + { + value: AuthType.SSL, + label: i18n.AUTHENTICATION_SSL, + children: authType === AuthType.SSL && ( + <SSLCertFields + readOnly={readOnly} + certTypeDefaultValue={certTypeDefaultValue} + certType={certType} + /> + ), + 'data-test-subj': 'authSSL', + }, + ], }} /> <EuiSpacer size="m" /> @@ -208,81 +200,77 @@ export const AuthConfig: FunctionComponent<Props> = ({ readOnly, hideSSL }) => { </UseArray> )} <EuiSpacer size="m" /> - {!hideSSLFields && ( + <UseField + path="__internal__.hasCA" + component={ToggleField} + config={{ defaultValue: hasCADefaultValue, label: i18n.ADD_CA_LABEL }} + componentProps={{ + euiFieldProps: { + disabled: readOnly, + 'data-test-subj': 'webhookViewCASwitch', + }, + }} + /> + {hasCA && ( <> - <UseField - path="__internal__.hasCA" - component={ToggleField} - config={{ defaultValue: hasCADefaultValue, label: i18n.ADD_CA_LABEL }} - componentProps={{ - euiFieldProps: { - disabled: readOnly, - 'data-test-subj': 'webhookViewCASwitch', - }, - }} - /> - {hasCA && ( + <EuiSpacer size="s" /> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem> + <UseField + path="config.ca" + config={{ + label: 'CA file', + validations: [ + { + validator: + config?.verificationMode !== 'none' + ? emptyField(i18n.CA_REQUIRED) + : () => {}, + }, + ], + }} + component={FilePickerField} + componentProps={{ + euiFieldProps: { + display: 'default', + 'data-test-subj': 'webhookCAInput', + accept: '.ca,.pem', + }, + }} + /> + </EuiFlexItem> + <EuiFlexItem> + <UseField + path="config.verificationMode" + component={SelectField} + config={{ + label: i18n.VERIFICATION_MODE_LABEL, + defaultValue: VERIFICATION_MODE_DEFAULT, + validations: [ + { + validator: emptyField(i18n.VERIFICATION_MODE_LABEL), + }, + ], + }} + componentProps={{ + euiFieldProps: { + 'data-test-subj': 'webhookVerificationModeSelect', + options: [ + { text: 'None', value: 'none' }, + { text: 'Certificate', value: 'certificate' }, + { text: 'Full', value: 'full' }, + ], + fullWidth: true, + readOnly, + }, + }} + /> + </EuiFlexItem> + </EuiFlexGroup> + {hasInitialCA && ( <> <EuiSpacer size="s" /> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem> - <UseField - path="config.ca" - config={{ - label: 'CA file', - validations: [ - { - validator: - config?.verificationMode !== 'none' - ? emptyField(i18n.CA_REQUIRED) - : () => {}, - }, - ], - }} - component={FilePickerField} - componentProps={{ - euiFieldProps: { - display: 'default', - 'data-test-subj': 'webhookCAInput', - accept: '.ca,.pem', - }, - }} - /> - </EuiFlexItem> - <EuiFlexItem> - <UseField - path="config.verificationMode" - component={SelectField} - config={{ - label: i18n.VERIFICATION_MODE_LABEL, - defaultValue: VERIFICATION_MODE_DEFAULT, - validations: [ - { - validator: emptyField(i18n.VERIFICATION_MODE_LABEL), - }, - ], - }} - componentProps={{ - euiFieldProps: { - 'data-test-subj': 'webhookVerificationModeSelect', - options: [ - { text: 'None', value: 'none' }, - { text: 'Certificate', value: 'certificate' }, - { text: 'Full', value: 'full' }, - ], - fullWidth: true, - readOnly, - }, - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - {hasInitialCA && ( - <> - <EuiSpacer size="s" /> - <EuiCallOut size="s" iconType="document" title={i18n.EDIT_CA_CALLOUT} /> - </> - )} + <EuiCallOut size="s" iconType="document" title={i18n.EDIT_CA_CALLOUT} /> </> )} </> diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/auth.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/auth.tsx index 3d7b5bf27e17..cbe250ea823e 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/auth.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/auth.tsx @@ -19,7 +19,7 @@ interface Props { export const AuthStep: FunctionComponent<Props> = ({ display, readOnly }) => { return ( <span data-test-subj="authStep" style={{ display: display ? 'block' : 'none' }}> - <AuthConfig readOnly={readOnly} hideSSL /> + <AuthConfig readOnly={readOnly} /> <EuiSpacer size="s" /> </span> ); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx index 66d15550b7b7..92ea2fd6a1b3 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx @@ -74,7 +74,7 @@ describe('CasesWebhookActionConnectorFields renders', () => { expect(await screen.findByTestId('authNone')).toBeInTheDocument(); expect(await screen.findByTestId('authBasic')).toBeInTheDocument(); - // expect(await screen.findByTestId('authSSL')).toBeInTheDocument(); + expect(await screen.findByTestId('authSSL')).toBeInTheDocument(); expect(await screen.findByTestId('webhookUserInput')).toBeInTheDocument(); expect(await screen.findByTestId('webhookPasswordInput')).toBeInTheDocument(); expect(await screen.findByTestId('webhookHeadersKeyInput')).toBeInTheDocument(); @@ -363,7 +363,7 @@ describe('CasesWebhookActionConnectorFields renders', () => { data: { ...rest, __internal__: { - // hasCA: false, + hasCA: false, hasHeaders: true, }, }, @@ -404,7 +404,7 @@ describe('CasesWebhookActionConnectorFields renders', () => { authType: null, }, __internal__: { - // hasCA: false, + hasCA: false, hasHeaders: true, }, }, @@ -442,7 +442,7 @@ describe('CasesWebhookActionConnectorFields renders', () => { ...rest, config: rest2, __internal__: { - // hasCA: false, + hasCA: false, hasHeaders: false, }, }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts index fe18cf956c0e..f3787e8d367d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts @@ -828,6 +828,76 @@ describe('execute()', () => { `); }); + test('returns expected result when a 450 error is thrown', async () => { + const customExecutorOptions: EmailConnectorTypeExecutorOptions = { + ...executorOptions, + config: { + ...config, + hasAuth: false, + }, + secrets: { + ...secrets, + user: null, + password: null, + }, + }; + + const errorResponse = { + message: 'Mail command failed: 450 4.7.1 Error: too much mail', + response: { + status: 450, + }, + }; + + sendEmailMock.mockReset(); + sendEmailMock.mockRejectedValue(errorResponse); + const result = await connectorType.executor(customExecutorOptions); + expect(result).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "errorSource": "user", + "message": "error sending email", + "serviceMessage": "Mail command failed: 450 4.7.1 Error: too much mail", + "status": "error", + } + `); + }); + + test('returns expected result when a 554 error is thrown', async () => { + const customExecutorOptions: EmailConnectorTypeExecutorOptions = { + ...executorOptions, + config: { + ...config, + hasAuth: false, + }, + secrets: { + ...secrets, + user: null, + password: null, + }, + }; + + const errorResponse = { + message: "Can't send mail - all recipients were rejected: 554 5.7.1", + response: { + status: 554, + }, + }; + + sendEmailMock.mockReset(); + sendEmailMock.mockRejectedValue(errorResponse); + const result = await connectorType.executor(customExecutorOptions); + expect(result).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "errorSource": "user", + "message": "error sending email", + "serviceMessage": "Can't send mail - all recipients were rejected: 554 5.7.1", + "status": "error", + } + `); + }); + test('renders parameter templates as expected', async () => { expect(connectorType.renderParameterTemplates).toBeTruthy(); const paramsWithTemplates = { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts index 1705342e7374..785ace370323 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts @@ -28,6 +28,7 @@ import { renderMustacheString, } from '@kbn/actions-plugin/server/lib/mustache_renderer'; import { ActionExecutionSourceType } from '@kbn/actions-plugin/server/types'; +import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { AdditionalEmailServices } from '../../../common'; import { sendEmail, JSON_TRANSPORT_SERVICE, SendEmailOptions, Transport } from './send_email'; import { portSchema } from '../lib/schemas'; @@ -370,12 +371,23 @@ async function executor( const message = i18n.translate('xpack.stackConnectors.email.errorSendingErrorMessage', { defaultMessage: 'error sending email', }); - return { + const errorResult: ConnectorTypeExecutorResult<unknown> = { status: 'error', actionId, message, serviceMessage: err.message, }; + + // Mark 4xx and 5xx errors as user errors + const statusCode = err?.response?.status; + if (statusCode >= 400 && statusCode < 600) { + return { + ...errorResult, + errorSource: TaskErrorSource.USER, + }; + } + + return errorResult; } return { status: 'ok', data: result, actionId }; diff --git a/x-pack/plugins/stack_connectors/tsconfig.json b/x-pack/plugins/stack_connectors/tsconfig.json index 7a6898a5ce82..8a37f4edaa0b 100644 --- a/x-pack/plugins/stack_connectors/tsconfig.json +++ b/x-pack/plugins/stack_connectors/tsconfig.json @@ -40,6 +40,7 @@ "@kbn/cases-components", "@kbn/code-editor-mock", "@kbn/utility-types", + "@kbn/task-manager-plugin", "@kbn/alerting-types", ], "exclude": [ diff --git a/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts index 81b681dfd812..ba97282c6de5 100644 --- a/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts @@ -76,7 +76,6 @@ export interface IndexFieldsStrategyResponse extends IEsSearchResponse { */ export interface BrowserField { aggregatable: boolean; - fields: Record<string, Partial<BrowserField>>; // FIXME: missing in FieldSpec format: string; indexes: string[]; // FIXME: missing in FieldSpec name: string; @@ -88,6 +87,12 @@ export interface BrowserField { runtimeField?: RuntimeField; } +type FieldCategoryName = string; + +export interface FieldCategory { + fields: Record<string, Partial<BrowserField>>; +} + /** * @deprecated use fields list on dataview / "indexPattern" * about to use browserFields? Reconsider! Maybe you can accomplish @@ -95,7 +100,7 @@ export interface BrowserField { * you are working with? Or perhaps you need a description for a * particular field? Consider using the EcsFlat module from `@kbn/ecs` */ -export type BrowserFields = Record<string, Partial<BrowserField>>; +export type BrowserFields = Record<FieldCategoryName, FieldCategory>; export const EMPTY_BROWSER_FIELDS = {}; export const EMPTY_INDEX_FIELDS: FieldSpec[] = []; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a1871e039fbb..d6496c14a2e1 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1069,7 +1069,6 @@ "core.ui.searchNavList.label": "Recherche", "core.ui.securityNavList.label": "Sécurité", "core.ui.skipToMainButton": "Passer au contenu principal", - "core.ui.welcomeErrorMessage": "Elastic ne s'est pas chargé correctement. Vérifiez la sortie du serveur pour plus d'informations.", "core.ui.welcomeMessage": "Chargement d'Elastic", "customIntegrations.components.replacementAccordion.recommendationDescription": "Les intégrations d'Elastic Agent sont recommandées, mais vous pouvez également utiliser Beats. Pour plus de détails, consultez notre {link}.", "customIntegrations.languageClients.DotnetElasticsearch.readme.connectingText": "Vous pouvez vous connecter à Elastic Cloud à l'aide d'une {api_key} et d'un {cloud_id} :", @@ -13342,8 +13341,6 @@ "xpack.elasticAssistant.assistant.content.prompts.system.superheroPersonality": "Donnez la réponse la plus pertinente et détaillée possible, comme si vous deviez communiquer ces informations à un expert en cybersécurité.", "xpack.elasticAssistant.assistant.content.prompts.system.superheroSystemPromptName": "Invite système améliorée", "xpack.elasticAssistant.assistant.content.prompts.system.youAreAHelpfulExpertAssistant": "Vous êtes un assistant expert et serviable qui répond à des questions au sujet d’Elastic Security.", - "xpack.elasticAssistant.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown": "Ajoutez votre description, les actions que vous recommandez ainsi que les étapes de triage à puces. Utilisez les données \"MITRE ATT&CK\" fournies pour ajouter du contexte et des recommandations de MITRE ainsi que des liens hypertexte vers les pages pertinentes sur le site web de MITRE. Assurez-vous d’inclure les scores de risque de l’utilisateur et de l’hôte du contexte. Votre réponse doit inclure des étapes qui pointent vers les fonctionnalités spécifiques d’Elastic Security, y compris les actions de réponse du terminal, l’intégration OSQuery Manager d’Elastic Agent (avec des exemples de requêtes OSQuery), des analyses de timeline et d’entités, ainsi qu’un lien pour toute la documentation Elastic Security pertinente.", - "xpack.elasticAssistant.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries": "Évaluer l’événement depuis le contexte ci-dessus et formater soigneusement la sortie en syntaxe Markdown pour mon cas Elastic Security.", "xpack.elasticAssistant.assistant.conversations.settings.connectorTitle": "Connecteur", "xpack.elasticAssistant.assistant.conversations.settings.promptHelpTextTitle": "Contexte fournit dans le cadre de chaque conversation", "xpack.elasticAssistant.assistant.conversations.settings.promptTitle": "Invite système", @@ -13618,7 +13615,6 @@ "xpack.enterpriseSearch.connectors.connectorStats.p.DocumentsLabel": "{documentAmount} documents", "xpack.enterpriseSearch.connectorStats.connectedBadgeLabel": "{number} connecté(s)", "xpack.enterpriseSearch.connectorStats.runningSyncsTextLabel": "{syncs} synchronisations en cours", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.connectorConnected": "Votre connecteur {name} s'est bien connecté à Search.", "xpack.enterpriseSearch.content.connectors.connectorsTable.columns.actions.viewIndex.caption": "Voir l'index {connectorName}", "xpack.enterpriseSearch.content.connectors.connectorTable.column.actions.deleteIndex": "Supprimer le connecteur {connectorName}", "xpack.enterpriseSearch.content.connectors.deleteModal.syncsWarning.indexNameDescription": "Cette action ne peut pas être annulée. Veuillez saisir {connectorName} pour confirmer.", @@ -14902,23 +14898,7 @@ "xpack.enterpriseSearch.content.analytics.api.generateAnalyticsApiKeyModal.title": "Créer une clé d'API d'analyse", "xpack.enterpriseSearch.content.cannotConnect.body": "En savoir plus.", "xpack.enterpriseSearch.content.cannotConnect.title": "Impossible de se connecter à Enterprise Search", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.configurationFileLink": "fichier de configuration", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnector.button.label": "Revérifier maintenant", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorText": "Votre connecteur ne s'est pas connecté à Search. Résolvez vos problèmes de configuration et actualisez la page.", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorTitle": "En attente de votre connecteur", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.scheduleSync.description": "Finalisez votre connecteur en déclenchant une synchronisation unique ou en définissant une synchronisation récurrente pour assurer la synchronisation de votre source de données au fil du temps", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.deployConnector.title": "Déployer un connecteur", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.enhance.title": "Améliorer votre client connecteur", "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.generateApiKey.title": "Générer une clé d’API", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.button.label": "Définir un calendrier et synchroniser", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.title": "Synchroniser vos données", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.deploy.label": "Déployer sans Docker", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.description": "Vous devez déployer ce connecteur dans votre propre infrastructure.", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.dockerDeploy.label": "Déployer avec Docker", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.manageKeys.label": "Gérer les clés d'API", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.readme.label": "Fichier readme du connecteur", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.title": "Support technique et documentation", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.viewDocumentation.label": "Afficher la documentation", "xpack.enterpriseSearch.content.connectors.breadcrumb": "Connecteurs", "xpack.enterpriseSearch.content.connectors.connectorDetail.configurationTabLabel": "Configuration", "xpack.enterpriseSearch.content.connectors.connectorDetail.documentsTabLabel": "Documents", @@ -15047,25 +15027,14 @@ "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.saveButtonLabel": "Enregistrer le nom et la description", "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.title": "Décrire ce robot d'indexation", "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionForm.description": "En nommant et en décrivant ce connecteur, vos collègues et votre équipe tout entière sauront à quelle utilisation ce connecteur est dédié.", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.encryptionWarningMessage": "Le chiffrement pour les informations d'identification de la source de données n'est pas disponible dans cette version. Les informations d'identification de votre source de données seront stockées, non chiffrées, dans Elasticsearch.", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.securityDocumentationLinkLabel": "En savoir plus sur Elasticsearch Security", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.buttonTitle": "Convertir un connecteur", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.linkTitle": "client de connecteur", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.title": "Autogestion de ce connecteur", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.text": "Les connecteurs natifs nécessitent une instance Enterprise Search pour synchroniser le contenu à partir de la source.", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.title": "Aucune instance Enterprise Search en cours d'exécution détectée", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.description": "N'oubliez pas de définir un calendrier de synchronisation dans l'onglet Planification pour actualiser continuellement vos données interrogeables.", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.title": "Calendrier de synchronisation configurable", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.description": "Limitez et personnalisez l'accès en lecture dont les utilisateurs disposent sur les documents d'indexation à l'heure de la requête.", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.securityLinkLabel": "Sécurité au niveau du document", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.title": "Sécurité au niveau du document", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.advancedConfigurationTitle": "Synchroniser vos données", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.configurationTitle": "Configuration", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.researchConfigurationTitle": "Exigences de la configuration des recherches", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.description": "Finalisez votre connecteur en déclenchant une synchronisation unique, ou en définissant un calendrier de synchronisation récurrent.", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.schedulingButtonLabel": "Définir un calendrier et synchroniser", "xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.connectorDocumentationLinkLabel": "Documentation", - "xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.description": "Ce connecteur prend en charge plusieurs méthodes d'authentification. Demandez à votre administrateur les informations d'identification correctes pour la connexion.", "xpack.enterpriseSearch.content.indices.configurationConnector.scheduling.successToast.title": "Mise à jour réussie du calendrier", "xpack.enterpriseSearch.content.indices.connector.syncRules.advancedRules.error": "Le format JSON n'est pas valide", "xpack.enterpriseSearch.content.indices.connector.syncRules.advancedRules.title": "Règles avancées", @@ -18212,7 +18181,6 @@ "xpack.fleet.epm.featuresLabel": "Fonctionnalités", "xpack.fleet.epm.InputTemplates.guideLink": "Guide de Fleet et d'Elastic Agent", "xpack.fleet.epm.InputTemplates.installCallout": "Installer l'intégration pour utiliser les configurations suivantes.", - "xpack.fleet.epm.InputTemplates.loadingErro": "Modèles d'entrée d'erreur", "xpack.fleet.epm.integrationPreference.beatsLabel": "Beats uniquement", "xpack.fleet.epm.integrationPreference.elasticAgentLabel": "Elastic Agent uniquement", "xpack.fleet.epm.integrationPreference.recommendedLabel": "Recommandé", @@ -29511,7 +29479,6 @@ "xpack.observability_onboarding.installElasticAgent.installStep.description": "Sélectionnez votre plateforme et exécutez la commande install dans votre terminal pour enregistrer, puis démarrez Elastic Agent. Faites ceci pour chaque hôte. Vérifiez {hostRequirementsLink} avant l'installation.", "xpack.observability_onboarding.installElasticAgent.integrationSuccessCallout.title": "Intégration {integrationName} installée.", "xpack.observability_onboarding.installElasticAgent.progress.eaConfig.completedTitle": "La configuration Elastic Agent est écrite dans {configPath}", - "xpack.observability_onboarding.installSystemIntegration.error.unauthorized": "Le privilège Kibana {requiredKibanaPrivileges} requis est manquant. Veuillez ajouter le privilège requis au rôle de l'utilisateur authentifié.", "xpack.observability_onboarding.systemIntegration.installed": "Intégration du système installée. {systemIntegrationTooltip}", "xpack.observability_onboarding.systemIntegration.installed.tooltip.link": "{learnMoreLink} sur les données que vous pouvez collecter à l'aide de l'intégration des systèmes.", "xpack.observability_onboarding.apiKeyBanner.created": "Clé d’API créée.", @@ -33700,7 +33667,6 @@ "xpack.securitySolution.dashboards.description": "Description", "xpack.securitySolution.dashboards.landing.createButton": "Créer un tableau de bord", "xpack.securitySolution.dashboards.landing.section.custom": "PERSONNALISÉ", - "xpack.securitySolution.dashboards.landing.section.default": "PAR DÉFAUT", "xpack.securitySolution.dashboards.pageTitle": "Tableaux de bord", "xpack.securitySolution.dashboards.queryError": "Erreur lors de la récupération des tableaux de bord de sécurité", "xpack.securitySolution.dashboards.title": "Titre", @@ -34022,7 +33988,6 @@ "xpack.securitySolution.detectionEngine.eqlValidation.requestError": "Une erreur s'est produite lors de la validation de votre requête EQL", "xpack.securitySolution.detectionEngine.eqlValidation.showErrorsLabel": "Afficher les erreurs de validation EQL", "xpack.securitySolution.detectionEngine.eqlValidation.title": "Erreurs de validation EQL", - "xpack.securitySolution.detectionEngine.esqlValidation.missingIdInQueryError": "Les requêtes n'utilisant pas la fonction STATS...BY (requêtes non-agrégées) doivent inclurent l'opérateur [metadata _id, _version, _index] après la commande de la source. Par exemple : FROM logs* [metadata _id, _version, _index]. En plus les propriétés de métadonnées (_id, _version, and _index) doivent être retournées dans la réponse de requête.", "xpack.securitySolution.detectionEngine.esqlValidation.unknownError": "Erreur inconnue lors de la validation ES|QL", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "Afficher la documentation", "xpack.securitySolution.detectionEngine.groups.additionalActions.takeAction": "Entreprendre des actions", @@ -44227,8 +44192,6 @@ "uiActions.errors.incompatibleAction": "Action non compatible", "uiActions.triggers.rowClickkDescription": "Un clic sur une ligne de tableau", "uiActions.triggers.rowClickTitle": "Clic sur ligne de tableau", - "uiActions.triggers.dashboard.addPanelMenu.description": "Une nouvelle action apparaîtra dans le menu Ajouter un panneau du tableau de bord", - "uiActions.triggers.dashboard.addPanelMenu.title": "Menu Ajouter un panneau", "unsavedChangesBadge.contextMenu.openButton": "Afficher les actions disponibles", "unsavedChangesBadge.contextMenu.revertChangesButton": "Restaurer les modifications", "unsavedChangesBadge.contextMenu.revertingChangesButtonStatus": "Annuler les modifications", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7b1fb5c238f5..f958e2e72478 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1069,7 +1069,6 @@ "core.ui.searchNavList.label": "検索", "core.ui.securityNavList.label": "セキュリティ", "core.ui.skipToMainButton": "メインコンテンツに移動", - "core.ui.welcomeErrorMessage": "Elasticが正常に読み込まれませんでした。詳細はサーバーアウトプットを確認してください。", "core.ui.welcomeMessage": "Elastic の読み込み中", "customIntegrations.components.replacementAccordion.recommendationDescription": "Elasticエージェント統合が推奨されますが、Beatsも使用できます。詳細については、{link}。", "customIntegrations.languageClients.DotnetElasticsearch.readme.connectingText": "{api_key}と{cloud_id}を使用して、Elastic Cloudに接続できます。", @@ -13321,8 +13320,6 @@ "xpack.elasticAssistant.assistant.content.prompts.system.superheroPersonality": "サイバーセキュリティの専門家に情報を伝えるつもりで、できるだけ詳細で関連性のある回答を入力してください。", "xpack.elasticAssistant.assistant.content.prompts.system.superheroSystemPromptName": "拡張システムプロンプト", "xpack.elasticAssistant.assistant.content.prompts.system.youAreAHelpfulExpertAssistant": "あなたはElasticセキュリティに関する質問に答える、親切で専門的なアシスタントです。", - "xpack.elasticAssistant.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown": "説明、推奨されるアクション、箇条書きのトリアージステップを追加します。提供された MITRE ATT&CKデータを使用して、MITREからのコンテキストや推奨事項を追加し、MITREのWebサイトの関連ページにハイパーリンクを貼ります。コンテキストのユーザーとホストのリスクスコアデータを必ず含めてください。回答には、エンドポイント対応アクション、ElasticエージェントOSQueryマネージャー統合(osqueryクエリの例を付けて)、タイムライン、エンティティ分析など、Elasticセキュリティ固有の機能を指す手順を含め、関連するElasticセキュリティのドキュメントすべてにリンクしてください。", - "xpack.elasticAssistant.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries": "上記のコンテキストからイベントを評価し、Elasticセキュリティのケース用に、出力をマークダウン構文で正しく書式設定してください。", "xpack.elasticAssistant.assistant.conversations.settings.connectorTitle": "コネクター", "xpack.elasticAssistant.assistant.conversations.settings.promptHelpTextTitle": "すべての会話の一部として提供されたコンテキスト", "xpack.elasticAssistant.assistant.conversations.settings.promptTitle": "システムプロンプト", @@ -13597,7 +13594,6 @@ "xpack.enterpriseSearch.connectors.connectorStats.p.DocumentsLabel": "{documentAmount}ドキュメント", "xpack.enterpriseSearch.connectorStats.connectedBadgeLabel": "{number}個が接続済み", "xpack.enterpriseSearch.connectorStats.runningSyncsTextLabel": "{syncs}実行中の同期", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.connectorConnected": "コネクター{name}は、正常にSearchに接続されました。", "xpack.enterpriseSearch.content.connectors.connectorsTable.columns.actions.viewIndex.caption": "インデックス{connectorName}を表示", "xpack.enterpriseSearch.content.connectors.connectorTable.column.actions.deleteIndex": "コネクター\"{connectorName}\"を削除", "xpack.enterpriseSearch.content.connectors.deleteModal.syncsWarning.indexNameDescription": "この操作は元に戻すことができません。{connectorName}を入力して確認してください。", @@ -14880,23 +14876,7 @@ "xpack.enterpriseSearch.content.analytics.api.generateAnalyticsApiKeyModal.title": "分析APIキーを作成", "xpack.enterpriseSearch.content.cannotConnect.body": "詳細。", "xpack.enterpriseSearch.content.cannotConnect.title": "エンタープライズ サーチに接続できません", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.configurationFileLink": "構成ファイル", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnector.button.label": "今すぐ再確認", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorText": "コネクターはSearchに接続されていません。構成のトラブルシューティングを行い、ページを更新してください。", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorTitle": "コネクターを待機しています", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.scheduleSync.description": "ワンタイム同期をトリガーするか、経時的にデータソースを同期し続ける繰り返し同期を設定して、コネクターを確定", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.deployConnector.title": "コネクターをデプロイ", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.enhance.title": "コネクタークライアントを強化", "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.generateApiKey.title": "APIキーを生成", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.button.label": "スケジュールを設定して同期", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.title": "データを同期", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.deploy.label": "Dockerを使用せずにデプロイ", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.description": "このコネクターは、お客様自身のインフラにデプロイする必要があります。", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.dockerDeploy.label": "Dockerを使用してデプロイ", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.manageKeys.label": "APIキーの管理", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.readme.label": "コネクターReadme", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.title": "サポートとドキュメント", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.viewDocumentation.label": "ドキュメンテーションを表示", "xpack.enterpriseSearch.content.connectors.breadcrumb": "コネクター", "xpack.enterpriseSearch.content.connectors.connectorDetail.configurationTabLabel": "構成", "xpack.enterpriseSearch.content.connectors.connectorDetail.documentsTabLabel": "ドキュメント", @@ -15025,25 +15005,14 @@ "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.saveButtonLabel": "名前と説明を保存", "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.title": "このクローラーの説明", "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionForm.description": "このコネクターの名前と説明を設定すると、他のユーザーやチームでもこのコネクターの目的がわかります。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.encryptionWarningMessage": "このバージョンでは、データソース資格情報の暗号化を使用できません。データソース資格情報は、暗号化されずに、Elasticsearchに保存されます。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.securityDocumentationLinkLabel": "Elasticsearchセキュリティの詳細", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.buttonTitle": "コネクターを変換", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.linkTitle": "コネクタークライアント", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.title": "このコネクターを自己管理", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.text": "ネイティブコネクターは、ソースからコンテンツを同期するために、実行中のエンタープライズ サーチインスタンスが必要です。", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.title": "実行中のエンタープライズ サーチインスタンスが検出されません", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.description": "必ず[スケジュール]タブで同期スケジュールを設定し、検索可能データを継続的に更新してください。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.title": "設定可能な同期スケジュール", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.description": "クエリ時にユーザーに割り当てられているインデックスドキュメントの読み取りアクセス権を制限、パーソナライズします。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.securityLinkLabel": "ドキュメントレベルのセキュリティ", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.title": "ドキュメントレベルのセキュリティ", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.advancedConfigurationTitle": "データを同期", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.configurationTitle": "構成", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.researchConfigurationTitle": "構成要件の調査", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.description": "ワンタイム同期をトリガーするか、繰り返し同期スケジュールを設定して、コネクターを確定します。", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.schedulingButtonLabel": "スケジュールを設定して同期", "xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.connectorDocumentationLinkLabel": "ドキュメント", - "xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.description": "このコネクターは複数の認証方法をサポートします。正しい接続資格情報については、管理者に確認してください。", "xpack.enterpriseSearch.content.indices.configurationConnector.scheduling.successToast.title": "スケジュールは正常に更新されました", "xpack.enterpriseSearch.content.indices.connector.syncRules.advancedRules.error": "JSON形式が無効です", "xpack.enterpriseSearch.content.indices.connector.syncRules.advancedRules.title": "詳細ルール", @@ -18190,7 +18159,6 @@ "xpack.fleet.epm.featuresLabel": "機能", "xpack.fleet.epm.InputTemplates.guideLink": "FleetおよびElasticエージェントガイド", "xpack.fleet.epm.InputTemplates.installCallout": "次の構成を使用するために、統合をインストールします。", - "xpack.fleet.epm.InputTemplates.loadingErro": "エラー入力テンプレート", "xpack.fleet.epm.integrationPreference.beatsLabel": "Beatsのみ", "xpack.fleet.epm.integrationPreference.elasticAgentLabel": "Elasticエージェントのみ", "xpack.fleet.epm.integrationPreference.recommendedLabel": "推奨", @@ -29488,7 +29456,6 @@ "xpack.observability_onboarding.installElasticAgent.installStep.description": "プラットフォームを選択し、ターミナルでinstallコマンドを実行してElasticエージェントを登録、起動します。各ホストでこの手順を実行します。インストール前に{hostRequirementsLink}を確認してください。", "xpack.observability_onboarding.installElasticAgent.integrationSuccessCallout.title": "{integrationName}統合がインストールされました。", "xpack.observability_onboarding.installElasticAgent.progress.eaConfig.completedTitle": "Elasticエージェント構成が{configPath}に書き込まれました", - "xpack.observability_onboarding.installSystemIntegration.error.unauthorized": "必要なkibana権限{requiredKibanaPrivileges}がありません。認証されたユーザーのロールに必要な権限を追加してください。", "xpack.observability_onboarding.systemIntegration.installed": "システム統合がインストールされました。{systemIntegrationTooltip}", "xpack.observability_onboarding.systemIntegration.installed.tooltip.link": "システム統合を使用して収集できるデータについて{learnMoreLink}。", "xpack.observability_onboarding.apiKeyBanner.created": "APIキーが作成されました。", @@ -33675,7 +33642,6 @@ "xpack.securitySolution.dashboards.description": "説明", "xpack.securitySolution.dashboards.landing.createButton": "ダッシュボードを作成", "xpack.securitySolution.dashboards.landing.section.custom": "カスタム", - "xpack.securitySolution.dashboards.landing.section.default": "デフォルト", "xpack.securitySolution.dashboards.pageTitle": "ダッシュボード", "xpack.securitySolution.dashboards.queryError": "セキュリティダッシュボードの取得エラー", "xpack.securitySolution.dashboards.title": "タイトル", @@ -33997,7 +33963,6 @@ "xpack.securitySolution.detectionEngine.eqlValidation.requestError": "EQLクエリの確認中にエラーが発生しました", "xpack.securitySolution.detectionEngine.eqlValidation.showErrorsLabel": "EQL確認エラーを表示", "xpack.securitySolution.detectionEngine.eqlValidation.title": "EQL確認エラー", - "xpack.securitySolution.detectionEngine.esqlValidation.missingIdInQueryError": "STATS...BY関数を使用しないクエリ(非集約クエリ)は、ソースコマンドの後に[metadata _id、_version、_index]演算子を含める必要があります。例:FROM logs* [metadata _id, _version, _index] さらに、メタデータプロパティ(_id、_version、_index)をクエリレスポンスで返さなければなりません。", "xpack.securitySolution.detectionEngine.esqlValidation.unknownError": "ES|QLの検証中の不明なエラー", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "ドキュメンテーションを表示", "xpack.securitySolution.detectionEngine.groups.additionalActions.takeAction": "アクションを実行", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c783af0ea506..bfd84fcc41cb 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1071,7 +1071,6 @@ "core.ui.searchNavList.label": "搜索", "core.ui.securityNavList.label": "安全", "core.ui.skipToMainButton": "跳到主要内容", - "core.ui.welcomeErrorMessage": "Elastic 未正确加载。检查服务器输出以了解详情。", "core.ui.welcomeMessage": "正在加载 Elastic", "customIntegrations.components.replacementAccordion.recommendationDescription": "建议使用 Elastic 代理集成,但也可以使用 Beats。有关更多详情,请访问 {link}。", "customIntegrations.languageClients.DotnetElasticsearch.readme.connectingText": "您可以使用 {api_key} 和 {cloud_id} 连接到 Elastic Cloud:", @@ -13347,8 +13346,6 @@ "xpack.elasticAssistant.assistant.content.prompts.system.superheroPersonality": "提供可能的最详细、最相关的答案,就好像您正将此信息转发给网络安全专家一样。", "xpack.elasticAssistant.assistant.content.prompts.system.superheroSystemPromptName": "已增强系统提示", "xpack.elasticAssistant.assistant.content.prompts.system.youAreAHelpfulExpertAssistant": "您是一位可帮助回答 Elastic Security 相关问题的专家助手。", - "xpack.elasticAssistant.assistant.content.prompts.user.finallySuggestInvestigationGuideAndFormatAsMarkdown": "添加描述、建议操作和带项目符号的分类步骤。使用提供的 MITRE ATT&CK 数据以从 MITRE 添加更多上下文和建议,以及指向 MITRE 网站上的相关页面的超链接。确保包括上下文中的用户和主机风险分数数据。您的响应应包含指向 Elastic Security 特定功能的步骤,包括终端响应操作、Elastic 代理 OSQuery 管理器集成(带示例 osquery 查询)、时间线和实体分析,以及所有相关 Elastic Security 文档的链接。", - "xpack.elasticAssistant.assistant.content.prompts.user.thenSummarizeSuggestedKqlAndEqlQueries": "评估来自上述上下文的事件,并以用于我的 Elastic Security 案例的 Markdown 语法对您的输出进行全面格式化。", "xpack.elasticAssistant.assistant.conversations.settings.connectorTitle": "连接器", "xpack.elasticAssistant.assistant.conversations.settings.promptHelpTextTitle": "已作为每个对话的一部分提供上下文", "xpack.elasticAssistant.assistant.conversations.settings.promptTitle": "系统提示", @@ -13623,7 +13620,6 @@ "xpack.enterpriseSearch.connectors.connectorStats.p.DocumentsLabel": "{documentAmount} 个文档", "xpack.enterpriseSearch.connectorStats.connectedBadgeLabel": "{number} 个已连接", "xpack.enterpriseSearch.connectorStats.runningSyncsTextLabel": "{syncs} 个正在运行的同步", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.connectorConnected": "您的连接器 {name} 已成功连接到 Search。", "xpack.enterpriseSearch.content.connectors.connectorsTable.columns.actions.viewIndex.caption": "查看索引 {connectorName}", "xpack.enterpriseSearch.content.connectors.connectorTable.column.actions.deleteIndex": "删除连接器 {connectorName}", "xpack.enterpriseSearch.content.connectors.deleteModal.syncsWarning.indexNameDescription": "此操作无法撤消。请尝试 {connectorName} 以确认。", @@ -14907,23 +14903,7 @@ "xpack.enterpriseSearch.content.analytics.api.generateAnalyticsApiKeyModal.title": "创建分析 API 密钥", "xpack.enterpriseSearch.content.cannotConnect.body": "更多信息。", "xpack.enterpriseSearch.content.cannotConnect.title": "无法连接到 Enterprise Search", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.configurationFileLink": "配置文件", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnector.button.label": "立即重新检查", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorText": "您的连接器尚未连接到 Search。排除配置故障并刷新页面。", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.connectorPackage.waitingForConnectorTitle": "等候您的连接器", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.scheduleSync.description": "通过触发一次性同步或设置重复同步来最终确定您的连接器,以使数据源在一段时间内保持同步", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.deployConnector.title": "部署连接器", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.enhance.title": "增强连接器客户端", "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.generateApiKey.title": "生成 API 密钥", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.button.label": "设置计划并同步", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.schedule.title": "同步您的数据", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.deploy.label": "不通过 Docker 部署", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.description": "您需要在自己的基础设施上部署此连接器。", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.dockerDeploy.label": "通过 Docker 部署", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.manageKeys.label": "管理 API 密钥", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.readme.label": "连接器自述文件", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.title": "支持和文档", - "xpack.enterpriseSearch.content.connector_detail.configurationConnector.support.viewDocumentation.label": "查看文档", "xpack.enterpriseSearch.content.connectors.breadcrumb": "连接器", "xpack.enterpriseSearch.content.connectors.connectorDetail.configurationTabLabel": "配置", "xpack.enterpriseSearch.content.connectors.connectorDetail.documentsTabLabel": "文档", @@ -15052,25 +15032,14 @@ "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.saveButtonLabel": "保存名称和描述", "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.title": "描述此网络爬虫", "xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionForm.description": "通过命名和描述此连接器,您的同事和更广泛的团队将了解本连接器的用途。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.encryptionWarningMessage": "在此版本中无法加密数据源凭据。将在 Elasticsearch 中以未加密方式存储您的数据源凭据。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.config.securityDocumentationLinkLabel": "详细了解 Elasticsearch 安全", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.buttonTitle": "转换连接器", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.linkTitle": "连接器客户端", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.convertConnector.title": "自我管理此连接器", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.text": "本机连接器需要正在运行的 Enterprise Search 实例才能同步源中的内容。", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.entSearchWarning.title": "未检测到正在运行的 Enterprise Search 实例", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.description": "请记得在“计划”选项卡中设置同步计划,以继续刷新您的可搜索数据。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.schedulingReminder.title": "可配置同步计划", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.description": "在查询时将用户拥有的读取访问权限限定为索引文档并进行个性化。", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.securityLinkLabel": "文档级别安全性", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.securityReminder.title": "文档级别安全性", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.advancedConfigurationTitle": "同步您的数据", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.configurationTitle": "配置", - "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnector.steps.researchConfigurationTitle": "研究配置要求", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.description": "通过触发一次时间同步或设置重复同步计划来最终确定您的连接器。", "xpack.enterpriseSearch.content.indices.configurationConnector.nativeConnectorAdvancedConfiguration.schedulingButtonLabel": "设置计划并同步", "xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.connectorDocumentationLinkLabel": "文档", - "xpack.enterpriseSearch.content.indices.configurationConnector.researchConfiguration.description": "此连接器支持几种身份验证方法。请联系管理员获取正确的连接凭据。", "xpack.enterpriseSearch.content.indices.configurationConnector.scheduling.successToast.title": "计划已成功更新", "xpack.enterpriseSearch.content.indices.connector.syncRules.advancedRules.error": "JSON 格式无效", "xpack.enterpriseSearch.content.indices.connector.syncRules.advancedRules.title": "高级规则", @@ -18219,7 +18188,6 @@ "xpack.fleet.epm.featuresLabel": "功能", "xpack.fleet.epm.InputTemplates.guideLink": "Fleet 和 Elastic 代理指南", "xpack.fleet.epm.InputTemplates.installCallout": "安装集成以使用以下配置。", - "xpack.fleet.epm.InputTemplates.loadingErro": "输入模板出错", "xpack.fleet.epm.integrationPreference.beatsLabel": "仅限 Beats", "xpack.fleet.epm.integrationPreference.elasticAgentLabel": "仅限 Elastic 代理", "xpack.fleet.epm.integrationPreference.recommendedLabel": "推荐", @@ -29528,7 +29496,6 @@ "xpack.observability_onboarding.installElasticAgent.installStep.description": "选择平台并在终端中运行安装命令,以注册并启动 Elastic 代理。对每台主机执行此操作。请在安装之前复查{hostRequirementsLink}。", "xpack.observability_onboarding.installElasticAgent.integrationSuccessCallout.title": "已安装 {integrationName} 集成。", "xpack.observability_onboarding.installElasticAgent.progress.eaConfig.completedTitle": "Elastic 代理配置已写入到 {configPath}", - "xpack.observability_onboarding.installSystemIntegration.error.unauthorized": "缺失所需的 Kibana 权限 {requiredKibanaPrivileges},请将所需权限添加到已通过身份验证的用户的角色。", "xpack.observability_onboarding.systemIntegration.installed": "已安装系统集成。{systemIntegrationTooltip}", "xpack.observability_onboarding.systemIntegration.installed.tooltip.link": "使用系统集成{learnMoreLink}有关您可收集的数据的信息。", "xpack.observability_onboarding.apiKeyBanner.created": "已创建 API 密钥。", @@ -33718,7 +33685,6 @@ "xpack.securitySolution.dashboards.description": "描述", "xpack.securitySolution.dashboards.landing.createButton": "创建仪表板", "xpack.securitySolution.dashboards.landing.section.custom": "定制", - "xpack.securitySolution.dashboards.landing.section.default": "默认", "xpack.securitySolution.dashboards.pageTitle": "仪表板", "xpack.securitySolution.dashboards.queryError": "检索安全仪表板时出错", "xpack.securitySolution.dashboards.title": "标题", @@ -34040,7 +34006,6 @@ "xpack.securitySolution.detectionEngine.eqlValidation.requestError": "验证 EQL 查询时发生错误", "xpack.securitySolution.detectionEngine.eqlValidation.showErrorsLabel": "显示 EQL 验证错误", "xpack.securitySolution.detectionEngine.eqlValidation.title": "EQL 验证错误", - "xpack.securitySolution.detectionEngine.esqlValidation.missingIdInQueryError": "未使用 STATS...BY 函数的查询(非聚合查询)必须在源命令后包括 [metadata _id, _version, _index] 运算符。例如:FROM logs* [metadata _id, _version, _index]。 此外,还必须在查询响应中返回元数据属性(_id、_version 和 _index)。", "xpack.securitySolution.detectionEngine.esqlValidation.unknownError": "验证 ES|QL 时发生未知错误", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "查看文档", "xpack.securitySolution.detectionEngine.groups.additionalActions.takeAction": "采取操作", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx index bf5f487cfafa..743c475a287b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { uiSettingsServiceMock } from '@kbn/core/public/mocks'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { AlertSummaryWidget } from './alert_summary_widget'; import { AlertSummaryWidgetDependencies, AlertSummaryWidgetProps } from './types'; @@ -18,10 +19,6 @@ import { } from './components/constants'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({ - useUiSetting: jest.fn().mockImplementation(() => true), -})); - jest.mock('../../hooks/use_load_alert_summary', () => ({ useLoadAlertSummary: jest.fn().mockReturnValue({ alertSummary: { @@ -41,6 +38,7 @@ const useLoadAlertSummaryMock = useLoadAlertSummary as jest.Mock; const dependencies: AlertSummaryWidgetDependencies['dependencies'] = { charts: chartPluginMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), }; describe('AlertSummaryWidget', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx index 3e6b37993439..b3ed78ba43a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx @@ -6,6 +6,7 @@ */ import React, { useEffect } from 'react'; +import { getTimeZone } from '@kbn/visualization-utils'; import { useLoadAlertSummary } from '../../hooks/use_load_alert_summary'; import { AlertSummaryWidgetProps } from '.'; import { @@ -26,7 +27,7 @@ export const AlertSummaryWidget = ({ hideChart, hideStats, onLoaded, - dependencies: { charts }, + dependencies: { charts, uiSettings }, }: AlertSummaryWidgetProps & AlertSummaryWidgetDependencies) => { const { alertSummary: { activeAlertCount, activeAlerts, recoveredAlertCount }, @@ -63,6 +64,7 @@ export const AlertSummaryWidget = ({ chartProps={chartProps} dateFormat={timeRange.dateFormat} recoveredAlertCount={recoveredAlertCount} + timeZone={getTimeZone(uiSettings)} hideChart={hideChart} hideStats={hideStats} dependencyProps={dependencyProps} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx index 0f9a7fae4f3a..376d0a6599bf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx @@ -29,6 +29,7 @@ describe('AlertSummaryWidgetFullSize', () => { <AlertSummaryWidgetFullSize chartProps={mockedChartProps} dependencyProps={dependencyProps} + timeZone="UTC" {...mockedAlertSummaryResponse} {...props} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx index 19713b02b6fa..cc695fe51aa6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx @@ -28,6 +28,7 @@ export interface AlertSummaryWidgetFullSizeProps { activeAlerts: Alert[]; chartProps?: ChartProps; recoveredAlertCount: number; + timeZone: string; dateFormat?: string; hideChart?: boolean; hideStats?: boolean; @@ -40,6 +41,7 @@ export const AlertSummaryWidgetFullSize = ({ chartProps: { themeOverrides, onBrushEnd } = {}, dateFormat, recoveredAlertCount, + timeZone, hideChart, hideStats, dependencyProps: { baseTheme }, @@ -134,6 +136,7 @@ export const AlertSummaryWidgetFullSize = ({ point: { visible: false }, }} curve={CurveType.CURVE_MONOTONE_X} + timeZone={timeZone} /> </Chart> </div> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/types.ts index 21e663f511b9..2393ff9c16a6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/types.ts @@ -7,6 +7,7 @@ import type { BrushEndListener, PartialTheme, SettingsProps, Theme } from '@elastic/charts'; import { estypes } from '@elastic/elasticsearch'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import { AlertStatus, ValidFeatureId } from '@kbn/rule-data-utils'; import { ChartsPluginStart } from '@kbn/charts-plugin/public'; @@ -37,6 +38,7 @@ interface AlertsCount { export interface AlertSummaryWidgetDependencies { dependencies: { charts: ChartsPluginStart; + uiSettings: IUiSettingsClient; }; } 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 6319d75c5eaf..7feff2414383 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 @@ -794,7 +794,7 @@ export const RuleForm = ({ data-test-subj="intervalFormRow" display="rowCompressed" helpText={getHelpTextForInterval()} - isInvalid={errors['schedule.interval'].length > 0} + isInvalid={!!errors['schedule.interval'].length} error={errors['schedule.interval']} > <EuiFlexGroup gutterSize="s"> @@ -803,7 +803,7 @@ export const RuleForm = ({ prepend={labelForRuleChecked} fullWidth min={1} - isInvalid={errors['schedule.interval'].length > 0} + isInvalid={!!errors['schedule.interval'].length} value={ruleInterval || ''} name="interval" data-test-subj="intervalInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.test.tsx index 0ddee15a050d..1fb7ebaca3de 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.test.tsx @@ -107,7 +107,7 @@ const ShowRequestModalWithProviders: React.FunctionComponent<ShowRequestModalPro </IntlProvider> ); -describe('rules_settings_modal', () => { +describe('showRequestModal', () => { afterEach(() => { jest.clearAllMocks(); cleanup(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.tsx index f987243b436d..ab21a5a1b7a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/show_request_modal.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { transformUpdateRuleBody as rewriteUpdateBodyRequest, - UPDATE_FIELDS, + UPDATE_FIELDS_WITH_ACTIONS, } from '@kbn/alerts-ui-shared/src/common/apis/update_rule'; import { transformCreateRuleBody as rewriteCreateBodyRequest } from '@kbn/alerts-ui-shared/src/common/apis/create_rule'; import * as i18n from '../translations'; @@ -30,7 +30,7 @@ import { BASE_ALERTING_API_PATH } from '../../constants'; const stringify = (rule: RuleUpdates, edit: boolean): string => { try { const request = edit - ? rewriteUpdateBodyRequest(pick(rule, UPDATE_FIELDS)) + ? rewriteUpdateBodyRequest(pick(rule, UPDATE_FIELDS_WITH_ACTIONS)) : rewriteCreateBodyRequest(rule); return JSON.stringify(request, null, 2); } catch { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx index 075940ff3e14..4ab3e582a8fc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx @@ -926,6 +926,7 @@ export const RulesListTable = (props: RulesListTableProps) => { itemId="id" columns={[selectionColumn, ...rulesListColumns]} sorting={{ sort }} + rowHeader="name" rowProps={rowProps} cellProps={(rule: RuleTableItem) => ({ 'data-test-subj': 'cell', diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index e6d98240d2c5..7fccc65a2b6b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -8,6 +8,7 @@ import { RuleAction } from '@kbn/alerting-plugin/common'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { TypeRegistry } from '@kbn/alerts-ui-shared/src/common/type_registry'; +import { uiSettingsServiceMock } from '@kbn/core/public/mocks'; import { getAlertsTableDefaultAlertActionsLazy } from './common/get_alerts_table_default_row_actions'; import type { TriggersAndActionsUIPublicPluginStart } from './plugin'; @@ -146,6 +147,7 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getAlertSummaryWidget: (props) => { const dependencies: AlertSummaryWidgetDependencies['dependencies'] = { charts: chartPluginMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), }; return getAlertSummaryWidgetLazy({ ...props, dependencies }); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index f3fd01e66856..514f77310bb0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -448,7 +448,7 @@ export class Plugin }; } - public start(_: CoreStart, plugins: PluginsStart): TriggersAndActionsUIPublicPluginStart { + public start(core: CoreStart, plugins: PluginsStart): TriggersAndActionsUIPublicPluginStart { import('./application/sections/alerts_table/configuration').then( ({ createGenericAlertsTableConfigurations }) => { createGenericAlertsTableConfigurations(plugins.fieldFormats).forEach((c) => @@ -562,6 +562,7 @@ export class Plugin getAlertSummaryWidget: (props: AlertSummaryWidgetProps) => { const dependencies: AlertSummaryWidgetDependencies['dependencies'] = { charts: plugins.charts, + uiSettings: core.uiSettings, }; return getAlertSummaryWidgetLazy({ ...props, dependencies }); }, diff --git a/x-pack/plugins/triggers_actions_ui/tsconfig.json b/x-pack/plugins/triggers_actions_ui/tsconfig.json index 363c6bc1b231..abcf5c1e5bf8 100644 --- a/x-pack/plugins/triggers_actions_ui/tsconfig.json +++ b/x-pack/plugins/triggers_actions_ui/tsconfig.json @@ -68,7 +68,9 @@ "@kbn/controls-plugin", "@kbn/search-types", "@kbn/alerting-comparators", - "@kbn/alerting-types" + "@kbn/alerting-types", + "@kbn/visualization-utils", + "@kbn/core-ui-settings-browser" ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring_collection.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring_collection.ts index e4e52d6af314..f87c1c095dd8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring_collection.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring_collection.ts @@ -38,7 +38,8 @@ export default function alertingMonitoringCollectionTests({ getService }: FtrPro ? dedicatedTaskRunner.getSupertest() : supertest; - describe('monitoring_collection', () => { + // Failing: See https://github.com/elastic/kibana/issues/187275 + describe.skip('monitoring_collection', () => { let endDate: string; const objectRemover = new ObjectRemover(supertest); diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics_process_list.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics_process_list.ts index 87e736c0bc82..5cee23beba8d 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics_process_list.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics_process_list.ts @@ -34,7 +34,7 @@ export default function ({ getService }: FtrProviderContext) { hostTerm: { 'host.name': 'gke-observability-8--observability-8--bc1afd95-nhhw', }, - indexPattern: 'metrics-*,metricbeat-*', + sourceId: 'default', to: 1564432800000, sortBy: { name: 'cpu', diff --git a/x-pack/test/api_integration/apis/ml/datafeeds/index.ts b/x-pack/test/api_integration/apis/ml/datafeeds/index.ts index 449a9b2622b8..e7cd57640f28 100644 --- a/x-pack/test/api_integration/apis/ml/datafeeds/index.ts +++ b/x-pack/test/api_integration/apis/ml/datafeeds/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get_with_spaces')); loadTestFile(require.resolve('./get_stats_with_spaces')); loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./preview')); }); } diff --git a/x-pack/test/api_integration/apis/ml/datafeeds/preview.ts b/x-pack/test/api_integration/apis/ml/datafeeds/preview.ts new file mode 100644 index 000000000000..8fd305fb5b0d --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/datafeeds/preview.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { getCommonRequestHeader } from '../../../../functional/services/ml/common_api'; + +export default ({ getService }: FtrProviderContext) => { + const ml = getService('ml'); + const esArchiver = getService('esArchiver'); + const spacesService = getService('spaces'); + const supertest = getService('supertestWithoutAuth'); + + const jobIdSpace1 = 'fq_single_space1'; + const datafeedIdSpace1 = `datafeed-${jobIdSpace1}`; + const idSpace1 = 'space1'; + const idSpace2 = 'space2'; + + async function getDatafeedPreview( + datafeedId: string, + expectedStatusCode: number, + space?: string + ) { + const { body, status } = await supertest + .get(`${space ? `/s/${space}` : ''}/internal/ml/datafeeds/${datafeedId}/_preview`) + .auth( + USER.ML_POWERUSER_ALL_SPACES, + ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER_ALL_SPACES) + ) + .set(getCommonRequestHeader('1')); + ml.api.assertResponseStatusCode(expectedStatusCode, status, body); + + return body; + } + + describe('GET datafeed preview', () => { + before(async () => { + await spacesService.create({ id: idSpace1, name: 'space_one', disabledFeatures: [] }); + await spacesService.create({ id: idSpace2, name: 'space_two', disabledFeatures: [] }); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + + const jobConfig = ml.commonConfig.getADFqSingleMetricJobConfig(jobIdSpace1); + await ml.api.createAnomalyDetectionJob(jobConfig, idSpace1); + const datafeedConfig = ml.commonConfig.getADFqDatafeedConfig(jobIdSpace1); + await ml.api.createDatafeed(datafeedConfig, idSpace1); + + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + after(async () => { + await spacesService.delete(idSpace1); + await spacesService.delete(idSpace2); + await ml.api.cleanMlIndices(); + await ml.testResources.cleanMLSavedObjects(); + }); + + it('should fail with non-existing datafeed', async () => { + await getDatafeedPreview('non-existing-datafeed', 404); + }); + + it('should return datafeed preview with datafeed id from correct space', async () => { + const body = await getDatafeedPreview(datafeedIdSpace1, 200, idSpace1); + expect(body.length).to.eql(1000, `response length should be 1000 (got ${body.length})`); + }); + + it('should fail with datafeed from different space', async () => { + await getDatafeedPreview(datafeedIdSpace1, 404, idSpace2); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/system/index.ts b/x-pack/test/api_integration/apis/ml/system/index.ts index 8b9aef9b813c..5c332fb33ced 100644 --- a/x-pack/test/api_integration/apis/ml/system/index.ts +++ b/x-pack/test/api_integration/apis/ml/system/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./capabilities')); loadTestFile(require.resolve('./space_capabilities')); loadTestFile(require.resolve('./index_exists')); + loadTestFile(require.resolve('./node_count')); }); } diff --git a/x-pack/test/api_integration/apis/ml/system/node_count.ts b/x-pack/test/api_integration/apis/ml/system/node_count.ts new file mode 100644 index 000000000000..08fa7abe482e --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/system/node_count.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { getCommonRequestHeader } from '../../../../functional/services/ml/common_api'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + async function runRequest(user: USER, expectedStatusCode: number) { + const { body, status } = await supertest + .get(`/internal/ml/ml_node_count`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(getCommonRequestHeader('1')); + ml.api.assertResponseStatusCode(expectedStatusCode, status, body); + + return body; + } + + describe('GET ml/ml_node_count', function () { + describe('get ml node count', () => { + it('should match expected values', async () => { + const resp = await runRequest(USER.ML_POWERUSER, 200); + expect(resp.count).to.be.greaterThan(0, 'count should be greater than 0'); + expect(resp.lazyNodeCount).to.be.greaterThan( + -1, + 'lazyNodeCount should be greater or equal to 0' + ); + }); + + it('should should fail for a unauthorized user', async () => { + await runRequest(USER.ML_UNAUTHORIZED, 403); + }); + }); + }); +}; diff --git a/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts index 46d62449de47..45a95db642d2 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts @@ -8,7 +8,6 @@ import { ApmRuleType } from '@kbn/rule-data-utils'; import { errorCountActionVariables } from '@kbn/apm-plugin/server/routes/alerts/rule_types/error_count/register_error_count_rule_type'; import { apm, timerange } from '@kbn/apm-synthtrace-client'; -import { getErrorGroupingKey } from '@kbn/apm-synthtrace-client/src/lib/apm/instance'; import expect from '@kbn/expect'; import { omit } from 'lodash'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -105,7 +104,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { after(() => apmSynthtraceEsClient.clean()); // FLAKY: https://github.com/elastic/kibana/issues/176948 - describe('create rule without kql filter', () => { + describe.skip('create rule without kql filter', () => { let ruleId: string; let alerts: ApmAlertFields[]; let actionId: string; @@ -214,14 +213,14 @@ export default function ApiTest({ getService }: FtrProviderContext) { serviceName: 'opbeans-php', environment: 'production', transactionName: 'tx-php', - errorGroupingKey: getErrorGroupingKey(phpErrorMessage), + errorGroupingKey: '000000000000000000000a php error', errorGroupingName: phpErrorMessage, }, { serviceName: 'opbeans-java', environment: 'production', transactionName: 'tx-java', - errorGroupingKey: getErrorGroupingKey(javaErrorMessage), + errorGroupingKey: '00000000000000000000a java error', errorGroupingName: javaErrorMessage, }, ]); @@ -254,7 +253,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/176964 - describe('create rule with kql filter for opbeans-php', () => { + describe.skip('create rule with kql filter for opbeans-php', () => { let ruleId: string; before(async () => { @@ -283,7 +282,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('produces one alert for the opbeans-php service', async () => { const alerts = await waitForAlertsForRule({ es, ruleId }); expect(alerts[0]['kibana.alert.reason']).to.be( - 'Error count is 30 in the last 1 hr for service: opbeans-php, env: production, name: tx-php, error key: c85df8159a74b47b461d6ddaa6ba7da38cfc3e74019aef66257d10df74adeb99, error name: a php error. Alert when > 1.' + 'Error count is 30 in the last 1 hr for service: opbeans-php, env: production, name: tx-php, error key: 000000000000000000000a php error, error name: a php error. Alert when > 1.' ); }); }); diff --git a/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_count.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_count.spec.ts index 897f44673444..2ceb5608e3a4 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_count.spec.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_count.spec.ts @@ -68,7 +68,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - registry.when(`with data loaded`, { config: 'basic', archives: [] }, () => { + registry.when.skip(`with data loaded`, { config: 'basic', archives: [] }, () => { // FLAKY: https://github.com/elastic/kibana/issues/172769 describe('error_count', () => { beforeEach(async () => { @@ -304,255 +304,259 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - registry.when(`with data loaded and using KQL filter`, { config: 'basic', archives: [] }, () => { - // FLAKY: https://github.com/elastic/kibana/issues/176975 - describe('error_count', () => { - before(async () => { - await generateErrorData({ serviceName: 'synth-go', start, end, apmSynthtraceEsClient }); - await generateErrorData({ serviceName: 'synth-java', start, end, apmSynthtraceEsClient }); - }); + registry.when.skip( + `with data loaded and using KQL filter`, + { config: 'basic', archives: [] }, + () => { + // FLAKY: https://github.com/elastic/kibana/issues/176975 + describe('error_count', () => { + before(async () => { + await generateErrorData({ serviceName: 'synth-go', start, end, apmSynthtraceEsClient }); + await generateErrorData({ serviceName: 'synth-java', start, end, apmSynthtraceEsClient }); + }); - after(() => apmSynthtraceEsClient.clean()); + after(() => apmSynthtraceEsClient.clean()); - it('with data', async () => { - const options = getOptionsWithFilterQuery(); + it('with data', async () => { + const options = getOptionsWithFilterQuery(); - const response = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', - ...options, - }); + const response = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + ...options, + }); - expect(response.status).to.be(200); - expect( - response.body.errorCountChartPreview.series.some((item: PreviewChartResponseItem) => - item.data.some((coordinate) => coordinate.x && coordinate.y) - ) - ).to.equal(true); - }); + expect(response.status).to.be(200); + expect( + response.body.errorCountChartPreview.series.some((item: PreviewChartResponseItem) => + item.data.some((coordinate) => coordinate.x && coordinate.y) + ) + ).to.equal(true); + }); - it('with error grouping key in filter query', async () => { - const options = { - params: { - query: { - ...getOptionsWithFilterQuery().params.query, - searchConfiguration: JSON.stringify({ - query: { - query: `service.name: synth-go and error.grouping_key: ${getErrorGroupingKey( - 'Error 1' - )}`, - language: 'kuery', - }, - }), + it('with error grouping key in filter query', async () => { + const options = { + params: { + query: { + ...getOptionsWithFilterQuery().params.query, + searchConfiguration: JSON.stringify({ + query: { + query: `service.name: synth-go and error.grouping_key: ${getErrorGroupingKey( + 'Error 1' + )}`, + language: 'kuery', + }, + }), + }, }, - }, - }; - - const response = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', - ...options, + }; + + const response = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + ...options, + }); + + expect(response.status).to.be(200); + expect( + response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ + name: item.name, + y: item.data[0].y, + })) + ).to.eql([{ name: 'synth-go_production', y: 250 }]); }); - expect(response.status).to.be(200); - expect( - response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ - name: item.name, - y: item.data[0].y, - })) - ).to.eql([{ name: 'synth-go_production', y: 250 }]); - }); - - it('with no group by parameter', async () => { - const options = getOptionsWithFilterQuery(); - const response = await apmApiClient.readUser({ - ...options, - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + it('with no group by parameter', async () => { + const options = getOptionsWithFilterQuery(); + const response = await apmApiClient.readUser({ + ...options, + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }); + + expect(response.status).to.be(200); + expect(response.body.errorCountChartPreview.series.length).to.equal(1); + expect( + response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ + name: item.name, + y: item.data[0].y, + })) + ).to.eql([{ name: 'synth-go_production', y: 375 }]); }); - expect(response.status).to.be(200); - expect(response.body.errorCountChartPreview.series.length).to.equal(1); - expect( - response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ - name: item.name, - y: item.data[0].y, - })) - ).to.eql([{ name: 'synth-go_production', y: 375 }]); - }); - - it('with default group by fields', async () => { - const options = { - params: { - query: { - ...getOptionsWithFilterQuery().params.query, - groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT], + it('with default group by fields', async () => { + const options = { + params: { + query: { + ...getOptionsWithFilterQuery().params.query, + groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT], + }, }, - }, - }; - - const response = await apmApiClient.readUser({ - ...options, - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }; + + const response = await apmApiClient.readUser({ + ...options, + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }); + + expect(response.status).to.be(200); + expect(response.body.errorCountChartPreview.series.length).to.equal(1); + expect( + response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ + name: item.name, + y: item.data[0].y, + })) + ).to.eql([{ name: 'synth-go_production', y: 375 }]); }); - expect(response.status).to.be(200); - expect(response.body.errorCountChartPreview.series.length).to.equal(1); - expect( - response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ - name: item.name, - y: item.data[0].y, - })) - ).to.eql([{ name: 'synth-go_production', y: 375 }]); - }); - - it('with group by on error grouping key', async () => { - const options = { - params: { - query: { - ...getOptionsWithFilterQuery().params.query, - groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID], + it('with group by on error grouping key', async () => { + const options = { + params: { + query: { + ...getOptionsWithFilterQuery().params.query, + groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID], + }, }, - }, - }; - - const response = await apmApiClient.readUser({ - ...options, - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }; + + const response = await apmApiClient.readUser({ + ...options, + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }); + + expect(response.status).to.be(200); + expect(response.body.errorCountChartPreview.series.length).to.equal(2); + expect( + response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ + name: item.name, + y: item.data[0].y, + })) + ).to.eql([ + { + name: `synth-go_production_${getErrorGroupingKey('Error 1')}`, + y: 250, + }, + { + name: `synth-go_production_${getErrorGroupingKey('Error 0')}`, + y: 125, + }, + ]); }); - expect(response.status).to.be(200); - expect(response.body.errorCountChartPreview.series.length).to.equal(2); - expect( - response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ - name: item.name, - y: item.data[0].y, - })) - ).to.eql([ - { - name: `synth-go_production_${getErrorGroupingKey('Error 1')}`, - y: 250, - }, - { - name: `synth-go_production_${getErrorGroupingKey('Error 0')}`, - y: 125, - }, - ]); - }); - - it('with group by on error grouping key and filter on error grouping key', async () => { - const options = { - params: { - query: { - ...getOptionsWithFilterQuery().params.query, - searchConfiguration: JSON.stringify({ - query: { - query: `service.name: synth-go and error.grouping_key: ${getErrorGroupingKey( - 'Error 0' - )}`, - language: 'kuery', - }, - }), - groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID], + it('with group by on error grouping key and filter on error grouping key', async () => { + const options = { + params: { + query: { + ...getOptionsWithFilterQuery().params.query, + searchConfiguration: JSON.stringify({ + query: { + query: `service.name: synth-go and error.grouping_key: ${getErrorGroupingKey( + 'Error 0' + )}`, + language: 'kuery', + }, + }), + groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID], + }, }, - }, - }; - - const response = await apmApiClient.readUser({ - ...options, - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }; + + const response = await apmApiClient.readUser({ + ...options, + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }); + + expect(response.status).to.be(200); + expect(response.body.errorCountChartPreview.series.length).to.equal(1); + expect( + response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ + name: item.name, + y: item.data[0].y, + })) + ).to.eql([ + { + name: `synth-go_production_${getErrorGroupingKey('Error 0')}`, + y: 125, + }, + ]); }); - expect(response.status).to.be(200); - expect(response.body.errorCountChartPreview.series.length).to.equal(1); - expect( - response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ - name: item.name, - y: item.data[0].y, - })) - ).to.eql([ - { - name: `synth-go_production_${getErrorGroupingKey('Error 0')}`, - y: 125, - }, - ]); - }); - - it('with empty filter query', async () => { - const options = { - params: { - query: { - ...getOptionsWithFilterQuery().params.query, - searchConfiguration: JSON.stringify({ - query: { - query: '', - language: 'kuery', - }, - }), + it('with empty filter query', async () => { + const options = { + params: { + query: { + ...getOptionsWithFilterQuery().params.query, + searchConfiguration: JSON.stringify({ + query: { + query: '', + language: 'kuery', + }, + }), + }, }, - }, - }; - - const response = await apmApiClient.readUser({ - ...options, - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }; + + const response = await apmApiClient.readUser({ + ...options, + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }); + + expect(response.status).to.be(200); + expect( + response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ + name: item.name, + y: item.data[0].y, + })) + ).to.eql([ + { name: 'synth-go_production', y: 375 }, + { name: 'synth-java_production', y: 375 }, + ]); }); - expect(response.status).to.be(200); - expect( - response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ - name: item.name, - y: item.data[0].y, - })) - ).to.eql([ - { name: 'synth-go_production', y: 375 }, - { name: 'synth-java_production', y: 375 }, - ]); - }); - - it('with empty filter query and group by on error grouping key', async () => { - const options = { - params: { - query: { - ...getOptionsWithFilterQuery().params.query, - searchConfiguration: JSON.stringify({ - query: { - query: '', - language: 'kuery', - }, - }), - groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID], + it('with empty filter query and group by on error grouping key', async () => { + const options = { + params: { + query: { + ...getOptionsWithFilterQuery().params.query, + searchConfiguration: JSON.stringify({ + query: { + query: '', + language: 'kuery', + }, + }), + groupBy: [SERVICE_NAME, SERVICE_ENVIRONMENT, ERROR_GROUP_ID], + }, }, - }, - }; - - const response = await apmApiClient.readUser({ - ...options, - endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }; + + const response = await apmApiClient.readUser({ + ...options, + endpoint: 'GET /internal/apm/rule_types/error_count/chart_preview', + }); + + expect(response.status).to.be(200); + expect( + response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ + name: item.name, + y: item.data[0].y, + })) + ).to.eql([ + { + name: `synth-go_production_${getErrorGroupingKey('Error 1')}`, + y: 250, + }, + { + name: `synth-java_production_${getErrorGroupingKey('Error 1')}`, + y: 250, + }, + { + name: `synth-go_production_${getErrorGroupingKey('Error 0')}`, + y: 125, + }, + { + name: `synth-java_production_${getErrorGroupingKey('Error 0')}`, + y: 125, + }, + ]); }); - - expect(response.status).to.be(200); - expect( - response.body.errorCountChartPreview.series.map((item: PreviewChartResponseItem) => ({ - name: item.name, - y: item.data[0].y, - })) - ).to.eql([ - { - name: `synth-go_production_${getErrorGroupingKey('Error 1')}`, - y: 250, - }, - { - name: `synth-java_production_${getErrorGroupingKey('Error 1')}`, - y: 250, - }, - { - name: `synth-go_production_${getErrorGroupingKey('Error 0')}`, - y: 125, - }, - { - name: `synth-java_production_${getErrorGroupingKey('Error 0')}`, - y: 125, - }, - ]); }); - }); - }); + } + ); } diff --git a/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_rate.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_rate.spec.ts index bc11d1cb68fd..8f968a89bac5 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_rate.spec.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/preview_chart_error_rate.spec.ts @@ -249,7 +249,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ).to.eql([{ name: 'synth-go_production_request_GET /apple', y: 25 }]); }); - it('with empty service name, transaction name and transaction type', async () => { + it.skip('with empty service name, transaction name and transaction type', async () => { const options = { params: { query: { @@ -548,7 +548,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ]); }); - it('with empty filter query and group by on transaction name', async () => { + it.skip('with empty filter query and group by on transaction name', async () => { const options = { params: { query: { diff --git a/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts index 37424562ecc8..13754f6c7eb5 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { orderBy } from 'lodash'; import expect from '@kbn/expect'; - import type { FailedTransactionsCorrelationsResponse } from '@kbn/apm-plugin/common/correlations/failed_transactions_correlations/types'; import { EVENT_OUTCOME } from '@kbn/apm-plugin/common/es_fields/apm'; import { EventOutcome } from '@kbn/apm-plugin/common/event_outcome'; @@ -27,7 +27,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { kuery: '', }); - registry.when.skip('failed transactions without data', { config: 'trial', archives: [] }, () => { + registry.when('failed transactions without data', { config: 'trial', archives: [] }, () => { it('handles the empty state', async () => { const overallDistributionResponse = await apmApiClient.readUser({ endpoint: 'POST /internal/apm/latency/overall_distribution/transactions', @@ -104,127 +104,120 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/176544 - registry.when.skip( - 'failed transactions with data', - { config: 'trial', archives: ['8.0.0'] }, - () => { - it('runs queries and returns results', async () => { - const overallDistributionResponse = await apmApiClient.readUser({ - endpoint: 'POST /internal/apm/latency/overall_distribution/transactions', - params: { - body: { - ...getOptions(), - percentileThreshold: 95, - chartType: LatencyDistributionChartType.failedTransactionsCorrelations, - }, + registry.when('failed transactions with data', { config: 'trial', archives: ['8.0.0'] }, () => { + it('runs queries and returns results', async () => { + const overallDistributionResponse = await apmApiClient.readUser({ + endpoint: 'POST /internal/apm/latency/overall_distribution/transactions', + params: { + body: { + ...getOptions(), + percentileThreshold: 95, + chartType: LatencyDistributionChartType.failedTransactionsCorrelations, }, - }); + }, + }); + + expect(overallDistributionResponse.status).to.eql( + 200, + `Expected status to be '200', got '${overallDistributionResponse.status}'` + ); - expect(overallDistributionResponse.status).to.eql( - 200, - `Expected status to be '200', got '${overallDistributionResponse.status}'` - ); - - const errorDistributionResponse = await apmApiClient.readUser({ - endpoint: 'POST /internal/apm/latency/overall_distribution/transactions', - params: { - body: { - ...getOptions(), - percentileThreshold: 95, - termFilters: [{ fieldName: EVENT_OUTCOME, fieldValue: EventOutcome.failure }], - chartType: LatencyDistributionChartType.failedTransactionsCorrelations, - }, + const errorDistributionResponse = await apmApiClient.readUser({ + endpoint: 'POST /internal/apm/latency/overall_distribution/transactions', + params: { + body: { + ...getOptions(), + percentileThreshold: 95, + termFilters: [{ fieldName: EVENT_OUTCOME, fieldValue: EventOutcome.failure }], + chartType: LatencyDistributionChartType.failedTransactionsCorrelations, }, - }); + }, + }); - expect(errorDistributionResponse.status).to.eql( - 200, - `Expected status to be '200', got '${errorDistributionResponse.status}'` - ); + expect(errorDistributionResponse.status).to.eql( + 200, + `Expected status to be '200', got '${errorDistributionResponse.status}'` + ); - const fieldCandidatesResponse = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/correlations/field_candidates/transactions', - params: { - query: getOptions(), - }, - }); + const fieldCandidatesResponse = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/correlations/field_candidates/transactions', + params: { + query: getOptions(), + }, + }); - expect(fieldCandidatesResponse.status).to.eql( - 200, - `Expected status to be '200', got '${fieldCandidatesResponse.status}'` - ); - - const fieldCandidates = fieldCandidatesResponse.body?.fieldCandidates.filter( - (t) => !(t === EVENT_OUTCOME) - ); - - // Identified 68 fieldCandidates. - expect(fieldCandidates.length).to.eql( - 68, - `Expected field candidates length to be '68', got '${fieldCandidates.length}'` - ); - - const failedTransactionsCorrelationsResponse = await apmApiClient.readUser({ - endpoint: 'POST /internal/apm/correlations/p_values/transactions', - params: { - body: { - ...getOptions(), - fieldCandidates, - }, + expect(fieldCandidatesResponse.status).to.eql( + 200, + `Expected status to be '200', got '${fieldCandidatesResponse.status}'` + ); + + const fieldCandidates = fieldCandidatesResponse.body?.fieldCandidates.filter( + (t) => !(t === EVENT_OUTCOME) + ); + + // Identified 80 fieldCandidates. + expect(fieldCandidates.length).to.eql( + 80, + `Expected field candidates length to be '80', got '${fieldCandidates.length}'` + ); + + const failedTransactionsCorrelationsResponse = await apmApiClient.readUser({ + endpoint: 'POST /internal/apm/correlations/p_values/transactions', + params: { + body: { + ...getOptions(), + fieldCandidates, }, + }, + }); + + expect(failedTransactionsCorrelationsResponse.status).to.eql( + 200, + `Expected status to be '200', got '${failedTransactionsCorrelationsResponse.status}'` + ); + + const fieldsToSample = new Set<string>(); + if (failedTransactionsCorrelationsResponse.body?.failedTransactionsCorrelations.length > 0) { + failedTransactionsCorrelationsResponse.body?.failedTransactionsCorrelations.forEach((d) => { + fieldsToSample.add(d.fieldName); }); + } - expect(failedTransactionsCorrelationsResponse.status).to.eql( - 200, - `Expected status to be '200', got '${failedTransactionsCorrelationsResponse.status}'` - ); - - const fieldsToSample = new Set<string>(); - if ( - failedTransactionsCorrelationsResponse.body?.failedTransactionsCorrelations.length > 0 - ) { - failedTransactionsCorrelationsResponse.body?.failedTransactionsCorrelations.forEach( - (d) => { - fieldsToSample.add(d.fieldName); - } - ); - } - - const finalRawResponse: FailedTransactionsCorrelationsResponse = { - ccsWarning: failedTransactionsCorrelationsResponse.body?.ccsWarning, - percentileThresholdValue: overallDistributionResponse.body?.percentileThresholdValue, - overallHistogram: overallDistributionResponse.body?.overallHistogram, - errorHistogram: errorDistributionResponse.body?.overallHistogram, - failedTransactionsCorrelations: - failedTransactionsCorrelationsResponse.body?.failedTransactionsCorrelations, - }; - - expect(finalRawResponse?.percentileThresholdValue).to.be(1309695.875); - expect(finalRawResponse?.errorHistogram?.length).to.be(101); - expect(finalRawResponse?.overallHistogram?.length).to.be(101); - - expect(finalRawResponse?.failedTransactionsCorrelations?.length).to.eql( - 30, - `Expected 30 identified correlations, got ${finalRawResponse?.failedTransactionsCorrelations?.length}.` - ); - - const sortedCorrelations = finalRawResponse?.failedTransactionsCorrelations?.sort( - (a, b) => b.score - a.score - ); - const correlation = sortedCorrelations?.[0]; - - expect(typeof correlation).to.be('object'); - expect(correlation?.doc_count).to.be(31); - expect(correlation?.score).to.be(83.70467673605746); - expect(correlation?.bg_count).to.be(31); - expect(correlation?.fieldName).to.be('http.response.status_code'); - expect(correlation?.fieldValue).to.be(500); - expect(typeof correlation?.pValue).to.be('number'); - expect(typeof correlation?.normalizedScore).to.be('number'); - expect(typeof correlation?.failurePercentage).to.be('number'); - expect(typeof correlation?.successPercentage).to.be('number'); - }); - } - ); + const finalRawResponse: FailedTransactionsCorrelationsResponse = { + ccsWarning: failedTransactionsCorrelationsResponse.body?.ccsWarning, + percentileThresholdValue: overallDistributionResponse.body?.percentileThresholdValue, + overallHistogram: overallDistributionResponse.body?.overallHistogram, + errorHistogram: errorDistributionResponse.body?.overallHistogram, + failedTransactionsCorrelations: + failedTransactionsCorrelationsResponse.body?.failedTransactionsCorrelations, + }; + + expect(finalRawResponse?.percentileThresholdValue).to.be(1309695.875); + expect(finalRawResponse?.errorHistogram?.length).to.be(101); + expect(finalRawResponse?.overallHistogram?.length).to.be(101); + + expect(finalRawResponse?.failedTransactionsCorrelations?.length).to.eql( + 29, + `Expected 29 identified correlations, got ${finalRawResponse?.failedTransactionsCorrelations?.length}.` + ); + + const sortedCorrelations = orderBy( + finalRawResponse?.failedTransactionsCorrelations, + ['score', 'fieldName', 'fieldValue'], + ['desc', 'asc', 'asc'] + ); + const correlation = sortedCorrelations?.[0]; + + expect(typeof correlation).to.be('object'); + expect(correlation?.doc_count).to.be(31); + expect(correlation?.score).to.be(83.70467673605746); + expect(correlation?.bg_count).to.be(31); + expect(correlation?.fieldName).to.be('transaction.result'); + expect(correlation?.fieldValue).to.be('HTTP 5xx'); + expect(typeof correlation?.pValue).to.be('number'); + expect(typeof correlation?.normalizedScore).to.be('number'); + expect(typeof correlation?.failurePercentage).to.be('number'); + expect(typeof correlation?.successPercentage).to.be('number'); + }); + }); } diff --git a/x-pack/test/apm_api_integration/tests/correlations/field_candidates.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/field_candidates.spec.ts index 1984f7f652c2..4a5472cf5cb2 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/field_candidates.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/field_candidates.spec.ts @@ -33,12 +33,13 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); expect(response.status).to.be(200); - expect(response.body?.fieldCandidates.length).to.be(14); + // If the source indices are empty, there will be no field candidates + // because of the `include_empty_fields: false` option in the query. + expect(response.body?.fieldCandidates.length).to.be(0); }); }); - // FLAKY: https://github.com/elastic/kibana/issues/176119 - registry.when.skip( + registry.when( 'field candidates with data and default args', { config: 'trial', archives: ['8.0.0'] }, () => { @@ -49,7 +50,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); expect(response.status).to.eql(200); - expect(response.body?.fieldCandidates.length).to.be(69); + expect(response.body?.fieldCandidates.length).to.be(81); }); } ); diff --git a/x-pack/test/apm_api_integration/tests/correlations/field_value_pairs.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/field_value_pairs.spec.ts index f2bb340cb9d6..4765e83342e5 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/field_value_pairs.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/field_value_pairs.spec.ts @@ -53,8 +53,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/176425 - registry.when.skip( + registry.when( 'field value pairs with data and default args', { config: 'trial', archives: ['8.0.0'] }, () => { diff --git a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts index bceea736f553..532613697642 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { chunk } from 'lodash'; +import { chunk, orderBy } from 'lodash'; import expect from '@kbn/expect'; @@ -107,8 +107,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { } ); - // FLAKY: https://github.com/elastic/kibana/issues/175855 - registry.when.skip( + registry.when( 'correlations latency with data and opbeans-node args', { config: 'trial', archives: ['8.0.0'] }, () => { @@ -142,10 +141,10 @@ export default function ApiTest({ getService }: FtrProviderContext) { `Expected status to be '200', got '${fieldCandidatesResponse.status}'` ); - // Identified 69 fieldCandidates. + // Identified 81 fieldCandidates. expect(fieldCandidatesResponse.body?.fieldCandidates.length).to.eql( - 69, - `Expected field candidates length to be '69', got '${fieldCandidatesResponse.body?.fieldCandidates.length}'` + 81, + `Expected field candidates length to be '81', got '${fieldCandidatesResponse.body?.fieldCandidates.length}'` ); const fieldValuePairsResponse = await apmApiClient.readUser({ @@ -163,15 +162,15 @@ export default function ApiTest({ getService }: FtrProviderContext) { `Expected status to be '200', got '${fieldValuePairsResponse.status}'` ); - // Identified 379 fieldValuePairs. + // Identified 374 fieldValuePairs. expect(fieldValuePairsResponse.body?.fieldValuePairs.length).to.eql( - 379, - `Expected field value pairs length to be '379', got '${fieldValuePairsResponse.body?.fieldValuePairs.length}'` + 374, + `Expected field value pairs length to be '374', got '${fieldValuePairsResponse.body?.fieldValuePairs.length}'` ); // This replicates the code used in the `useLatencyCorrelations` hook to chunk requests for correlation analysis. // Tests turned out to be flaky and occasionally overload ES with a `search_phase_execution_exception` - // when all 379 field value pairs from above are queried in parallel. + // when all 374 field value pairs from above are queried in parallel. // The chunking sends 10 field value pairs with each request to the Kibana API endpoint. // Kibana itself will then run those 10 requests in parallel against ES. const latencyCorrelations: LatencyCorrelation[] = []; @@ -232,20 +231,23 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(finalRawResponse?.percentileThresholdValue).to.be(1309695.875); expect(finalRawResponse?.overallHistogram?.length).to.be(101); - // Identified 13 significant correlations out of 379 field/value pairs. + // Identified 13 significant correlations out of 374 field/value pairs. expect(finalRawResponse?.latencyCorrelations?.length).to.eql( 13, `Expected 13 identified correlations, got ${finalRawResponse?.latencyCorrelations?.length}.` ); - const correlation = finalRawResponse?.latencyCorrelations?.sort( - (a, b) => b.correlation - a.correlation - )[0]; + const sortedCorrelations = orderBy( + finalRawResponse?.latencyCorrelations, + ['score', 'fieldName', 'fieldValue'], + ['desc', 'asc', 'asc'] + ); + const correlation = sortedCorrelations?.[0]; expect(typeof correlation).to.be('object'); - expect(correlation?.fieldName).to.be('transaction.result'); - expect(correlation?.fieldValue).to.be('success'); - expect(correlation?.correlation).to.be(0.6275246559191225); - expect(correlation?.ksTest).to.be(4.806503252860024e-13); + expect(correlation?.fieldName).to.be('agent.hostname'); + expect(correlation?.fieldValue).to.be('rum-js'); + expect(correlation?.correlation).to.be(0.34798078715348596); + expect(correlation?.ksTest).to.be(1.9848961005439386e-12); expect(correlation?.histogram?.length).to.be(101); }); } diff --git a/x-pack/test/apm_api_integration/tests/correlations/p_values.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/p_values.spec.ts index 466e166078d8..42a9fdadbb48 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/p_values.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/p_values.spec.ts @@ -53,8 +53,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/175911 - registry.when.skip( + registry.when( 'p values with data and default args', { config: 'trial', archives: ['8.0.0'] }, () => { diff --git a/x-pack/test/apm_api_integration/tests/correlations/significant_correlations.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/significant_correlations.spec.ts index 5ce0e74ad246..d4450c192a02 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/significant_correlations.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/significant_correlations.spec.ts @@ -77,8 +77,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/176780 - registry.when.skip( + registry.when( 'significant correlations with data and default args', { config: 'trial', archives: ['8.0.0'] }, () => { diff --git a/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts b/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts index b07c7c323ed9..1002f6dc09ee 100644 --- a/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts +++ b/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts @@ -157,7 +157,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(javaSpans.length + goSpans.length).to.eql(spans.length); expect(omit(javaSpans[0], 'spanId', 'traceId', 'transactionId')).to.eql({ - '@timestamp': 1609459200000, + '@timestamp': 1609460040000, agentName: 'java', duration: 100000, serviceName: 'java', @@ -168,7 +168,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); expect(omit(goSpans[0], 'spanId', 'traceId', 'transactionId')).to.eql({ - '@timestamp': 1609459200000, + '@timestamp': 1609460040000, agentName: 'go', duration: 50000, serviceName: 'go', diff --git a/x-pack/test/apm_api_integration/tests/diagnostics/data_streams.spec.ts b/x-pack/test/apm_api_integration/tests/diagnostics/data_streams.spec.ts index 969ce9fabd5a..80fa34dbaa00 100644 --- a/x-pack/test/apm_api_integration/tests/diagnostics/data_streams.spec.ts +++ b/x-pack/test/apm_api_integration/tests/diagnostics/data_streams.spec.ts @@ -75,17 +75,20 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); expect(status).to.be(200); expect(body.dataStreams).to.eql([ - { name: 'metrics-apm.internal-default', template: 'metrics-apm.internal' }, + { name: 'metrics-apm.internal-default', template: 'metrics-apm.internal@template' }, { name: 'metrics-apm.service_summary.1m-default', - template: 'metrics-apm.service_summary.1m', + template: 'metrics-apm.service_summary.1m@template', }, { name: 'metrics-apm.service_transaction.1m-default', - template: 'metrics-apm.service_transaction.1m', + template: 'metrics-apm.service_transaction.1m@template', }, - { name: 'metrics-apm.transaction.1m-default', template: 'metrics-apm.transaction.1m' }, - { name: 'traces-apm-default', template: 'traces-apm' }, + { + name: 'metrics-apm.transaction.1m-default', + template: 'metrics-apm.transaction.1m@template', + }, + { name: 'traces-apm-default', template: 'traces-apm@template' }, ]); }); diff --git a/x-pack/test/apm_api_integration/tests/diagnostics/index_templates.spec.ts b/x-pack/test/apm_api_integration/tests/diagnostics/index_templates.spec.ts index 5c94de56abb3..1bbc799b3bf7 100644 --- a/x-pack/test/apm_api_integration/tests/diagnostics/index_templates.spec.ts +++ b/x-pack/test/apm_api_integration/tests/diagnostics/index_templates.spec.ts @@ -20,7 +20,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { const start = new Date('2021-01-01T00:00:00.000Z').getTime(); const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; - registry.when('Diagnostics: Index Templates', { config: 'basic', archives: [] }, () => { + registry.when.skip('Diagnostics: Index Templates', { config: 'basic', archives: [] }, () => { describe('When there is no data', () => { before(async () => { // delete APM index templates diff --git a/x-pack/test/apm_api_integration/tests/entities/logs/service_logs_error_rate_timeseries.spec.ts b/x-pack/test/apm_api_integration/tests/entities/logs/service_logs_error_rate_timeseries.spec.ts new file mode 100644 index 000000000000..b880354e1a70 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/entities/logs/service_logs_error_rate_timeseries.spec.ts @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { log, timerange } from '@kbn/apm-synthtrace-client'; +import { first, last } from 'lodash'; +import { RecursivePartial } from '@kbn/apm-plugin/typings/common'; +import { APIClientRequestParamsOf } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const logSynthtrace = getService('logSynthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2024-01-01T00:00:00.000Z').getTime(); + const end = new Date('2024-01-01T00:15:00.000Z').getTime() - 1; + + const hostName = 'synth-host'; + + async function getLogsErrorRateTimeseries( + overrides?: RecursivePartial< + APIClientRequestParamsOf<'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries'>['params'] + > + ) { + const response = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries', + params: { + path: { + serviceName: 'synth-go', + ...overrides?.path, + }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + kuery: '', + ...overrides?.query, + }, + }, + }); + return response; + } + + registry.when( + 'Logs error rate timeseries when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('Logs error rate api', () => { + it('handles the empty state', async () => { + const response = await getLogsErrorRateTimeseries(); + expect(response.status).to.be(200); + expect(response.body.currentPeriod).to.empty(); + }); + }); + } + ); + + registry.when( + 'Logs error rate timeseries when data loaded', + { config: 'basic', archives: [] }, + () => { + describe('Logs without log level field', () => { + before(async () => { + return logSynthtrace.index([ + timerange(start, end) + .interval('1m') + .rate(1) + .generator((timestamp) => + log.create().message('This is a log message').timestamp(timestamp).defaults({ + 'log.file.path': '/my-service.log', + 'service.name': serviceName, + 'host.name': hostName, + }) + ), + ]); + }); + after(async () => { + await logSynthtrace.clean(); + }); + + it('returns {} if log level is not available ', async () => { + const response = await getLogsErrorRateTimeseries(); + expect(response.status).to.be(200); + }); + }); + + describe('Logs with log.level=error', () => { + before(async () => { + return logSynthtrace.index([ + timerange(start, end) + .interval('1m') + .rate(1) + .generator((timestamp) => + log + .create() + .message('This is a log message') + .logLevel('error') + .timestamp(timestamp) + .defaults({ + 'log.file.path': '/my-service.log', + 'service.name': serviceName, + 'host.name': hostName, + 'service.environment': 'test', + }) + ), + timerange(start, end) + .interval('2m') + .rate(1) + .generator((timestamp) => + log + .create() + .message('This is an error log message') + .logLevel('error') + .timestamp(timestamp) + .defaults({ + 'log.file.path': '/my-service.log', + 'service.name': 'my-service', + 'host.name': hostName, + 'service.environment': 'production', + }) + ), + timerange(start, end) + .interval('5m') + .rate(1) + .generator((timestamp) => + log + .create() + .message('This is an info message') + .logLevel('info') + .timestamp(timestamp) + .defaults({ + 'log.file.path': '/my-service.log', + 'service.name': 'my-service', + 'host.name': hostName, + 'service.environment': 'production', + }) + ), + ]); + }); + after(async () => { + await logSynthtrace.clean(); + }); + + it('returns log error rate timeseries', async () => { + const response = await getLogsErrorRateTimeseries(); + expect(response.status).to.be(200); + expect(response.body.currentPeriod[serviceName].every(({ y }) => y === 1)).to.be(true); + }); + + it('handles environment filter', async () => { + const response = await getLogsErrorRateTimeseries({ query: { environment: 'foo' } }); + expect(response.status).to.be(200); + expect(response.body.currentPeriod).to.empty(); + }); + + describe('when my-service is selected', () => { + it('returns some data', async () => { + const response = await getLogsErrorRateTimeseries({ + path: { serviceName: 'my-service' }, + }); + + expect(response.status).to.be(200); + expect(first(response.body.currentPeriod?.['my-service'])?.y).to.be(0.5); + expect(last(response.body.currentPeriod?.['my-service'])?.y).to.be(1); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/entities/logs/service_logs_rate_timeseries.spec.ts b/x-pack/test/apm_api_integration/tests/entities/logs/service_logs_rate_timeseries.spec.ts new file mode 100644 index 000000000000..d4717b25bba9 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/entities/logs/service_logs_rate_timeseries.spec.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { log, timerange } from '@kbn/apm-synthtrace-client'; +import { RecursivePartial } from '@kbn/apm-plugin/typings/common'; +import { APIClientRequestParamsOf } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; +import { first, last } from 'lodash'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const logSynthtrace = getService('logSynthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2024-01-01T00:00:00.000Z').getTime(); + const end = new Date('2024-01-01T00:15:00.000Z').getTime() - 1; + + const hostName = 'synth-host'; + + async function getLogsRateTimeseries( + overrides?: RecursivePartial< + APIClientRequestParamsOf<'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries'>['params'] + > + ) { + const response = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries', + params: { + path: { + serviceName: 'synth-go', + ...overrides?.path, + }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + kuery: '', + ...overrides?.query, + }, + }, + }); + return response; + } + + registry.when( + 'Logs rate timeseries when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('Logs rate api', () => { + it('handles the empty state', async () => { + const response = await getLogsRateTimeseries(); + expect(response.status).to.be(200); + expect(response.body.currentPeriod).to.empty(); + }); + }); + } + ); + + registry.when('Logs rate timeseries when data loaded', { config: 'basic', archives: [] }, () => { + describe('Logs without log level field', () => { + before(async () => { + return logSynthtrace.index([ + timerange(start, end) + .interval('1m') + .rate(1) + .generator((timestamp) => + log.create().message('This is a log message').timestamp(timestamp).defaults({ + 'log.file.path': '/my-service.log', + 'service.name': serviceName, + 'host.name': hostName, + }) + ), + ]); + }); + after(async () => { + await logSynthtrace.clean(); + }); + + it('returns {} if log level is not available ', async () => { + const response = await getLogsRateTimeseries(); + expect(response.status).to.be(200); + }); + }); + + describe('Logs with log.level=error', () => { + before(async () => { + return logSynthtrace.index([ + timerange(start, end) + .interval('1m') + .rate(1) + .generator((timestamp) => + log + .create() + .message('This is a log message') + .logLevel('error') + .timestamp(timestamp) + .defaults({ + 'log.file.path': '/my-service.log', + 'service.name': serviceName, + 'host.name': hostName, + 'service.environment': 'test', + }) + ), + timerange(start, end) + .interval('2m') + .rate(1) + .generator((timestamp) => + log + .create() + .message('This is an error log message') + .logLevel('error') + .timestamp(timestamp) + .defaults({ + 'log.file.path': '/my-service.log', + 'service.name': 'my-service', + 'host.name': hostName, + 'service.environment': 'production', + }) + ), + timerange(start, end) + .interval('5m') + .rate(1) + .generator((timestamp) => + log + .create() + .message('This is an info message') + .logLevel('info') + .timestamp(timestamp) + .defaults({ + 'log.file.path': '/my-service.log', + 'service.name': 'my-service', + 'host.name': hostName, + 'service.environment': 'production', + }) + ), + ]); + }); + after(async () => { + await logSynthtrace.clean(); + }); + + it('returns log rate timeseries', async () => { + const response = await getLogsRateTimeseries(); + expect(response.status).to.be(200); + expect( + response.body.currentPeriod[serviceName].every(({ y }) => y === 0.06666666666666667) + ).to.be(true); + }); + + it('handles environment filter', async () => { + const response = await getLogsRateTimeseries({ query: { environment: 'foo' } }); + expect(response.status).to.be(200); + expect(response.body.currentPeriod).to.empty(); + }); + + describe('when my-service is selected', () => { + it('returns some data', async () => { + const response = await getLogsRateTimeseries({ + path: { serviceName: 'my-service' }, + }); + + expect(response.status).to.be(200); + expect(first(response.body.currentPeriod?.['my-service'])?.y).to.be(0.18181818181818182); + expect(last(response.body.currentPeriod?.['my-service'])?.y).to.be(0.09090909090909091); + }); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/errors/group_id_samples.spec.ts b/x-pack/test/apm_api_integration/tests/errors/group_id_samples.spec.ts index 004f853b6c56..ea74f1fa622d 100644 --- a/x-pack/test/apm_api_integration/tests/errors/group_id_samples.spec.ts +++ b/x-pack/test/apm_api_integration/tests/errors/group_id_samples.spec.ts @@ -76,7 +76,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177397 - registry.when('when samples data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when samples data is loaded', { config: 'basic', archives: [] }, () => { const { bananaTransaction } = config; describe('error group id', () => { before(async () => { @@ -105,7 +105,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177383 - registry.when('when error sample data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when error sample data is loaded', { config: 'basic', archives: [] }, () => { describe('error sample id', () => { before(async () => { await generateData({ serviceName, start, end, apmSynthtraceEsClient }); diff --git a/x-pack/test/apm_api_integration/tests/errors/top_erroneous_transactions/top_erroneous_transactions.spec.ts b/x-pack/test/apm_api_integration/tests/errors/top_erroneous_transactions/top_erroneous_transactions.spec.ts index ff985e0af388..53b305f093ce 100644 --- a/x-pack/test/apm_api_integration/tests/errors/top_erroneous_transactions/top_erroneous_transactions.spec.ts +++ b/x-pack/test/apm_api_integration/tests/errors/top_erroneous_transactions/top_erroneous_transactions.spec.ts @@ -65,7 +65,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177637 - registry.when('when data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when data is loaded', { config: 'basic', archives: [] }, () => { const { firstTransaction: { name: firstTransactionName, failureRate: firstTransactionFailureRate }, secondTransaction: { name: secondTransactionName, failureRate: secondTransactionFailureRate }, @@ -89,7 +89,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { erroneousTransactions = response.body; }); - it('displays the correct number of occurrences', () => { + it.skip('displays the correct number of occurrences', () => { const { topErroneousTransactions } = erroneousTransactions; expect(topErroneousTransactions.length).to.be(2); diff --git a/x-pack/test/apm_api_integration/tests/errors/top_errors_for_transaction/top_errors_main_stats.spec.ts b/x-pack/test/apm_api_integration/tests/errors/top_errors_for_transaction/top_errors_main_stats.spec.ts index 8e946e081554..a6476e76a391 100644 --- a/x-pack/test/apm_api_integration/tests/errors/top_errors_for_transaction/top_errors_main_stats.spec.ts +++ b/x-pack/test/apm_api_integration/tests/errors/top_errors_for_transaction/top_errors_main_stats.spec.ts @@ -59,7 +59,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177638 - registry.when('when data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when data is loaded', { config: 'basic', archives: [] }, () => { describe('top errors for transaction', () => { const { firstTransaction: { name: firstTransactionName, failureRate: firstTransactionFailureRate }, diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index ae5b30b17582..3b332fdba0d0 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -30,7 +30,7 @@ export default function apmApiIntegrationTests({ getService, loadTestFile }: Ftr // Skipping here will skip the entire apm api test suite // Instead skip (flaky) tests individually // Failing: See https://github.com/elastic/kibana/issues/176948 - describe.skip('APM API tests', function () { + describe('APM API tests', function () { const filePattern = getGlobPattern(); const tests = globby.sync(filePattern, { cwd }); diff --git a/x-pack/test/apm_api_integration/tests/mobile/crashes/crash_group_list.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/crashes/crash_group_list.spec.ts index 274199437f18..a36036b3ec8e 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/crashes/crash_group_list.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/crashes/crash_group_list.spec.ts @@ -54,7 +54,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177651 - registry.when('when data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when data is loaded', { config: 'basic', archives: [] }, () => { describe('errors group', () => { const appleTransaction = { name: 'GET /apple 🍎 ', diff --git a/x-pack/test/apm_api_integration/tests/mobile/crashes/distribution.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/crashes/distribution.spec.ts index aad32f3490d2..2fabce70d269 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/crashes/distribution.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/crashes/distribution.spec.ts @@ -61,7 +61,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177652 - registry.when('when data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when data is loaded', { config: 'basic', archives: [] }, () => { describe('errors distribution', () => { const { appleTransaction, bananaTransaction } = config; before(async () => { diff --git a/x-pack/test/apm_api_integration/tests/mobile/errors/group_id_samples.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/errors/group_id_samples.spec.ts index e3e69a540881..129cbe2a7180 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/errors/group_id_samples.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/errors/group_id_samples.spec.ts @@ -76,7 +76,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177654 - registry.when('when samples data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when samples data is loaded', { config: 'basic', archives: [] }, () => { const { bananaTransaction } = config; describe('error group id', () => { before(async () => { @@ -105,7 +105,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177665 - registry.when('when error sample data is loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('when error sample data is loaded', { config: 'basic', archives: [] }, () => { describe('error sample id', () => { before(async () => { await generateData({ serviceName, start, end, apmSynthtraceEsClient }); diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts index 40bf9729bee6..a8912989e295 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts @@ -73,7 +73,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ); // FLAKY: https://github.com/elastic/kibana/issues/177388 - registry.when( + registry.when.skip( 'Mobile detailed statistics when data is loaded', { config: 'basic', archives: [] }, () => { diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_filters.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_filters.spec.ts index 428626680160..edebde9f0d43 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_filters.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_filters.spec.ts @@ -178,7 +178,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177389 - registry.when('Mobile filters', { config: 'basic', archives: [] }, () => { + registry.when.skip('Mobile filters', { config: 'basic', archives: [] }, () => { before(async () => { await generateData({ apmSynthtraceEsClient, diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_http_requests_timeseries.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_http_requests_timeseries.spec.ts index 4c661c9ae14f..ccd4ddd23ca5 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_http_requests_timeseries.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_http_requests_timeseries.spec.ts @@ -47,7 +47,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); } - registry.when( + registry.when.skip( 'Mobile HTTP requests without data loaded', { config: 'basic', archives: [] }, () => { @@ -63,75 +63,81 @@ export default function ApiTest({ getService }: FtrProviderContext) { ); // FLAKY: https://github.com/elastic/kibana/issues/177390 - registry.when('Mobile HTTP requests with data loaded', { config: 'basic', archives: [] }, () => { - before(async () => { - await generateMobileData({ - apmSynthtraceEsClient, - start, - end, + registry.when.skip( + 'Mobile HTTP requests with data loaded', + { config: 'basic', archives: [] }, + () => { + before(async () => { + await generateMobileData({ + apmSynthtraceEsClient, + start, + end, + }); }); - }); - after(() => apmSynthtraceEsClient.clean()); + after(() => apmSynthtraceEsClient.clean()); - describe('when data is loaded', () => { - it('returns timeseries for http requests chart', async () => { - const response = await getHttpRequestsChart({ - serviceName: 'synth-android', - offset: '1d', - }); + describe('when data is loaded', () => { + it('returns timeseries for http requests chart', async () => { + const response = await getHttpRequestsChart({ + serviceName: 'synth-android', + offset: '1d', + }); - expect(response.status).to.be(200); - expect(response.body.currentPeriod.timeseries.some((item) => item.x && item.y)).to.eql( - true - ); - expect(response.body.previousPeriod.timeseries[0].y).to.eql(0); - }); + expect(response.status).to.be(200); + expect(response.body.currentPeriod.timeseries.some((item) => item.x && item.y)).to.eql( + true + ); + expect(response.body.previousPeriod.timeseries[0].y).to.eql(0); + }); - it('returns only current period timeseries when offset is not available', async () => { - const response = await getHttpRequestsChart({ serviceName: 'synth-android' }); + it('returns only current period timeseries when offset is not available', async () => { + const response = await getHttpRequestsChart({ serviceName: 'synth-android' }); - expect(response.status).to.be(200); - expect( - response.body.currentPeriod.timeseries.some((item) => item.y === 0 && item.x) - ).to.eql(true); + expect(response.status).to.be(200); + expect( + response.body.currentPeriod.timeseries.some((item) => item.y === 0 && item.x) + ).to.eql(true); - expect(response.body.currentPeriod.timeseries[0].y).to.eql(7); - expect(response.body.previousPeriod.timeseries).to.eql([]); + expect(response.body.currentPeriod.timeseries[0].y).to.eql(7); + expect(response.body.previousPeriod.timeseries).to.eql([]); + }); }); - }); - describe('when filters are applied', () => { - it('returns empty state for filters', async () => { - const response = await getHttpRequestsChart({ - serviceName: 'synth-android', - environment: 'production', - kuery: `app.version:"none"`, + describe('when filters are applied', () => { + it('returns empty state for filters', async () => { + const response = await getHttpRequestsChart({ + serviceName: 'synth-android', + environment: 'production', + kuery: `app.version:"none"`, + }); + + expect(response.status).to.be(200); + expect(response.body.currentPeriod.timeseries.every((item) => item.y === 0)).to.eql(true); + expect(response.body.previousPeriod.timeseries.every((item) => item.y === 0)).to.eql( + true + ); }); - expect(response.status).to.be(200); - expect(response.body.currentPeriod.timeseries.every((item) => item.y === 0)).to.eql(true); - expect(response.body.previousPeriod.timeseries.every((item) => item.y === 0)).to.eql(true); - }); + it('returns the correct values when filter is applied', async () => { + const response = await getHttpRequestsChart({ + serviceName: 'synth-android', + environment: 'production', + kuery: `network.connection.type:"wifi"`, + }); - it('returns the correct values when filter is applied', async () => { - const response = await getHttpRequestsChart({ - serviceName: 'synth-android', - environment: 'production', - kuery: `network.connection.type:"wifi"`, - }); + const ntcCell = await getHttpRequestsChart({ + serviceName: 'synth-android', + environment: 'production', + kuery: `network.connection.type:"cell"`, + }); - const ntcCell = await getHttpRequestsChart({ - serviceName: 'synth-android', - environment: 'production', - kuery: `network.connection.type:"cell"`, + expect(response.status).to.be(200); + expect(ntcCell.status).to.be(200); + expect(response.body.currentPeriod.timeseries[0].y).to.eql(5); + expect(ntcCell.body.currentPeriod.timeseries[0].y).to.eql(2); }); - - expect(response.status).to.be(200); - expect(ntcCell.status).to.be(200); - expect(response.body.currentPeriod.timeseries[0].y).to.eql(5); - expect(ntcCell.body.currentPeriod.timeseries[0].y).to.eql(2); }); - }); - }); + } + ); } diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_location_stats.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_location_stats.spec.ts index 0acf17308b0d..ec82de406e0e 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_location_stats.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_location_stats.spec.ts @@ -233,7 +233,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177396 - registry.when('Location stats', { config: 'basic', archives: [] }, () => { + registry.when.skip('Location stats', { config: 'basic', archives: [] }, () => { before(async () => { await generateData({ apmSynthtraceEsClient, diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts index a3f95eaeb495..945ed5970e00 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts @@ -179,7 +179,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ); // FLAKY: https://github.com/elastic/kibana/issues/177395 - registry.when('Mobile main statistics', { config: 'basic', archives: [] }, () => { + registry.when.skip('Mobile main statistics', { config: 'basic', archives: [] }, () => { before(async () => { await generateData({ apmSynthtraceEsClient, diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_most_used_chart.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_most_used_chart.spec.ts index 497e6987a3e2..cde19d07344d 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_most_used_chart.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_most_used_chart.spec.ts @@ -65,7 +65,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ); // FLAKY: https://github.com/elastic/kibana/issues/177394 - registry.when('Mobile stats', { config: 'basic', archives: [] }, () => { + registry.when.skip('Mobile stats', { config: 'basic', archives: [] }, () => { before(async () => { await generateMobileData({ apmSynthtraceEsClient, diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_sessions_timeseries.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_sessions_timeseries.spec.ts index 99f0f245c8c4..f7f3092935c3 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_sessions_timeseries.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_sessions_timeseries.spec.ts @@ -47,7 +47,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); } - registry.when('without data loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('without data loaded', { config: 'basic', archives: [] }, () => { describe('when no data', () => { it('handles empty state', async () => { const response = await getSessionsChart({ serviceName: 'foo' }); @@ -59,7 +59,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177393 - registry.when('with data loaded', { config: 'basic', archives: [] }, () => { + registry.when.skip('with data loaded', { config: 'basic', archives: [] }, () => { before(async () => { await generateMobileData({ apmSynthtraceEsClient, diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_stats.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_stats.spec.ts index 22b7d0c8b8f6..0b1e71471a2b 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_stats.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_stats.spec.ts @@ -185,7 +185,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177392 - registry.when('Mobile stats', { config: 'basic', archives: [] }, () => { + registry.when.skip('Mobile stats', { config: 'basic', archives: [] }, () => { before(async () => { await generateData({ apmSynthtraceEsClient, diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_terms_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_terms_by_field.spec.ts index d50371423c16..3ccdba0a2423 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/mobile_terms_by_field.spec.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_terms_by_field.spec.ts @@ -186,7 +186,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/177498 - registry.when('Mobile terms', { config: 'basic', archives: [] }, () => { + registry.when.skip('Mobile terms', { config: 'basic', archives: [] }, () => { before(async () => { await generateData({ apmSynthtraceEsClient, diff --git a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts index c74c6be78da8..99479082f655 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts @@ -43,6 +43,7 @@ export const getConfigurationRequest = ({ closure_type: 'close-by-user', owner: 'securitySolutionFixture', customFields: [], + templates: [], ...overrides, }; }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts index 4a9e99016c80..5ccf9015839c 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -80,6 +84,61 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput(false, customFields)); }); + it('should return a configuration with templates', async () => { + const templates = { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + tags: [], + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: ['foobar'], + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ], + }; + + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: templates, + }) + ); + const configuration = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput(false, templates)); + }); + it('should get a single configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts index c8e0f092edf3..114182a1ad20 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts @@ -6,7 +6,11 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { ConfigurationPatchRequest } from '@kbn/cases-plugin/common/types/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -127,6 +131,163 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); + it('should patch a configuration with templates', async () => { + const customFieldsConfiguration = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + tags: ['foo', 'bar'], + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + tags: [], + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ] as ConfigurationPatchRequest['templates']; + + const configuration = await createConfiguration(supertest, { + ...getConfigurationRequest(), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + }); + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + customFields: customFieldsConfiguration, + templates, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + templates, + }); + }); + + it('should remove custom fields from templates', async () => { + const customFieldsConfiguration = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + ]; + + const configuration = await createConfiguration(supertest, { + ...getConfigurationRequest(), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + }); + + // delete custom fields + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + customFields: [], + templates: templates as ConfigurationPatchRequest['templates'], + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + customFields: [], + templates: [ + { ...templates[0], caseFields: { ...templates[0].caseFields, customFields: [] } }, + ], + }); + }); + describe('validation', () => { it('should not patch a configuration with unsupported connector type', async () => { const configuration = await createConfiguration(supertest); @@ -270,6 +431,64 @@ export default ({ getService }: FtrProviderContext): void => { 400 ); }); + + it("should not update a configuration with templates with custom fields that don't exist in the configuration", async () => { + const configuration = await createConfiguration(supertest); + + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + key: 'random_key', + type: CustomFieldTypes.TEXT, + value: 'Test', + }, + ], + }, + }, + ], + }, + 400 + ); + }); + + it('should not patch a configuration with duplicated template keys', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_1', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ] as ConfigurationPatchRequest['templates'], + }, + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts index a7461d5f1fc1..8a81214f009d 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts @@ -6,7 +6,11 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '@kbn/cases-plugin/common/constants'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -98,6 +102,84 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput(false, customFields)); }); + it('should create a configuration with templates', async () => { + const customFields = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + tags: ['foobar'], + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ]; + + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { customFields, templates }, + }) + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration); + expect(data).to.eql({ ...getConfigurationOutput(false), customFields, templates }); + }); + it('should keep only the latest configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); @@ -410,6 +492,61 @@ export default ({ getService }: FtrProviderContext): void => { 400 ); }); + + it("should not create a configuration with templates with custom fields that don't exist in the configuration", async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + key: 'random_key', + type: CustomFieldTypes.TEXT, + value: 'Test', + }, + ], + }, + }, + ], + }, + }), + 400 + ); + }); + + it('should not create a configuration with duplicated template keys', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_1', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ], + }, + }), + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts index 0df5147574d2..1b2eda553dba 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts @@ -278,6 +278,10 @@ export function AddCisIntegrationFormPageProvider({ return await (await checkBox.findByCssSelector(`input[id='${id}']`)).getAttribute('checked'); }; + const getReplaceSecretButton = async (secretField: string) => { + return await testSubjects.find(`button-replace-${secretField}`); + }; + return { cisAzure, cisAws, @@ -311,5 +315,6 @@ export function AddCisIntegrationFormPageProvider({ getValueInEditPage, isOptionChecked, checkIntegrationPliAuthBlockExists, + getReplaceSecretButton, }; } diff --git a/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_aws.ts b/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_aws.ts index cae23e9bfe8a..5523e0512091 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_aws.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_aws.ts @@ -124,6 +124,7 @@ export default function (providerContext: FtrProviderContext) { (await cisIntegration.getFieldValueInEditPage(DIRECT_ACCESS_KEY_ID_TEST_ID)) === directAccessKeyId ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('secret-access-key')).to.not.be(null); }); }); @@ -159,6 +160,7 @@ export default function (providerContext: FtrProviderContext) { (await cisIntegration.getValueInEditPage(TEMP_ACCESS_SESSION_TOKEN_TEST_ID)) === tempAccessSessionToken ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('secret-access-key')).to.not.be(null); }); }); @@ -247,6 +249,7 @@ export default function (providerContext: FtrProviderContext) { (await cisIntegration.getFieldValueInEditPage(DIRECT_ACCESS_KEY_ID_TEST_ID)) === directAccessKeyId ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('secret-access-key')).to.not.be(null); }); }); @@ -283,6 +286,7 @@ export default function (providerContext: FtrProviderContext) { (await cisIntegration.getValueInEditPage(TEMP_ACCESS_SESSION_TOKEN_TEST_ID)) === tempAccessSessionToken ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('secret-access-key')).to.not.be(null); }); }); diff --git a/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_azure.ts b/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_azure.ts index 6bd117c36a85..99cc57905d46 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_azure.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/cspm/cis_integration_azure.ts @@ -111,11 +111,7 @@ export default function (providerContext: FtrProviderContext) { CIS_AZURE_INPUT_FIELDS_TEST_SUBJECTS.TENANT_ID )) === tenantId ).to.be(true); - expect( - (await cisIntegration.getValueInEditPage( - CIS_AZURE_INPUT_FIELDS_TEST_SUBJECTS.CLIENT_SECRET - )) === clientSecret - ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('client-secret')).to.not.be(null); }); }); @@ -227,11 +223,7 @@ export default function (providerContext: FtrProviderContext) { CIS_AZURE_INPUT_FIELDS_TEST_SUBJECTS.TENANT_ID )) === tenantId ).to.be(true); - expect( - (await cisIntegration.getValueInEditPage( - CIS_AZURE_INPUT_FIELDS_TEST_SUBJECTS.CLIENT_SECRET - )) === clientSecret - ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('client-secret')).to.not.be(null); }); }); diff --git a/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/kspm/cis_integration_eks.ts b/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/kspm/cis_integration_eks.ts index ec4a7c61239e..db567d335b0f 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/kspm/cis_integration_eks.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/cis_integrations/kspm/cis_integration_eks.ts @@ -73,6 +73,7 @@ export default function (providerContext: FtrProviderContext) { (await cisIntegration.getFieldValueInEditPage(DIRECT_ACCESS_KEY_ID_TEST_ID)) === directAccessKeyId ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('secret-access-key')).to.not.be(null); }); }); @@ -107,6 +108,7 @@ export default function (providerContext: FtrProviderContext) { (await cisIntegration.getValueInEditPage(TEMP_ACCESS_SESSION_TOKEN_TEST_ID)) === tempAccessSessionToken ).to.be(true); + expect(await cisIntegration.getReplaceSecretButton('secret-access-key')).to.not.be(null); }); }); diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts index 76ea64f6e619..16e63fac3491 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings.ts @@ -17,7 +17,6 @@ import type { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ getPageObjects, getService }: FtrProviderContext) { - const filterBar = getService('filterBar'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); const supertest = getService('supertest'); @@ -189,19 +188,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('DistributionBar', () => { - (['passed', 'failed'] as const).forEach((type) => { - it(`filters by ${type} findings`, async () => { - await distributionBar.filterBy(type); - - const items = data.filter(({ result }) => result.evaluation === type); - expect(await latestFindingsTable.getFindingsCount(type)).to.eql(items.length); - - await filterBar.removeFilter('result.evaluation'); - }); - }); - }); - describe('Findings - Fields selector', () => { const CSP_FIELDS_SELECTOR_MODAL = 'cloudSecurityFieldsSelectorModal'; const CSP_FIELDS_SELECTOR_OPEN_BUTTON = 'cloudSecurityFieldsSelectorOpenButton'; diff --git a/x-pack/test/common/utils/security_solution/detections_response/delete_all_anomalies.ts b/x-pack/test/common/utils/security_solution/detections_response/delete_all_anomalies.ts new file mode 100644 index 000000000000..1f9df710c5d5 --- /dev/null +++ b/x-pack/test/common/utils/security_solution/detections_response/delete_all_anomalies.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 { ToolingLog } from '@kbn/tooling-log'; +import type { Client } from '@elastic/elasticsearch'; + +import { countDownTest } from './count_down_test'; + +export const deleteAllAnomalies = async ( + log: ToolingLog, + es: Client, + index: string[] = ['.ml-anomalies-*'] +): Promise<void> => { + await countDownTest( + async () => { + await es.deleteByQuery({ + index, + body: { + query: { + match_all: {}, + }, + }, + refresh: true, + }); + return { + passed: true, + }; + }, + 'deleteAllAnomalies', + log + ); +}; diff --git a/x-pack/test/common/utils/security_solution/detections_response/index.ts b/x-pack/test/common/utils/security_solution/detections_response/index.ts index d6a06f8e5779..43c2a54900c1 100644 --- a/x-pack/test/common/utils/security_solution/detections_response/index.ts +++ b/x-pack/test/common/utils/security_solution/detections_response/index.ts @@ -7,6 +7,7 @@ export * from './rules'; export * from './alerts'; +export * from './delete_all_anomalies'; export * from './count_down_test'; export * from './route_with_namespace'; export * from './wait_for'; diff --git a/x-pack/test/dataset_quality_api_integration/common/config.ts b/x-pack/test/dataset_quality_api_integration/common/config.ts index e5a45b72d7f0..e297d8eaf035 100644 --- a/x-pack/test/dataset_quality_api_integration/common/config.ts +++ b/x-pack/test/dataset_quality_api_integration/common/config.ts @@ -22,6 +22,7 @@ import { import { createDatasetQualityApiClient } from './dataset_quality_api_supertest'; import { RegistryProvider } from './registry'; import { DatasetQualityFtrConfigName } from '../configs'; +import { PackageService } from './package_service'; export interface DatasetQualityFtrConfig { name: DatasetQualityFtrConfigName; @@ -70,6 +71,7 @@ export interface CreateTest { context: InheritedFtrProviderContext ) => Promise<LogsSynthtraceEsClient>; datasetQualityApiClient: (context: InheritedFtrProviderContext) => DatasetQualityApiClient; + packageService: ({ getService }: FtrProviderContext) => ReturnType<typeof PackageService>; }; junit: { reportName: string }; esTestCluster: any; @@ -98,6 +100,7 @@ export function createTestConfig( servicesRequiredForTestAnalysis: ['datasetQualityFtrConfig', 'registry'], services: { ...services, + packageService: PackageService, datasetQualityFtrConfig: () => config, registry: RegistryProvider, logSynthtraceEsClient: (context: InheritedFtrProviderContext) => diff --git a/x-pack/test/dataset_quality_api_integration/common/ftr_provider_context.ts b/x-pack/test/dataset_quality_api_integration/common/ftr_provider_context.ts index 69de63c48402..f48da8f57c18 100644 --- a/x-pack/test/dataset_quality_api_integration/common/ftr_provider_context.ts +++ b/x-pack/test/dataset_quality_api_integration/common/ftr_provider_context.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { GenericFtrProviderContext } from '@kbn/test'; +import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test'; import { FtrProviderContext as InheritedFtrProviderContext } from '../../api_integration/ftr_provider_context'; import { DatasetQualityServices } from './config'; @@ -18,3 +18,4 @@ export type InheritedServices = InheritedFtrProviderContext extends GenericFtrPr export type { InheritedFtrProviderContext }; export type FtrProviderContext = GenericFtrProviderContext<DatasetQualityServices, {}>; +export class FtrService extends GenericFtrService<FtrProviderContext> {} diff --git a/x-pack/test/dataset_quality_api_integration/common/package_service.ts b/x-pack/test/dataset_quality_api_integration/common/package_service.ts new file mode 100644 index 000000000000..0449fd9f921d --- /dev/null +++ b/x-pack/test/dataset_quality_api_integration/common/package_service.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from './ftr_provider_context'; + +interface IntegrationPackage { + name: string; + version: string; +} + +export function PackageService({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + function uninstallPackage({ name, version }: IntegrationPackage) { + return supertest.delete(`/api/fleet/epm/packages/${name}/${version}`).set('kbn-xsrf', 'xxxx'); + } + + function installPackage({ name, version }: IntegrationPackage) { + return supertest + .post(`/api/fleet/epm/packages/${name}/${version}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }); + } + + return { uninstallPackage, installPackage }; +} diff --git a/x-pack/test/dataset_quality_api_integration/tests/data_streams/data_stream_settings.spec.ts b/x-pack/test/dataset_quality_api_integration/tests/data_streams/data_stream_settings.spec.ts index e6f75d53b6c7..e5b38d2463cb 100644 --- a/x-pack/test/dataset_quality_api_integration/tests/data_streams/data_stream_settings.spec.ts +++ b/x-pack/test/dataset_quality_api_integration/tests/data_streams/data_stream_settings.spec.ts @@ -21,13 +21,19 @@ export default function ApiTest({ getService }: FtrProviderContext) { const synthtrace = getService('logSynthtraceEsClient'); const esClient = getService('es'); const datasetQualityApiClient = getService('datasetQualityApiClient'); + const pkgService = getService('packageService'); const start = '2023-12-11T18:00:00.000Z'; const end = '2023-12-11T18:01:00.000Z'; const type = 'logs'; - const dataset = 'nginx.access'; + const dataset = 'synth.1'; + const integrationDataset = 'apache.access'; const namespace = 'default'; const serviceName = 'my-service'; const hostName = 'synth-host'; + const pkg = { + name: 'apache', + version: '1.14.0', + }; async function callApiAs(user: DatasetQualityApiClientKey, dataStream: string) { return await datasetQualityApiClient[user]({ @@ -43,6 +49,27 @@ export default function ApiTest({ getService }: FtrProviderContext) { registry.when('DataStream Settings', { config: 'basic' }, () => { describe('gets the data stream settings', () => { before(async () => { + // Install Integration and ingest logs for it + await pkgService.installPackage(pkg); + await synthtrace.index([ + timerange(start, end) + .interval('1m') + .rate(1) + .generator((timestamp) => + log + .create() + .message('This is a log message') + .timestamp(timestamp) + .dataset(integrationDataset) + .namespace(namespace) + .defaults({ + 'log.file.path': '/my-service.log', + 'service.name': serviceName, + 'host.name': hostName, + }) + ), + ]); + // Ingest basic logs await synthtrace.index([ timerange(start, end) .interval('1m') @@ -98,8 +125,22 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(resp.body.createdOn).to.be(Number(dataStreamSettings?.index?.creation_date)); }); + it('returns "createdOn" and "integration" correctly when available', async () => { + const dataStreamSettings = await getDataStreamSettingsOfEarliestIndex( + esClient, + `${type}-${integrationDataset}-${namespace}` + ); + const resp = await callApiAs( + 'datasetQualityLogsUser', + `${type}-${integrationDataset}-${namespace}` + ); + expect(resp.body.createdOn).to.be(Number(dataStreamSettings?.index?.creation_date)); + expect(resp.body.integration).to.be('apache'); + }); + after(async () => { await synthtrace.clean(); + await pkgService.uninstallPackage(pkg); }); }); }); diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_custom.ts b/x-pack/test/fleet_api_integration/apis/epm/install_custom.ts index c1dec4e1eb30..bcd4421f493d 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_custom.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_custom.ts @@ -26,13 +26,12 @@ export default function (providerContext: FtrProviderContext) { .send({ force: true }); }; - // FLAKY: https://github.com/elastic/kibana/issues/180062 - describe.skip('Installing custom integrations', async () => { + describe('TESTME Installing custom integrations', async () => { afterEach(async () => { await uninstallPackage(); }); - it("Correcty installs a custom integration and all of it's assets", async () => { + it('Correcty installs a custom integration and all of its assets', async () => { const response = await supertest .post(`/api/fleet/epm/custom_integrations`) .set('kbn-xsrf', 'xxxx') @@ -170,20 +169,40 @@ export default function (providerContext: FtrProviderContext) { dynamic_templates: [ { ecs_timestamp: { - match: '@timestamp', mapping: { ignore_malformed: false, type: 'date', }, + match: '@timestamp', }, }, { ecs_message_match_only_text: { + mapping: { + type: 'match_only_text', + }, path_match: ['message', '*.message'], unmatch_mapping_type: 'object', + }, + }, + { + ecs_non_indexed_keyword: { mapping: { - type: 'match_only_text', + doc_values: false, + index: false, + type: 'keyword', + }, + path_match: 'event.original', + }, + }, + { + ecs_non_indexed_long: { + mapping: { + doc_values: false, + index: false, + type: 'long', }, + path_match: '*.x509.public_key_exponent', }, }, { @@ -305,7 +324,7 @@ export default function (providerContext: FtrProviderContext) { }, { ecs_geo_point: { - path_match: ['location', '*.location'], + path_match: '*.geo.location', mapping: { type: 'geo_point', }, @@ -426,6 +445,7 @@ export default function (providerContext: FtrProviderContext) { 'event.ingested': { type: 'date', format: 'strict_date_time_no_millis||strict_date_optional_time||epoch_millis', + ignore_malformed: false, }, 'host.architecture': { type: 'keyword', @@ -508,6 +528,34 @@ export default function (providerContext: FtrProviderContext) { }); }); + it('Works correctly when there is an existing datastream with the same name', async () => { + const INTEGRATION_NAME_1 = 'myintegration'; + const DATASET_NAME = 'test'; + await esClient.transport.request({ + method: 'POST', + path: `logs-${INTEGRATION_NAME_1}.${DATASET_NAME}-default/_doc`, + body: { + '@timestamp': '2015-01-01', + logs_test_name: `${DATASET_NAME}`, + data_stream: { + dataset: `${INTEGRATION_NAME_1}.${DATASET_NAME}_logs`, + namespace: 'default', + type: 'logs', + }, + }, + }); + await supertest + .post(`/api/fleet/epm/custom_integrations`) + .set('kbn-xsrf', 'xxxx') + .type('application/json') + .send({ + force: true, + integrationName: INTEGRATION_NAME_1, + datasets: [{ name: `${INTEGRATION_NAME_1}.${DATASET_NAME}`, type: 'logs' }], + }) + .expect(200); + }); + it('Throws an error when there is a naming collision with a current package installation', async () => { await supertest .post(`/api/fleet/epm/custom_integrations`) diff --git a/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts b/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts index 59e11b225772..6aa16cf806fe 100644 --- a/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts +++ b/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts @@ -271,6 +271,11 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid }); describe('navigation', () => { + afterEach(async () => { + // Navigate back to dataset quality page after each test + await PageObjects.datasetQuality.navigateTo(); + }); + it('should go to log explorer page when the open in log explorer button is clicked', async () => { const testDatasetName = datasetNames[2]; await PageObjects.datasetQuality.openDatasetFlyout(testDatasetName); @@ -283,9 +288,6 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.eql(testDatasetName); - - // Should bring back the test to the dataset quality page - await PageObjects.datasetQuality.navigateTo(); }); it('should go log explorer for degraded docs when the show all button is clicked', async () => { @@ -293,17 +295,11 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const degradedDocsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.degradedDocs}`; await testSubjects.click(degradedDocsShowAllSelector); - await browser.switchTab(1); // Confirm dataset selector text in observability logs explorer const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.contain(apacheAccessDatasetName); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); // Blocked by https://github.com/elastic/kibana/issues/181705 @@ -313,7 +309,6 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const hostsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.hosts}`; await testSubjects.click(hostsShowAllSelector); - await browser.switchTab(1); // Confirm url contains metrics/hosts await retry.tryForTime(5000, async () => { @@ -321,11 +316,6 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const parsedUrl = new URL(currentUrl); expect(parsedUrl.pathname).to.contain('/app/metrics/hosts'); }); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); }); diff --git a/x-pack/test/functional/apps/infra/helpers.ts b/x-pack/test/functional/apps/infra/helpers.ts index c313626b8382..2ddabf314390 100644 --- a/x-pack/test/functional/apps/infra/helpers.ts +++ b/x-pack/test/functional/apps/infra/helpers.ts @@ -60,19 +60,7 @@ export function generateDockerContainersData({ const containers = Array(count) .fill(0) - .map((_, idx) => - infra.dockerContainer(`container-id-${idx}`).defaults({ - 'container.name': `container-id-${idx}`, - 'container.id': `container-id-${idx}`, - 'container.runtime': 'docker', - 'container.image.name': 'image-1', - 'host.name': 'host-1', - 'cloud.instance.id': 'instance-1', - 'cloud.image.id': 'image-1', - 'cloud.provider': 'aws', - 'event.dataset': 'docker.container', - }) - ); + .map((_, idx) => infra.dockerContainer(`container-id-${idx}`)); return range .interval('30s') diff --git a/x-pack/test/functional/es_archives/security_solution/anomalies/mappings.json b/x-pack/test/functional/es_archives/security_solution/anomalies/mappings.json index 484e0f3fc9aa..56a26b937a49 100644 --- a/x-pack/test/functional/es_archives/security_solution/anomalies/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/anomalies/mappings.json @@ -2,22 +2,21 @@ "type": "index", "value": { "aliases": { - ".ml-anomalies-.write-linux_anomalous_network_activity_ecs": { + ".ml-anomalies-.write-v3_linux_anomalous_network_activity": { "is_hidden": true }, - ".ml-anomalies-linux_anomalous_network_activity_ecs": { + ".ml-anomalies-v3_linux_anomalous_network_activity": { "filter": { "term": { "job_id": { - "boost": 1, - "value": "linux_anomalous_network_activity_ecs" + "value": "v3_linux_anomalous_network_activity" } } }, "is_hidden": true } }, - "index": ".ml-anomalies-custom-linux_anomalous_network_activity_ecs", + "index": ".ml-anomalies-custom-v3_linux_anomalous_network_activity", "mappings": { "_meta": { "version": "8.0.0" diff --git a/x-pack/test/functional/page_objects/dataset_quality.ts b/x-pack/test/functional/page_objects/dataset_quality.ts index dc580d2951c6..7cb633a8113b 100644 --- a/x-pack/test/functional/page_objects/dataset_quality.ts +++ b/x-pack/test/functional/page_objects/dataset_quality.ts @@ -96,6 +96,7 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv datasetQualityFlyoutFieldValue: 'datasetQualityFlyoutFieldValue', datasetQualityFlyoutFieldsListIntegrationDetails: 'datasetQualityFlyoutFieldsList-integration_details', + datasetQualityFlyoutIntegrationLoading: 'datasetQualityFlyoutIntegrationLoading', datasetQualityFlyoutIntegrationActionsButton: 'datasetQualityFlyoutIntegrationActionsButton', datasetQualityFlyoutIntegrationAction: (action: string) => `datasetQualityFlyoutIntegrationAction${action}`, @@ -163,6 +164,13 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv await find.waitForDeletedByCssSelector('.euiFlyoutBody .euiBasicTable-loading', 20 * 1000); }, + async waitUntilIntegrationsInFlyoutLoaded() { + await find.waitForDeletedByCssSelector( + '.euiSkeletonTitle .datasetQualityFlyoutIntegrationLoading', + 10 * 1000 + ); + }, + async waitUntilSummaryPanelLoaded(isStateful: boolean = true) { await testSubjects.missingOrFail(`datasetQuality-${texts.activeDatasets}-loading`); if (isStateful) { @@ -321,6 +329,8 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv if (isCollapsed) { await datasetExpandButton.click(); } + + await this.waitUntilIntegrationsInFlyoutLoaded(); }, async closeFlyout() { diff --git a/x-pack/test/functional/services/cases/api.ts b/x-pack/test/functional/services/cases/api.ts index 72a65bc98cb6..7a1d4f52108d 100644 --- a/x-pack/test/functional/services/cases/api.ts +++ b/x-pack/test/functional/services/cases/api.ts @@ -161,5 +161,23 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) { }) ); }, + + async createConfigWithTemplates({ + templates, + owner, + }: { + templates: Configuration['templates']; + owner: string; + }) { + return createConfiguration( + kbnSupertest, + getConfigurationRequest({ + overrides: { + templates, + owner, + }, + }) + ); + }, }; } diff --git a/x-pack/test/functional/services/cases/create.ts b/x-pack/test/functional/services/cases/create.ts index fb018615dd19..3f7b6e1e65f9 100644 --- a/x-pack/test/functional/services/cases/create.ts +++ b/x-pack/test/functional/services/cases/create.ts @@ -58,6 +58,10 @@ export function CasesCreateViewServiceProvider( category, owner, }: CreateCaseParams) { + if (owner) { + await this.setSolution(owner); + } + await this.setTitle(title); await this.setDescription(description); await this.setTags(tag); @@ -70,10 +74,6 @@ export function CasesCreateViewServiceProvider( await this.setSeverity(severity); } - if (owner) { - await this.setSolution(owner); - } - await this.submitCase(); }, @@ -96,7 +96,8 @@ export function CasesCreateViewServiceProvider( }, async setSolution(owner: string) { - await testSubjects.click(`${owner}RadioButton`); + await testSubjects.click('caseOwnerSuperSelect'); + await testSubjects.click(`${owner}OwnerOption`); }, async setSeverity(severity: CaseSeverity) { diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index fcb1e23d6f9b..c9a16b6e4598 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -93,7 +93,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { 'The length of the tag is too long. The maximum length is 256 characters.' ); - const category = await testSubjects.find('case-create-form-category'); + const category = await testSubjects.find('caseCategory'); expect(await category.getVisibleText()).contain( 'The length of the category is too long. The maximum length is 50 characters.' ); @@ -150,7 +150,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); @@ -207,7 +207,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts index 8c4dd4753225..c714cdba2563 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts @@ -235,8 +235,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('renders solutions selection', async () => { await openFlyout(); + await testSubjects.click('caseOwnerSelector'); + for (const owner of TOTAL_OWNERS) { - await testSubjects.existOrFail(`${owner}RadioButton`); + await testSubjects.existOrFail(`${owner}OwnerOption`); } await closeFlyout(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts index 29eb8c991952..ee013b882c48 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts @@ -15,7 +15,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const toasts = getService('toasts'); const header = getPageObject('header'); + const comboBox = getService('comboBox'); const find = getService('find'); + const retry = getService('retry'); describe('Configure', function () { before(async () => { @@ -81,13 +83,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -105,7 +107,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -119,12 +121,111 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + before(async () => { + await cases.api.createConfigWithTemplates({ + templates: [ + { + key: 'o11y_template', + name: 'My template 1', + description: 'this is my first template', + tags: ['foo'], + caseFields: null, + }, + ], + owner: 'observability', + }); + }); + + it('existing configurations do not interfere', async () => { + // A configuration created in o11y should not be visible in stack + expect(await testSubjects.getVisibleText('empty-templates')).to.be( + 'You do not have any templates yet' + ); + }); + + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); + }); }); }; 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 7256432174e3..a47e43bd426e 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 @@ -84,6 +84,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s 'riskScoringPersistence', 'riskScoringRoutesEnabled', 'bulkCustomHighlightedFieldsEnabled', + 'alertSuppressionForMachineLearningRuleEnabled', 'manualRuleRunEnabled', ])}`, '--xpack.task_manager.poll_interval=1000', diff --git a/x-pack/test/security_solution_api_integration/package.json b/x-pack/test/security_solution_api_integration/package.json index 52ff9a233b47..ffba287fb07c 100644 --- a/x-pack/test/security_solution_api_integration/package.json +++ b/x-pack/test/security_solution_api_integration/package.json @@ -35,6 +35,12 @@ "initialize-server:lists:complete": "node ./scripts/index.js server lists_and_exception_lists trial_license_complete_tier", "run-tests:lists:complete": "node ./scripts/index.js runner lists_and_exception_lists trial_license_complete_tier", + "initialize-server:investigations": "node scripts/index.js server investigation trial_license_complete_tier", + "run-tests:investigations": "node scripts/index.js runner investigation trial_license_complete_tier", + + "intialize-server:explore": "node scripts/index.js server explore trial_license_complete_tier", + "run-tests:explore": "node scripts/index.js runner explore trial_license_complete_tier", + "genai:server:serverless": "npm run initialize-server:genai:trial_complete invoke_ai serverless", "genai:runner:serverless": "npm run run-tests:genai:trial_complete invoke_ai serverless serverlessEnv", "genai:qa:serverless": "npm run run-tests:genai:trial_complete invoke_ai serverless qaPeriodicEnv", @@ -302,6 +308,48 @@ "rules_management:essentials:qa:serverless": "npm run run-tests:rm:basic_essentials rule_management serverless qaPeriodicEnv", "rules_management:essentials:qa:serverless:release": "npm run run-tests:rm:basic_essentials rule_management serverless qaEnv", "rules_management:basic:server:ess": "npm run initialize-server:rm:basic_essentials rule_management ess", - "rules_management:basic:runner:ess": "npm run run-tests:rm:basic_essentials rule_management ess essEnv" + "rules_management:basic:runner:ess": "npm run run-tests:rm:basic_essentials rule_management ess essEnv", + + "investigations:timeline:server:serverless": "npm run initialize-server:investigations timeline serverless", + "investigations:timeline:runner:serverless": "npm run run-tests:investigations timeline serverless serverlessEnv", + "investigations:timeline:runner:qa:serverless": "npm run run-tests:investigations timeline serverless qaPeriodicEnv", + "investigations:timeline:runner:qa:serverless:release": "npm run run-tests:investigations timeline serverless qaEnv", + "investigations:timeline:server:ess": "npm run initialize-server:investigations timeline ess", + "investigations:timeline:runner:ess": "npm run run-tests:investigations timeline ess essEnv", + + "investigations:saved-objects:server:serverless": "npm run initialize-server:investigations saved_objects serverless", + "investigations:saved-objects:runner:serverless": "npm run run-tests:investigations saved_objects serverless serverlessEnv", + "investigations:saved-objects:runner:qa:serverless": "npm run run-tests:investigations saved_objects serverless qaPeriodicEnv", + "investigations:saved-objects:runner:qa:serverless:release": "npm run run-tests:investigations saved_objects serverless qaEnv", + "investigations:saved-objects:server:ess": "npm run initialize-server:investigations saved_objects ess", + "investigations:saved-objects:runner:ess": "npm run run-tests:investigations saved_objects ess essEnv", + + "explore:hosts:server:serverless": "npm run intialize-server:explore hosts serverless", + "explore:hosts:runner:serverless": "npm run run-tests:explore hosts serverless serverlessEnv", + "explore:hosts:runner:qa:serverless": "npm run run-tests:explore hosts serverless qaPeriodicEnv", + "explore:hosts:runner:qa:serverless:release": "npm run run-tests:explore hosts serverless qaEnv", + "explore:hosts:server:ess": "npm run intialize-server:explore hosts ess", + "explore:hosts:runner:ess": "npm run run-tests:explore hosts ess essEnv", + + "explore:network:server:serverless": "npm run intialize-server:explore network serverless", + "explore:network:runner:serverless": "npm run run-tests:explore network serverless serverlessEnv", + "explore:network:runner:qa:serverless": "npm run run-tests:explore network serverless qaPeriodicEnv", + "explore:network:runner:qa:serverless:release": "npm run run-tests:explore network serverless qaEnv", + "explore:network:server:ess": "npm run intialize-server:explore network ess", + "explore:network:runner:ess": "npm run run-tests:explore network ess essEnv", + + "explore:overview:server:serverless": "npm run intialize-server:explore overview serverless", + "explore:overview:runner:serverless": "npm run run-tests:explore overview serverless serverlessEnv", + "explore:overview:runner:qa:serverless": "npm run run-tests:explore overview serverless qaPeriodicEnv", + "explore:overview:runner:qa:serverless:release": "npm run run-tests:explore overview serverless qaEnv", + "explore:overview:server:ess": "npm run intialize-server:explore overview ess", + "explore:overview:runner:ess": "npm run run-tests:explore overview ess essEnv", + + "explore:users:server:serverless": "npm run intialize-server:explore users serverless", + "explore:users:runner:serverless": "npm run run-tests:explore users serverless serverlessEnv", + "explore:users:runner:qa:serverless": "npm run run-tests:explore users serverless qaPeriodicEnv", + "explore:users:runner:qa:serverless:release": "npm run run-tests:explore users serverless qaEnv", + "explore:users:server:ess": "npm run intialize-server:explore users ess", + "explore:users:runner:ess": "npm run run-tests:explore users ess essEnv" } -} +} \ No newline at end of file diff --git a/x-pack/test/security_solution_api_integration/scripts/index.js b/x-pack/test/security_solution_api_integration/scripts/index.js index 52f4964aa511..c3f4637d2b13 100644 --- a/x-pack/test/security_solution_api_integration/scripts/index.js +++ b/x-pack/test/security_solution_api_integration/scripts/index.js @@ -9,6 +9,21 @@ const { spawn } = require('child_process'); const [, , type, area, licenseFolder, domain, projectType, environment, ...args] = process.argv; +const commandUsage = ` +Usage: node index.js <type> <area> <licenseFolder> <domain> <projectType> <environment> [args] + +Arguments: + type: server | runner + environment: serverlessEnv | essEnv | qaPeriodicEnv | qaEnv. Mandatory for runner type + +area, domain, licenseFolder, projectType, environment are required arguments to locate the config file with below path + : ./test_suites/<area>/<domain>/<licenseFolder>/configs/<projectType>.config.ts +`; + +if (!type || !area || !licenseFolder || !domain || !projectType) { + console.error(commandUsage); +} + const configPath = `./test_suites/${area}/${domain}/${licenseFolder}/configs/${projectType}.config.ts`; const command = @@ -37,11 +52,24 @@ if (type !== 'server') { break; default: - console.error(`Unsupported environment: ${environment}`); + console.error( + `Unsupported environment: ${environment}. + ${commandUsage} + ` + ); process.exit(1); } } +console.log( + "Spawning child process with command: 'node',", + command, + '--config', + configPath, + ...grepArgs, + ...args +); + const child = spawn('node', [command, '--config', configPath, ...grepArgs, ...args], { stdio: 'inherit', }); 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 76c73ff71cc1..825d6a0e5833 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 @@ -19,6 +19,7 @@ export default createTestConfig({ ])}`, // See tests within the file "ignore_fields.ts" which use these values in "alertIgnoreFields" `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'bulkCustomHighlightedFieldsEnabled', + 'alertSuppressionForMachineLearningRuleEnabled', 'alertSuppressionForEsqlRuleEnabled', ])}`, ], 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 3ea2c4e6c935..5d0e8f4db406 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 @@ -14,6 +14,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./esql')); loadTestFile(require.resolve('./esql_suppression')); loadTestFile(require.resolve('./machine_learning')); + loadTestFile(require.resolve('./machine_learning_alert_suppression')); loadTestFile(require.resolve('./new_terms')); loadTestFile(require.resolve('./new_terms_alert_suppression')); loadTestFile(require.resolve('./saved_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/machine_learning.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning.ts index 3fb077df86a3..5d73249e576f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning.ts @@ -151,7 +151,7 @@ export default ({ getService }: FtrProviderContext) => { [SPACE_IDS]: ['default'], [ALERT_SEVERITY]: 'critical', [ALERT_RISK_SCORE]: 50, - [ALERT_RULE_PARAMETERS]: { + [ALERT_RULE_PARAMETERS]: expect.objectContaining({ anomaly_threshold: 30, author: [], description: 'Test ML rule description', @@ -174,7 +174,7 @@ export default ({ getService }: FtrProviderContext) => { to: 'now', type: 'machine_learning', version: 1, - }, + }), [ALERT_DEPTH]: 1, [ALERT_REASON]: `event with process store, by root on mothra created critical alert Test ML rule.`, [ALERT_ORIGINAL_TIME]: expect.any(String), 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/machine_learning_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning_alert_suppression.ts new file mode 100644 index 000000000000..b29ce8abbb8e --- /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/machine_learning_alert_suppression.ts @@ -0,0 +1,1106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { + MachineLearningRuleCreateProps, + RuleExecutionStatusEnum, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import type { Anomaly } from '@kbn/security-solution-plugin/server/lib/machine_learning'; +import { + ALERT_LAST_DETECTED, + ALERT_START, + ALERT_SUPPRESSION_DOCS_COUNT, + ALERT_SUPPRESSION_END, + ALERT_SUPPRESSION_START, + ALERT_SUPPRESSION_TERMS, + TIMESTAMP, +} from '@kbn/rule-data-utils'; +import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; +import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL } from '@kbn/security-solution-plugin/common/constants'; +import { EsArchivePathBuilder } from '../../../../../../es_archive_path_builder'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + dataGeneratorFactory, + executeSetupModuleRequest, + forceStartDatafeeds, + getAlerts, + getOpenAlerts, + getPreviewAlerts, + patchRule, + previewRule, + previewRuleWithExceptionEntries, + setAlertStatus, +} from '../../../../utils'; +import { + createRule, + deleteAllAlerts, + deleteAllAnomalies, + deleteAllRules, +} from '../../../../../../../common/utils/security_solution'; +import { deleteAllExceptions } from '../../../../../lists_and_exception_lists/utils'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + const log = getService('log'); + const config = getService('config'); + + const isServerless = config.get('serverless'); + const dataPathBuilder = new EsArchivePathBuilder(isServerless); + const auditbeatArchivePath = dataPathBuilder.getPath('auditbeat/hosts'); + + const { indexListOfDocuments } = dataGeneratorFactory({ + es, + index: '.ml-anomalies-custom-v3_linux_anomalous_network_activity', + log, + }); + + const mlModuleName = 'security_linux_v3'; + const mlJobId = 'v3_linux_anomalous_network_activity'; + const baseRuleProps: MachineLearningRuleCreateProps = { + name: 'Test ML rule', + description: 'Test ML rule description', + risk_score: 50, + severity: 'critical', + type: 'machine_learning', + anomaly_threshold: 40, + machine_learning_job_id: mlJobId, + from: '1900-01-01T00:00:00.000Z', + rule_id: 'ml-rule-id', + }; + let ruleProps: MachineLearningRuleCreateProps; + const baseAnomaly: Partial<Anomaly> = { + is_interim: false, + record_score: 43, // exceeds anomaly_threshold above + result_type: 'record', + job_id: mlJobId, + 'user.name': ['root'], + }; + + // The tests described in this file rely on the + // 'alertSuppressionForMachineLearningRuleEnabled' feature flag, and are thus + // skipped in MKI + describe('@ess @serverless @skipInServerlessMKI Machine Learning Detection Rule - Alert Suppression', () => { + describe('with an active ML Job', () => { + before(async () => { + // Order is critical here: auditbeat data must be loaded before attempting to start the ML job, + // as the job looks for certain indices on start + await esArchiver.load(auditbeatArchivePath); + await executeSetupModuleRequest({ module: mlModuleName, rspCode: 200, supertest }); + await forceStartDatafeeds({ jobId: mlJobId, rspCode: 200, supertest }); + await esArchiver.load('x-pack/test/functional/es_archives/security_solution/anomalies'); + await deleteAllAnomalies(log, es); + }); + + after(async () => { + await esArchiver.load(auditbeatArchivePath); + await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/anomalies'); + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + }); + + afterEach(async () => { + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + await deleteAllAnomalies(log, es); + }); + + describe('with per-execution suppression duration', () => { + beforeEach(() => { + ruleProps = { + ...baseRuleProps, + alert_suppression: { + group_by: ['user.name'], + missing_fields_strategy: 'suppress', + }, + }; + }); + + it('performs no suppression if a single alert is generated', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + }; + await indexListOfDocuments([anomaly]); + const createdRule = await createRule(supertest, log, ruleProps); + const alerts = await getAlerts(supertest, log, es, createdRule); + + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [{ field: 'user.name', value: ['root'] }], + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + }); + + it('suppresses alerts within a single execution', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + }; + await indexListOfDocuments([anomaly, anomaly]); + + const createdRule = await createRule(supertest, log, { + ...ruleProps, + from: timestamp, + }); + + const alerts = await getAlerts(supertest, log, es, createdRule); + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, + }) + ); + }); + + it('deduplicates previously suppressed alerts if rule has overlapping execution windows', async () => { + const firstTimestamp = new Date().toISOString(); + const firstAnomaly = { + ...baseAnomaly, + timestamp: firstTimestamp, + }; + await indexListOfDocuments([firstAnomaly]); + + const createdRule = await createRule(supertest, log, { + ...ruleProps, + from: firstTimestamp, + }); + const alerts = await getAlerts(supertest, log, es, createdRule); + + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + // suppression boundaries equal to original event time, since no alert been suppressed + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: firstTimestamp, + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + + const secondTimestamp = new Date().toISOString(); + const secondAnomaly = { + ...baseAnomaly, + timestamp: secondTimestamp, + }; + + // Add more anomalies, then disable and re-enable to trigger another + // rule run. The second anomaly should trigger an update to the + // existing alert without changing the timestamp + await indexListOfDocuments([secondAnomaly, secondAnomaly]); + await patchRule(supertest, log, { id: createdRule.id, enabled: false }); + await patchRule(supertest, log, { id: createdRule.id, enabled: true }); + const secondAlerts = await getOpenAlerts( + supertest, + log, + es, + createdRule, + RuleExecutionStatusEnum.succeeded, + undefined, + new Date() + ); + + expect(secondAlerts.hits.hits).toHaveLength(2); + expect(secondAlerts.hits.hits[1]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_ORIGINAL_TIME]: secondTimestamp, + [ALERT_SUPPRESSION_START]: secondTimestamp, + [ALERT_SUPPRESSION_END]: secondTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, // 1 of the two new anomalies was suppressed on this execution + }) + ); + }); + }); + + describe('with interval suppression duration', () => { + beforeEach(() => { + ruleProps = { + ...baseRuleProps, + alert_suppression: { + duration: { + value: 300, + unit: 'm', + }, + group_by: ['user.name'], + missing_fields_strategy: 'suppress', + }, + }; + }); + + it('performs no suppression if a single alert is generated', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + }; + await indexListOfDocuments([anomaly]); + const createdRule = await createRule(supertest, log, ruleProps); + const alerts = await getAlerts(supertest, log, es, createdRule); + + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [{ field: 'user.name', value: ['root'] }], + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + }); + + it('suppresses alerts across two executions', async () => { + const firstTimestamp = new Date().toISOString(); + const firstAnomaly = { + ...baseAnomaly, + timestamp: firstTimestamp, + }; + await indexListOfDocuments([firstAnomaly]); + + const createdRule = await createRule(supertest, log, { + ...ruleProps, + from: firstTimestamp, + }); + const alerts = await getAlerts(supertest, log, es, createdRule); + + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + // suppression boundaries equal to original event time, since no alert been suppressed + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: firstTimestamp, + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + + const secondTimestamp = new Date().toISOString(); + const secondAnomaly = { + ...baseAnomaly, + timestamp: secondTimestamp, + }; + + // Add more anomalies, then disable and re-enable to trigger another + // rule run. The second anomaly should trigger an update to the + // existing alert without changing the timestamp + await indexListOfDocuments([secondAnomaly, secondAnomaly]); + await patchRule(supertest, log, { id: createdRule.id, enabled: false }); + await patchRule(supertest, log, { id: createdRule.id, enabled: true }); + const secondAlerts = await getOpenAlerts( + supertest, + log, + es, + createdRule, + RuleExecutionStatusEnum.succeeded, + undefined, + new Date() + ); + + expect(secondAlerts.hits.hits).toHaveLength(1); + expect(secondAlerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: secondTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 2, // 1 of the two new anomalies was suppressed on this execution + }) + ); + }); + + describe('with anomalies spanning multiple rule execution windows', () => { + const firstTimestamp = '2020-10-28T05:45:00.000Z'; + const secondTimestamp = '2020-10-28T06:15:00.000Z'; + const thirdTimestamp = '2020-10-28T06:45:00.000Z'; + const afterThirdTimestamp = '2020-10-28T07:00:00.000Z'; + + beforeEach(async () => { + const firstAnomaly = { + ...baseAnomaly, + timestamp: firstTimestamp, + }; + const secondAnomaly = { + ...baseAnomaly, + timestamp: secondTimestamp, + }; + const thirdAnomaly = { + ...baseAnomaly, + timestamp: thirdTimestamp, + }; + + await indexListOfDocuments([ + firstAnomaly, + firstAnomaly, + secondAnomaly, + secondAnomaly, + thirdAnomaly, + ]); + }); + + it('suppresses alerts across three executions', async () => { + const rule = { ...ruleProps, interval: '30m' }; + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date(afterThirdTimestamp), + invocationCount: 3, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [TIMESTAMP]: '2020-10-28T06:00:00.000Z', + [ALERT_LAST_DETECTED]: afterThirdTimestamp, + [ALERT_START]: '2020-10-28T06:00:00.000Z', + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: thirdTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 4, // in total 4 alert got suppressed: 1 from the first run, 2 from the second, 1 from the third + }) + ); + }); + + it('suppresses alerts across multiple, sparse executions', async () => { + const fifthTimestamp = '2020-10-28T07:45:00.000Z'; + const afterFifthTimestamp = '2020-10-28T08:00:00.000Z'; + const fifthAnomaly = { ...baseAnomaly, timestamp: fifthTimestamp }; + // no anomaly for fourth execution + await indexListOfDocuments([fifthAnomaly]); + + const rule = { ...ruleProps, interval: '30m' }; + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date(afterFifthTimestamp), + invocationCount: 5, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [TIMESTAMP]: '2020-10-28T06:00:00.000Z', + [ALERT_LAST_DETECTED]: afterFifthTimestamp, + [ALERT_START]: '2020-10-28T06:00:00.000Z', + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: fifthTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 5, // in total 5 alerts were suppressed: 1 from the first run, 2 from the second, 1 from the third run, none from the fourth, and one from the fifth. + }) + ); + }); + }); + + it('suppresses alerts on multiple fields', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + 'process.name': ['auditbeat'], + }; + await indexListOfDocuments([anomaly, anomaly]); + + const rule = { + ...ruleProps, + alert_suppression: { + ...ruleProps.alert_suppression, + group_by: ['user.name', 'process.name'], + }, + }; + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date(timestamp), + invocationCount: 1, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + { + field: 'process.name', + value: ['auditbeat'], + }, + ], + [TIMESTAMP]: timestamp, + [ALERT_START]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, + }) + ); + }); + + it('suppresses alerts with missing fields, if configured to do so', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + 'host.name': ['relevant'], + }; + const anomalyWithoutSuppressionField = { + ...baseAnomaly, + timestamp, + }; + await indexListOfDocuments([anomaly, anomaly, anomalyWithoutSuppressionField]); + + const rule = { + ...ruleProps, + alert_suppression: { + ...ruleProps.alert_suppression, + group_by: ['host.name'], + }, + }; + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date(timestamp), + invocationCount: 1, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_SUPPRESSION_DOCS_COUNT], + }); + + expect(previewAlerts.length).toEqual(2); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: null, + }, + ], + [TIMESTAMP]: timestamp, + [ALERT_START]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + + expect(previewAlerts[1]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: ['relevant'], + }, + ], + [TIMESTAMP]: timestamp, + [ALERT_START]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, // the anomaly without `host.name` is not represented here + }) + ); + }); + + it('does not suppress alerts with missing fields, if not configured to do so', async () => { + const rule = { + ...ruleProps, + alert_suppression: { + ...ruleProps.alert_suppression, + group_by: ['host.name'], + missing_fields_strategy: 'doNotSuppress' as const, + }, + }; + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + 'host.name': ['relevant'], + }; + const anomalyWithoutSuppressionField = { + ...baseAnomaly, + timestamp, + 'user.name': ['irrelevant'], + }; + await indexListOfDocuments([ + anomaly, + anomaly, + anomalyWithoutSuppressionField, + anomalyWithoutSuppressionField, + ]); + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date(timestamp), + invocationCount: 1, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts.length).toEqual(3); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + 'user.name': ['irrelevant'], + [TIMESTAMP]: timestamp, + [ALERT_START]: timestamp, + }) + ); + + expect(previewAlerts[0]._source).toEqual( + expect.not.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: expect.anything(), + [ALERT_ORIGINAL_TIME]: expect.anything(), + [ALERT_SUPPRESSION_START]: expect.anything(), + [ALERT_SUPPRESSION_END]: expect.anything(), + [ALERT_SUPPRESSION_DOCS_COUNT]: expect.anything(), + }) + ); + + expect(previewAlerts[1]._source).toEqual( + expect.objectContaining({ + 'user.name': ['irrelevant'], + [TIMESTAMP]: timestamp, + [ALERT_START]: timestamp, + }) + ); + expect(previewAlerts[1]._source).toEqual( + expect.not.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: expect.anything(), + [ALERT_ORIGINAL_TIME]: expect.anything(), + [ALERT_SUPPRESSION_START]: expect.anything(), + [ALERT_SUPPRESSION_END]: expect.anything(), + [ALERT_SUPPRESSION_DOCS_COUNT]: expect.anything(), + }) + ); + + expect(previewAlerts[2]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: ['relevant'], + }, + ], + [TIMESTAMP]: timestamp, + [ALERT_START]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, // the anomaly without `host.name` is not represented here + }) + ); + }); + + it('does not suppress into a closed alert', async () => { + const firstTimestamp = new Date().toISOString(); + const firstAnomaly = { + ...baseAnomaly, + timestamp: firstTimestamp, + }; + await indexListOfDocuments([firstAnomaly]); + + const createdRule = await createRule(supertest, log, { + ...ruleProps, + from: firstTimestamp, + }); + const alerts = await getAlerts(supertest, log, es, createdRule); + + expect(alerts.hits.hits).toHaveLength(1); + const alertId = alerts.hits.hits[0]._id!; + + // close generated alert + await supertest + .post(DETECTION_ENGINE_ALERTS_STATUS_URL) + .set('kbn-xsrf', 'true') + .send(setAlertStatus({ alertIds: [alertId], status: 'closed' })) + .expect(200); + + const secondTimestamp = new Date().toISOString(); + const secondAnomaly = { + ...baseAnomaly, + timestamp: secondTimestamp, + }; + + // Add more anomalies, then disable and re-enable to trigger another + // rule run. The second anomalies should create a new alert, since the existing alert is closed. + await indexListOfDocuments([secondAnomaly, secondAnomaly]); + await patchRule(supertest, log, { id: createdRule.id, enabled: false }); + await patchRule(supertest, log, { id: createdRule.id, enabled: true }); + const secondAlerts = await getOpenAlerts( + supertest, + log, + es, + createdRule, + RuleExecutionStatusEnum.succeeded, + undefined, + new Date() + ); + + expect(secondAlerts.hits.hits).toHaveLength(1); + expect(secondAlerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_ORIGINAL_TIME]: secondTimestamp, + [ALERT_SUPPRESSION_START]: secondTimestamp, + [ALERT_SUPPRESSION_END]: secondTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, + }) + ); + }); + + it('does not suppress into an unsuppressed alert', async () => { + const firstTimestamp = new Date().toISOString(); + const firstAnomaly = { + ...baseAnomaly, + timestamp: firstTimestamp, + }; + await indexListOfDocuments([firstAnomaly]); + + const ruleWithoutSuppression = { ...ruleProps, alert_suppression: undefined }; + const createdRule = await createRule(supertest, log, { + ...ruleWithoutSuppression, + from: firstTimestamp, + }); + const alerts = await getAlerts(supertest, log, es, createdRule); + + expect(alerts.hits.hits).toHaveLength(1); + + // update the rule to include suppression + await patchRule(supertest, log, { + id: createdRule.id, + alert_suppression: ruleProps.alert_suppression, + }); + + const secondTimestamp = new Date().toISOString(); + const secondAnomaly = { + ...baseAnomaly, + timestamp: secondTimestamp, + }; + + // Add more anomalies, then disable and re-enable to trigger another + // rule run. The second anomalies should create a new suppressed alert, since the original was not suppressed. + await indexListOfDocuments([secondAnomaly, secondAnomaly, secondAnomaly]); + await patchRule(supertest, log, { id: createdRule.id, enabled: false }); + await patchRule(supertest, log, { id: createdRule.id, enabled: true }); + const secondAlerts = await getOpenAlerts( + supertest, + log, + es, + createdRule, + RuleExecutionStatusEnum.succeeded, + undefined, + new Date() + ); + + expect(secondAlerts.hits.hits).toHaveLength(2); + // assert that the first alert does not have suppression fields + expect(secondAlerts.hits.hits[0]._source).toEqual( + expect.not.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: expect.anything(), + [ALERT_ORIGINAL_TIME]: expect.anything(), + [ALERT_SUPPRESSION_START]: expect.anything(), + [ALERT_SUPPRESSION_END]: expect.anything(), + [ALERT_SUPPRESSION_DOCS_COUNT]: expect.anything(), + }) + ); + + expect(secondAlerts.hits.hits[1]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_ORIGINAL_TIME]: secondTimestamp, + [ALERT_SUPPRESSION_START]: secondTimestamp, + [ALERT_SUPPRESSION_END]: secondTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 2, + }) + ); + }); + + it('suppresses alerts that would be _created_ within the suppression duration window, even if the original anomalies were outside that suppression duration window', async () => { + const rule = { + ...ruleProps, + interval: '30m', + alert_suppression: { + ...ruleProps.alert_suppression, + duration: { + value: 1, + unit: 'm', + }, + }, + } as MachineLearningRuleCreateProps; + const firstTimestamp = '2020-10-28T06:00:00.000Z'; + const secondTimestamp = '2020-10-28T06:15:00.000Z'; + const firstAnomaly = { ...baseAnomaly, timestamp: firstTimestamp }; + const secondAnomaly = { ...baseAnomaly, timestamp: secondTimestamp }; + await indexListOfDocuments([firstAnomaly, secondAnomaly]); + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date(secondTimestamp), + invocationCount: 1, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [TIMESTAMP]: secondTimestamp, + [ALERT_LAST_DETECTED]: secondTimestamp, + [ALERT_START]: secondTimestamp, + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: secondTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, + }) + ); + }); + + it('does not suppress across multiple runs if the suppression interval is less than the rule interval ', async () => { + const rule = { + ...ruleProps, + interval: '5m', + alert_suppression: { + ...ruleProps.alert_suppression, + duration: { + value: 1, + unit: 'm', + }, + }, + } as MachineLearningRuleCreateProps; + const firstTimestamp = '2020-10-28T06:00:00.000Z'; + const secondTimestamp = '2020-10-28T06:15:00.000Z'; + const firstAnomaly = { ...baseAnomaly, timestamp: firstTimestamp }; + const secondAnomaly = { ...baseAnomaly, timestamp: secondTimestamp }; + await indexListOfDocuments([firstAnomaly, secondAnomaly]); + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date(secondTimestamp), + invocationCount: 3, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts.length).toEqual(2); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: firstTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + expect(previewAlerts[1]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_ORIGINAL_TIME]: secondTimestamp, + [ALERT_SUPPRESSION_START]: secondTimestamp, + [ALERT_SUPPRESSION_END]: secondTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + }); + + it('suppresses alerts within a single execution', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + }; + await indexListOfDocuments([anomaly, anomaly]); + + const createdRule = await createRule(supertest, log, { + ...ruleProps, + from: timestamp, + }); + + const alerts = await getAlerts(supertest, log, es, createdRule); + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, + }) + ); + }); + + it('deduplicates previously suppressed alerts if rule has overlapping execution windows', async () => { + const firstTimestamp = new Date().toISOString(); + const firstAnomaly = { + ...baseAnomaly, + timestamp: firstTimestamp, + }; + await indexListOfDocuments([firstAnomaly]); + + const createdRule = await createRule(supertest, log, { + ...ruleProps, + from: firstTimestamp, + }); + const alerts = await getAlerts(supertest, log, es, createdRule); + + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + // suppression boundaries equal to original event time, since no alert been suppressed + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: firstTimestamp, + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }) + ); + + const secondTimestamp = new Date().toISOString(); + const secondAnomaly = { + ...baseAnomaly, + timestamp: secondTimestamp, + }; + + // Add more anomalies, then disable and re-enable to trigger another + // rule run. The second anomaly should trigger an update to the + // existing alert without changing the timestamp + await indexListOfDocuments([secondAnomaly, secondAnomaly]); + await patchRule(supertest, log, { id: createdRule.id, enabled: false }); + await patchRule(supertest, log, { id: createdRule.id, enabled: true }); + const secondAlerts = await getOpenAlerts( + supertest, + log, + es, + createdRule, + RuleExecutionStatusEnum.succeeded, + undefined, + new Date() + ); + + expect(secondAlerts.hits.hits).toHaveLength(1); + expect(secondAlerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [ALERT_ORIGINAL_TIME]: firstTimestamp, + [ALERT_SUPPRESSION_START]: firstTimestamp, + [ALERT_SUPPRESSION_END]: secondTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 2, // both new anomalies were suppressed into the original + }) + ); + }); + + it('suppresses alerts with array field values', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + 'user.name': ['host1', 'host2'], + timestamp, + }; + await indexListOfDocuments([anomaly, anomaly]); + + const createdRule = await createRule(supertest, log, { + ...ruleProps, + from: timestamp, + }); + + const alerts = await getAlerts(supertest, log, es, createdRule); + expect(alerts.hits.hits).toHaveLength(1); + expect(alerts.hits.hits[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['host1', 'host2'], + }, + ], + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, + }) + ); + }); + + describe('with exceptions', () => { + beforeEach(async () => { + await deleteAllExceptions(supertest, log); + }); + + it('applies exceptions before suppression', async () => { + const timestamp = new Date().toISOString(); + const anomaly = { + ...baseAnomaly, + timestamp, + }; + const anomalyWithExceptionField = { + ...anomaly, + 'process.name': ['auditbeat'], + }; + await indexListOfDocuments([anomaly, anomalyWithExceptionField]); + + const { previewId } = await previewRuleWithExceptionEntries({ + supertest, + rule: ruleProps, + log, + timeframeEnd: new Date(timestamp), + entries: [ + [ + { + field: 'process.name', + operator: 'included', + type: 'match', + value: 'auditbeat', + }, + ], + ], + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual( + expect.objectContaining({ + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'user.name', + value: ['root'], + }, + ], + [TIMESTAMP]: timestamp, + [ALERT_START]: timestamp, + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: timestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, // the anomaly with the exception field was not suppressed but omitted due to the exception + }) + ); + }); + }); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/machine_learning/machine_learning_setup.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/machine_learning/machine_learning_setup.ts index a9b9bf1c8ce5..fa0c6fa4f78b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/machine_learning/machine_learning_setup.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/machine_learning/machine_learning_setup.ts @@ -6,6 +6,7 @@ */ import type SuperTest from 'supertest'; +import { ML_GROUP_ID } from '@kbn/security-solution-plugin/common/constants'; import { getCommonRequestHeader } from '../../../../../functional/services/ml/common_api'; export const executeSetupModuleRequest = async ({ @@ -22,7 +23,7 @@ export const executeSetupModuleRequest = async ({ .set(getCommonRequestHeader('1')) .send({ prefix: '', - groups: ['auditbeat'], + groups: [ML_GROUP_ID], indexPatternName: 'auditbeat-*', startDatafeed: false, useDedicatedIndex: true, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/remove_server_generated_properties_including_rule_id.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/remove_server_generated_properties_including_rule_id.ts index 1b57b5663ec2..176ce575a645 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/remove_server_generated_properties_including_rule_id.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/remove_server_generated_properties_including_rule_id.ts @@ -7,7 +7,10 @@ import type { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine'; -import { removeServerGeneratedProperties } from './remove_server_generated_properties'; +import { + removeServerGeneratedProperties, + type RuleWithoutServerGeneratedProperties, +} from './remove_server_generated_properties'; /** * This will remove server generated properties such as date times, etc... including the rule_id @@ -15,9 +18,8 @@ import { removeServerGeneratedProperties } from './remove_server_generated_prope */ export const removeServerGeneratedPropertiesIncludingRuleId = ( rule: RuleResponse -): Partial<RuleResponse> => { +): Omit<RuleWithoutServerGeneratedProperties, 'rule_id'> => { const ruleWithRemovedProperties = removeServerGeneratedProperties(rule); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { rule_id, ...additionalRuledIdRemoved } = ruleWithRemovedProperties; + const { rule_id: _, ...additionalRuledIdRemoved } = ruleWithRemovedProperties; return additionalRuledIdRemoved; }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts index 2f7b7d44898c..581af2aec601 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts @@ -76,7 +76,7 @@ export default ({ getService }: FtrProviderContext): void => { }); }; - describe('@ess @serverless Risk Scoring Entity Calculation API', () => { + describe('@ess @serverless @serverlessQA Risk Scoring Entity Calculation API', () => { before(async () => { enableAssetCriticalityAdvancedSetting(kibanaServer, log); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts index dfd7efe8d658..b2b72cc5a4b3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts @@ -566,5 +566,16 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }); + + it('does not return an 404 when the data_view_id is an non existent index', async () => { + const { scores } = await previewRiskScores({ + body: { data_view_id: 'invalid-index' }, + }); + + expect(scores).to.eql({ + host: [], + user: [], + }); + }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/configs/serverless.config.ts index b7d273add372..a5d28b90c8dc 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/configs/serverless.config.ts @@ -11,13 +11,13 @@ export default createTestConfig({ kbnTestServerArgs: [ `--xpack.securitySolution.enableExperimental=${JSON.stringify([])}`, `--xpack.securitySolutionServerless.productTypes=${JSON.stringify([ - { product_line: 'security', product_tier: 'essentials' }, - { product_line: 'endpoint', product_tier: 'essentials' }, - { product_line: 'cloud', product_tier: 'essentials' }, + { product_line: 'security', product_tier: 'complete' }, + { product_line: 'endpoint', product_tier: 'complete' }, + { product_line: 'cloud', product_tier: 'complete' }, ])}`, ], testFiles: [require.resolve('..')], junit: { - reportName: 'Saved Objects Integration Tests - Serverless Env - Essentials Tier', + reportName: 'Saved Objects Integration Tests - Serverless Env - Complete Tier', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/artifact_entries_list.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/artifact_entries_list.ts index cd294c8a3357..1c184502244c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/artifact_entries_list.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/artifact_entries_list.ts @@ -29,7 +29,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'artifactEntriesList']); const testSubjects = getService('testSubjects'); const browser = getService('browser'); - const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + const endpointArtifactsTestResources = getService('endpointArtifactTestResources'); const endpointTestResources = getService('endpointTestResources'); const retry = getService('retry'); const esClient = getService('es'); @@ -51,8 +51,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { .delete(`${EXCEPTION_LIST_URL}?list_id=${listId}&namespace_type=agnostic`) .set('kbn-xsrf', 'true'); }; - // It's flaky only in Serverless - // Failing: See https://github.com/elastic/kibana/issues/186004 + + // Failing: See https://github.com/elastic/kibana/issues/187314 + // Failing: See https://github.com/elastic/kibana/issues/187383 describe.skip('@ess @serverless For each artifact list under management', function () { let indexedData: IndexedHostsAndAlertsResponse; let policyInfo: PolicyTestResourceInfo; @@ -73,13 +74,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Check edited artifact is in the list with new values (wait for list to be updated) let updatedArtifact: ArtifactElasticsearchProperties | undefined; await retry.waitForWithTimeout('fleet artifact is updated', 120_000, async () => { - const artifacts = await endpointArtifactTestResources.getArtifacts(); + const artifacts = await endpointArtifactsTestResources.getArtifactsFromUnifiedManifestSO(); + // This expects manifest artifact to come from unified so const manifestArtifact = artifacts.find((artifact) => { return ( - artifact.artifactId === - `${expectedArtifact.identifier}-${expectedArtifact.decoded_sha256}` && - artifact.policyId === policy?.packagePolicy.id + artifact.artifactIds.includes( + `${expectedArtifact.identifier}-${expectedArtifact.decoded_sha256}` + ) && artifact.policyId === policy?.packagePolicy.id ); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/endpoint_exceptions.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/endpoint_exceptions.ts index a36db81c37de..e85325f63d09 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/endpoint_exceptions.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/endpoint_exceptions.ts @@ -99,18 +99,18 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const checkArtifact = (expectedArtifact: object) => { return retry.tryForTime(2 * MINUTES, async () => { - const artifacts = await endpointArtifactTestResources.getArtifacts(); + const artifacts = await endpointArtifactTestResources.getArtifactsFromUnifiedManifestSO(); - const manifestArtifact = artifacts.find((artifact) => - artifact.artifactId.startsWith('endpoint-exceptionlist-macos-v1') - ); + const foundArtifactId = artifacts + .flatMap((artifact) => artifact.artifactIds) + .find((artifactId) => artifactId.startsWith('endpoint-exceptionlist-macos-v1')); - expect(manifestArtifact).to.not.be(undefined); + expect(foundArtifactId).to.not.be(undefined); // Get fleet artifact const artifactResult = await esClient.get({ index: '.fleet-artifacts-7', - id: `endpoint:${manifestArtifact!.artifactId}`, + id: `endpoint:${foundArtifactId!}`, }); const artifact = artifactResult._source as ArtifactElasticsearchProperties; diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/mocks.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/mocks.ts index 90d0439f520d..47523694b434 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/mocks.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations/mocks.ts @@ -7,7 +7,7 @@ import { FullAgentPolicy } from '@kbn/fleet-plugin/common/types'; import { ArtifactElasticsearchProperties } from '@kbn/fleet-plugin/server/services/artifacts/types'; -import { InternalManifestSchema } from '@kbn/security-solution-plugin/server/endpoint/schemas/artifacts'; +import { InternalUnifiedManifestBaseSchema } from '@kbn/security-solution-plugin/server/endpoint/schemas/artifacts'; import { TranslatedExceptionListItem } from '@kbn/security-solution-plugin/server/endpoint/schemas/artifacts/lists'; export interface AgentPolicyResponseType { @@ -17,12 +17,12 @@ export interface AgentPolicyResponseType { _source: { data: FullAgentPolicy }; } -export interface InternalManifestSchemaResponseType { +export interface InternalUnifiedManifestSchemaResponseType { _index: string; _id: string; _score: number; _source: { - 'endpoint:user-artifact-manifest': InternalManifestSchema; + 'endpoint:unified-user-artifact-manifest': InternalUnifiedManifestBaseSchema; }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/artifact_entries_list.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/artifact_entries_list.ts deleted file mode 100644 index c7e442e15dd9..000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/artifact_entries_list.ts +++ /dev/null @@ -1,373 +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 { unzip } from 'zlib'; -import { promisify } from 'util'; -import expect from '@kbn/expect'; -import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; -import { - ENDPOINT_ARTIFACT_LIST_IDS, - EXCEPTION_LIST_URL, -} from '@kbn/securitysolution-list-constants'; -import { ArtifactElasticsearchProperties } from '@kbn/fleet-plugin/server/services'; -import { FtrProviderContext } from '../../configs/ftr_provider_context'; -import { - ArtifactBodyType, - getArtifactsListTestsData, - ArtifactActionsType, - AgentPolicyResponseType, - getCreateMultipleData, - MultipleArtifactActionsType, -} from './mocks'; -import { PolicyTestResourceInfo } from '../../services/endpoint_policy'; - -export default ({ getPageObjects, getService }: FtrProviderContext) => { - const pageObjects = getPageObjects(['common', 'artifactEntriesList']); - const testSubjects = getService('testSubjects'); - const browser = getService('browser'); - const endpointArtifactsTestResources = getService('endpointArtifactTestResources'); - const endpointTestResources = getService('endpointTestResources'); - const retry = getService('retry'); - const esClient = getService('es'); - const supertest = getService('supertest'); - const find = getService('find'); - const toasts = getService('toasts'); - const policyTestResources = getService('policyTestResources'); - const unzipPromisify = promisify(unzip); - - const removeAllArtifacts = async () => { - for (const listId of ENDPOINT_ARTIFACT_LIST_IDS) { - await removeExceptionsList(listId); - } - }; - - const removeExceptionsList = async (listId: string) => { - await supertest - .delete(`${EXCEPTION_LIST_URL}?list_id=${listId}&namespace_type=agnostic`) - .set('kbn-xsrf', 'true'); - }; - - // Failing: See https://github.com/elastic/kibana/issues/183860 - describe.skip('@ess @serverless For each artifact list under management', function () { - let indexedData: IndexedHostsAndAlertsResponse; - let policyInfo: PolicyTestResourceInfo; - - before(async () => { - indexedData = await endpointTestResources.loadEndpointData(); - }); - after(async () => { - await endpointTestResources.unloadEndpointData(indexedData); - }); - - const checkFleetArtifacts = async ( - identifier: string, - expectedArtifact: ArtifactElasticsearchProperties, - expectedDecodedBodyArtifact: ArtifactBodyType, - policy?: PolicyTestResourceInfo - ) => { - // Check edited artifact is in the list with new values (wait for list to be updated) - let updatedArtifact: ArtifactElasticsearchProperties | undefined; - await retry.waitForWithTimeout('fleet artifact is updated', 120_000, async () => { - const artifacts = await endpointArtifactsTestResources.getArtifactsFromUnifiedManifestSO(); - - // This expects manifest artifact to come from unified so - const manifestArtifact = artifacts.find((artifact) => { - return ( - artifact.artifactIds.includes( - `${expectedArtifact.identifier}-${expectedArtifact.decoded_sha256}` - ) && artifact.policyId === policy?.packagePolicy.id - ); - }); - - if (!manifestArtifact) return false; - - // Get fleet artifact - const windowsArtifactResult = await esClient.get({ - index: '.fleet-artifacts-7', - id: `endpoint:${expectedArtifact.identifier}-${expectedArtifact.decoded_sha256}`, - }); - - const windowsArtifact = windowsArtifactResult._source as ArtifactElasticsearchProperties; - - // Get agent policy - const { - hits: { hits: policiesResults }, - } = await esClient.search({ - index: '.fleet-policies*', - query: { - bool: { - filter: [ - { - match: { - policy_id: policy?.agentPolicy.id, - }, - }, - ], - }, - }, - sort: [{ revision_idx: { order: 'desc' } }], - size: 1, - }); - - const agentPolicyResults = policiesResults[0] as AgentPolicyResponseType; - const policyArtifactManifest = agentPolicyResults._source.data.inputs[0] - ? agentPolicyResults._source.data.inputs[0].artifact_manifest - : undefined; - - let isUpdated: boolean = false; - if (policyArtifactManifest) { - // Compare artifacts from fleet artifacts and agent policy are the expecteds - isUpdated = - windowsArtifact.encoded_sha256 === expectedArtifact.encoded_sha256 && - policyArtifactManifest.artifacts[identifier].encoded_sha256 === - expectedArtifact.encoded_sha256; - } - - if (isUpdated) updatedArtifact = windowsArtifact; - return isUpdated; - }); - - updatedArtifact!.created = expectedArtifact.created; - const bodyFormBuffer = Buffer.from(updatedArtifact!.body, 'base64'); - const unzippedBody = await unzipPromisify(bodyFormBuffer); - - // Check decoded body first to detect possible body changes - expect(JSON.parse(unzippedBody.toString())).eql(expectedDecodedBodyArtifact); - expect(updatedArtifact).eql(expectedArtifact); - }; - - const performActions = async ( - actions: - | ArtifactActionsType['create']['formFields'] - | ArtifactActionsType['update']['formFields'], - suffix?: string - ) => { - for (const formAction of actions) { - if (formAction.type === 'customClick') { - await find.clickByCssSelector(formAction.selector, testSubjects.FIND_TIME); - } else if (formAction.type === 'click') { - await testSubjects.click(formAction.selector); - } else if (formAction.type === 'input') { - await testSubjects.setValue( - formAction.selector, - (formAction.value || '') + (suffix ? suffix : '') - ); - } else if (formAction.type === 'clear') { - await ( - await (await testSubjects.find(formAction.selector)).findByCssSelector('button') - ).click(); - } - } - }; - - const deleteArtifact = async (actions: ArtifactActionsType) => { - await pageObjects.artifactEntriesList.clickCardActionMenu(actions.pagePrefix); - await testSubjects.click(`${actions.pagePrefix}-card-cardDeleteAction`); - await testSubjects.click(`${actions.pagePrefix}-deleteModal-submitButton`); - await testSubjects.waitForDeleted(actions.delete.confirmSelector); - }; - - const createArtifact = async ( - actions: ArtifactActionsType | MultipleArtifactActionsType, - options?: { policyId?: string; suffix?: string; createButton?: string } - ) => { - // Opens add flyout - if (options?.createButton) { - await testSubjects.click(`${actions.pagePrefix}-${options.createButton}`); - } else { - await testSubjects.click(`${actions.pagePrefix}-emptyState-addButton`); - } - - await performActions(actions.create.formFields, options?.suffix); - - if (options?.policyId) { - await testSubjects.click(`${actions.pageObject}-form-effectedPolicies-perPolicy`); - await testSubjects.click(`policy-${options.policyId}-checkbox`); - } - - // Submit create artifact form - await testSubjects.click(`${actions.pagePrefix}-flyout-submitButton`); - }; - - const updateArtifact = async ( - actions: ArtifactActionsType, - options?: { policyId?: string; suffix?: string } - ) => { - // Opens edit flyout - await pageObjects.artifactEntriesList.clickCardActionMenu(actions.pagePrefix); - await testSubjects.click(`${actions.pagePrefix}-card-cardEditAction`); - - await performActions(actions.update.formFields); - - if (options?.policyId) { - await testSubjects.click(`${actions.pageObject}-form-effectedPolicies-perPolicy`); - await testSubjects.click(`policy-${options.policyId}-checkbox`); - } - - // Submit edit artifact form - await testSubjects.click(`${actions.pagePrefix}-flyout-submitButton`); - }; - - for (const testData of getArtifactsListTestsData()) { - describe(`When on the ${testData.title} entries list`, function () { - beforeEach(async () => { - policyInfo = await policyTestResources.createPolicy(); - await removeAllArtifacts(); - await browser.refresh(); - await pageObjects.artifactEntriesList.navigateToList(testData.urlPath); - }); - - afterEach(async () => { - await removeAllArtifacts(); - if (policyInfo) { - await policyInfo.cleanup(); - } - }); - - it(`should not show page title if there is no ${testData.title} entry`, async () => { - await testSubjects.missingOrFail('header-page-title'); - }); - - it(`should be able to add a new ${testData.title} entry`, async () => { - await createArtifact(testData, { policyId: policyInfo.packagePolicy.id }); - // Check new artifact is in the list - for (const checkResult of testData.create.checkResults) { - expect(await testSubjects.getVisibleText(checkResult.selector)).to.equal( - checkResult.value - ); - } - await toasts.dismiss(); - - // Title is shown after adding an item - expect(await testSubjects.getVisibleText('header-page-title')).to.equal(testData.title); - - // Checks if fleet artifact has been updated correctly - await checkFleetArtifacts( - testData.fleetArtifact.identifier, - testData.fleetArtifact.getExpectedUpdatedtArtifactWhenCreate(), - testData.fleetArtifact.getExpectedUpdatedArtifactBodyWhenCreate(), - policyInfo - ); - }); - - it(`should be able to update an existing ${testData.title} entry`, async () => { - await createArtifact(testData); - await updateArtifact(testData, { policyId: policyInfo.packagePolicy.id }); - - // Check edited artifact is in the list with new values (wait for list to be updated) - await retry.waitForWithTimeout('entry is updated in list', 20000, async () => { - const currentValue = await testSubjects.getVisibleText( - `${testData.pagePrefix}-card-criteriaConditions${ - testData.pagePrefix === 'EventFiltersListPage' ? '-condition' : '' - }` - ); - return currentValue === testData.update.waitForValue; - }); - - for (const checkResult of testData.update.checkResults) { - expect(await testSubjects.getVisibleText(checkResult.selector)).to.equal( - checkResult.value - ); - } - - await toasts.dismiss(); - - // Title still shown after editing an item - expect(await testSubjects.getVisibleText('header-page-title')).to.equal(testData.title); - - // Checks if fleet artifact has been updated correctly - await checkFleetArtifacts( - testData.fleetArtifact.identifier, - testData.fleetArtifact.getExpectedUpdatedArtifactWhenUpdate(), - testData.fleetArtifact.getExpectedUpdatedArtifactBodyWhenUpdate(), - policyInfo - ); - }); - - it(`should be able to delete the existing ${testData.title} entry`, async () => { - await createArtifact(testData); - await deleteArtifact(testData); - // We only expect one artifact to have been visible - await testSubjects.missingOrFail(testData.delete.card); - // Header has gone because there is no artifact - await testSubjects.missingOrFail('header-page-title'); - }); - }); - } - - describe('Should check artifacts are correctly generated when multiple entries', function () { - let firstPolicy: PolicyTestResourceInfo; - let secondPolicy: PolicyTestResourceInfo; - - const firstSuffix = 'first'; - const secondSuffix = 'second'; - const thirdSuffix = 'third'; - - beforeEach(async () => { - firstPolicy = await policyTestResources.createPolicy(); - secondPolicy = await policyTestResources.createPolicy(); - await removeAllArtifacts(); - await browser.refresh(); - await pageObjects.artifactEntriesList.navigateToList(testData.urlPath); - }); - - afterEach(async () => { - await removeAllArtifacts(); - if (firstPolicy) { - await firstPolicy.cleanup(); - } - if (secondPolicy) { - await secondPolicy.cleanup(); - } - }); - - const testData = getCreateMultipleData(); - it(`should get correct atifact when multiple entries are created`, async () => { - // Create first trusted app - await createArtifact(testData, { - policyId: firstPolicy.packagePolicy.id, - suffix: firstSuffix, - }); - await toasts.dismiss(); - - // Create second trusted app - await createArtifact(testData, { - policyId: secondPolicy.packagePolicy.id, - suffix: secondSuffix, - createButton: 'pageAddButton', - }); - await toasts.dismiss(); - - // Create third trusted app - await createArtifact(testData, { suffix: thirdSuffix, createButton: 'pageAddButton' }); - await toasts.dismiss(); - - // Checks if fleet artifact has been updated correctly - await checkFleetArtifacts( - testData.fleetArtifact.identifier, - testData.fleetArtifact.getExpectedUpdatedArtifactWhenCreateMultipleFirst(), - testData.fleetArtifact.getExpectedUpdatedArtifactBodyWhenCreateMultipleFirst( - thirdSuffix, - firstSuffix - ), - firstPolicy - ); - - // Checks if fleet artifact has been updated correctly - await checkFleetArtifacts( - testData.fleetArtifact.identifier, - testData.fleetArtifact.getExpectedUpdatedArtifactWhenCreateMultipleSecond(), - testData.fleetArtifact.getExpectedUpdatedArtifactBodyWhenCreateMultipleSecond( - thirdSuffix, - secondSuffix - ), - secondPolicy - ); - }); - }); - }); -}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/endpoint_exceptions.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/endpoint_exceptions.ts deleted file mode 100644 index e6ffdb159813..000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/endpoint_exceptions.ts +++ /dev/null @@ -1,250 +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 { unzip } from 'zlib'; -import { promisify } from 'util'; -import expect from '@kbn/expect'; -import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; -import { EXCEPTION_LIST_ITEM_URL } from '@kbn/securitysolution-list-constants'; -import { ArtifactElasticsearchProperties } from '@kbn/fleet-plugin/server/services'; -import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; -import { FtrProviderContext } from '../../configs/ftr_provider_context'; - -export default ({ getPageObjects, getService }: FtrProviderContext) => { - const pageObjects = getPageObjects(['common', 'header']); - const queryBar = getService('queryBar'); - const testSubjects = getService('testSubjects'); - const endpointTestResources = getService('endpointTestResources'); - const endpointArtifactTestResources = getService('endpointArtifactTestResources'); - const retry = getService('retry'); - const esClient = getService('es'); - const supertest = getService('supertest'); - const find = getService('find'); - const unzipPromisify = promisify(unzip); - const comboBox = getService('comboBox'); - const toasts = getService('toasts'); - const timeout = 60000 * 10; - - // Failing: See https://github.com/elastic/kibana/issues/184585 - describe.skip('@ess @serverless Endpoint Exceptions', function () { - const clearPrefilledEntries = async () => { - const entriesContainer = await testSubjects.find('exceptionEntriesContainer'); - - let deleteButtons: WebElementWrapper[]; - do { - deleteButtons = await testSubjects.findAllDescendant( - 'builderItemEntryDeleteButton', - entriesContainer - ); - - await deleteButtons[0].click(); - } while (deleteButtons.length > 1); - }; - - const openNewEndpointExceptionFlyout = async () => { - await testSubjects.click('timeline-context-menu-button'); - await testSubjects.click('add-endpoint-exception-menu-item'); - await testSubjects.existOrFail('addExceptionFlyout'); - - await retry.waitFor('entries should be loaded', () => - testSubjects.exists('exceptionItemEntryContainer') - ); - }; - - const setLastFieldsValue = async ({ - testSubj, - value, - }: { - testSubj: string; - value: string; - optionSelector?: string; - }) => { - const fields = await find.allByCssSelector(`[data-test-subj="${testSubj}"]`); - - const lastField = fields[fields.length - 1]; - await lastField.click(); - - await retry.try( - async () => { - await comboBox.setElement(lastField, value); - }, - async () => { - // If the above fails due to an option not existing, create the value custom instead - await comboBox.setFilterValue(lastField, value); - await pageObjects.common.pressEnterKey(); - } - ); - }; - - const setLastEntry = async ({ - field, - operator, - value, - }: { - field: string; - operator: 'matches' | 'is'; - value: string; - }) => { - await setLastFieldsValue({ testSubj: 'fieldAutocompleteComboBox', value: field }); - await setLastFieldsValue({ testSubj: 'operatorAutocompleteComboBox', value: operator }); - await setLastFieldsValue({ - testSubj: operator === 'matches' ? 'valuesAutocompleteWildcard' : 'valuesAutocompleteMatch', - value, - }); - }; - - const checkArtifact = (expectedArtifact: object) => { - return retry.tryForTime(120_000, async () => { - const artifacts = await endpointArtifactTestResources.getArtifactsFromUnifiedManifestSO(); - - const foundArtifactId = artifacts - .flatMap((artifact) => artifact.artifactIds) - .find((artifactId) => artifactId.startsWith('endpoint-exceptionlist-macos-v1')); - - expect(foundArtifactId).to.not.be(undefined); - - // Get fleet artifact - const artifactResult = await esClient.get({ - index: '.fleet-artifacts-7', - id: `endpoint:${foundArtifactId!}`, - }); - - const artifact = artifactResult._source as ArtifactElasticsearchProperties; - - const zippedBody = Buffer.from(artifact.body, 'base64'); - const artifactBody = await unzipPromisify(zippedBody); - - expect(JSON.parse(artifactBody.toString())).to.eql(expectedArtifact); - }); - }; - - let indexedData: IndexedHostsAndAlertsResponse; - before(async () => { - indexedData = await endpointTestResources.loadEndpointData(); - - const waitForAlertsToAppear = async () => { - await pageObjects.common.navigateToUrlWithBrowserHistory('security', `/alerts`); - await pageObjects.header.waitUntilLoadingHasFinished(); - await retry.waitForWithTimeout('alerts to appear', 10 * 60_000, async () => { - await queryBar.clickQuerySubmitButton(); - return testSubjects.exists('timeline-context-menu-button'); - }); - }; - - await waitForAlertsToAppear(); - }); - - after(async () => { - await endpointTestResources.unloadEndpointData(indexedData); - }); - - beforeEach(async () => { - const deleteEndpointExceptions = async () => { - const { body } = await supertest - .get(`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=endpoint_list&namespace_type=agnostic`) - .set('kbn-xsrf', 'true'); - - for (const exceptionListItem of (body as FoundExceptionListItemSchema).data) { - await supertest - .delete(`${EXCEPTION_LIST_ITEM_URL}?id=${exceptionListItem.id}&namespace_type=agnostic`) - .set('kbn-xsrf', 'true'); - } - }; - - await deleteEndpointExceptions(); - }, timeout); - - it( - 'should add `event.module=endpoint` to entry if only wildcard operator is present', - async () => { - await pageObjects.common.navigateToUrlWithBrowserHistory('security', `/alerts`); - - await openNewEndpointExceptionFlyout(); - await clearPrefilledEntries(); - - await testSubjects.setValue('exceptionFlyoutNameInput', 'test exception'); - await setLastEntry({ field: 'file.path', operator: 'matches', value: '*/cheese/*' }); - await testSubjects.click('exceptionsAndButton'); - await setLastEntry({ field: 'process.executable', operator: 'matches', value: 'ex*' }); - - await testSubjects.click('addExceptionConfirmButton'); - await toasts.dismiss(); - - await checkArtifact({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'file.path', - operator: 'included', - type: 'wildcard_cased', - value: '*/cheese/*', - }, - { - field: 'process.executable', - operator: 'included', - type: 'wildcard_cased', - value: 'ex*', - }, - { - // this additional entry should be added - field: 'event.module', - operator: 'included', - type: 'exact_cased', - value: 'endpoint', - }, - ], - }, - ], - }); - }, - timeout - ); - - it( - 'should NOT add `event.module=endpoint` to entry if there is another operator', - async () => { - await pageObjects.common.navigateToUrlWithBrowserHistory('security', `/alerts`); - - await openNewEndpointExceptionFlyout(); - await clearPrefilledEntries(); - - await testSubjects.setValue('exceptionFlyoutNameInput', 'test exception'); - await setLastEntry({ field: 'file.path', operator: 'matches', value: '*/cheese/*' }); - await testSubjects.click('exceptionsAndButton'); - await setLastEntry({ field: 'process.executable', operator: 'is', value: 'something' }); - - await testSubjects.click('addExceptionConfirmButton'); - await toasts.dismiss(); - - await checkArtifact({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'file.path', - operator: 'included', - type: 'wildcard_cased', - value: '*/cheese/*', - }, - { - field: 'process.executable', - operator: 'included', - type: 'exact_cased', - value: 'something', - }, - ], - }, - ], - }); - }, - timeout - ); - }); -}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/index.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/index.ts deleted file mode 100644 index 2a6eb2a6d357..000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/index.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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getRegistryUrl as getRegistryUrlFromIngest } from '@kbn/fleet-plugin/server'; -import { isServerlessKibanaFlavor } from '@kbn/security-solution-plugin/scripts/endpoint/common/stack_services'; -import { FtrProviderContext } from '../../configs/ftr_provider_context'; -import { - getRegistryUrlFromTestEnv, - isRegistryEnabled, -} from '../../../security_solution_endpoint_api_int/registry'; - -export default function (providerContext: FtrProviderContext) { - const { loadTestFile, getService, getPageObjects } = providerContext; - - describe('endpoint', function () { - const ingestManager = getService('ingestManager'); - const log = getService('log'); - const endpointTestResources = getService('endpointTestResources'); - const kbnClient = getService('kibanaServer'); - - if (!isRegistryEnabled()) { - log.warning('These tests are being run with an external package registry'); - } - - const registryUrl = getRegistryUrlFromTestEnv() ?? getRegistryUrlFromIngest(); - log.info(`Package registry URL for tests: ${registryUrl}`); - - before(async () => { - log.info('calling Fleet setup'); - await ingestManager.setup(); - - log.info('installing/upgrading Endpoint fleet package'); - await endpointTestResources.installOrUpgradeEndpointFleetPackage(); - - if (await isServerlessKibanaFlavor(kbnClient)) { - log.info('login for serverless environment'); - const pageObjects = getPageObjects(['svlCommonPage']); - await pageObjects.svlCommonPage.login(); - } - }); - loadTestFile(require.resolve('./artifact_entries_list')); - loadTestFile(require.resolve('./endpoint_exceptions')); - }); -} diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/mocks.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/mocks.ts deleted file mode 100644 index 47523694b434..000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/apps/integrations_feature_flag/mocks.ts +++ /dev/null @@ -1,807 +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 { FullAgentPolicy } from '@kbn/fleet-plugin/common/types'; -import { ArtifactElasticsearchProperties } from '@kbn/fleet-plugin/server/services/artifacts/types'; -import { InternalUnifiedManifestBaseSchema } from '@kbn/security-solution-plugin/server/endpoint/schemas/artifacts'; -import { TranslatedExceptionListItem } from '@kbn/security-solution-plugin/server/endpoint/schemas/artifacts/lists'; - -export interface AgentPolicyResponseType { - _index: string; - _id: string; - _score: number; - _source: { data: FullAgentPolicy }; -} - -export interface InternalUnifiedManifestSchemaResponseType { - _index: string; - _id: string; - _score: number; - _source: { - 'endpoint:unified-user-artifact-manifest': InternalUnifiedManifestBaseSchema; - }; -} - -export interface ArtifactBodyType { - entries: TranslatedExceptionListItem[]; -} - -export type ArtifactActionsType = ReturnType<typeof getArtifactsListTestsData>[0]; -export type MultipleArtifactActionsType = ReturnType<typeof getCreateMultipleData>; - -export const getArtifactsListTestsData = () => [ - { - title: 'Trusted applications', - pagePrefix: 'trustedAppsListPage', - create: { - formFields: [ - { - type: 'input', - selector: 'trustedApps-form-descriptionField', - value: 'This is the trusted application description', - }, - { - type: 'input', - selector: 'trustedApps-form-nameTextField', - value: 'Trusted application name', - }, - { - type: 'click', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-field', - }, - { - type: 'click', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-field-type-Hash', - }, - { - type: 'input', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-value', - value: 'A4370C0CF81686C0B696FA6261c9d3e0d810ae704ab8301839dffd5d5112f476', - }, - ], - checkResults: [ - { - selector: 'trustedAppsListPage-card-criteriaConditions', - value: - 'OSIS Windows\nAND process.hash.*IS a4370c0cf81686c0b696fa6261c9d3e0d810ae704ab8301839dffd5d5112f476', - }, - ], - }, - update: { - formFields: [ - { - type: 'input', - selector: 'trustedApps-form-descriptionField', - value: 'This is the trusted application description edited', - }, - { - type: 'input', - selector: 'trustedApps-form-nameTextField', - value: 'Trusted application name edited', - }, - { - type: 'click', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-field', - }, - { - type: 'click', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-field-type-Path', - }, - { - type: 'input', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-value', - value: 'c:\\randomFolder\\randomFile.exe, c:\\randomFolder\\randomFile2.exe', - }, - ], - checkResults: [ - { - selector: 'trustedAppsListPage-card-criteriaConditions', - value: - 'OSIS Windows\nAND process.executable.caselessIS c:\\randomFolder\\randomFile.exe, c:\\randomFolder\\randomFile2.exe', - }, - { - selector: 'trustedAppsListPage-card-header-title', - value: 'Trusted application name edited', - }, - { - selector: 'trustedAppsListPage-card-description', - value: 'This is the trusted application description edited', - }, - ], - waitForValue: - 'OSIS Windows\nAND process.executable.caselessIS c:\\randomFolder\\randomFile.exe, c:\\randomFolder\\randomFile2.exe', - }, - delete: { - confirmSelector: 'trustedAppsListPage-deleteModal-submitButton', - card: 'trustedAppsListPage-card', - }, - urlPath: 'trusted_apps', - pageObject: 'trustedApps', - fleetArtifact: { - identifier: 'endpoint-trustlist-windows-v1', - type: 'trustedApplications', - getExpectedUpdatedtArtifactWhenCreate: (): ArtifactElasticsearchProperties => ({ - type: 'trustlist', - identifier: 'endpoint-trustlist-windows-v1', - body: 'eJxVzNEKgyAUgOF3OdcxNMvMVxkxTp4jCa5EbWxE7z422MVuvx/+A3itOXABez2gvhKDhRLuKTI0f80HjgQWUt4cl3JZsCyXsmDba2hgS5yxbhkshNXFnZig+f34ia7eHJYvPjDuH8VODcIJ543URjsx61F71K2WbiTFgowUyIPocDZKSKNG8p566qVsfTdoOKdzOt89hz0Q', - package_name: 'endpoint', - created: '2000-01-01T00:00:00.000Z', - relative_url: - '/api/fleet/artifacts/endpoint-trustlist-windows-v1/016bec11c5b1d6f8609fd3525202aa12baf0132484abf368d5011100d5ec1ec4', - compression_algorithm: 'zlib', - decoded_size: 193, - decoded_sha256: '016bec11c5b1d6f8609fd3525202aa12baf0132484abf368d5011100d5ec1ec4', - encryption_algorithm: 'none', - encoded_sha256: '814aabc04d674ccdeb7c1acfe74120cb52ad1392d6924a7d813e08f8b6cd0f0f', - encoded_size: 153, - }), - getExpectedUpdatedArtifactBodyWhenCreate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'process.hash.sha256', - operator: 'included', - type: 'exact_cased', - value: 'a4370c0cf81686c0b696fa6261c9d3e0d810ae704ab8301839dffd5d5112f476', - }, - ], - }, - ], - }), - getExpectedUpdatedArtifactWhenUpdate: (): ArtifactElasticsearchProperties => ({ - type: 'trustlist', - identifier: 'endpoint-trustlist-windows-v1', - body: 'eJx9jEEKwjAUBa8ibx1cuMwBvIQtEpMnBH6TkJ9KpeTuEkHBjcthhtnB1Gqkwl52tGchLDQuRQjz4+6REmBRavZUPXKjX5u7vcNcWF3LFRYxeVkDA8xnx835dvVOKVSFwcPJOoS301RdCnk5ZwmsX4rC8TeHf8VpJOhzn/sLJpZG8A==', - package_name: 'endpoint', - created: '2000-01-01T00:00:00.000Z', - relative_url: - '/api/fleet/artifacts/endpoint-trustlist-windows-v1/ac2bf74a73885f9a5a1700c328bf1a5a8f6cb72f2465a575335ea99dac0d4c10', - compression_algorithm: 'zlib', - decoded_size: 198, - decoded_sha256: 'ac2bf74a73885f9a5a1700c328bf1a5a8f6cb72f2465a575335ea99dac0d4c10', - encryption_algorithm: 'none', - encoded_sha256: '28d81b2787cea23fcb88d02b1c09940858963a62c60cdfd7a2b7564cfc251708', - encoded_size: 130, - }), - getExpectedUpdatedArtifactBodyWhenUpdate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'process.executable', - operator: 'included', - type: 'exact_caseless', - value: 'c:\\randomFolder\\randomFile.exe, c:\\randomFolder\\randomFile2.exe', - }, - ], - }, - ], - }), - }, - }, - { - title: 'Event Filters', - pagePrefix: 'EventFiltersListPage', - create: { - formFields: [ - { - type: 'input', - selector: 'eventFilters-form-name-input', - value: 'Event filter name', - }, - { - type: 'input', - selector: 'eventFilters-form-description-input', - value: 'This is the event filter description', - }, - { - type: 'click', - selector: 'fieldAutocompleteComboBox', - }, - { - type: 'customClick', - selector: 'button[title="agent.ephemeral_id"]', - }, - { - type: 'click', - selector: 'valuesAutocompleteMatch', - }, - { - type: 'input', - selector: 'valuesAutocompleteMatch', - value: 'endpoint', - }, - ], - checkResults: [ - { - selector: 'EventFiltersListPage-card-criteriaConditions-condition', - value: 'AND agent.ephemeral_idIS endpoint', - }, - ], - }, - update: { - formFields: [ - { - type: 'input', - selector: 'eventFilters-form-name-input', - value: 'Event filter name edited', - }, - { - type: 'input', - selector: 'eventFilters-form-description-input', - value: 'This is the event filter description edited', - }, - { - type: 'click', - selector: 'fieldAutocompleteComboBox', - }, - { - type: 'input', - selector: 'fieldAutocompleteComboBox', - value: 'agent.id', - }, - { - type: 'customClick', - selector: 'button[title="agent.id"]', - }, - { - type: 'input', - selector: 'valuesAutocompleteMatch', - value: 'test super large value', - }, - { - type: 'click', - selector: 'eventFilters-form-description-input', - }, - ], - checkResults: [ - { - selector: 'EventFiltersListPage-card-criteriaConditions-condition', - value: 'AND agent.idIS test super large value', - }, - { - selector: 'EventFiltersListPage-card-header-title', - value: 'Event filter name edited', - }, - { - selector: 'EventFiltersListPage-card-description', - value: 'This is the event filter description edited', - }, - ], - waitForValue: 'AND agent.idIS test super large value', - }, - delete: { - confirmSelector: 'EventFiltersListPage-deleteModal-submitButton', - card: 'EventFiltersListPage-card', - }, - urlPath: 'event_filters', - pageObject: 'eventFilters', - fleetArtifact: { - identifier: 'endpoint-eventfilterlist-windows-v1', - type: 'eventfilterlist', - getExpectedUpdatedtArtifactWhenCreate: (): ArtifactElasticsearchProperties => ({ - type: 'eventfilterlist', - identifier: 'endpoint-eventfilterlist-windows-v1', - body: 'eJxVzFEKwjAQRdG9vO/iArKVUsqQPHVgmoRkWpSSvYvFH3/PhXuC2ZuyI8wn/F2JgK5bNWL6a3elJQTIg9lvrE9ubGKrJkwolU28NARojrYnfvW340uir1H6hYfYfmlOtWh2jGUs4wOrCC+X', - package_name: 'endpoint', - created: '2000-01-01T00:00:00.000Z', - relative_url: - '/api/fleet/artifacts/endpoint-eventfilterlist-windows-v1/b3373c93ffc795d954f22c625c084dc5874a156ec0cb3d4af1c3dab0b965fa30', - compression_algorithm: 'zlib', - decoded_size: 136, - decoded_sha256: 'b3373c93ffc795d954f22c625c084dc5874a156ec0cb3d4af1c3dab0b965fa30', - encryption_algorithm: 'none', - encoded_sha256: 'cc9bc4e3cc2c2767c3f56b17ebf4901dbe7e82f15720d48c745370e028c5e887', - encoded_size: 108, - }), - getExpectedUpdatedArtifactBodyWhenCreate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'agent.ephemeral_id', - operator: 'included', - type: 'exact_cased', - value: 'endpoint', - }, - ], - }, - ], - }), - getExpectedUpdatedArtifactWhenUpdate: (): ArtifactElasticsearchProperties => ({ - type: 'eventfilterlist', - identifier: 'endpoint-eventfilterlist-windows-v1', - body: 'eJxVzEEKwyAURdGtyBuHLsCtlFA++hoEa+T7LQnBvZc0nXR6LtwDLKaJDf5+wPZKeLT0qpmY/tozMUd4yMJitxQxYa1UsVXhkUrIPfLU34SbBHsEaV98S+6nGpu51ivVZdGF7gpjHvP4ADqUMJs=', - package_name: 'endpoint', - created: '2000-01-01T00:00:00.000Z', - relative_url: - '/api/fleet/artifacts/endpoint-eventfilterlist-windows-v1/e4f00c88380d2c429eeb2741ad19383b94d76f79744b098b095befc24003e158', - compression_algorithm: 'zlib', - decoded_size: 140, - decoded_sha256: 'e4f00c88380d2c429eeb2741ad19383b94d76f79744b098b095befc24003e158', - encryption_algorithm: 'none', - encoded_sha256: 'e371e2a28b59bd942ca7ef9665dae7c9b27409ad6f2ca3bff6357a98deb23c12', - encoded_size: 110, - }), - getExpectedUpdatedArtifactBodyWhenUpdate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'agent.id', - operator: 'included', - type: 'exact_cased', - value: 'test super large value', - }, - ], - }, - ], - }), - }, - }, - { - title: 'Blocklist', - pagePrefix: 'blocklistPage', - create: { - formFields: [ - { - type: 'input', - selector: 'blocklist-form-name-input', - value: 'Blocklist name', - }, - { - type: 'input', - selector: 'blocklist-form-description-input', - value: 'This is the blocklist description', - }, - { - type: 'click', - selector: 'blocklist-form-field-select', - }, - { - type: 'click', - selector: 'blocklist-form-file.hash.*', - }, - { - type: 'input', - selector: 'blocklist-form-values-input', - value: - 'A4370C0CF81686C0B696FA6261c9d3e0d810ae704ab8301839dffd5d5112f476,aedb279e378BED6C2DB3C9DC9e12ba635e0b391c,741462ab431a22233C787BAAB9B653C7', - }, - { - type: 'click', - selector: 'blocklist-form-name-input', - }, - ], - checkResults: [ - { - selector: 'blocklistPage-card-criteriaConditions', - value: - 'OSIS Windows\nAND file.hash.*IS ONE OF\n741462ab431a22233c787baab9b653c7\naedb279e378bed6c2db3c9dc9e12ba635e0b391c\na4370c0cf81686c0b696fa6261c9d3e0d810ae704ab8301839dffd5d5112f476', - }, - ], - }, - update: { - formFields: [ - { - type: 'input', - selector: 'blocklist-form-name-input', - value: 'Blocklist name edited', - }, - { - type: 'input', - selector: 'blocklist-form-description-input', - value: 'This is the blocklist description edited', - }, - { - type: 'click', - selector: 'blocklist-form-field-select', - }, - { - type: 'click', - selector: 'blocklist-form-file.path.caseless', - }, - { - type: 'clear', - selector: - 'blocklist-form-values-input-a4370c0cf81686c0b696fa6261c9d3e0d810ae704ab8301839dffd5d5112f476', - }, - { - type: 'clear', - selector: 'blocklist-form-values-input-741462ab431a22233c787baab9b653c7', - }, - { - type: 'clear', - selector: 'blocklist-form-values-input-aedb279e378bed6c2db3c9dc9e12ba635e0b391c', - }, - { - type: 'input', - selector: 'blocklist-form-values-input', - value: 'c:\\randomFolder\\randomFile.exe, c:\\randomFolder\\randomFile2.exe', - }, - { - type: 'click', - selector: 'blocklist-form-name-input', - }, - ], - checkResults: [ - { - selector: 'blocklistPage-card-criteriaConditions', - value: - 'OSIS Windows\nAND file.path.caselessIS ONE OF\nc:\\randomFolder\\randomFile.exe\nc:\\randomFolder\\randomFile2.exe', - }, - { - selector: 'blocklistPage-card-header-title', - value: 'Blocklist name edited', - }, - { - selector: 'blocklistPage-card-description', - value: 'This is the blocklist description edited', - }, - ], - waitForValue: - 'OSIS Windows\nAND file.path.caselessIS ONE OF\nc:\\randomFolder\\randomFile.exe\nc:\\randomFolder\\randomFile2.exe', - }, - delete: { - confirmSelector: 'blocklistDeletionConfirm', - card: 'blocklistCard', - }, - pageObject: 'blocklist', - urlPath: 'blocklist', - fleetArtifact: { - identifier: 'endpoint-blocklist-windows-v1', - type: 'blocklist', - getExpectedUpdatedtArtifactWhenCreate: (): ArtifactElasticsearchProperties => ({ - type: 'blocklist', - identifier: 'endpoint-blocklist-windows-v1', - relative_url: - '/api/fleet/artifacts/endpoint-blocklist-windows-v1/637f1e8795406904980ae2ab4a69cea967756571507f6bd7fc94cde0add20df2', - body: 'eJylzk1qw0AMQOG7aG3C/GpmfJVggkbSYIPjmNgpDcF3LxS66LLN+sHje4Eu+33SDfrzC/bnqtDDNl3XWaH71dqks0APbZr1NNI2nq4SoYPbqnfab3foYVp4fogKdD8n/STeL0ybyoWWJ3TwQfNDoT9DCjagoxq8Jeec95xyqkS1VIyeEwzHcHR/NW0j2TdQpFJdKupTrirITqrnIlzUukroo5rqi+V/41zEd3jBJ8OGW7aYkU3Fgo3QoeUiXo1ka0iTCVSzNzb7Iq1JlGitayHhN3s4vgDTjqDt', - encryption_algorithm: 'none', - package_name: 'endpoint', - encoded_size: 219, - encoded_sha256: 'e803c1ee6aec0885092bfd6c288839f42b31107dd6d0bb2c8e2d2b9f8fc8b293', - decoded_size: 501, - decoded_sha256: '637f1e8795406904980ae2ab4a69cea967756571507f6bd7fc94cde0add20df2', - compression_algorithm: 'zlib', - created: '2000-01-01T00:00:00.000Z', - }), - getExpectedUpdatedArtifactBodyWhenCreate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'file.hash.md5', - operator: 'included', - type: 'exact_cased_any', - value: ['741462ab431a22233c787baab9b653c7'], - }, - ], - }, - { - type: 'simple', - entries: [ - { - field: 'file.hash.sha1', - operator: 'included', - type: 'exact_cased_any', - value: ['aedb279e378bed6c2db3c9dc9e12ba635e0b391c'], - }, - ], - }, - { - type: 'simple', - entries: [ - { - field: 'file.hash.sha256', - operator: 'included', - type: 'exact_cased_any', - value: ['a4370c0cf81686c0b696fa6261c9d3e0d810ae704ab8301839dffd5d5112f476'], - }, - ], - }, - ], - }), - getExpectedUpdatedArtifactWhenUpdate: (): ArtifactElasticsearchProperties => ({ - type: 'blocklist', - identifier: 'endpoint-blocklist-windows-v1', - relative_url: - '/api/fleet/artifacts/endpoint-blocklist-windows-v1/3ead6ce4e34cb4411083a44bfe813d9442d296981ee8d56e727e6cff14dea0f0', - body: 'eJx9jUEKwjAURK8isw4uXOYAXqKV8kmmGPhNQpJKS/HuEkHBjcxqmMebA4ytBFbY4UDbM2FRw5KVMD/bHKgeFnNQnrO0OwxSZpGWCixCdLp6epiPhZu4NjmpVNY6Sdxh8BBdCTvA2XEsEn1arkk9y7d1Pbf+fvrHXN7Q7dnzAojqRb8=', - encryption_algorithm: 'none', - package_name: 'endpoint', - encoded_size: 131, - encoded_sha256: 'f0e2dc2aa8d968b704baa11bf3100db91a85991d5de431f8c389b7417335a701', - decoded_size: 197, - decoded_sha256: '3ead6ce4e34cb4411083a44bfe813d9442d296981ee8d56e727e6cff14dea0f0', - compression_algorithm: 'zlib', - created: '2000-01-01T00:00:00.000Z', - }), - getExpectedUpdatedArtifactBodyWhenUpdate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'file.path', - operator: 'included', - type: 'exact_caseless_any', - value: ['c:\\randomFolder\\randomFile.exe', ' c:\\randomFolder\\randomFile2.exe'], - }, - ], - }, - ], - }), - }, - }, - { - title: 'Host isolation exceptions', - pagePrefix: 'hostIsolationExceptionsListPage', - create: { - formFields: [ - { - type: 'input', - selector: 'hostIsolationExceptions-form-name-input', - value: 'Host Isolation exception name', - }, - { - type: 'input', - selector: 'hostIsolationExceptions-form-description-input', - value: 'This is the host isolation exception description', - }, - { - type: 'input', - selector: 'hostIsolationExceptions-form-ip-input', - value: '1.1.1.1', - }, - ], - checkResults: [ - { - selector: 'hostIsolationExceptionsListPage-card-criteriaConditions', - value: 'OSIS Windows, Linux, Mac\nAND destination.ipIS 1.1.1.1', - }, - ], - }, - update: { - formFields: [ - { - type: 'input', - selector: 'hostIsolationExceptions-form-name-input', - value: 'Host Isolation exception name edited', - }, - { - type: 'input', - selector: 'hostIsolationExceptions-form-description-input', - value: 'This is the host isolation exception description edited', - }, - { - type: 'input', - selector: 'hostIsolationExceptions-form-ip-input', - value: '2.2.2.2/24', - }, - ], - checkResults: [ - { - selector: 'hostIsolationExceptionsListPage-card-criteriaConditions', - value: 'OSIS Windows, Linux, Mac\nAND destination.ipIS 2.2.2.2/24', - }, - { - selector: 'hostIsolationExceptionsListPage-card-header-title', - value: 'Host Isolation exception name edited', - }, - { - selector: 'hostIsolationExceptionsListPage-card-description', - value: 'This is the host isolation exception description edited', - }, - ], - waitForValue: 'OSIS Windows, Linux, Mac\nAND destination.ipIS 2.2.2.2/24', - }, - delete: { - confirmSelector: 'hostIsolationExceptionsDeletionConfirm', - card: 'hostIsolationExceptionsCard', - }, - pageObject: 'hostIsolationExceptions', - urlPath: 'host_isolation_exceptions', - fleetArtifact: { - identifier: 'endpoint-hostisolationexceptionlist-windows-v1', - type: 'hostisolationexceptionlist', - getExpectedUpdatedtArtifactWhenCreate: (): ArtifactElasticsearchProperties => ({ - type: 'hostisolationexceptionlist', - identifier: 'endpoint-hostisolationexceptionlist-windows-v1', - relative_url: - '/api/fleet/artifacts/endpoint-hostisolationexceptionlist-windows-v1/2c3ee2b5e7f86f8c336a3df7e59a1151b11d7eec382442032e69712d6a6459e0', - body: 'eJxVjEEKgzAUBe/y1kFwm6uIyCd5hQ9pEpKvWCR3LxVclNnNwFxgtqbs8MsF+1TCo+u7JsL9tZcyRXhEdtMspiVPWuFQKptYafDQHNIeGeGeFU8JtgXptzwk7T87TzcY61jHF647LBE=', - encryption_algorithm: 'none', - package_name: 'endpoint', - encoded_size: 104, - encoded_sha256: 'f958ada742a0be63d136901317c6bfd04b2ab5f52cdd0e872461089b0009bb3e', - decoded_size: 131, - decoded_sha256: '2c3ee2b5e7f86f8c336a3df7e59a1151b11d7eec382442032e69712d6a6459e0', - compression_algorithm: 'zlib', - created: '2000-01-01T00:00:00.000Z', - }), - getExpectedUpdatedArtifactBodyWhenCreate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'destination.ip', - operator: 'included', - type: 'exact_cased', - value: '1.1.1.1', - }, - ], - }, - ], - }), - getExpectedUpdatedArtifactWhenUpdate: (): ArtifactElasticsearchProperties => ({ - type: 'hostisolationexceptionlist', - identifier: 'endpoint-hostisolationexceptionlist-windows-v1', - relative_url: - '/api/fleet/artifacts/endpoint-hostisolationexceptionlist-windows-v1/4b62473b4cf057277b3297896771cc1061c3bea2c4f7ec1ef5c2546f33d5d9e8', - body: 'eJxVjEEKwyAUBe/y1pJC6MqrlBA++gofrIr+hJbg3UsCXZTZzcAcYLam7PCPA/aphEfXV02E+2tPZYrwiOymWUxLnrTCoVQ2sdLgoTmkLTLC/VZ8S7A1SL/kLmk77Txd3OY7xjKW8QUwWyyq', - encryption_algorithm: 'none', - package_name: 'endpoint', - encoded_size: 108, - encoded_sha256: '84df618343078f43a54299bcebef03010f3ec4ffdf7160448882fee9bafa1adb', - decoded_size: 134, - decoded_sha256: '4b62473b4cf057277b3297896771cc1061c3bea2c4f7ec1ef5c2546f33d5d9e8', - compression_algorithm: 'zlib', - created: '2000-01-01T00:00:00.000Z', - }), - getExpectedUpdatedArtifactBodyWhenUpdate: (): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'destination.ip', - operator: 'included', - type: 'exact_cased', - value: '2.2.2.2/24', - }, - ], - }, - ], - }), - }, - }, -]; - -export const getCreateMultipleData = () => ({ - title: 'Trusted applications', - pagePrefix: 'trustedAppsListPage', - create: { - formFields: [ - { - type: 'input', - selector: 'trustedApps-form-descriptionField', - value: 'This is the trusted application description', - }, - { - type: 'input', - selector: 'trustedApps-form-nameTextField', - value: 'Trusted application name', - }, - { - type: 'click', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-field', - }, - { - type: 'click', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-field-type-Path', - }, - { - type: 'input', - selector: 'trustedApps-form-conditionsBuilder-group1-entry0-value', - value: 'c:\\randomFolder\\randomFile.exe', - }, - ], - }, - - urlPath: 'trusted_apps', - pageObject: 'trustedApps', - fleetArtifact: { - identifier: 'endpoint-trustlist-windows-v1', - type: 'trustedApplications', - getExpectedUpdatedArtifactWhenCreateMultipleFirst: (): ArtifactElasticsearchProperties => ({ - type: 'trustlist', - identifier: 'endpoint-trustlist-windows-v1', - body: 'eJzNjlEKwjAQBe+y38ED5ABewhaJySsubJuwu5VK6d0lgoI38PMxj2F2wuLKMIqXnfzZQJGM5yag8MMmhhSK1LRmmJ2wIa+ebu9jbdDkVSkSL1nWgkLho8OWsl9zMgjMKNAjydpBjsOgaSl1Plcp0O9iQff7nbXQMR7h79ImVvOeNh4vUR5zdA==', - package_name: 'endpoint', - created: '2000-01-01T00:00:00.000Z', - relative_url: - '/api/fleet/artifacts/endpoint-trustlist-windows-v1/329fc9176a24d64f4376d2c25d5db5b31cf86b288dac83c8a004dfe5bbfdc7d0', - compression_algorithm: 'zlib', - decoded_size: 323, - decoded_sha256: '329fc9176a24d64f4376d2c25d5db5b31cf86b288dac83c8a004dfe5bbfdc7d0', - encryption_algorithm: 'none', - encoded_sha256: '4d9eecb830948eabd721563fd2473900207d043126e66eac2ef78f9e05a80adb', - encoded_size: 136, - }), - getExpectedUpdatedArtifactBodyWhenCreateMultipleFirst: ( - firstSuffix: string, - secondSuffix: string - ): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'process.executable', - operator: 'included', - type: 'exact_caseless', - value: `c:\\randomFolder\\randomFile.exe${firstSuffix}`, - }, - ], - }, - { - entries: [ - { - field: 'process.executable', - operator: 'included', - type: 'exact_caseless', - value: `c:\\randomFolder\\randomFile.exe${secondSuffix}`, - }, - ], - type: 'simple', - }, - ], - }), - getExpectedUpdatedArtifactWhenCreateMultipleSecond: (): ArtifactElasticsearchProperties => ({ - type: 'trustlist', - identifier: 'endpoint-trustlist-windows-v1', - body: 'eJzNjlEKwjAQRO8y38ED5ABewhaJyYiBbRJ2U6mU3l1aUPAGfg5veLwVLF0zDf6yor8a4WF5akK4H3bPlASPpjXS7MSFce7hdhxro4ZeFR65RJkTE9xHxyXEfo3BKDSDwzPIvIPoh0FDSXU6V0nU78rC3d8fWRO2cXN/l2aMtRxt4/YGxIFzyA==', - package_name: 'endpoint', - created: '2000-01-01T00:00:00.000Z', - relative_url: - '/api/fleet/artifacts/endpoint-trustlist-windows-v1/3be2ce848f9b49d6531e6dc80f43579e00adbc640d3f785c14c8f9fa2652500a', - compression_algorithm: 'zlib', - decoded_size: 324, - decoded_sha256: '3be2ce848f9b49d6531e6dc80f43579e00adbc640d3f785c14c8f9fa2652500a', - encryption_algorithm: 'none', - encoded_sha256: '68304c35bbe863d0fbb15cf7e5ae5c84bad17aa7a3bc26828f9f0b20e0df6ed8', - encoded_size: 136, - }), - getExpectedUpdatedArtifactBodyWhenCreateMultipleSecond: ( - firstSuffix: string, - secondSuffix: string - ): ArtifactBodyType => ({ - entries: [ - { - type: 'simple', - entries: [ - { - field: 'process.executable', - operator: 'included', - type: 'exact_caseless', - value: `c:\\randomFolder\\randomFile.exe${firstSuffix}`, - }, - ], - }, - { - entries: [ - { - field: 'process.executable', - operator: 'included', - type: 'exact_caseless', - value: `c:\\randomFolder\\randomFile.exe${secondSuffix}`, - }, - ], - type: 'simple', - }, - ], - }), - }, -}); diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/integrations_feature_flag.config.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/integrations_feature_flag.config.ts deleted file mode 100644 index 8f15a4781476..000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/integrations_feature_flag.config.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 { resolve } from 'path'; -import { FtrConfigProviderContext } from '@kbn/test'; -import { generateConfig } from './config.base'; -import { services } from '../services'; - -export default async function (ftrConfigProviderContext: FtrConfigProviderContext) { - const { readConfigFile } = ftrConfigProviderContext; - - const xpackFunctionalConfig = await readConfigFile( - require.resolve('../../../../functional/config.base.js') - ); - - return generateConfig({ - ftrConfigProviderContext, - baseConfig: xpackFunctionalConfig, - testFiles: [resolve(__dirname, '../apps/integrations_feature_flag')], - junitReportName: - 'X-Pack Endpoint Integrations With Feature Flags turned on Functional Tests on ESS', - target: 'ess', - kbnServerArgs: [ - // set the packagerTaskInterval to 5s in order to speed up test executions when checking fleet artifacts - '--xpack.securitySolution.packagerTaskInterval=5s', - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['unifiedManifestEnabled'])}`, - ], - services, - }); -} diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/serverless.integrations_feature_flag.config.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/serverless.integrations_feature_flag.config.ts deleted file mode 100644 index cdbbaa587cb9..000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/configs/serverless.integrations_feature_flag.config.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 { resolve } from 'path'; -import { FtrConfigProviderContext } from '@kbn/test'; -import { generateConfig } from './config.base'; -import { svlServices } from '../services'; - -export default async function (ftrConfigProviderContext: FtrConfigProviderContext) { - const { readConfigFile } = ftrConfigProviderContext; - - const svlBaseConfig = await readConfigFile( - require.resolve('../../../../../test_serverless/shared/config.base.ts') - ); - - return generateConfig({ - ftrConfigProviderContext, - baseConfig: svlBaseConfig, - testFiles: [resolve(__dirname, '../apps/integrations_feature_flag')], - junitReportName: - 'X-Pack Endpoint Integrations With Feature Flags turned on Functional Tests on ESS', - target: 'serverless', - kbnServerArgs: [ - '--serverless=security', - // set the packagerTaskInterval to 5s in order to speed up test executions when checking fleet artifacts - '--xpack.securitySolution.packagerTaskInterval=5s', - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['unifiedManifestEnabled'])}`, - ], - services: svlServices, - }); -} diff --git a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/services/endpoint_artifacts.ts b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/services/endpoint_artifacts.ts index e4d24c27895f..118c097a2f00 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/services/endpoint_artifacts.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/security_solution_endpoint/services/endpoint_artifacts.ts @@ -20,8 +20,7 @@ import { HOST_ISOLATION_EXCEPTIONS_LIST_DEFINITION } from '@kbn/security-solutio import { BLOCKLISTS_LIST_DEFINITION } from '@kbn/security-solution-plugin/public/management/pages/blocklist/constants'; import { ManifestConstants } from '@kbn/security-solution-plugin/server/endpoint/lib/artifacts'; import { FtrService } from '../../../../functional/ftr_provider_context'; -import { InternalManifestSchemaResponseType } from '../apps/integrations/mocks'; -import { InternalUnifiedManifestSchemaResponseType } from '../apps/integrations_feature_flag/mocks'; +import { InternalUnifiedManifestSchemaResponseType } from '../apps/integrations/mocks'; export interface ArtifactTestData { artifact: ExceptionListItemSchema; @@ -123,19 +122,6 @@ export class EndpointArtifactsTestResources extends FtrService { return this.createExceptionItem(blocklist); } - async getArtifacts() { - const { - hits: { hits: manifestResults }, - } = await this.esClient.search({ - index: '.kibana*', - query: { bool: { filter: [{ term: { type: ManifestConstants.SAVED_OBJECT_TYPE } }] } }, - size: 1, - }); - - const manifestResult = manifestResults[0] as InternalManifestSchemaResponseType; - return manifestResult._source['endpoint:user-artifact-manifest'].artifacts; - } - async getArtifactsFromUnifiedManifestSO(): Promise< Array< InternalUnifiedManifestSchemaResponseType['_source']['endpoint:unified-user-artifact-manifest'] diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index 6e65ab15324a..092fe4b79d38 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -47,6 +47,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'alertSuppressionForEsqlRuleEnabled', 'bulkCustomHighlightedFieldsEnabled', + 'alertSuppressionForMachineLearningRuleEnabled', 'manualRuleRunEnabled', ])}`, // mock cloud to enable the guided onboarding tour in e2e tests diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/callouts/missing_privileges_callout.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/callouts/missing_privileges_callout.cy.ts index bcb29a456bdd..69e6bb8b3525 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/callouts/missing_privileges_callout.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/callouts/missing_privileges_callout.cy.ts @@ -44,106 +44,91 @@ const waitForPageTitleToBeShown = () => { cy.get(PAGE_TITLE).should('be.visible'); }; -// FLAKY: https://github.com/elastic/kibana/issues/178176 -describe.skip( - 'Detections > Callouts', - { tags: ['@ess', '@serverless', '@skipInServerless'] }, - () => { - before(() => { - // First, we have to open the app on behalf of a privileged user in order to initialize it. - // Otherwise the app will be disabled and show a "welcome"-like page. - login(); - visit(ALERTS_URL); - waitForPageTitleToBeShown(); - }); - - context('indicating read-only access to resources', () => { - context('On Detections home page', () => { - beforeEach(() => { - loadPageAsReadOnlyUser(ALERTS_URL); - }); - - it('We show one primary callout', () => { - waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - }); - - context('When a user clicks Dismiss on the callout', () => { - it('We hide it and persist the dismissal', () => { - waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - dismissCallOut(MISSING_PRIVILEGES_CALLOUT); - reloadPage(); - getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); - }); - }); +describe('Detections > Callouts', { tags: ['@ess', '@serverless', '@skipInServerless'] }, () => { + before(() => { + // First, we have to open the app on behalf of a privileged user in order to initialize it. + // Otherwise the app will be disabled and show a "welcome"-like page. + login(); + visit(ALERTS_URL); + waitForPageTitleToBeShown(); + }); + + context('indicating read-only access to resources', () => { + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsReadOnlyUser(ALERTS_URL); }); - // FYI: Rules Management check moved to ../detection_rules/all_rules_read_only.spec.ts + it('dismisses callout and persists its state', () => { + waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - context('On Rule Details page', () => { - beforeEach(() => { - createRule(getNewRule()).then((rule) => - loadPageAsReadOnlyUser(ruleDetailsUrl(rule.body.id)) - ); - }); + dismissCallOut(MISSING_PRIVILEGES_CALLOUT); + reloadPage(); - afterEach(() => { - deleteCustomRule(); - }); + getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); + }); + }); + + // FYI: Rules Management check moved to ../detection_rules/all_rules_read_only.spec.ts + + context('On Rule Details page', () => { + beforeEach(() => { + createRule(getNewRule()).then((rule) => + loadPageAsReadOnlyUser(ruleDetailsUrl(rule.body.id)) + ); + }); - it('We show one primary callout', () => { - waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - }); + afterEach(() => { + deleteCustomRule(); + }); - context('When a user clicks Dismiss on the callouts', () => { - it('We hide them and persist the dismissal', () => { - waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); + it('dismisses callout and persists its state', () => { + waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - dismissCallOut(MISSING_PRIVILEGES_CALLOUT); - reloadPage(); + dismissCallOut(MISSING_PRIVILEGES_CALLOUT); + reloadPage(); - getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); - }); - }); + getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); }); }); + }); - context('indicating read-write access to resources', () => { - context('On Detections home page', () => { - beforeEach(() => { - loadPageAsPlatformEngineer(ALERTS_URL); - }); + context('indicating read-write access to resources', () => { + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineer(ALERTS_URL); + }); - it('We show no callout', () => { - getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); - }); + it('We show no callout', () => { + getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); }); + }); - context('On Rules Management page', () => { - beforeEach(() => { - login(ROLES.platform_engineer); - loadPageAsPlatformEngineer(RULES_MANAGEMENT_URL); - }); + context('On Rules Management page', () => { + beforeEach(() => { + login(ROLES.platform_engineer); + loadPageAsPlatformEngineer(RULES_MANAGEMENT_URL); + }); - it('We show no callout', () => { - getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); - }); + it('We show no callout', () => { + getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); }); + }); - context('On Rule Details page', () => { - beforeEach(() => { - createRule(getNewRule()).then((rule) => - loadPageAsPlatformEngineer(ruleDetailsUrl(rule.body.id)) - ); - }); + context('On Rule Details page', () => { + beforeEach(() => { + createRule(getNewRule()).then((rule) => + loadPageAsPlatformEngineer(ruleDetailsUrl(rule.body.id)) + ); + }); - afterEach(() => { - deleteCustomRule(); - }); + afterEach(() => { + deleteCustomRule(); + }); - it('We show no callouts', () => { - getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); - }); + it('We show no callouts', () => { + getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist'); }); }); - } -); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts index 07be6f79efd4..cf11b9553f84 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts @@ -48,185 +48,190 @@ import { deleteExceptionLists, } from '../../../../../tasks/api_calls/exceptions'; -describe('Add endpoint exception from rule details', { tags: ['@ess', '@serverless'] }, () => { - const ITEM_NAME = 'Sample Exception List Item'; - const NEW_ITEM_NAME = 'Exception item-EDITED'; - const ITEM_FIELD = 'event.code'; - const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.type'; - - before(() => { - cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); - }); +// https://github.com/elastic/kibana/issues/187279 +describe( + 'Add endpoint exception from rule details', + { tags: ['@ess', '@serverless, @skipInServerlessMKI'] }, + () => { + const ITEM_NAME = 'Sample Exception List Item'; + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_FIELD = 'event.code'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.type'; + + before(() => { + cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); + }); - after(() => { - cy.task('esArchiverUnload', { archiveName: 'auditbeat_multiple' }); - }); + after(() => { + cy.task('esArchiverUnload', { archiveName: 'auditbeat_multiple' }); + }); - beforeEach(() => { - deleteExceptionLists(); - deleteEndpointExceptionList(); + beforeEach(() => { + deleteExceptionLists(); + deleteEndpointExceptionList(); - login(); - deleteAlertsAndRules(); - }); + login(); + deleteAlertsAndRules(); + }); - describe('without exception items', () => { - beforeEach(() => { - createEndpointExceptionList().then((response) => { - createRule( - getNewRule({ - query: 'event.code:*', - index: ['auditbeat*'], - exceptions_list: [ - { - id: response.body.id, - list_id: response.body.list_id, - type: response.body.type, - namespace_type: response.body.namespace_type, - }, - ], - rule_id: '2', - enabled: false, - }) - ).then((rule) => visitRuleDetailsPage(rule.body.id, { tab: 'endpoint_exceptions' })); + describe('without exception items', () => { + beforeEach(() => { + createEndpointExceptionList().then((response) => { + createRule( + getNewRule({ + query: 'event.code:*', + index: ['auditbeat*'], + exceptions_list: [ + { + id: response.body.id, + list_id: response.body.list_id, + type: response.body.type, + namespace_type: response.body.namespace_type, + }, + ], + rule_id: '2', + enabled: false, + }) + ).then((rule) => visitRuleDetailsPage(rule.body.id, { tab: 'endpoint_exceptions' })); + }); }); - }); - it('creates an exception item', () => { - // when no exceptions exist, empty component shows with action to add exception + it('creates an exception item', () => { + // when no exceptions exist, empty component shows with action to add exception - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); - // open add exception modal - openExceptionFlyoutFromEmptyViewerPrompt(); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); - // submit button is disabled if no paramerters were added - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + // submit button is disabled if no paramerters were added + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - // for endpoint exceptions, must specify OS - selectOs('windows'); + // for endpoint exceptions, must specify OS + selectOs('windows'); - // add exception item conditions - addExceptionConditions({ - field: 'event.code', - operator: 'is', - values: ['foo'], - }); + // add exception item conditions + addExceptionConditions({ + field: 'event.code', + operator: 'is', + values: ['foo'], + }); - // Name is required so want to check that submit is still disabled - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - // add exception item name - addExceptionFlyoutItemName(ITEM_NAME); + // add exception item name + addExceptionFlyoutItemName(ITEM_NAME); - // Option to add to rule or add to list should NOT appear - cy.get(ADD_TO_RULE_OR_LIST_SECTION).should('not.exist'); + // Option to add to rule or add to list should NOT appear + cy.get(ADD_TO_RULE_OR_LIST_SECTION).should('not.exist'); - // not testing close alert functionality here, just ensuring that the options appear as expected - cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).should('not.exist'); - cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).should('not.exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); - // submit - submitNewExceptionItem(); + // submit + submitNewExceptionItem(); - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + }); }); - }); - - // FLAKY: https://github.com/elastic/kibana/issues/179582 - describe.skip('with exception items', () => { - beforeEach(() => { - createEndpointExceptionList().then((response) => { - createEndpointExceptionListItem({ - comments: [], - description: 'Exception list item', - entries: [ - { - field: ITEM_FIELD, - operator: 'included', - type: 'match', - value: 'foo', - }, - ], - name: ITEM_NAME, - tags: [], - type: 'simple', - os_types: ['windows'], - }); - createRule( - getNewRule({ - name: 'Rule with exceptions', - query: 'event.code:*', - index: ['auditbeat*'], - exceptions_list: [ + // FLAKY: https://github.com/elastic/kibana/issues/179582 + describe.skip('with exception items', () => { + beforeEach(() => { + createEndpointExceptionList().then((response) => { + createEndpointExceptionListItem({ + comments: [], + description: 'Exception list item', + entries: [ { - id: response.body.id, - list_id: response.body.list_id, - type: response.body.type, - namespace_type: response.body.namespace_type, + field: ITEM_FIELD, + operator: 'included', + type: 'match', + value: 'foo', }, ], - rule_id: '2', - enabled: false, - }) - ).then((rule) => { - visitRuleDetailsPage(rule.body.id, { tab: 'endpoint_exceptions' }); - waitForRuleDetailsPageToBeLoaded('Rule with exceptions'); + name: ITEM_NAME, + tags: [], + type: 'simple', + os_types: ['windows'], + }); + + createRule( + getNewRule({ + name: 'Rule with exceptions', + query: 'event.code:*', + index: ['auditbeat*'], + exceptions_list: [ + { + id: response.body.id, + list_id: response.body.list_id, + type: response.body.type, + namespace_type: response.body.namespace_type, + }, + ], + rule_id: '2', + enabled: false, + }) + ).then((rule) => { + visitRuleDetailsPage(rule.body.id, { tab: 'endpoint_exceptions' }); + waitForRuleDetailsPageToBeLoaded('Rule with exceptions'); + }); }); }); - }); - it('edits an endpoint exception item', () => { - // displays existing exception items - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); - cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); - cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ` ${ITEM_FIELD}IS foo`); + it('edits an endpoint exception item', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ` ${ITEM_FIELD}IS foo`); - // open edit exception modal - openEditException(); + // open edit exception modal + openEditException(); - // edit exception item name - editExceptionFlyoutItemName(NEW_ITEM_NAME); + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); - // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER) - .eq(0) - .find(FIELD_INPUT_PARENT) - .eq(0) - .should('have.value', ITEM_FIELD); - cy.get(VALUES_INPUT).should('have.value', 'foo'); + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT_PARENT) + .eq(0) + .should('have.value', ITEM_FIELD); + cy.get(VALUES_INPUT).should('have.value', 'foo'); - // edit conditions - editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); - // submit - submitEditedExceptionItem(); + // submit + submitEditedExceptionItem(); - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - // check that updates stuck - cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); - cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.typeIS foo'); - }); + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.typeIS foo'); + }); - it('allows user to search for items', () => { - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + it('allows user to search for items', () => { + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - // can search for an exception value - searchForExceptionItem('foo'); + // can search for an exception value + searchForExceptionItem('foo'); - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - // displays empty search result view if no matches found - searchForExceptionItem('abc'); + // displays empty search result view if no matches found + searchForExceptionItem('abc'); - // new exception item displays - cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + // new exception item displays + cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + }); }); - }); -}); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_suppression_serverless_essentials.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_suppression_serverless_essentials.cy.ts index d6f23687cf41..946e0190bc1f 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_suppression_serverless_essentials.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_suppression_serverless_essentials.cy.ts @@ -15,6 +15,7 @@ import { login } from '../../../../tasks/login'; import { visit } from '../../../../tasks/navigation'; import { ALERT_SUPPRESSION_FIELDS_INPUT, + MACHINE_LEARNING_TYPE, THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX, } from '../../../../screens/create_new_rule'; import { CREATE_RULE_URL } from '../../../../urls/navigation'; @@ -22,7 +23,7 @@ import { CREATE_RULE_URL } from '../../../../urls/navigation'; describe( 'Detection rules, Alert Suppression for Essentials tier', { - // skipped in MKI as it depends on feature flag alertSuppressionForEsqlRuleEnabled + // skipped in MKI as it depends on feature flag alertSuppressionForEsqlRuleEnabled, alertSuppressionForMachineLearningRuleEnabled tags: ['@serverless', '@skipInServerlessMKI'], env: { ftrConfig: { @@ -35,6 +36,7 @@ describe( kbnServerArgs: [ `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'alertSuppressionForEsqlRuleEnabled', + 'alertSuppressionForMachineLearningRuleEnabled', ])}`, ], }, @@ -60,6 +62,9 @@ describe( selectEsqlRuleType(); cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.enabled'); + + // ML Rules require Complete tier + cy.get(MACHINE_LEARNING_TYPE).get('button').should('be.disabled'); }); } ); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_supression_ess_basic.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_supression_ess_basic.cy.ts index 1f86d6d0dd78..a4e7a7dabb5f 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_supression_ess_basic.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows_supression_ess_basic.cy.ts @@ -8,6 +8,7 @@ import { THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX, ALERT_SUPPRESSION_DURATION_INPUT, + MACHINE_LEARNING_TYPE, } from '../../../../screens/create_new_rule'; import { @@ -52,6 +53,9 @@ describe( selectEsqlRuleType(); openSuppressionFieldsTooltipAndCheckLicense(); + // ML Rules require Platinum license + cy.get(MACHINE_LEARNING_TYPE).get('button').should('be.disabled'); + selectThresholdRuleType(); cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).should('be.disabled'); cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).parent().trigger('mouseover'); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_query_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_query_rule.cy.ts index a40d635cd4bf..4bb45e03a15f 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_query_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_query_rule.cy.ts @@ -61,7 +61,7 @@ describe('Create custom query rule', { tags: ['@ess', '@serverless'] }, () => { }); // FLAKEY - see https://github.com/elastic/kibana/issues/182891 - it('@skipInServerless Adds filter on define step', () => { + it('Adds filter on define step', { tags: ['@skipInServerless'] }, () => { visit(CREATE_RULE_URL); fillDefineCustomRule(rule); openAddFilterPopover(); @@ -73,7 +73,8 @@ describe('Create custom query rule', { tags: ['@ess', '@serverless'] }, () => { cy.get(GLOBAL_SEARCH_BAR_FILTER_ITEM).should('have.text', 'host.name: exists'); }); - describe('Alert suppression', () => { + // https://github.com/elastic/kibana/issues/187277 + describe('Alert suppression', { tags: ['@skipInServerlessMKI'] }, () => { const SUPPRESS_BY_FIELDS = ['source.ip']; it('creates rule with suppression', () => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule.cy.ts index 0f90e406682f..57c0c39f8a8f 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule.cy.ts @@ -70,10 +70,10 @@ describe('Machine Learning rules', { tags: ['@ess', '@serverless'] }, () => { ); // ensure no ML jobs are started before the suite machineLearningJobIds.forEach((jobId) => forceStopAndCloseJob({ jobId })); - deleteAlertsAndRules(); }); beforeEach(() => { + deleteAlertsAndRules(); login(); visit(CREATE_RULE_URL); }); 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 new file mode 100644 index 000000000000..befa75fce93f --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule_suppression.cy.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getMachineLearningRule } from '../../../../objects/rule'; +import { TOOLTIP } from '../../../../screens/common'; +import { + ALERT_SUPPRESSION_FIELDS, + ALERT_SUPPRESSION_FIELDS_INPUT, +} from '../../../../screens/create_new_rule'; +import { + DEFINITION_DETAILS, + DETAILS_TITLE, + SUPPRESS_BY_DETAILS, + SUPPRESS_FOR_DETAILS, + SUPPRESS_MISSING_FIELD, +} from '../../../../screens/rule_details'; +import { + executeSetupModuleRequest, + forceStartDatafeeds, + forceStopAndCloseJob, +} from '../../../../support/machine_learning'; +import { + continueFromDefineStep, + fillAlertSuppressionFields, + fillDefineMachineLearningRule, + selectMachineLearningRuleType, + selectAlertSuppressionPerInterval, + setAlertSuppressionDuration, + selectDoNotSuppressForMissingFields, + skipScheduleRuleAction, + fillAboutRuleMinimumAndContinue, + createRuleWithoutEnabling, +} from '../../../../tasks/create_new_rule'; +import { login } from '../../../../tasks/login'; +import { visit } from '../../../../tasks/navigation'; +import { getDetails } from '../../../../tasks/rule_details'; +import { CREATE_RULE_URL } from '../../../../urls/navigation'; + +describe( + 'Machine Learning Detection Rules - Creation', + { + // Skipped in MKI as tests depend on feature flag alertSuppressionForMachineLearningRuleEnabled + tags: ['@ess', '@serverless', '@skipInServerlessMKI'], + env: { + ftrConfig: { + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'alertSuppressionForMachineLearningRuleEnabled', + ])}`, + ], + }, + }, + }, + () => { + let mlRule: ReturnType<typeof getMachineLearningRule>; + const jobId = 'v3_linux_anomalous_network_activity'; + const suppressByFields = ['by_field_name', 'by_field_value']; + + beforeEach(() => { + login(); + visit(CREATE_RULE_URL); + }); + + describe('with Alert Suppression', () => { + describe('when no ML jobs have run', () => { + before(() => { + const machineLearningJobIds = ([] as string[]).concat( + getMachineLearningRule().machine_learning_job_id + ); + // ensure no ML jobs are started before the suite + machineLearningJobIds.forEach((j) => forceStopAndCloseJob({ jobId: j })); + }); + + beforeEach(() => { + mlRule = getMachineLearningRule(); + selectMachineLearningRuleType(); + fillDefineMachineLearningRule(mlRule); + }); + + it('disables the suppression fields and displays a message', () => { + cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.disabled'); + cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).realHover(); + cy.get(TOOLTIP).should( + 'contain.text', + 'To enable alert suppression, start relevant Machine Learning jobs.' + ); + }); + }); + + describe('when ML jobs have run', () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: '../auditbeat/hosts', type: 'ftr' }); + executeSetupModuleRequest({ moduleName: 'security_linux_v3' }); + forceStartDatafeeds({ jobIds: [jobId] }); + cy.task('esArchiverLoad', { archiveName: 'anomalies', type: 'ftr' }); + }); + + after(() => { + cy.task('esArchiverUnload', { archiveName: 'anomalies', type: 'ftr' }); + cy.task('esArchiverUnload', { archiveName: '../auditbeat/hosts', type: 'ftr' }); + }); + + describe('when not all jobs are running', () => { + beforeEach(() => { + mlRule = getMachineLearningRule(); + selectMachineLearningRuleType(); + fillDefineMachineLearningRule(mlRule); + }); + + it('displays a warning message on the suppression fields', () => { + cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.enabled'); + cy.get(ALERT_SUPPRESSION_FIELDS).should( + 'contain.text', + 'This list of fields might be incomplete as some Machine Learning jobs are not running. Start all relevant jobs for a complete list.' + ); + }); + }); + + describe('when all jobs are running', () => { + beforeEach(() => { + mlRule = getMachineLearningRule({ machine_learning_job_id: [jobId] }); + selectMachineLearningRuleType(); + fillDefineMachineLearningRule(mlRule); + }); + + it('allows a rule with per-execution suppression to be created and displayed', () => { + fillAlertSuppressionFields(suppressByFields); + continueFromDefineStep(); + + // ensures details preview works correctly + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(SUPPRESS_BY_DETAILS).should('have.text', suppressByFields.join('')); + getDetails(SUPPRESS_FOR_DETAILS).should('have.text', 'One rule execution'); + getDetails(SUPPRESS_MISSING_FIELD).should( + '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); + skipScheduleRuleAction(); + createRuleWithoutEnabling(); + + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(SUPPRESS_BY_DETAILS).should('have.text', suppressByFields.join('')); + getDetails(SUPPRESS_FOR_DETAILS).should('have.text', 'One rule execution'); + getDetails(SUPPRESS_MISSING_FIELD).should( + 'have.text', + 'Suppress and group alerts for events with missing fields' + ); + }); + }); + + it('allows a rule with interval suppression to be created and displayed', () => { + fillAlertSuppressionFields(suppressByFields); + selectAlertSuppressionPerInterval(); + setAlertSuppressionDuration(45, 'm'); + selectDoNotSuppressForMissingFields(); + continueFromDefineStep(); + + // ensures details preview works correctly + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(SUPPRESS_BY_DETAILS).should('have.text', suppressByFields.join('')); + getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '45m'); + getDetails(SUPPRESS_MISSING_FIELD).should( + '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); + skipScheduleRuleAction(); + createRuleWithoutEnabling(); + + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(SUPPRESS_BY_DETAILS).should('have.text', suppressByFields.join('')); + getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '45m'); + getDetails(SUPPRESS_MISSING_FIELD).should( + 'have.text', + 'Do not suppress alerts for events with missing fields' + ); + }); + }); + }); + }); + }); + } +); 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 new file mode 100644 index 000000000000..5e6cd673070b --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/machine_learning_rule.cy.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getMachineLearningRule } from '../../../../objects/rule'; +import { + ALERT_SUPPRESSION_DURATION_INPUT, + ALERT_SUPPRESSION_FIELDS, + ALERT_SUPPRESSION_MISSING_FIELDS_DO_NOT_SUPPRESS, +} from '../../../../screens/create_new_rule'; +import { + DEFINITION_DETAILS, + DETAILS_TITLE, + SUPPRESS_BY_DETAILS, + SUPPRESS_FOR_DETAILS, + SUPPRESS_MISSING_FIELD, +} from '../../../../screens/rule_details'; +import { + executeSetupModuleRequest, + forceStartDatafeeds, + forceStopAndCloseJob, +} from '../../../../support/machine_learning'; +import { editFirstRule } from '../../../../tasks/alerts_detection_rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { + clearAlertSuppressionFields, + fillAlertSuppressionFields, + selectAlertSuppressionPerInterval, + selectAlertSuppressionPerRuleExecution, + setAlertSuppressionDuration, +} from '../../../../tasks/create_new_rule'; +import { saveEditedRule } from '../../../../tasks/edit_rule'; +import { login } from '../../../../tasks/login'; +import { visit } from '../../../../tasks/navigation'; +import { assertDetailsNotExist, getDetails } from '../../../../tasks/rule_details'; +import { RULES_MANAGEMENT_URL } from '../../../../urls/rules_management'; + +describe( + 'Machine Learning Detection Rules - Editing', + { + // Skipping in MKI as it depends on feature flag alertSuppressionForMachineLearningRuleEnabled + tags: ['@ess', '@serverless', '@skipInServerlessMKI'], + env: { + ftrConfig: { + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'alertSuppressionForMachineLearningRuleEnabled', + ])}`, + ], + }, + }, + }, + () => { + let mlRule: ReturnType<typeof getMachineLearningRule>; + const suppressByFields = ['by_field_name', 'by_field_value']; + const jobId = 'v3_linux_anomalous_network_activity'; + + before(() => { + const machineLearningJobIds = ([] as string[]).concat( + getMachineLearningRule().machine_learning_job_id + ); + // ensure no ML jobs are started before the test + machineLearningJobIds.forEach((j) => forceStopAndCloseJob({ jobId: j })); + }); + + beforeEach(() => { + login(); + deleteAlertsAndRules(); + cy.task('esArchiverLoad', { archiveName: '../auditbeat/hosts', type: 'ftr' }); + executeSetupModuleRequest({ moduleName: 'security_linux_v3' }); + forceStartDatafeeds({ jobIds: [jobId] }); + cy.task('esArchiverLoad', { archiveName: 'anomalies', type: 'ftr' }); + }); + + describe('without Alert Suppression', () => { + beforeEach(() => { + mlRule = getMachineLearningRule({ machine_learning_job_id: [jobId] }); + createRule(mlRule); + visit(RULES_MANAGEMENT_URL); + editFirstRule(); + }); + + it('allows editing of a rule to add suppression configuration', () => { + fillAlertSuppressionFields(suppressByFields); + selectAlertSuppressionPerInterval(); + setAlertSuppressionDuration(2, 'h'); + + saveEditedRule(); + + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(SUPPRESS_BY_DETAILS).should('have.text', suppressByFields.join('')); + getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '2h'); + getDetails(SUPPRESS_MISSING_FIELD).should( + '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'); + }); + }); + }); + + describe('with Alert Suppression', () => { + beforeEach(() => { + mlRule = { + ...getMachineLearningRule({ machine_learning_job_id: [jobId] }), + alert_suppression: { + group_by: suppressByFields, + duration: { value: 360, unit: 's' }, + missing_fields_strategy: 'doNotSuppress', + }, + }; + + createRule(mlRule); + visit(RULES_MANAGEMENT_URL); + editFirstRule(); + }); + + it('allows editing of a rule to change its suppression configuration', () => { + // check saved suppression settings + cy.get(ALERT_SUPPRESSION_DURATION_INPUT) + .eq(0) + .should('be.enabled') + .should('have.value', 360); + cy.get(ALERT_SUPPRESSION_DURATION_INPUT) + .eq(1) + .should('be.enabled') + .should('have.value', 's'); + + cy.get(ALERT_SUPPRESSION_FIELDS).should('contain', suppressByFields.join('')); + cy.get(ALERT_SUPPRESSION_MISSING_FIELDS_DO_NOT_SUPPRESS).should('be.checked'); + + // set new duration first to overcome some flaky racing conditions during form save + setAlertSuppressionDuration(2, 'h'); + selectAlertSuppressionPerRuleExecution(); + + saveEditedRule(); + + // check execution duration has changed + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(SUPPRESS_FOR_DETAILS).should('have.text', 'One rule execution'); + }); + }); + + it('allows editing of a rule to remove suppression configuration', () => { + // check saved suppression settings + cy.get(ALERT_SUPPRESSION_DURATION_INPUT) + .eq(0) + .should('be.enabled') + .should('have.value', 360); + cy.get(ALERT_SUPPRESSION_DURATION_INPUT) + .eq(1) + .should('be.enabled') + .should('have.value', 's'); + + cy.get(ALERT_SUPPRESSION_FIELDS).should('contain', suppressByFields.join('')); + cy.get(ALERT_SUPPRESSION_MISSING_FIELDS_DO_NOT_SUPPRESS).should('be.checked'); + + // set new duration first to overcome some flaky racing conditions during form save + setAlertSuppressionDuration(2, 'h'); + + clearAlertSuppressionFields(); + saveEditedRule(); + + // check suppression is now absent + cy.get(DEFINITION_DETAILS).within(() => { + assertDetailsNotExist(SUPPRESS_FOR_DETAILS); + assertDetailsNotExist(SUPPRESS_BY_DETAILS); + }); + }); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts index 4b4a9542ff1b..fca78851ddf0 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts @@ -220,9 +220,17 @@ describe('Detection rules, Prebuilt Rules Installation and Update workflow', () type: 'machine_learning', anomaly_threshold: 65, machine_learning_job_id: ['auth_high_count_logon_events', 'auth_high_count_logon_fails'], + alert_suppression: { + group_by: ['host.name'], + duration: { unit: 'm', value: 5 }, + missing_fields_strategy: 'suppress', + }, }), ['security-rule.query', 'security-rule.language'] - ) as typeof CUSTOM_QUERY_INDEX_PATTERN_RULE; + ) as Omit< + ReturnType<typeof createRuleAssetSavedObject>, + 'security-rule.query' | 'security-rule.language' + >; const THRESHOLD_RULE_INDEX_PATTERN = createRuleAssetSavedObject({ name: 'Threshold index pattern rule', @@ -500,24 +508,30 @@ describe('Detection rules, Prebuilt Rules Installation and Update workflow', () }); it('Machine learning rule properties', function () { - clickAddElasticRulesButton(); - - openRuleInstallPreview(MACHINE_LEARNING_RULE['security-rule'].name); - - assertCommonPropertiesShown(commonProperties); - const { + name, + alert_suppression: alertSuppression, anomaly_threshold: anomalyThreshold, machine_learning_job_id: machineLearningJobIds, } = MACHINE_LEARNING_RULE['security-rule'] as { + name: string; anomaly_threshold: number; machine_learning_job_id: string[]; + alert_suppression: AlertSuppression; }; + + clickAddElasticRulesButton(); + openRuleInstallPreview(name); + + assertCommonPropertiesShown(commonProperties); + assertMachineLearningPropertiesShown( anomalyThreshold, machineLearningJobIds, this.mlModules ); + + assertAlertSuppressionPropertiesShown(alertSuppression); }); it('Threshold rule properties', () => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules.cy.ts index 1c7bb46c4cbc..41bcc0ff3f93 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules.cy.ts @@ -611,7 +611,7 @@ describe('Detection rules, bulk edit', { tags: ['@ess', '@serverless'] }, () => cy.get(RULES_BULK_EDIT_INVESTIGATION_FIELDS_WARNING).should( 'have.text', - `You’re about to overwrite custom highlighted fields for ${rows.length} selected rules, press Save to apply changes.` + `You’re about to overwrite custom highlighted fields for the ${rows.length} rules you selected. To apply and save the changes, click Save.` ); typeInvestigationFields(fieldsToOverwrite); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts index 6506b0985ee2..9c84a9067fbf 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts @@ -33,6 +33,7 @@ describe('Cases connectors', { tags: ['@ess', '@serverless'] }, () => { updated_at: null, updated_by: null, customFields: [], + templates: [], mappings: [ { source: 'title', target: 'summary', action_type: 'overwrite' }, { source: 'description', target: 'description', action_type: 'overwrite' }, diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_preview_panel_rule_preview.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_preview_panel_rule_preview.cy.ts index 7e3bc819df85..e798f181593d 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_preview_panel_rule_preview.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_preview_panel_rule_preview.cy.ts @@ -120,7 +120,7 @@ describe( cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_FOOTER).should('be.visible'); cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_FOOTER_LINK).should( 'contain.text', - 'Show rule details' + 'Show full rule details' ); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/support/machine_learning.ts b/x-pack/test/security_solution_cypress/cypress/support/machine_learning.ts index e562a693865e..5fb869cebc29 100644 --- a/x-pack/test/security_solution_cypress/cypress/support/machine_learning.ts +++ b/x-pack/test/security_solution_cypress/cypress/support/machine_learning.ts @@ -5,8 +5,72 @@ * 2.0. */ +import { ML_GROUP_ID } from '@kbn/security-solution-plugin/common/constants'; import { rootRequest } from '../tasks/api_calls/common'; +/** + * + * Calls the internal ML Module API to set up a module, which installs the jobs + * contained in that module. + * @param moduleName the name of the ML module to set up + * @returns the response from the setup module request + */ +export const executeSetupModuleRequest = ({ moduleName }: { moduleName: string }) => + rootRequest({ + headers: { + 'elastic-api-version': 1, + }, + method: 'POST', + url: `/internal/ml/modules/setup/${moduleName}`, + failOnStatusCode: true, + body: { + prefix: '', + groups: [ML_GROUP_ID], + indexPatternName: 'auditbeat-*', + startDatafeed: false, + useDedicatedIndex: true, + applyToAllSpaces: true, + }, + }); + +/** + * + * Calls the internal ML Jobs API to force start the datafeeds for the given job IDs. Necessary to get them in the "started" state for the purposes of the detection engine + * @param jobIds the job IDs for which to force start datafeeds + * @returns the response from the force start datafeeds request + */ +export const forceStartDatafeeds = ({ jobIds }: { jobIds: string[] }) => + rootRequest({ + headers: { + 'elastic-api-version': 1, + }, + method: 'POST', + url: '/internal/ml/jobs/force_start_datafeeds', + failOnStatusCode: true, + body: { + datafeedIds: jobIds.map((jobId) => `datafeed-${jobId}`), + start: new Date().getUTCMilliseconds(), + }, + }); + +/** + * Calls the internal ML Jobs API to stop the datafeeds for the given job IDs. + * @param jobIds the job IDs for which to stop datafeeds + * @returns the response from the stop datafeeds request + */ +export const stopDatafeeds = ({ jobIds }: { jobIds: string[] }) => + rootRequest({ + headers: { + 'elastic-api-version': 1, + }, + method: 'POST', + url: '/internal/ml/jobs/stop_datafeeds', + failOnStatusCode: true, + body: { + datafeedIds: jobIds.map((jobId) => `datafeed-${jobId}`), + }, + }); + /** * Calls the internal ML Jobs API to force stop the datafeed of, and force close * the job with the given ID. diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts index 7304a23f75e7..4be7fb43cd11 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts @@ -214,7 +214,6 @@ export const filterByDisabledRules = () => { export const goToRuleDetailsOf = (ruleName: string) => { cy.contains(RULE_NAME, ruleName).click(); - cy.get(PAGE_CONTENT_SPINNER).should('be.visible'); cy.contains(RULE_NAME_HEADER, ruleName).should('be.visible'); cy.get(PAGE_CONTENT_SPINNER).should('not.exist'); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts index aa154cd15b03..6f99331f9181 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts @@ -65,6 +65,7 @@ export const attachTimeline = (newCase: TestCase) => { cy.get('body').type('{esc}'); cy.get(INSERT_TIMELINE_BTN).click(); cy.get(LOADING_INDICATOR).should('not.exist'); + cy.get('[data-test-subj="selectable-input"]').click(); cy.get(TIMELINE_SEARCHBOX).should('exist'); cy.get(TIMELINE_SEARCHBOX).should('be.visible'); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts index e8be51d0d373..65203d2594d0 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts @@ -802,13 +802,16 @@ export const continueFromDefineStep = () => { getDefineContinueButton().should('exist').click({ force: true }); }; +const optionsToComboboxText = (options: string[]) => { + return options.map((o) => `${o}{downArrow}{enter}{esc}`).join(''); +}; + export const fillDefineMachineLearningRule = (rule: MachineLearningRuleCreateProps) => { const jobsAsArray = isArray(rule.machine_learning_job_id) ? rule.machine_learning_job_id : [rule.machine_learning_job_id]; cy.get(MACHINE_LEARNING_DROPDOWN_INPUT).click({ force: true }); cy.get(MACHINE_LEARNING_DROPDOWN_INPUT).type(optionsToComboboxText(jobsAsArray)); - cy.get(ANOMALY_THRESHOLD_INPUT).type(`{selectall}${rule.anomaly_threshold}`, { force: true, }); @@ -908,14 +911,12 @@ export const enablesAndPopulatesThresholdSuppression = ( cy.get(ALERT_SUPPRESSION_DURATION_PER_TIME_INTERVAL).should('be.enabled').should('be.checked'); }; -const optionsToComboboxText = (options: string[]) => { - return options.map((o) => `${o}{downArrow}{enter}{esc}`).join(''); -}; - export const fillAlertSuppressionFields = (fields: string[]) => { cy.get(ALERT_SUPPRESSION_FIELDS_COMBO_BOX).should('not.be.disabled'); cy.get(ALERT_SUPPRESSION_FIELDS_COMBO_BOX).click(); - cy.get(ALERT_SUPPRESSION_FIELDS_COMBO_BOX).type(optionsToComboboxText(fields)); + fields.forEach((field) => { + cy.get(ALERT_SUPPRESSION_FIELDS_COMBO_BOX).type(`${field}{downArrow}{enter}{esc}`); + }); }; export const clearAlertSuppressionFields = () => { diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/rules_bulk_actions.ts b/x-pack/test/security_solution_cypress/cypress/tasks/rules_bulk_actions.ts index fab3221fecb9..c551054fbc6c 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/rules_bulk_actions.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/rules_bulk_actions.ts @@ -273,10 +273,10 @@ export const typeInvestigationFields = (fields: string[]) => { export const checkOverwriteInvestigationFieldsCheckbox = () => { cy.get(RULES_BULK_EDIT_OVERWRITE_INVESTIGATION_FIELDS_CHECKBOX) - .should('have.text', "Overwrite all selected rules' custom highlighted fields") + .should('have.text', 'Overwrite the custom highlighted fields for the selected rules') .click(); cy.get(RULES_BULK_EDIT_OVERWRITE_INVESTIGATION_FIELDS_CHECKBOX) - .should('have.text', "Overwrite all selected rules' custom highlighted fields") + .should('have.text', 'Overwrite the custom highlighted fields for the selected rules') .get('input') .should('be.checked'); }; diff --git a/x-pack/test/security_solution_cypress/serverless_config.ts b/x-pack/test/security_solution_cypress/serverless_config.ts index ebdd5d1b333c..b9f153028e5c 100644 --- a/x-pack/test/security_solution_cypress/serverless_config.ts +++ b/x-pack/test/security_solution_cypress/serverless_config.ts @@ -37,6 +37,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'alertSuppressionForEsqlRuleEnabled', 'bulkCustomHighlightedFieldsEnabled', + 'alertSuppressionForMachineLearningRuleEnabled', 'manualRuleRunEnabled', ])}`, ], diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/infra/processes.ts b/x-pack/test_serverless/api_integration/test_suites/observability/infra/processes.ts index e6f490ec4bfa..a7f61d2a7ea1 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/infra/processes.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/infra/processes.ts @@ -43,7 +43,7 @@ export default function ({ getService }: FtrProviderContext) { hostTerm: { 'host.name': 'serverless-host', }, - indexPattern: 'metrics-*,metricbeat-*', + sourceId: 'default', to: DATES.serverlessTestingHost.max, sortBy: { name: 'cpu', diff --git a/x-pack/test_serverless/api_integration/test_suites/search/cases/find_cases.ts b/x-pack/test_serverless/api_integration/test_suites/search/cases/find_cases.ts index b847833b30b4..c03cab368d5d 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/cases/find_cases.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/cases/find_cases.ts @@ -6,17 +6,30 @@ */ import { CASES_URL } from '@kbn/cases-plugin/common/constants'; +import type { RoleCredentials } from '../../../../shared/services'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const svlCommonApi = getService('svlCommonApi'); + const svlUserManager = getService('svlUserManager'); describe('find_cases', () => { + let roleAuthc: RoleCredentials; + + before(async () => { + roleAuthc = await svlUserManager.createApiKeyForRole('viewer'); + }); + + after(async () => { + await svlUserManager.invalidateApiKeyForRole(roleAuthc); + }); + it('403 when calling find cases API', async () => { - await supertest + await supertestWithoutAuth .get(`${CASES_URL}/_find`) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) .expect(403); }); }); diff --git a/x-pack/test_serverless/api_integration/test_suites/search/cases/post_case.ts b/x-pack/test_serverless/api_integration/test_suites/search/cases/post_case.ts index 4391fa29e083..f6f596d6b045 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/cases/post_case.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/cases/post_case.ts @@ -8,18 +8,31 @@ import { CASES_URL } from '@kbn/cases-plugin/common/constants'; import { CaseSeverity } from '@kbn/cases-plugin/common/types/domain'; import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; +import type { RoleCredentials } from '../../../../shared/services'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const svlCommonApi = getService('svlCommonApi'); + const svlUserManager = getService('svlUserManager'); describe('post_case', () => { + let roleAuthc: RoleCredentials; + + before(async () => { + roleAuthc = await svlUserManager.createApiKeyForRole('viewer'); + }); + + after(async () => { + await svlUserManager.invalidateApiKeyForRole(roleAuthc); + }); + it('403 when trying to create case', async () => { - await supertest + await supertestWithoutAuth .post(CASES_URL) - .set('kbn-xsrf', 'foo') - .set('x-elastic-internal-origin', 'foo') + .set(svlCommonApi.getInternalRequestHeader()) + .set(roleAuthc.apiKeyHeader) .send({ description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Observability Issue', diff --git a/x-pack/test_serverless/functional/page_objects/svl_common_page.ts b/x-pack/test_serverless/functional/page_objects/svl_common_page.ts index 827821c128da..a74fde47a0d4 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_common_page.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_common_page.ts @@ -70,6 +70,9 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide }; return { + /** + * Login to Kibana using SAML authentication with provided project-specfic role + */ async loginWithRole(role: string) { log.debug(`Fetch the cookie for '${role}' role`); const sidCookie = await svlUserManager.getSessionCookieForRole(role); @@ -121,10 +124,17 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide ); }, + /** + * + * Login to Kibana using SAML authentication with Admin role + */ async loginAsAdmin() { await this.loginWithRole('admin'); }, + /** + * Login to Kibana using SAML authentication with Editor/Developer role + */ async loginWithPrivilegedRole() { await this.loginWithRole(svlUserManager.DEFAULT_ROLE); }, @@ -138,6 +148,11 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide }); }, + /** + * Log out from Kibana, only required when test uses basic authentication: svlCommonPage.login() + * + * @deprecated in favor of role-based SAML authentication, no need to call it when test is migrated to SAML auth with `svlCommonPage.loginWithRole(role: string)` + */ async forceLogout() { log.debug('SvlCommonPage.forceLogout'); if (await find.existsByDisplayedByCssSelector('.login-form', 2000)) { @@ -174,6 +189,14 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide }); }, + /** + * Login to Kibana with operator user using basic authentication via '/login' route + * + * @deprecated in favor of role-based SAML authentication: `svlCommonPage.loginWithRole(role: string)` + * + * Meta issue https://github.com/elastic/kibana/issues/183512 + * Target date is end of June 2024 + */ async login() { await this.forceLogout(); diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts b/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts index 6ce802102436..600ce61167c7 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts @@ -35,8 +35,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { defaultIndex: 'logstash-*', }; - // Failing: See https://github.com/elastic/kibana/issues/183493 - describe.skip('discover esql view', async function () { + describe('discover esql view', async function () { before(async () => { await kibanaServer.savedObjects.cleanStandardList(); log.debug('load kibana index with default index pattern'); @@ -50,8 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - // FLAKY: https://github.com/elastic/kibana/issues/183193 - describe.skip('test', () => { + describe('test', () => { it('should render esql view correctly', async function () { await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); @@ -133,11 +131,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should query an index pattern that doesnt translate to a dataview correctly', async function () { await PageObjects.discover.selectTextBaseLang(); - const testQuery = `from logstash* | limit 10 | stats countB = count(bytes) by geo.dest | sort countB`; + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + const testQuery = `from logstash* | limit 10 | stats countB = count(bytes) by geo.dest | sort countB`; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); const cell = await dataGrid.getCellElement(0, 2); expect(await cell.getVisibleText()).to.be('1'); @@ -175,10 +176,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/183479 - describe.skip('errors', () => { + describe('errors', () => { it('should show error messages for syntax errors in query', async function () { await PageObjects.discover.selectTextBaseLang(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + const brokenQueries = [ 'from logstash-* | limit 10*', 'from logstash-* | limit A', @@ -305,7 +308,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const historyItems = await esql.getHistoryItems(); log.debug(historyItems); const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 10'; + return item[1] === 'FROM logstash-* | LIMIT 10'; }); expect(queryAdded).to.be(true); diff --git a/x-pack/test_serverless/functional/test_suites/common/platform_security/user_profiles/user_profiles.ts b/x-pack/test_serverless/functional/test_suites/common/platform_security/user_profiles/user_profiles.ts index 50313bc4da7c..4909f4a99a8f 100644 --- a/x-pack/test_serverless/functional/test_suites/common/platform_security/user_profiles/user_profiles.ts +++ b/x-pack/test_serverless/functional/test_suites/common/platform_security/user_profiles/user_profiles.ts @@ -20,10 +20,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.svlCommonPage.loginWithRole(VIEWER_ROLE); }); - after(async () => { - await pageObjects.svlCommonPage.forceLogout(); - }); - describe('User details', async () => { it('should display correct user details', async () => { await pageObjects.common.navigateToApp('security_account'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/advanced_settings.ts b/x-pack/test_serverless/functional/test_suites/observability/advanced_settings.ts index 0e3c4d65349c..6a9f06974a5c 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/advanced_settings.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/advanced_settings.ts @@ -22,10 +22,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.common.navigateToApp('settings'); }); - after(async () => { - await pageObjects.svlCommonPage.forceLogout(); - }); - it('renders the page', async () => { await retry.waitFor('title to be visible', async () => { return await testSubjects.exists('managementSettingsTitle'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts index 7719ba01ce8e..72fb345fe012 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts @@ -26,7 +26,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Cases persistable attachments', function () { describe('lens visualization', () => { before(async () => { - await svlCommonPage.loginWithRole('admin'); + await svlCommonPage.loginWithPrivilegedRole(); await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await kibanaServer.importExport.load( @@ -50,7 +50,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { ); await kibanaServer.savedObjects.cleanStandardList(); - await svlCommonPage.forceLogout(); }); it('adds lens visualization to a new case', async () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index 8a966c6b7d13..1909408d0533 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -20,10 +20,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); const retry = getService('retry'); const find = getService('find'); + const comboBox = getService('comboBox'); describe('Configure Case', function () { before(async () => { - await svlCommonPage.loginWithRole('admin'); + await svlCommonPage.loginWithPrivilegedRole(); await svlObltNavigation.navigateToLandingPage(); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'observability-overview:cases' }); await header.waitUntilLoadingHasFinished(); @@ -42,7 +43,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { after(async () => { await svlCases.api.deleteAllCaseItems(); - await svlCommonPage.forceLogout(); }); describe('Closure options', function () { @@ -76,13 +76,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Custom fields', function () { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -100,7 +100,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -114,12 +114,89 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); + }); }); }; diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts index 88f2663a1074..7f393c4457d7 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts @@ -25,7 +25,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { const header = getPageObject('header'); before(async () => { - await svlCommonPage.loginWithRole('admin'); + await svlCommonPage.loginWithPrivilegedRole(); }); beforeEach(async () => { @@ -35,7 +35,6 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { after(async () => { await svlCases.api.deleteAllCaseItems(); - await svlCommonPage.forceLogout(); }); it('creates a case', async () => { @@ -97,7 +96,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts index e98cc99bdec5..0605df54825e 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts @@ -21,7 +21,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Cases list', function () { before(async () => { - await svlCommonPage.loginWithRole('admin'); + await svlCommonPage.loginWithPrivilegedRole(); await svlObltNavigation.navigateToLandingPage(); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'observability-overview:cases' }); }); @@ -29,7 +29,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { after(async () => { await svlCases.api.deleteAllCaseItems(); await cases.casesTable.waitForCasesToBeDeleted(); - await svlCommonPage.forceLogout(); }); describe('empty state', () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts index 5962a5f7c419..4446638ff080 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts @@ -36,12 +36,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Case View', function () { before(async () => { - await svlCommonPage.loginWithRole('admin'); + await svlCommonPage.loginWithPrivilegedRole(); }); after(async () => { await svlCases.api.deleteAllCaseItems(); - await svlCommonPage.forceLogout(); }); describe('page', () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts b/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts index 24f6011c7885..9d20aefcebd2 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts @@ -276,6 +276,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('navigation', () => { + afterEach(async () => { + // Navigate back to dataset quality page after each test + await PageObjects.datasetQuality.navigateTo(); + }); + it('should go to log explorer page when the open in log explorer button is clicked', async () => { const testDatasetName = datasetNames[2]; await PageObjects.datasetQuality.openDatasetFlyout(testDatasetName); @@ -288,9 +293,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.eql(testDatasetName); - - // Should bring back the test to the dataset quality page - await PageObjects.datasetQuality.navigateTo(); }); it('should go log explorer for degraded docs when the show all button is clicked', async () => { @@ -298,17 +300,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const degradedDocsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.degradedDocs}`; await testSubjects.click(degradedDocsShowAllSelector); - await browser.switchTab(1); // Confirm dataset selector text in observability logs explorer const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.contain(apacheAccessDatasetName); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); // Blocked by https://github.com/elastic/kibana/issues/181705 @@ -318,7 +314,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const hostsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.hosts}`; await testSubjects.click(hostsShowAllSelector); - await browser.switchTab(1); // Confirm url contains metrics/hosts await retry.tryForTime(5000, async () => { @@ -326,11 +321,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const parsedUrl = new URL(currentUrl); expect(parsedUrl.pathname).to.contain('/app/metrics/hosts'); }); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/infra/header_menu.ts b/x-pack/test_serverless/functional/test_suites/observability/infra/header_menu.ts index e59021dbd7e1..0abb121cd2d8 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/infra/header_menu.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/infra/header_menu.ts @@ -21,7 +21,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'); - await pageObjects.svlCommonPage.forceLogout(); }); describe('Alerts dropdown', () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts b/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts index 45aa62228131..9a2b6377e736 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts @@ -53,7 +53,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await Promise.all([ esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'), ]); - await pageObjects.svlCommonPage.forceLogout(); }); describe('#Single Host Flyout', () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/infra/infra.ts b/x-pack/test_serverless/functional/test_suites/observability/infra/infra.ts index 0f9ddcef05db..af663d98e0bc 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/infra/infra.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/infra/infra.ts @@ -34,10 +34,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.svlCommonPage.loginWithRole('viewer'); }); - after(async () => { - await pageObjects.svlCommonPage.forceLogout(); - }); - describe('Inventory page', function () { this.tags('includeFirefox'); before(async () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/infra/navigation.ts b/x-pack/test_serverless/functional/test_suites/observability/infra/navigation.ts index 6bbb746eb65a..9b30cb0d7d39 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/infra/navigation.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/infra/navigation.ts @@ -30,10 +30,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await svlObltNavigation.navigateToLandingPage(); }); - after(async () => { - await pageObjects.svlCommonPage.forceLogout(); - }); - describe('when Hosts settings is on', () => { before(async () => { await setHostsSetting(true); diff --git a/x-pack/test_serverless/functional/test_suites/observability/infra/node_details.ts b/x-pack/test_serverless/functional/test_suites/observability/infra/node_details.ts index bd56244eb12b..80111c3833f5 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/infra/node_details.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/infra/node_details.ts @@ -44,7 +44,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'); - await pageObjects.svlCommonPage.forceLogout(); }); describe('Osquery Tab', () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/ml/anomaly_detection_jobs_list.ts b/x-pack/test_serverless/functional/test_suites/observability/ml/anomaly_detection_jobs_list.ts index e8dddef3eb35..bdd5d443b359 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/ml/anomaly_detection_jobs_list.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/ml/anomaly_detection_jobs_list.ts @@ -19,7 +19,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Error: Failed to delete all indices with pattern [.ml-*] this.tags(['failsOnMKI']); before(async () => { - await PageObjects.svlCommonPage.loginWithRole('admin'); + await PageObjects.svlCommonPage.loginWithPrivilegedRole(); // Load logstash* data and create dataview for logstash*, logstash-2015.09.22 await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); @@ -31,7 +31,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - await PageObjects.svlCommonPage.forceLogout(); await ml.api.cleanMlIndices(); await ml.testResources.cleanMLSavedObjects(); await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/ml/search_bar_features.ts b/x-pack/test_serverless/functional/test_suites/observability/ml/search_bar_features.ts index 15b7d3d76c61..dbafa9d42022 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/ml/search_bar_features.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/ml/search_bar_features.ts @@ -49,10 +49,6 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.svlCommonPage.loginWithRole(role); }); - after(async () => { - await PageObjects.svlCommonPage.forceLogout(); - }); - describe('list features', () => { it('has the correct features enabled', async () => { await PageObjects.header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/columns_selection.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/columns_selection.ts index 14c5de718531..0f2b7a8c12e0 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/columns_selection.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/columns_selection.ts @@ -41,7 +41,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await synthtrace.clean(); - await PageObjects.svlCommonPage.forceLogout(); }); describe('columns selection initialization and update', () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/custom_control_columns.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/custom_control_columns.ts index 6e836f00cc3f..3b6495e79ab0 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/custom_control_columns.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/custom_control_columns.ts @@ -42,7 +42,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await synthtrace.clean(); - await PageObjects.svlCommonPage.forceLogout(); }); describe('should render custom control columns properly', async () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selection_state.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selection_state.ts index 871c0151432a..6f44f68308b4 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selection_state.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selection_state.ts @@ -37,10 +37,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.svlCommonPage.loginWithRole('viewer'); }); - after(async () => { - await PageObjects.svlCommonPage.forceLogout(); - }); - describe('when no dataSourceSelection is given', () => { it('should initialize the "All logs" selection', async () => { await PageObjects.observabilityLogsExplorer.navigateTo(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts index 3bb012c81311..4491df8d7c89 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/data_source_selector.ts @@ -44,10 +44,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.observabilityLogsExplorer.removeInstalledPackages(); }); - after(async () => { - await PageObjects.svlCommonPage.forceLogout(); - }); - describe('as consistent behavior', () => { before(async () => { await PageObjects.observabilityLogsExplorer.navigateTo(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/field_list.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/field_list.ts index 5c5cd85f624a..688a6844f606 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/field_list.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/field_list.ts @@ -41,7 +41,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await synthtrace.clean(); - await PageObjects.svlCommonPage.forceLogout(); }); describe('field list initialisation', () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/filter_controls.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/filter_controls.ts index 0dc479b39180..c63d41da70f7 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/filter_controls.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/filter_controls.ts @@ -18,7 +18,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after('clean up archives', async () => { - await PageObjects.svlCommonPage.forceLogout(); await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/flyout.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/flyout.ts index 2e181f1a7d9b..1019f56fd43f 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/flyout.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/flyout.ts @@ -61,7 +61,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after('clean up archives', async () => { - await PageObjects.svlCommonPage.forceLogout(); if (cleanupDataStreamSetup) { cleanupDataStreamSetup(); } diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/header_menu.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/header_menu.ts index a61c1afbfb61..82b58e5c4f92 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/header_menu.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer/header_menu.ts @@ -37,7 +37,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - await PageObjects.svlCommonPage.forceLogout(); await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); await esArchiver.unload( 'x-pack/test/functional/es_archives/observability_logs_explorer/data_streams' diff --git a/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts b/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts index 4696907ed8ee..8208c73ad0b6 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts @@ -103,7 +103,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); after(async () => { - await svlCommonPage.forceLogout(); await svlUserManager.invalidateApiKeyForRole(roleAuthc); }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts index b4666e780734..4d453a06daa7 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts @@ -55,15 +55,11 @@ export default function ({ getPageObject, getPageObjects, getService }: FtrProvi roleAuthc ); caseIdMonitoring = caseMonitoring.id; + await pageObjects.svlCommonPage.loginWithRole('admin'); }); after(async () => { await svlCases.api.deleteAllCaseItems(); - await pageObjects.svlCommonPage.forceLogout(); - }); - - beforeEach(async () => { - await pageObjects.svlCommonPage.loginWithRole('admin'); }); it('cases list screenshot', async () => { diff --git a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/server_log_connector.ts b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/server_log_connector.ts index a38807f4537c..cef28cd33ebf 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/server_log_connector.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/server_log_connector.ts @@ -18,10 +18,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await pageObjects.svlCommonPage.loginWithRole('admin'); }); - after(async () => { - await pageObjects.svlCommonPage.forceLogout(); - }); - it('server log connector screenshots', async () => { await pageObjects.common.navigateToApp('connectors'); await pageObjects.header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/maintenance_windows/create_window.ts b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/maintenance_windows/create_window.ts index 73f431eeb6cc..1346abe722a7 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/maintenance_windows/create_window.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/maintenance_windows/create_window.ts @@ -19,10 +19,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.svlCommonPage.loginWithRole('admin'); }); - after(async () => { - await pageObjects.svlCommonPage.forceLogout(); - }); - it('create maintenance window screenshot', async () => { await pageObjects.common.navigateToApp('maintenanceWindows'); await pageObjects.header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test_serverless/functional/test_suites/search/cases/attachment_framework.ts b/x-pack/test_serverless/functional/test_suites/search/cases/attachment_framework.ts index 831369311f94..566e145a4fd8 100644 --- a/x-pack/test_serverless/functional/test_suites/search/cases/attachment_framework.ts +++ b/x-pack/test_serverless/functional/test_suites/search/cases/attachment_framework.ts @@ -20,7 +20,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('persistable attachment', () => { before(async () => { - await svlCommonPage.loginWithRole('developer'); + await svlCommonPage.loginWithPrivilegedRole(); }); after(async () => { diff --git a/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.essentials.ts b/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.essentials.ts index 320b130e63e9..11b4f056841f 100644 --- a/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.essentials.ts +++ b/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.essentials.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CLOUD_SECURITY_PLUGIN_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants'; import { createTestConfig } from '../../config.base'; export default createTestConfig({ @@ -14,7 +15,7 @@ export default createTestConfig({ }, kbnServerArgs: [ `--xpack.fleet.packages.0.name=cloud_security_posture`, - `--xpack.fleet.packages.0.version=1.5.2`, + `--xpack.fleet.packages.0.version=${CLOUD_SECURITY_PLUGIN_VERSION}`, `--xpack.securitySolutionServerless.productTypes=${JSON.stringify([ { product_line: 'security', product_tier: 'essentials' }, { product_line: 'endpoint', product_tier: 'essentials' }, diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/advanced_settings.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/advanced_settings.ts index c6448aac27ec..9571ec689f7a 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/advanced_settings.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/advanced_settings.ts @@ -22,10 +22,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.common.navigateToApp('settings'); }); - after(async () => { - await pageObjects.svlCommonPage.forceLogout(); - }); - it('renders the page', async () => { await retry.waitFor('title to be visible', async () => { return await testSubjects.exists('managementSettingsTitle'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts index 99edcd792690..4e3283c6600d 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts @@ -101,6 +101,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await common.navigateToApp('security', { path: 'dashboards' }); await header.waitUntilLoadingHasFinished(); + await testSubjects.click('LandingImageCards-accordionButton'); if (await testSubjects.exists('edit-unsaved-New-Dashboard')) { await testSubjects.click('edit-unsaved-New-Dashboard'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index bd36f8f7a8ea..478cb6d78f77 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -22,6 +22,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); const retry = getService('retry'); const find = getService('find'); + const comboBox = getService('comboBox'); describe('Configure Case', function () { before(async () => { @@ -76,13 +77,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Custom fields', function () { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -100,7 +101,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -114,12 +115,89 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); + }); }); }; diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts index 27e4fda20f5e..4662e96c401f 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts @@ -97,7 +97,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/compliance_dashboard.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/compliance_dashboard.ts index a5b99f0dd31b..c7ea84a2abe2 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/compliance_dashboard.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/compliance_dashboard.ts @@ -58,7 +58,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { after(async () => { await cspDashboard.index.remove(); - await pageObjects.svlCommonPage.forceLogout(); }); describe('Kubernetes Dashboard', () => { diff --git a/x-pack/test_serverless/functional/test_suites/security/ml/anomaly_detection_jobs_list.ts b/x-pack/test_serverless/functional/test_suites/security/ml/anomaly_detection_jobs_list.ts index f73703010b87..9e1154ea09bb 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ml/anomaly_detection_jobs_list.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ml/anomaly_detection_jobs_list.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ServerlessRoleName } from '../../../../shared/lib/security/types'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -18,7 +19,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Error: Failed to delete all indices with pattern [.ml-*] this.tags(['failsOnMKI']); before(async () => { - await PageObjects.svlCommonPage.login(); + await PageObjects.svlCommonPage.loginWithRole(ServerlessRoleName.PLATFORM_ENGINEER); // Load logstash* data and create dataview for logstash*, logstash-2015.09.22 await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await kibanaServer.importExport.load( @@ -28,7 +29,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - await PageObjects.svlCommonPage.forceLogout(); await ml.api.cleanMlIndices(); await ml.testResources.cleanMLSavedObjects(); await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ml/data_frame_analytics_jobs_list.ts b/x-pack/test_serverless/functional/test_suites/security/ml/data_frame_analytics_jobs_list.ts index 3a4153b264cc..d3caa3425f75 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ml/data_frame_analytics_jobs_list.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ml/data_frame_analytics_jobs_list.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ServerlessRoleName } from '../../../../shared/lib/security/types'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -17,7 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Error: Failed to delete all indices with pattern [.ml-*] this.tags(['failsOnMKI']); before(async () => { - await PageObjects.svlCommonPage.login(); + await PageObjects.svlCommonPage.loginWithRole(ServerlessRoleName.PLATFORM_ENGINEER); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ihp_outlier'); await ml.testResources.createDataViewIfNeeded('ft_ihp_outlier', '@timestamp'); @@ -28,7 +29,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - await PageObjects.svlCommonPage.forceLogout(); await ml.api.cleanMlIndices(); await ml.testResources.cleanMLSavedObjects(); }); diff --git a/x-pack/test_serverless/functional/test_suites/security/ml/search_bar_features.ts b/x-pack/test_serverless/functional/test_suites/security/ml/search_bar_features.ts index 35075b9f0da4..85c710a3f380 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ml/search_bar_features.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ml/search_bar_features.ts @@ -5,6 +5,7 @@ * 2.0. */ import expect from '@kbn/expect'; +import { ServerlessRoleName } from '../../../../shared/lib'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getPageObjects }: FtrProviderContext) { @@ -41,11 +42,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { describe('Search bar features', () => { before(async () => { - await PageObjects.svlCommonPage.login(); - }); - - after(async () => { - await PageObjects.svlCommonPage.forceLogout(); + await PageObjects.svlCommonPage.loginWithRole(ServerlessRoleName.PLATFORM_ENGINEER); }); describe('list features', () => { diff --git a/x-pack/test_serverless/functional/test_suites/security/ml/trained_models_list.ts b/x-pack/test_serverless/functional/test_suites/security/ml/trained_models_list.ts index 745f2b8d4a65..51edbadf2e6b 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ml/trained_models_list.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ml/trained_models_list.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ServerlessRoleName } from '../../../../shared/lib'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -13,14 +14,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Trained models list', function () { before(async () => { - await PageObjects.svlCommonPage.login(); + await PageObjects.svlCommonPage.loginWithRole(ServerlessRoleName.PLATFORM_ENGINEER); await ml.api.syncSavedObjects(); }); - after(async () => { - await PageObjects.svlCommonPage.forceLogout(); - }); - describe('page navigation', () => { it('renders trained models list', async () => { await ml.navigation.navigateToMl(); diff --git a/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml b/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml index 9f3220959c48..9e1e542df8e8 100644 --- a/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml +++ b/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml @@ -34,6 +34,7 @@ viewer: - ".fleet-actions*" - "risk-score.risk-score-*" - ".asset-criticality.asset-criticality-*" + - ".ml-anomalies-*" privileges: - read applications: @@ -100,6 +101,10 @@ editor: - "read" - "write" allow_restricted_indices: false + - names: + - ".ml-anomalies-*" + privileges: + - read applications: - application: "kibana-.kibana" privileges: @@ -155,6 +160,7 @@ t1_analyst: - ".fleet-actions*" - risk-score.risk-score-* - .asset-criticality.asset-criticality-* + - ".ml-anomalies-*" privileges: - read applications: @@ -203,6 +209,7 @@ t2_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read - names: @@ -265,6 +272,7 @@ t3_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -284,6 +292,7 @@ t3_analyst: - feature_siem.process_operations_all - feature_siem.actions_log_management_all # Response actions history - feature_siem.file_operations_all + - feature_siem.scan_operations_all - feature_securitySolutionCases.all - feature_securitySolutionAssistant.all - feature_actions.read @@ -330,6 +339,7 @@ threat_intelligence_analyst: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -389,6 +399,7 @@ rule_author: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -454,6 +465,7 @@ soc_manager: - .fleet-agents* - .fleet-actions* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read applications: @@ -515,6 +527,7 @@ detections_admin: - metrics-endpoint.metadata_current_* - .fleet-agents* - .fleet-actions* + - ".ml-anomalies-*" privileges: - read - names: @@ -573,6 +586,10 @@ platform_engineer: privileges: - read - write + - names: + - ".ml-anomalies-*" + privileges: + - read applications: - application: "kibana-.kibana" privileges: @@ -624,6 +641,7 @@ endpoint_operations_analyst: - .lists* - .items* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read - names: @@ -692,6 +710,7 @@ endpoint_policy_manager: - packetbeat-* - winlogbeat-* - risk-score.risk-score-* + - ".ml-anomalies-*" privileges: - read - names: diff --git a/yarn.lock b/yarn.lock index 9de08dc5d49d..17882dae4ca6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1658,10 +1658,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@66.0.4": - version "66.0.4" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-66.0.4.tgz#aabb145687805ca7f491df22c15d7d535ca80031" - integrity sha512-fxOivMRUtcTrSOKTqxxDN5XVCzaW0lVrydCxGGHg3NoYEBtOktnF8SWATO8EYfg5cSBDcVbLGOnscqM84Q2aDA== +"@elastic/charts@66.0.5": + version "66.0.5" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-66.0.5.tgz#0913cd763fc4a4a6570fa617dbb513eba069c45c" + integrity sha512-a5TPbt7qWl0zFXT5jUqtHkRalc6OUppEKm+oZDKZNdwwYOVn4zb/uOvTQoBRYDHb7lqX/1Es1wVwsiIagifvEA== dependencies: "@popperjs/core" "^2.11.8" bezier-easing "^2.1.0"