diff --git a/.buildkite/pipeline-resource-definitions/kibana-uiam-cosmos-db-emulator-verify-and-promote.yml b/.buildkite/pipeline-resource-definitions/kibana-uiam-cosmos-db-emulator-verify-and-promote.yml new file mode 100644 index 0000000000000..e10c612a20044 --- /dev/null +++ b/.buildkite/pipeline-resource-definitions/kibana-uiam-cosmos-db-emulator-verify-and-promote.yml @@ -0,0 +1,54 @@ +# yaml-language-server: $schema=https://gist.githubusercontent.com/elasticmachine/988b80dae436cafea07d9a4a460a011d/raw/rre.schema.json +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: bk-kibana-uiam-cosmos-db-emulator-verify-and-promote + description: Verify & promote Cosmos DB Emulator images that pass Kibana's test suite + links: + - url: 'https://buildkite.com/elastic/kibana-uiam-cosmos-db-emulator-verify-and-promote' + title: Pipeline link +spec: + type: buildkite-pipeline + owner: 'group:kibana-operations' + system: buildkite + implementation: + apiVersion: buildkite.elastic.dev/v1 + kind: Pipeline + metadata: + name: kibana / uiam cosmos db emulator verify and promote + description: Verify & promote Cosmos DB Emulator images that pass Kibana's test suite + spec: + env: + SLACK_NOTIFICATIONS_CHANNEL: '#kibana-operations-alerts' + UIAM_COSMOSDB_IMAGE: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview + ELASTIC_SLACK_NOTIFICATIONS_ENABLED: 'true' + REPORT_FAILED_TESTS_TO_GITHUB: 'true' + allow_rebuilds: true + branch_configuration: main + default_branch: main + repository: elastic/kibana + pipeline_file: .buildkite/pipelines/uiam/verify_and_promote_cosmos_db_emulator.yml + provider_settings: + build_branches: false + build_pull_requests: false + publish_commit_status: false + trigger_mode: none + build_tags: false + prefix_pull_request_fork_branch_names: false + skip_pull_request_builds_for_existing_commits: false + teams: + everyone: + access_level: BUILD_AND_READ + kibana-operations: + access_level: MANAGE_BUILD_AND_READ + appex-qa: + access_level: MANAGE_BUILD_AND_READ + kibana-tech-leads: + access_level: MANAGE_BUILD_AND_READ + schedules: + Weekly build: + cronline: 0 15 * * 1 America/New_York + message: Weekly build + branch: main + tags: + - kibana diff --git a/.buildkite/pipeline-resource-definitions/locations.yml b/.buildkite/pipeline-resource-definitions/locations.yml index 0a8ba9b1678fb..b1d7fdca3b117 100644 --- a/.buildkite/pipeline-resource-definitions/locations.yml +++ b/.buildkite/pipeline-resource-definitions/locations.yml @@ -52,6 +52,7 @@ spec: - 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/kibana-storybooks-from-pr.yml + - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-uiam-cosmos-db-emulator-verify-and-promote.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-uiam-verify-and-promote.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-vm-images.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/scalability_testing-daily.yml diff --git a/.buildkite/pipeline-utils/ci-stats/client.ts b/.buildkite/pipeline-utils/ci-stats/client.ts index 288ffed2c184b..fe4e854d559be 100644 --- a/.buildkite/pipeline-utils/ci-stats/client.ts +++ b/.buildkite/pipeline-utils/ci-stats/client.ts @@ -172,6 +172,7 @@ export class CiStatsClient { maxMin: number; minimumIsolationMin?: number; overheadMin?: number; + warmupMin?: number; concurrency?: number; names: string[]; }>; diff --git a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts index cf54637aabbbc..85d2d55a9e263 100644 --- a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts +++ b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts @@ -286,6 +286,7 @@ export async function pickTestGroupRunOrder() { defaultMin: 4, maxMin: JEST_UNIT_MAX_MINUTES, overheadMin: 0.2, + warmupMin: 4, concurrency: 3, names: jestUnitConfigs, }, @@ -294,6 +295,7 @@ export async function pickTestGroupRunOrder() { defaultMin: 60, maxMin: JEST_INTEGRATION_MAX_MINUTES, overheadMin: 0.2, + warmupMin: 2, concurrency: 1, names: jestIntegrationConfigs, }, @@ -304,6 +306,7 @@ export async function pickTestGroupRunOrder() { maxMin: FUNCTIONAL_MAX_MINUTES, minimumIsolationMin: FUNCTIONAL_MINIMUM_ISOLATION_MIN, overheadMin: 0, + warmupMin: 3, names, })), ], diff --git a/.buildkite/pipeline-utils/scout/pick_scout_test_group_run_order.ts b/.buildkite/pipeline-utils/scout/pick_scout_test_group_run_order.ts index 570c9845a5102..c8749ecf2867b 100644 --- a/.buildkite/pipeline-utils/scout/pick_scout_test_group_run_order.ts +++ b/.buildkite/pipeline-utils/scout/pick_scout_test_group_run_order.ts @@ -36,6 +36,10 @@ if (process.env.UIAM_DOCKER_IMAGE) { scoutExtraEnv.UIAM_DOCKER_IMAGE = process.env.UIAM_DOCKER_IMAGE; } +if (process.env.UIAM_COSMOSDB_DOCKER_IMAGE) { + scoutExtraEnv.UIAM_COSMOSDB_DOCKER_IMAGE = process.env.UIAM_COSMOSDB_DOCKER_IMAGE; +} + export async function pickScoutTestGroupRunOrder(scoutConfigsPath: string) { const bk = new BuildkiteClient(); const envFromlabels: Record = collectEnvFromLabels(); diff --git a/.buildkite/pipelines/evals/llm_evals.yml b/.buildkite/pipelines/evals/llm_evals.yml index 829e27e1c3b42..165df5b7b2355 100644 --- a/.buildkite/pipelines/evals/llm_evals.yml +++ b/.buildkite/pipelines/evals/llm_evals.yml @@ -130,7 +130,6 @@ steps: - label: 'Evals: Observability AI Assistant (AI Insights)' key: kbn-evals-weekly-obs-ai-assistant-ai-insights - skip: 'Temporarily disabled (requires prerequisite data; will be re-enabled in follow-up PR)' command: bash .buildkite/scripts/steps/evals/run_suite.sh env: KBN_EVALS: '1' diff --git a/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml b/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml deleted file mode 100644 index f5269a564c530..0000000000000 --- a/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml +++ /dev/null @@ -1,23 +0,0 @@ -steps: - - command: .buildkite/scripts/steps/functional/exploratory_view_plugin.sh - label: 'Exploratory View @elastic/synthetics Tests' - agents: - machineType: n2-standard-4 - preemptible: true - spotZones: us-central1-f,us-central1-c,us-central1-a - depends_on: - - build - - quick_checks - - checks - - linting - - linting_with_types - - check_types - timeout_in_minutes: 60 - artifact_paths: - - 'x-pack/solutions/observability/plugins/exploratory_view/e2e/.journeys/**/*' - retry: - automatic: - - exit_status: '-1' - limit: 3 - - exit_status: '*' - limit: 1 diff --git a/.buildkite/pipelines/uiam/verify_and_promote.yml b/.buildkite/pipelines/uiam/verify_and_promote.yml index a8a4c9d409168..1ada1b0af4677 100644 --- a/.buildkite/pipelines/uiam/verify_and_promote.yml +++ b/.buildkite/pipelines/uiam/verify_and_promote.yml @@ -36,9 +36,6 @@ steps: - command: .buildkite/scripts/steps/test/scout/test_run_builder.sh label: 'Scout Test Run Builder' agents: - image: family/kibana-ubuntu-2404 - imageProject: elastic-images-prod - provider: gcp machineType: n2-standard-4 diskSizeGb: 115 key: build_scout_tests @@ -57,7 +54,7 @@ steps: - wait: ~ - label: ':arrow_up::key::arrow_up: Promote UIAM docker image' - command: .buildkite/scripts/steps/uiam/promote_uiam_image.sh $UIAM_IMAGE + command: .buildkite/scripts/steps/uiam/promote_image.sh $UIAM_IMAGE key: promote-uiam-image if: "build.branch == 'main' || build.env('FORCE_PROMOTE') == 'true'" retry: diff --git a/.buildkite/pipelines/uiam/verify_and_promote_cosmos_db_emulator.yml b/.buildkite/pipelines/uiam/verify_and_promote_cosmos_db_emulator.yml new file mode 100644 index 0000000000000..7b9a2f22e60b2 --- /dev/null +++ b/.buildkite/pipelines/uiam/verify_and_promote_cosmos_db_emulator.yml @@ -0,0 +1,69 @@ +# https://buildkite.com/elastic/kibana-uiam-cosmos-db-emulator-verify-and-promote/ + +### Parameters for this job: +# UIAM_COSMOSDB_IMAGE: the full image path for the docker image to test +# FORCE_PROMOTE: if set to true, promotion will happen on non-main branches + +agents: + image: family/kibana-ubuntu-2404 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-2 + +steps: + - label: 'Annotate runtime parameters' + command: .buildkite/scripts/steps/uiam/annotate_runtime_parameters_cosmos_db_emulator.sh + + - label: 'Pre-Build' + command: .buildkite/scripts/lifecycle/pre_build.sh + key: pre-build + timeout_in_minutes: 10 + + - label: 'Build Kibana Distribution' + command: .buildkite/scripts/steps/build_kibana.sh + agents: + machineType: n2-standard-8 + diskSizeGb: 100 + key: build + if: "build.env('KIBANA_BUILD_ID') == null || build.env('KIBANA_BUILD_ID') == ''" + depends_on: pre-build + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - command: .buildkite/scripts/steps/test/scout_test_run_builder.sh + label: 'Scout Test Run Builder' + agents: + machineType: n2-standard-4 + diskSizeGb: 115 + key: build_scout_tests + timeout_in_minutes: 30 + depends_on: + - build + env: + UIAM_COSMOSDB_DOCKER_IMAGE: $UIAM_COSMOSDB_IMAGE + SERVERLESS_TESTS_ONLY: 'true' + SCOUT_CONFIGS_SCRIPT: '.buildkite/scripts/steps/test/scout_configs.sh' + retry: + automatic: + - exit_status: '*' + limit: 1 + + - wait: ~ + + - label: ':arrow_up::key::arrow_up: Promote UIAM Cosmos DB Emulator docker image' + command: .buildkite/scripts/steps/uiam/promote_image_cosmos_db_emulator.sh $UIAM_COSMOSDB_IMAGE + key: promote-uiam-image + if: "build.branch == 'main' || build.env('FORCE_PROMOTE') == 'true'" + retry: + automatic: + - exit_status: '-1' + limit: 1 + + - wait: ~ + + - label: 'Post-Build' + command: .buildkite/scripts/lifecycle/post_build.sh + timeout_in_minutes: 10 diff --git a/.buildkite/scout_ci_config.yml b/.buildkite/scout_ci_config.yml index 9fd9c05a0a53d..79a02f4b6ca63 100644 --- a/.buildkite/scout_ci_config.yml +++ b/.buildkite/scout_ci_config.yml @@ -11,6 +11,7 @@ plugins: - data_views - discover_enhanced - entity_store + - exploratory_view - fleet - gen_ai_settings - global_search @@ -47,7 +48,6 @@ packages: # so they don't rerun alongside plugin/package Scout tests discovered later. - kbn-scout - kbn-scout-release-testing # Release tests will run separately as part of the release process - - kbn-evals-suite-agent-builder # Evaluation suite is run in dedicated eval pipelines, but Scout config must be registered for discovery validation # Define test configs to be excluded from automatic discovery & execution in CI environment (process.env.CI=true) excluded_configs: diff --git a/.buildkite/scripts/common/setup_job_env.sh b/.buildkite/scripts/common/setup_job_env.sh index f9483222a3016..cf0bb69c891f2 100644 --- a/.buildkite/scripts/common/setup_job_env.sh +++ b/.buildkite/scripts/common/setup_job_env.sh @@ -216,6 +216,9 @@ EOF if [[ -n "$TRACING_EXPORTERS_JSON" && "$TRACING_EXPORTERS_JSON" != "null" ]]; then export TRACING_EXPORTERS="$TRACING_EXPORTERS_JSON" fi + + # Optional: GCS service account credentials for snapshot restoration (e.g. AI Insights) + export GCS_CREDENTIALS="$(jq -c '.gcsDatasetAccessCredentials // empty' <<<"$KBN_EVALS_CONFIG_JSON")" fi } diff --git a/.buildkite/scripts/lifecycle/pre_build.sh b/.buildkite/scripts/lifecycle/pre_build.sh index 31e569b10ca59..d87d27587d9d3 100755 --- a/.buildkite/scripts/lifecycle/pre_build.sh +++ b/.buildkite/scripts/lifecycle/pre_build.sh @@ -26,3 +26,13 @@ if [[ "${KIBANA_BUILD_ID:-}" && "${KIBANA_REUSABLE_BUILD_JOB_URL:-}" ]]; then See job here: $KIBANA_REUSABLE_BUILD_JOB_URL EOF fi + +# Annotate ingestable meta-data (prefixed with 'ingest:') +if [[ "${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-}" != "" ]]; then # if we're in a PR build + # GITHUB_PR_DRAFT is set by our pr build trigger bot + buildkite-agent meta-data set "ingest:is_draft_pr" "${GITHUB_PR_DRAFT:-false}" + # GITHUB_PR_LABELS is set by our pr build trigger bot, and is a comma-separated list of labels on the PR + if [[ -n "${GITHUB_PR_LABELS:-}" ]]; then + buildkite-agent meta-data set "ingest:pr_labels" "$GITHUB_PR_LABELS" + fi +fi diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.ts b/.buildkite/scripts/pipelines/pull_request/pipeline.ts index c0f1d6ca404e1..33d8732be6ca2 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.ts +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.ts @@ -129,7 +129,6 @@ const SKIPPABLE_PR_MATCHERS = prConfig.skip_ci_on_only_changed!.map((r) => new R ) { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/synthetics_plugin.yml')); pipeline.push(getPipeline('.buildkite/pipelines/pull_request/uptime_plugin.yml')); - pipeline.push(getPipeline('.buildkite/pipelines/pull_request/exploratory_view_plugin.yml')); pipeline.push(getPipeline('.buildkite/pipelines/pull_request/ux_plugin_e2e.yml')); } @@ -144,10 +143,10 @@ const SKIPPABLE_PR_MATCHERS = prConfig.skip_ci_on_only_changed!.map((r) => new R /^x-pack\/platform\/plugins\/shared\/stack_connectors\/server\/connector_types\/openai/, /^x-pack\/platform\/plugins\/shared\/stack_connectors\/server\/connector_types\/inference/, ]; - // const agentBuilderPaths = [ - // /^x-pack\/platform\/plugins\/shared\/agent_builder/, - // /^x-pack\/platform\/packages\/shared\/agent_builder/, - // ]; + const agentBuilderPaths = [ + /^x-pack\/platform\/plugins\/shared\/agent_builder/, + /^x-pack\/platform\/packages\/shared\/agent_builder/, + ]; if ( (await doAnyChangesMatch([...aiInfraPaths, ...aiConnectorPaths])) || @@ -157,9 +156,8 @@ const SKIPPABLE_PR_MATCHERS = prConfig.skip_ci_on_only_changed!.map((r) => new R pipeline.push(getPipeline('.buildkite/pipelines/pull_request/ai_infra_gen_ai.yml')); } - // Temporarily disable auto-trigger on file changes - smoke tests still run daily if ( - // (await doAnyChangesMatch([...aiInfraPaths, ...aiConnectorPaths, ...agentBuilderPaths])) || + (await doAnyChangesMatch([...aiInfraPaths, ...aiConnectorPaths, ...agentBuilderPaths])) || GITHUB_PR_LABELS.includes('agent-builder:run-smoke-tests') || GITHUB_PR_LABELS.includes('ci:all-gen-ai-suites') || ALL_UI_TEST_SUITES diff --git a/.buildkite/scripts/steps/api_docs/publish_api_docs.sh b/.buildkite/scripts/steps/api_docs/publish_api_docs.sh index 7ab4ae6b3497c..6b2ce8e092a66 100755 --- a/.buildkite/scripts/steps/api_docs/publish_api_docs.sh +++ b/.buildkite/scripts/steps/api_docs/publish_api_docs.sh @@ -26,6 +26,6 @@ git commit -m "[api-docs] Daily api_docs build" 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") +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 "backport:skip" --label "docs") echo "Opened PR: $prUrl" gh pr merge --repo elastic/kibana --auto --squash "$prUrl" diff --git a/.buildkite/scripts/steps/check_saved_objects.sh b/.buildkite/scripts/steps/check_saved_objects.sh index c622f9db644a4..378fe6d849ca9 100755 --- a/.buildkite/scripts/steps/check_saved_objects.sh +++ b/.buildkite/scripts/steps/check_saved_objects.sh @@ -53,7 +53,7 @@ if is_pr; then # First, we try to obtain its SHA (or one of its ancestors) MERGE_BASE_REV="$(findExistingSnapshotSha "$GITHUB_PR_MERGE_BASE")" if [[ $? -ne 0 ]]; then - echo "❌ Could not find an existing snapshot to use as a baseline. Aborting Saved Objects checks" >&2 + echo "❌ Could not find an existing snapshot to use as a baseline. Please rebase this PR branch onto the latest 'main' commit, then rerun CI." >&2 exit 1 fi diff --git a/.buildkite/scripts/steps/scout_update_metadata.sh b/.buildkite/scripts/steps/scout_update_metadata.sh old mode 100644 new mode 100755 diff --git a/.buildkite/scripts/steps/uiam/annotate_runtime_parameters_cosmos_db_emulator.sh b/.buildkite/scripts/steps/uiam/annotate_runtime_parameters_cosmos_db_emulator.sh new file mode 100644 index 0000000000000..aec29bb297bc2 --- /dev/null +++ b/.buildkite/scripts/steps/uiam/annotate_runtime_parameters_cosmos_db_emulator.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +KIBANA_GITHUB_URL="https://github.com/elastic/kibana" + +if [[ -z "${UIAM_COSMOSDB_IMAGE:-}" ]]; then + echo "UIAM_COSMOSDB_IMAGE is not set" + exit 1 +elif [[ "$UIAM_COSMOSDB_IMAGE" != *"mcr.microsoft.com"* ]]; then + echo "UIAM_COSMOSDB_IMAGE should be a mcr.microsoft.com image" + exit 1 +fi + +# Pull the target image to get version info +docker_with_retry pull "$UIAM_COSMOSDB_IMAGE" +UIAM_COSMOSDB_VERSION=$(docker inspect --format='{{json .Config.Labels}}' "$UIAM_COSMOSDB_IMAGE" | jq -r '.["com.visualstudio.msdata.image.build.sourcebranchname"] // "unknown"') + +# Find the most accurate image tag +if [[ "$UIAM_COSMOSDB_IMAGE" == *":vnext-EN"* ]]; then + UIAM_COSMOSDB_IMAGE_FULL=$UIAM_COSMOSDB_IMAGE +else + IMAGE_WITHOUT_TAG=$(echo "$UIAM_COSMOSDB_IMAGE" | cut -d: -f1) + if [[ "$UIAM_COSMOSDB_VERSION" != "unknown" && "$UIAM_COSMOSDB_VERSION" != "null" ]]; then + UIAM_COSMOSDB_IMAGE_FULL="${IMAGE_WITHOUT_TAG}:vnext-${UIAM_COSMOSDB_VERSION}" + else + UIAM_COSMOSDB_IMAGE_FULL=$UIAM_COSMOSDB_IMAGE + fi +fi + +buildkite-agent annotate --context kibana-commit --style info "Kibana version: $BUILDKITE_BRANCH / [$BUILDKITE_COMMIT]($KIBANA_GITHUB_URL/commit/$BUILDKITE_COMMIT)" +buildkite-agent annotate --context uiam-version --style info "UIAM Cosmos DB Emulator version: \`${UIAM_COSMOSDB_VERSION}\`" + +cat << EOF | buildkite-agent annotate --context uiam-image --style info + UIAM Cosmos DB Emulator image: \`${UIAM_COSMOSDB_IMAGE_FULL}\` + + To run this locally: + \`\`\` + UIAM_COSMOSDB_DOCKER_IMAGE=$UIAM_COSMOSDB_IMAGE_FULL node scripts/es serverless --uiam + \`\`\` +EOF diff --git a/.buildkite/scripts/steps/uiam/promote_uiam_image.sh b/.buildkite/scripts/steps/uiam/promote_image.sh similarity index 100% rename from .buildkite/scripts/steps/uiam/promote_uiam_image.sh rename to .buildkite/scripts/steps/uiam/promote_image.sh diff --git a/.buildkite/scripts/steps/uiam/promote_image_cosmos_db_emulator.sh b/.buildkite/scripts/steps/uiam/promote_image_cosmos_db_emulator.sh new file mode 100755 index 0000000000000..90a12f97dba51 --- /dev/null +++ b/.buildkite/scripts/steps/uiam/promote_image_cosmos_db_emulator.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +BASE_COSMOSDB_REPO=mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator +TARGET_IMAGE=docker.elastic.co/kibana-ci/uiam-azure-cosmos-emulator:latest-verified + +SOURCE_IMAGE_OR_TAG=${1:-} +if [[ -z "$SOURCE_IMAGE_OR_TAG" ]]; then + echo "Usage: $0 " + echo "Example: $0 mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview" + echo "Example: $0 vnext-preview" + exit 1 +fi + +if [[ $SOURCE_IMAGE_OR_TAG =~ :[a-zA-Z0-9_.-]+$ ]]; then + # $SOURCE_IMAGE_OR_TAG was a full image + SOURCE_IMAGE=$SOURCE_IMAGE_OR_TAG +else + # $SOURCE_IMAGE_OR_TAG was an image tag + SOURCE_IMAGE="$BASE_COSMOSDB_REPO:$SOURCE_IMAGE_OR_TAG" +fi + +echo "--- Promoting ${SOURCE_IMAGE_OR_TAG} to '$TARGET_IMAGE'" + +echo "Re-tagging $SOURCE_IMAGE -> $TARGET_IMAGE" + +# Check if the image has a multi-arch manifest +if docker manifest inspect "$SOURCE_IMAGE" > manifests.json 2>/dev/null; then + # Multi-arch image handling + ARM_64_DIGEST=$(jq -r '.manifests[] | select(.platform.architecture == "arm64") | .digest // empty' manifests.json) + AMD_64_DIGEST=$(jq -r '.manifests[] | select(.platform.architecture == "amd64") | .digest // empty' manifests.json) + + if [[ -n "$ARM_64_DIGEST" && -n "$AMD_64_DIGEST" ]]; then + echo "Multi-arch image detected, pulling both architectures..." + + echo "docker pull --platform linux/arm64 $SOURCE_IMAGE@$ARM_64_DIGEST" + docker_with_retry pull --platform linux/arm64 "$SOURCE_IMAGE@$ARM_64_DIGEST" + echo "linux/arm64 image pulled, with digest: $ARM_64_DIGEST" + + echo "docker pull --platform linux/amd64 $SOURCE_IMAGE@$AMD_64_DIGEST" + docker_with_retry pull --platform linux/amd64 "$SOURCE_IMAGE@$AMD_64_DIGEST" + echo "linux/amd64 image pulled, with digest: $AMD_64_DIGEST" + + docker tag "$SOURCE_IMAGE@$ARM_64_DIGEST" "$TARGET_IMAGE-arm64" + docker tag "$SOURCE_IMAGE@$AMD_64_DIGEST" "$TARGET_IMAGE-amd64" + + docker_with_retry push "$TARGET_IMAGE-arm64" + docker_with_retry push "$TARGET_IMAGE-amd64" + + docker manifest rm "$TARGET_IMAGE" || echo "Nothing to delete" + + docker manifest create "$TARGET_IMAGE" \ + --amend "$TARGET_IMAGE-arm64" \ + --amend "$TARGET_IMAGE-amd64" + + docker manifest push "$TARGET_IMAGE" + else + # Fallback to single-arch handling if one of the architectures is missing + echo "One or more architectures missing from manifest, using single-arch promotion..." + docker_with_retry pull "$SOURCE_IMAGE" + docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" + docker_with_retry push "$TARGET_IMAGE" + fi +else + # Single-arch image handling (no manifest) + echo "Single-arch image detected, pulling and re-tagging..." + docker_with_retry pull "$SOURCE_IMAGE" + docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" + docker_with_retry push "$TARGET_IMAGE" +fi + +docker manifest inspect "$TARGET_IMAGE" || docker inspect "$TARGET_IMAGE" + +ORIG_IMG_DATA=$(docker inspect "$SOURCE_IMAGE" 2>/dev/null || echo '[]') +UIAM_COSMOSDB_COMMIT_HASH=$(echo "$ORIG_IMG_DATA" | jq -r '.[].Config.Labels["com.visualstudio.msdata.image.build.sourceversion"] // "unknown"') + +echo "Image push to $TARGET_IMAGE successful." + +echo "--- Annotating build with info" +cat << EOT | buildkite-agent annotate --style "success" +

UIAM Promotion successful!

+
New image: $TARGET_IMAGE +
Source image: $SOURCE_IMAGE +
Kibana commit: $BUILDKITE_COMMIT +
UIAM Cosmos DB Emulator commit: $UIAM_COSMOSDB_COMMIT_HASH +EOT + +cat << EOF | buildkite-agent pipeline upload +steps: + - label: "Update cache for UIAM Cosmos DB Emulator image" + trigger: kibana-vm-images + async: true + build: + env: + IMAGES_CONFIG: 'kibana/image_cache.tpl.yml' + BASE_IMAGES_CONFIG: 'core/images.yml,kibana/base_image.yml,kibana/packages_layer.yml' + RETRY: "1" +EOF diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c7d39e8bb5099..d0d7b7a0346b3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -496,6 +496,7 @@ src/platform/packages/shared/kbn-connector-schemas @elastic/response-ops src/platform/packages/shared/kbn-connector-specs @elastic/response-ops src/platform/packages/shared/kbn-content-management-utils @elastic/kibana-data-discovery src/platform/packages/shared/kbn-core-server-benchmarks @elastic/kibana-core +src/platform/packages/shared/kbn-cps-server-utils @elastic/kibana-core src/platform/packages/shared/kbn-cps-utils @elastic/kibana-presentation src/platform/packages/shared/kbn-crypto @elastic/kibana-security src/platform/packages/shared/kbn-crypto-browser @elastic/kibana-core @@ -537,6 +538,7 @@ src/platform/packages/shared/kbn-esql-resource-browser @elastic/kibana-managemen src/platform/packages/shared/kbn-esql-resource-browser/.storybook @elastic/kibana-management src/platform/packages/shared/kbn-esql-types @elastic/kibana-esql src/platform/packages/shared/kbn-esql-utils @elastic/kibana-esql +src/platform/packages/shared/kbn-eval-kql @elastic/workflows-eng src/platform/packages/shared/kbn-event-annotation-common @elastic/kibana-visualizations src/platform/packages/shared/kbn-event-annotation-components @elastic/kibana-visualizations src/platform/packages/shared/kbn-expect @elastic/kibana-operations @elastic/appex-qa @@ -544,7 +546,6 @@ src/platform/packages/shared/kbn-field-formats-common @elastic/kibana-visualizat src/platform/packages/shared/kbn-field-types @elastic/kibana-data-discovery src/platform/packages/shared/kbn-field-utils @elastic/kibana-data-discovery src/platform/packages/shared/kbn-flot-charts @elastic/kibana-presentation @elastic/stack-monitoring -src/platform/packages/shared/kbn-flyout-ui @elastic/security-threat-hunting-investigations src/platform/packages/shared/kbn-ftr-benchmarks @elastic/kibana-operations src/platform/packages/shared/kbn-ftr-common-functional-services @elastic/kibana-operations @elastic/appex-qa src/platform/packages/shared/kbn-ftr-common-functional-ui-services @elastic/appex-qa @@ -615,8 +616,6 @@ src/platform/packages/shared/kbn-search-errors @elastic/kibana-data-discovery src/platform/packages/shared/kbn-search-response-warnings @elastic/kibana-data-discovery src/platform/packages/shared/kbn-search-types @elastic/kibana-data-discovery src/platform/packages/shared/kbn-security-hardening @elastic/kibana-security -src/platform/packages/shared/kbn-security-solution-common @elastic/security-threat-hunting-investigations -src/platform/packages/shared/kbn-security-solution-flyout @elastic/security-threat-hunting-investigations src/platform/packages/shared/kbn-securitysolution-ecs @elastic/security-threat-hunting-investigations src/platform/packages/shared/kbn-securitysolution-es-utils @elastic/security-detection-engine src/platform/packages/shared/kbn-securitysolution-io-ts-types @elastic/security-detection-engine @@ -696,6 +695,7 @@ src/platform/packages/shared/serverless/settings/security_project @elastic/secur src/platform/packages/shared/serverless/settings/workplace_ai_project @elastic/search-kibana src/platform/packages/shared/serverless/storybook/config @elastic/appex-sharedux src/platform/packages/shared/serverless/types @elastic/appex-sharedux +src/platform/packages/shared/shared-ux/ai-components @elastic/appex-sharedux src/platform/packages/shared/shared-ux/avatar/solution @elastic/appex-sharedux src/platform/packages/shared/shared-ux/button_toolbar @elastic/appex-sharedux src/platform/packages/shared/shared-ux/button/exit_full_screen @elastic/appex-sharedux @@ -1026,6 +1026,7 @@ x-pack/platform/packages/shared/response-ops/retry-service @elastic/response-ops x-pack/platform/packages/shared/response-ops/rule_form @elastic/response-ops x-pack/platform/packages/shared/response-ops/rule_params @elastic/response-ops x-pack/platform/packages/shared/response-ops/rules-apis @elastic/response-ops +x-pack/platform/packages/shared/response-ops/scheduling-types @elastic/response-ops x-pack/platform/packages/shared/security/api_key_management @elastic/kibana-security x-pack/platform/packages/shared/security/form_components @elastic/kibana-security x-pack/platform/packages/shared/security/plugin_types_common @elastic/kibana-security @@ -1088,13 +1089,11 @@ x-pack/platform/plugins/shared/cloud @elastic/kibana-core x-pack/platform/plugins/shared/cloud_connect @elastic/kibana-management x-pack/platform/plugins/shared/content_connectors @elastic/search-kibana x-pack/platform/plugins/shared/dashboard_agent @elastic/appex-ai-infra -x-pack/platform/plugins/shared/dashboard_enhanced @elastic/kibana-presentation x-pack/platform/plugins/shared/data_catalog @elastic/workchat-eng x-pack/platform/plugins/shared/data_quality @elastic/obs-onboarding-team x-pack/platform/plugins/shared/data_sources @elastic/workchat-eng x-pack/platform/plugins/shared/dataset_quality @elastic/obs-onboarding-team x-pack/platform/plugins/shared/embeddable_alerts_table @elastic/response-ops -x-pack/platform/plugins/shared/embeddable_enhanced @elastic/kibana-presentation x-pack/platform/plugins/shared/encrypted_saved_objects @elastic/kibana-security x-pack/platform/plugins/shared/entity_manager @elastic/obs-entities x-pack/platform/plugins/shared/event_log @elastic/response-ops @@ -1183,6 +1182,7 @@ x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant @elasti x-pack/solutions/observability/packages/kbn-genai-cli @elastic/obs-ai-team x-pack/solutions/observability/packages/kbn-observability-schema @elastic/obs-presentation-team x-pack/solutions/observability/packages/kbn-scout-oblt @elastic/appex-qa +x-pack/solutions/observability/packages/kbn-synthetics-forge @elastic/obs-ux-management-team x-pack/solutions/observability/packages/nav-icons @elastic/obs-presentation-team x-pack/solutions/observability/packages/observability-ai/observability-ai-common @elastic/obs-ai-team x-pack/solutions/observability/packages/observability-ai/observability-ai-server @elastic/obs-ai-team @@ -1744,6 +1744,7 @@ x-pack/platform/plugins/shared/streams_app/public/components/data_management/str /x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/synthetics_private_location.ts @elastic/actionable-obs-team /x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/ @elastic/actionable-obs-team /x-pack/solutions/observability/test/api_integration_deployment_agnostic/configs/serverless/oblt.synthetics.index.ts @elastic/actionable-obs-team +/x-pack/solutions/observability/test/ensemble @elastic/obs-ux-management-team /x-pack/solutions/observability/test/api_integration_deployment_agnostic/configs/serverless/oblt.synthetics.serverless.config.ts @elastic/actionable-obs-team /x-pack/solutions/observability/test/api_integration_deployment_agnostic/feature_flag_configs/serverless/oblt.synthetics.index.ts @elastic/actionable-obs-team /x-pack/solutions/observability/test/api_integration_deployment_agnostic/feature_flag_configs/serverless/oblt.synthetics.serverless.config.ts @elastic/actionable-obs-team @@ -1812,7 +1813,7 @@ x-pack/platform/plugins/shared/streams_app/public/components/data_management/str # Observability-ui folder level permissions (need to be before individual files inside the folder) /x-pack/solutions/observability/test/serverless/functional/test_suites @elastic/observability-ui -/src/platform/test/functional/apps/discover/observability @elastic/observability-ui +/src/platform/test/functional/apps/discover/observability @elastic/obs-exploration-team # obs-exploration-team /x-pack/solutions/observability/plugins/apm/public/components/shared/links/discover_links @elastic/obs-exploration-team @@ -2438,6 +2439,7 @@ src/platform/packages/shared/kbn-connector-specs/src/specs/shodan/** @elastic/wo src/platform/packages/shared/kbn-connector-specs/src/specs/urlvoid/** @elastic/workflows-eng src/platform/packages/shared/kbn-connector-specs/src/specs/virustotal/** @elastic/workflows-eng src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/** @elastic/workchat-eng +src/platform/packages/shared/kbn-connector-specs/src/specs/slack/** @elastic/workchat-eng # Gap fill feature has shared responsibility between response-ops and security-detection-engine /x-pack/platform/plugins/shared/alerting/common/routes/gaps @elastic/response-ops @elastic/security-detection-engine diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index fef2fe312df42..a342f645ee854 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -10,6 +10,7 @@ paths-ignore: - '**/*.cy.*' - '**/*.md' - '**/*.yaml' + - '**/*.yml' - '**/*.hbs' - '**/*.njk' - '**/*.mock.*' diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml index 5946d34b2a208..6033660c4b294 100644 --- a/.github/paths-labeller.yml +++ b/.github/paths-labeller.yml @@ -17,8 +17,8 @@ - 'src/platform/plugins/shared/embeddable/**/*.*' - 'src/plugins/dashboard_embeddable_container/**/*.*' - 'Feature:Drilldowns': + - 'src/platform/plugins/shared/embeddable/**/*.*' - 'x-pack/plugins/drilldowns/**/*.*' - - 'x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/**/*.*' - 'Feature:ExpressionLanguage': - 'src/platform/plugins/shared/expressions/**/*.*' - 'src/plugins/bfetch/**/*.*' diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index c7161975afb08..196a3662e6fed 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -4,6 +4,14 @@ on: pull_request: branches: [ "main" ] types: [ opened, ready_for_review, synchronize ] + paths: + - '**/*.js' + - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' + - '**/*.mjs' + - '**/*.cjs' + - '.github/codeql/**' jobs: diff --git a/config/serverless.es.yml b/config/serverless.es.yml index 897a3b0af336d..c0185dfced637 100644 --- a/config/serverless.es.yml +++ b/config/serverless.es.yml @@ -80,7 +80,7 @@ telemetry.labels.serverless: search # Alerts and LLM config xpack.actions.enabledActionTypes: - ['.email', '.index', '.slack', '.slack_api', '.jira', '.webhook', '.teams', '.gen-ai', '.bedrock', '.gemini', '.inference', '.mcp'] + ['.email', '.index', '.slack', '.slack_api', '.jira', '.jira-cloud', '.webhook', '.teams', '.gen-ai', '.bedrock', '.gemini', '.inference', '.mcp', '.notion', '.github', '.google_drive', '.sharepoint-online'] # Customize empty page state for analytics apps no_data_page.analyticsNoDataPageFlavor: 'serverless_search' diff --git a/docs/extend/dashboard-enhanced-plugin.md b/docs/extend/dashboard-enhanced-plugin.md deleted file mode 100644 index fbe68ee5cc4b0..0000000000000 --- a/docs/extend/dashboard-enhanced-plugin.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -mapped_pages: - - https://www.elastic.co/guide/en/kibana/current/dashboard-enhanced-plugin.html ---- - -# Dashboard app enhancements plugin [dashboard-enhanced-plugin] - -Adds drilldown capabilities to dashboard. Owned by the Kibana App team. - diff --git a/docs/extend/development-tests.md b/docs/extend/development-tests.md index ae8d6ab7bced8..d658a5e51582e 100644 --- a/docs/extend/development-tests.md +++ b/docs/extend/development-tests.md @@ -9,11 +9,12 @@ mapped_pages: The following table outlines possible test file locations and how to invoke them: -| Test runner | Test location | Runner command (working directory is {{kib}} root) | -| ------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Jest | `**/*.test.{js,mjs,ts,tsx}` | `yarn test:jest [test path]` | -| Jest (integration) | `**/integration_tests/**/*.test.{js,mjs,ts,tsx}` | `yarn test:jest_integration [test path]` | -| Functional | `test/**/config.js` `x-pack/**/test/**/config.ts` | `node scripts/functional_tests_server --config [directory]/config.js` `node scripts/functional_test_runner --config [directory]/config.js --grep=regexp` | +| Test runner | Test location | Runner command (working directory is {{kib}} root) | +| ------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Jest | `**/*.test.{js,mjs,ts,tsx}` | `yarn test:jest [test path]` | +| Jest (integration) | `**/integration_tests/**/*.test.{js,mjs,ts,tsx}` | `yarn test:jest_integration [test path]` | +| Scout (Playwright) | `**/test/scout/**/*.spec.ts` | `node scripts/scout.js run-tests --config [directory]/playwright.config.ts --arch --domain ` (see [Run Scout tests](/extend/scout/run-tests.md)) | +| FTR (Mocha) | `test/**/config.js` `x-pack/**/test/**/config.ts` | `node scripts/functional_tests_server --config [directory]/config.js` `node scripts/functional_test_runner --config [directory]/config.js --grep=regexp` | Test runner arguments: - Where applicable, the optional arguments `--grep=regexp` will only run tests or test suites whose descriptions matches the regular expression. - `[test path]` is the relative path to the test file. @@ -59,6 +60,7 @@ You can also look into the [Scripts README.md](https://github.com/elastic/kibana #### More testing information: [_more_testing_information] +- [Scout](/extend/scout.md) - [Functional Testing](#development-functional-tests) - [Unit testing frameworks](#development-unit-tests) - [Automated Accessibility Testing](#development-accessibility-tests) @@ -66,9 +68,20 @@ You can also look into the [Scripts README.md](https://github.com/elastic/kibana ## Functional Testing [development-functional-tests] -We use functional tests to make sure the {{kib}} UI works as expected. It replaces hours of manual testing by automating user interaction. To have better control over our functional test environment, and to make it more accessible to plugin authors, {{kib}} uses a tool called the `FunctionalTestRunner`. +We use functional tests to make sure the {{kib}} UI works as expected. It replaces hours of manual testing by automating user interaction. -#### Running functional tests [_running_functional_tests] +{{kib}} has two end-to-end test frameworks: + +- **Scout**: modern UI and API testing built on Playwright. See [Scout](/extend/scout.md). +- **FunctionalTestRunner (FTR)**: legacy functional test framework built on Mocha + WebDriver. Many existing suites still use it. + +### Scout (Playwright-based) + +We recommend writing new functional tests with [Scout](/extend/scout.md), Kibana’s modern Playwright-based test framework. + +### `FunctionalTestRunner` (FTR) + +#### Running functional tests with FTR [_running_functional_tests] The `FunctionalTestRunner` (FTR) is very bare bones and gets most of its functionality from its config file. The {{kib}} repo contains many FTR config files which use slightly different configurations for the {{kib}} server or {{es}}, have different test files, and potentially other config differences. FTR config files are organised in manifest files, based on testing area and type of distribution: diff --git a/docs/extend/enhanced-embeddables-plugin.md b/docs/extend/enhanced-embeddables-plugin.md deleted file mode 100644 index 590814b06d765..0000000000000 --- a/docs/extend/enhanced-embeddables-plugin.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -mapped_pages: - - https://www.elastic.co/guide/en/kibana/current/enhanced-embeddables-plugin.html ---- - -# Enhanced embeddables plugin [enhanced-embeddables-plugin] - -Enhances Embeddables by registering a custom factory provider. The enhanced factory provider adds dynamic actions to every embeddables state, in order to support drilldowns. - diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index 0f2d12064813d..6e79ac24f1d27 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -23,7 +23,7 @@ mapped_pages: | [console](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/console/README.md) | Console provides the user with tools for storing and executing requests against Elasticsearch. | | [contentManagement](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/content_management/README.md) | The content management plugin provides functionality to manage content in Kibana. | | [controls](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/controls/README.mdx) | The Controls plugin contains Embeddables which can be used to add user-friendly interactivity to apps. | -| [cps](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/cps/README.md) | Retrieves project tags from Elasticsearch using the /_project/tags endpoint. | +| [cps](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/cps/README.md) | This plugin implements the Cross-Project Search (CPS) logic for Kibana. CPS enables users to search data across multiple Elastic projects as if it were local, without needing to manually specify project names in queries. | | [customIntegrations](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/custom_integrations/README.md) | Register add-data cards | | [dashboard](kibana-dashboard-plugin.md) | - Registers the dashboard application. - Adds a dashboard embeddable that can be used in other applications. | | [dashboardMarkdown](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/dashboard_markdown/README.md) | This plugin provides a simple Markdown component for embedding editable Markdown content in Kibana dashboards. | @@ -138,7 +138,6 @@ mapped_pages: | [crossClusterReplication](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/cross_cluster_replication/README.md) | You can run a local cluster and simulate a remote cluster within a single Kibana directory. | | [customBranding](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/custom_branding/README.md) | This is a plugin to configure custom branding. Plugin server-side only. Plugin has three main functions: | | [dashboardAgent](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/dashboard_agent/README.md) | Server-side plugin that provides a specialized AI agent and tools for dashboard management via the AgentBuilder framework. | -| [dashboardEnhanced](dashboard-enhanced-plugin.md) | Adds drilldown capabilities to dashboard. Owned by the Kibana App team. | | [dataCatalog](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/data_catalog/README.md) | A Kibana plugin providing a common abstraction (Data Source) for content connectors and federated connectors, as well as additional future data sources needed by O11y/Security solutions for chat. | | [dataQuality](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/data_quality/README.md) | Page where users can see the quality of their log data sets. | | [datasetQuality](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/dataset_quality/README.md) | In order to make ongoing maintenance of log collection easy we want to introduce the concept of data set quality, where users can easily get an overview on the data sets they have with information such as integration, size, last activity, among others. | @@ -150,11 +149,10 @@ mapped_pages: | [elasticAssistant](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/elastic_assistant/README.md) | This plugin implements server APIs for the Elastic AI Assistant. Furthermore, it registers the Elastic Assistant in the navigation bar. | | [elasticAssistantSharedState](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/elastic_assistant_shared_state/README.md) | This plugin acts as a reactive bridge between the elastic assistant plugin and other plugins. It exposes an RxJS-based interface where: | | [embeddableAlertsTable](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/embeddable_alerts_table/README.md) | Embeddable wrapper for the alerts table | -| [embeddableEnhanced](enhanced-embeddables-plugin.md) | Enhances Embeddables by registering a custom factory provider. The enhanced factory provider adds dynamic actions to every embeddables state, in order to support drilldowns. | | [encryptedSavedObjects](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/encrypted_saved_objects/README.md) | The purpose of this plugin is to provide a way to encrypt/decrypt attributes on the custom Saved Objects that works with security and spaces filtering. | | [enterpriseSearch](https://github.com/elastic/kibana/blob/main/x-pack/solutions/search/plugins/enterprise_search/README.md) | This plugin provides Kibana user interfaces for managing the Enterprise Search solution and its products, App Search and Workplace Search. | | [entityManager](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/entity_manager/README.md) | This plugin provides access to observed entity data, such as information about hosts, pods, containers, services, and more. | -| [entityStore](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/entity_store/README.md) | Central place for Entities management and logs extraction | +| [entityStore](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/entity_store/README.md) | Central place for Entities management and logs extraction. | | [eventLog](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/event_log/README.md) | The event log plugin provides a persistent history of alerting and action activities. | | [exploratoryView](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/exploratory_view/README.md) | A shared component for visualizing observability data types via lens embeddable. For further details. | | [features](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/features/README.md) | The features plugin enhance Kibana with a per-feature privilege system. | diff --git a/docs/extend/scout.md b/docs/extend/scout.md new file mode 100644 index 0000000000000..cb1ea0194031a --- /dev/null +++ b/docs/extend/scout.md @@ -0,0 +1,89 @@ +--- +navigation_title: Scout testing framework +--- + +# Scout [scout] + +Scout is Kibana’s **modern UI and API test framework** built on [Playwright](https://playwright.dev). It focuses on **fast test execution**, a good **developer experience**, and **reusable** test building blocks (e.g., [fixtures](./scout/fixtures.md), [page objects](./scout/page-objects.md) and [API services](./scout/api-services.md)). + +## Start here [scout-start-here] + +- [Getting started](./scout/getting-started.md) +- [Best practices](./scout/best-practices.md) +- [UI testing](./scout/ui-testing.md) +- [API testing](./scout/api-testing.md) + +## Why Scout? [scout-main-features] + +- **Parallel execution**: run UI suites in [parallel](./scout/parallelism.md) against the same deployment. +- **Co-located tests**: keep tests close to [plugin code](./scout/setup-plugin.md) for easier iteration and maintenance. +- **Deployment-agnostic**: write tests once, then use [tags](./scout/deployment-tags.md) to declare where they should run (stateful/serverless). +- **Fixture-based**: [fixtures](./scout/fixtures.md) cover auth, data setup, clients, and common workflows. +- **Better debugging**: use Playwright [UI Mode](https://playwright.dev/docs/test-ui-mode). +- **Reporting**: we capture test events that power our dashboards (for example, skipped tests, flaky tests, and more). +- **Reusability**: reuse or write reusable fixtures, page objects and API helpers to reduce duplication. +- **Follows modern best practices**: check out our [Scout best practices](./scout/best-practices.md). + +## Scout packages [scout-packages] + +**Import the right Scout package in your Scout tests:** + +- **Platform-owned tests** → `@kbn/scout` + +| Package | Use in tests | +| ------------ | -------------------------- | +| `@kbn/scout` | Platform (shared baseline) | + +- **Solution-owned tests** → your solution Scout package (it builds on `@kbn/scout`) + +| Package | Use in tests | +| --------------------- | ---------------------- | +| `@kbn/scout-oblt` | Observability solution | +| `@kbn/scout-security` | Security solution | +| `@kbn/scout-search` | Search solution | + +::::::{note} +Fixtures, page objects, and API helpers defined in `@kbn/scout` can be imported by solution-specific Scout packages. When they are defined in a solution package or a plugin they will only be available to that solution or plugin. +:::::: + +## Contribute to Scout when possible [contribute-to-scout-when-possible] + +We welcome contributions to one of the Scout packages. + +| If your helper/code… | Put it… | Examples | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| Is reusable across many plugins/teams | In `@kbn/scout` | Generic fixtures, page objects, and API helpers | +| Is reusable but scoped to a solution | In the solution Scout package (for example `@kbn/scout-security`, `@kbn/scout-oblt`, `@kbn/scout-search`) | Solution workflows and domain-specific helpers | +| Is specific to one plugin or package | In your plugin or package’s `test/scout` directory | Components specific to your plugin or package only | + +## Need help? + +- **Internal (Elasticians)**: reach out to the AppEx QA team in the `#kibana-scout` Slack channel for guidance. + +- **External contributors**: open an issue in the Kibana repository and label it with `Team:QA`. + +## FAQ [scout-faq] + +#### Q: Does Scout prevent flaky tests? [scout-faq-flakes] + +No—good test design still matters. + +#### Q: Is Scout designed to be _just_ a Playwright UI test runner? [scout-faq-ui-only] + +No. Scout supports both UI and API testing with Playwright. + +#### Q: Are test runs going to be faster? [scout-faq-faster] + +Often, yes—especially with [parallel test execution](./scout/parallelism.md). + +#### Q: Why is it a good idea for tests to be close to the plugin code? [scout-faq-colocation] + +It’s easier to iterate and maintain, and it can enable smarter test selection in the future. + +#### Q: Can I use FTR services in Scout (for example, `esArchiver`)? [scout-faq-ftr-services] + +Not directly—use Scout [fixtures](./scout/fixtures.md) instead. + +#### Q: Does Scout support feature flags? [scout-faq-feature-flags] + +If your feature is behind a feature flag, you can use the `coreApi` [fixture](https://github.com/elastic/kibana/blob/e4f12d39154fe286ce92217e00a7d8bd758ee02d/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/core/index.ts#L17-L30) to enable it during test execution (recommended). Alternatively, you can create a [custom config directory](https://github.com/elastic/kibana/pull/244306) and link your Scout tests (reach out for more info). diff --git a/docs/extend/scout/a11y-checks.md b/docs/extend/scout/a11y-checks.md new file mode 100644 index 0000000000000..9001a100e3558 --- /dev/null +++ b/docs/extend/scout/a11y-checks.md @@ -0,0 +1,117 @@ +--- +navigation_title: A11y checks +--- + +# Accessibility testing (a11y) with Scout [scout-a11y-checks] + +Scout includes built-in support for automated accessibility (a11y) checks in UI tests via `page.checkA11y`. + +:::::{warning} +Use automated a11y checks to augment, not replace, manual accessibility reviews and assistive technology testing. +::::: + +## Automated a11y checks with `page.checkA11y` [scout-checkA11y] + +Behind the scenes, `page.checkA11y` runs an [axe-core](https://github.com/dequelabs/axe-core) scan of the current page state. It analyzes the DOM for accessibility violations and returns results you can assert on. Configuration is unified with Cypress and FTR. + +### When to run a11y checks [scout-a11y-when] + +We recommend adding `page.checkA11y` to: + +- Key happy-path flows (for example, landing pages, dashboards, wizards) +- Important interaction states (for example, flyouts, modals, menus, toasts) +- Pages with frequent UI changes where regressions are likely + +Avoid running a11y checks on every interaction. Pick a few high-value checkpoints per test or suite to keep runs fast and reduce flakiness. + +### Run a full-page a11y check [scout-a11y-full-page] + +Run `page.checkA11y()` once the page is fully loaded and the UI has settled: + +```ts +test('Dashboard listing page has no basic accessibility violations', async ({ + pageObjects, + page, +}) => { + await pageObjects.dashboard.gotoApp(); + + // Wait for the page to be ready (example helper) + await pageObjects.dashboard.waitForListingTableToLoad(); + + const { violations } = await page.checkA11y(); + expect(violations).toHaveLength(0); +}); +``` + +### Check specific elements [scout-a11y-specific-elements] + +:::::{note} +Prefer running `checkA11y` with `include` set to the root element you are testing. This keeps the scan isolated and reduces runtime. +::::: + +```ts +test('Modal dialog is accessible', async ({ page }) => { + await page.goto('/app/my-plugin'); + await page.testSubj.click('open-modal'); + + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible(); + + const { violations } = await page.checkA11y({ + include: ['[role="dialog"]'], + }); + expect(violations).toHaveLength(0); +}); +``` + +### Use `test.step` to “box” checks [scout-a11y-boxing] + +```ts +test('should load dashboard from listing page', async ({ pageObjects, page }) => { + await pageObjects.dashboard.gotoApp(); + await pageObjects.dashboard.waitForListingTableToLoad(); + + await test.step('no basic accessibility violations on the listing page', async () => { + const { violations } = await page.checkA11y(); + expect(violations).toHaveLength(0); + }); + + // continue with the flow... +}); +``` + +### Troubleshooting flaky or failing checks [scout-a11y-troubleshooting] + +If a failure isn’t caused by your change and there isn’t a quick fix, don’t disable the entire a11y check. Prefer excluding the specific problematic element so the suite still provides coverage: + +```ts +test('Form components are accessible', async ({ page }) => { + await page.goto('/app/my-plugin/create'); + + const { violations } = await page.checkA11y({ + exclude: ['[data-test-subj="problematic-element"]'], + }); + + expect(violations).toHaveLength(0); +}); +``` + +## Understanding a11y violations in reports [scout-a11y-reporting] + +When `page.checkA11y` detects violations, the returned `violations` array includes details such as: + +- `id` (rule id, for example `color-contrast`) +- `impact` (`critical`, `serious`, `moderate`, `minor`) +- `description` +- `nodes` (elements that violate the rule) +- `helpUrl` + +Violations appear in the Scout HTML report. + +## Best practices [scout-a11y-best-practices] + +- Use `include` to target specific regions +- Wait for page readiness before scanning +- Focus on critical paths and key interaction states +- Assert `violations.length === 0` +- Combine with manual testing diff --git a/docs/extend/scout/api-auth.md b/docs/extend/scout/api-auth.md new file mode 100644 index 0000000000000..9559f67b1baba --- /dev/null +++ b/docs/extend/scout/api-auth.md @@ -0,0 +1,150 @@ +--- +navigation_title: API auth +--- + +# Authentication in Scout API tests [scout-api-auth] + +Scout supports two ways to authenticate API tests: + +- **API keys** via the `requestAuth` fixture (best for `api/*` endpoints) +- **Interactive session cookies** via the `samlAuth` fixture (best for `internal/*` endpoints) + +Both return headers you can spread into `apiClient` requests (`apiKeyHeader` or `cookieHeader`). + +## Choose an auth type [choose-auth-type] + +| Endpoint | Recommended auth | Fixture | +| ----------------------------------------------------------------------------------------- | -------------------- | ------------- | +| [Public APIs](https://www.elastic.co/docs/api/doc/kibana) (usually starting with `api/*`) | API key | `requestAuth` | +| Internal APIs (usually starting with `internal/*`) | Cookie-based session | `samlAuth` | + +## Common headers [common-headers] + +Many Kibana APIs use a few standard headers: + +- `kbn-xsrf`: required for most **non-safe** requests (`POST`, `PUT`, `PATCH`, `DELETE`) when Kibana’s XSRF protection is enabled (default), unless the route opts out / is allowlisted. Kibana also accepts `kbn-version`, but `kbn-xsrf` is easier for tests. It’s safe to include on `GET`. +- `x-elastic-internal-origin: kibana`: marks the request as an **internal API request** (required to call `internal/*` endpoints when internal APIs are restricted). It’s safe to include on `api/*` too. +- `Content-Type: application/json;charset=UTF-8`: include when you send a JSON request body. +- `elastic-api-version`: some endpoints are versioned and require this header (the required value depends on the endpoint). + +## API key auth [api-key-auth] + +Generate credentials once (often in `beforeAll`) and reuse them: + +Available methods on `requestAuth`: + +| Method | Description | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `getApiKeyForViewer()` | Shorthand for `getApiKey('viewer')` | +| `getApiKeyForPrivilegedUser()` | Elevated non-admin role; uses `editor` (most) or `developer` (Elasticsearch serverless projects) | +| `getApiKeyForAdmin()` | Shorthand for `getApiKey('admin')` (full access); avoid unless required | +| `getApiKey(role)` | Create an API key for a predefined role by name (the role must exist in the deployment) | +| `getApiKeyForCustomRole(roleDescriptor)` | Create an API key scoped to a custom Kibana/Elasticsearch role descriptor | + +```ts +let viewerApiKey: RoleApiCredentials; + +apiTest.beforeAll(async ({ requestAuth }) => { + viewerApiKey = await requestAuth.getApiKeyForViewer(); +}); + +apiTest('calls a public API', async ({ apiClient }) => { + const res = await apiClient.get('api/console/api_server', { + headers: { ...viewerApiKey.apiKeyHeader, 'kbn-xsrf': 'scout' }, + responseType: 'json', + }); + expect(res.statusCode).toBe(200); +}); +``` + +### API key auth with a custom role [api-key-auth-custom-role] + +Use `getApiKeyForCustomRole()` when you need fine-grained Kibana/Elasticsearch privileges: + +```ts +let credentials: RoleApiCredentials; + +apiTest.beforeAll(async ({ requestAuth }) => { + credentials = await requestAuth.getApiKeyForCustomRole({ + elasticsearch: { + cluster: [], + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { discover: ['read'] }, + }, + ], + }); +}); + +apiTest('can read discover resources', async ({ apiClient }) => { + const res = await apiClient.get('api/my-feature/data', { + headers: { + ...credentials.apiKeyHeader, + 'kbn-xsrf': 'scout', + }, + responseType: 'json', + }); + + expect(res.statusCode).toBe(200); +}); +``` + +## Cookie-based auth [cookie-auth] + +Use an interactive session to simulate how the Kibana UI calls internal endpoints: + +Available methods on `samlAuth`: + +| Method | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------ | +| `asInteractiveUser(role)` | Get a `cookieHeader` for a built-in role name (for example `viewer`) or a custom role descriptor | + +```ts +apiTest('calls an internal endpoint', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('viewer'); + + const res = await apiClient.get('internal/some/endpoint', { + headers: { ...cookieHeader, 'kbn-xsrf': 'scout', 'x-elastic-internal-origin': 'kibana' }, + responseType: 'json', + }); + expect(res.statusCode).toBe(200); +}); +``` + +### Cookie auth with a custom role [cookie-auth-custom-role] + +`asInteractiveUser()` can also take a custom role descriptor: + +```ts +apiTest('calls an internal endpoint with custom privileges', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser({ + kibana: [{ spaces: ['*'], base: [], feature: { discover: ['read'] } }], + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + }); + + const res = await apiClient.get('internal/some/endpoint', { + headers: { ...cookieHeader, 'kbn-xsrf': 'scout', 'x-elastic-internal-origin': 'kibana' }, + responseType: 'json', + }); + + expect(res.statusCode).toBe(200); +}); +``` + +## Predefined and custom roles + +- **Predefined roles**: built-in roles like `admin`, `editor`, `viewer`. Use `getApiKey()` (or `getApiKeyForViewer()` etc). Privileges are resolved from the appropriate `roles.yml` file. +- **Custom roles**: roles you define for a specific test with explicit and fine-grained Elasticsearch/Kibana privileges. + +## Best practices [best-practices] + +- Prefer minimal permissions; avoid `admin` unless required. +- Reuse credentials within a suite when possible. +- Include required headers (`kbn-xsrf`, and `x-elastic-internal-origin` for internal endpoints). +- When testing authorization, cover both **allowed** and **forbidden** cases (for example, assert that an under-privileged role receives `403`). diff --git a/docs/extend/scout/api-services.md b/docs/extend/scout/api-services.md new file mode 100644 index 0000000000000..351c728fd1aab --- /dev/null +++ b/docs/extend/scout/api-services.md @@ -0,0 +1,61 @@ +--- +navigation_title: API services +--- + +# API services [scout-api-services] + +API services are higher-level helpers for Kibana APIs used for **setup**, **teardown**, and **side-effect verification**. They’re accessed via the `apiServices` fixture (your editor should autocomplete `apiServices.`). + +::::::{tip} +API services should focus on **server-side interactions** (HTTP requests). UI interactions belong in [page objects](./page-objects.md). +:::::: + +## When to use `apiServices` vs `apiClient` [scout-api-services-when] + +- **`apiClient`**: validate the endpoint under test with scoped credentials (see [Write API tests](./write-api-tests.md)). +- **`apiServices`**: prepare state, clean up, or verify side effects with higher privileges. + +## Example [scout-api-services-example] + +```ts +test.beforeAll(async ({ apiServices }) => { + await apiServices.streams.enable(); +}); + +test.afterAll(async ({ apiServices }) => { + await apiServices.streams.disable(); +}); +``` + +## Create a new service [create-a-new-api-service] + +If you find repeated API setup/cleanup code across suites, add a helper to your plugin/solution’s `apiServices` extension. If it’s broadly useful, consider contributing it to `@kbn/scout` or your solution Scout package. + +## Extend `apiServices` in your plugin fixtures [extend-apiServices] + +You can add plugin-specific helpers by extending the `apiServices` fixture in your `fixtures/index.ts`. + +Example (API tests): + +```ts +import type { ApiServicesFixture } from '@kbn/scout'; +import { apiTest as baseApiTest } from '@kbn/scout'; +import { + getMyFeatureApiService, + type MyFeatureApiService, +} from '../services/my_feature_api_service'; + +export interface MyFeatureApiServicesFixture extends ApiServicesFixture { + myFeature: MyFeatureApiService; +} + +export const apiTest = baseApiTest.extend<{ apiServices: MyFeatureApiServicesFixture }>({ + apiServices: async ({ apiServices, kbnClient, log }, use) => { + const extendedApiServices = apiServices as MyFeatureApiServicesFixture; + extendedApiServices.myFeature = getMyFeatureApiService({ kbnClient, log }); + await use(extendedApiServices); + }, +}); +``` + +Use the helper for **setup/teardown** (and keep the endpoint under test in `apiClient` for readable, scoped tests). See [best practices](./best-practices.md#api-tests). diff --git a/docs/extend/scout/api-testing.md b/docs/extend/scout/api-testing.md new file mode 100644 index 0000000000000..1987ac02dc171 --- /dev/null +++ b/docs/extend/scout/api-testing.md @@ -0,0 +1,10 @@ +--- +navigation_title: API testing +--- + +# API testing with Scout [scout-api-testing] + +Use these pages when writing Scout API integration tests: + +- [Write Scout API tests](./write-api-tests.md) +- [Authentication in Scout API tests](./api-auth.md) diff --git a/docs/extend/scout/best-practices.md b/docs/extend/scout/best-practices.md new file mode 100644 index 0000000000000..b5b9bc7360e6d --- /dev/null +++ b/docs/extend/scout/best-practices.md @@ -0,0 +1,443 @@ +--- +navigation_title: Best practices +--- + +# Best practices for Scout tests [scout-best-practices] + +This guide covers best practices for writing Scout UI and API tests that are reliable, maintainable, and fast. + +Scout is built on Playwright, so the official [Playwright Best Practices](https://playwright.dev/docs/best-practices) apply. + +:::::{tip} +**New to Scout?** Start with our [Scout introduction page](../scout.md). +::::: + +## Quick reference [quick-reference] + +**UI and API tests** + +| Question | Section | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| How should I **organize** my test files? | [Organize test suites by role and user flow](#organize-test-suites-by-role-and-user-flow) | +| Where should **shared setup** go? | [Move repeated one-time setup to a global setup hook](#move-repeated-one-time-setup-operations-to-a-global-setup-hook) | +| Where should **cleanup code** go? | [Put cleanup code in hooks, not in the test body](#put-cleanup-code-in-hooks-not-in-the-test-body) | +| Where should **shared values** live? | [Use constants for shared test values](#use-constants-for-shared-test-values) | +| What **permissions** should my test use? | [Test with minimal permissions](#test-with-minimal-permissions-avoid-admin-when-possible) | +| How do I know if my test is **flaky**? | [Run tests multiple times to catch flakiness](#use-the-flaky-test-runner-to-catch-flaky-tests-early) | + +**UI tests** + +| Question | Section | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| What should I test in **UI tests** vs **API tests**? | [Focus UI tests on behavior, not data correctness](#focus-ui-tests-on-behavior-not-data-correctness) | +| Should my tests run in **parallel** or **sequentially**? | [Run tests in parallel whenever possible](#run-tests-in-parallel-whenever-possible) | +| Should I split into multiple `test()` blocks or use **`test.step`**? | [Use `test.step` for multi-step flows](#use-teststep-for-multi-step-flows) | +| How should I set up **test data**? | [Prefer Kibana APIs over UI for setup and teardown](#prefer-kibana-apis-over-ui-for-setup-and-teardown) | +| How do I skip **onboarding screens**? | [Skip onboarding flows with `addInitScript`](#skip-onboarding-flows-with-addinitscript) | +| Do I need to add explicit **waits** everywhere? | [Leverage Playwright auto-waiting](#leverage-playwright-auto-waiting) | +| How do I **wait for the UI** to be ready? | [Wait for UI updates when the next action requires it](#wait-for-ui-updates-when-the-next-action-requires-it) | +| How do I test **tables** and **complex components**? | [Wait for complex components to fully render](#wait-for-complex-components-to-fully-render) | +| What **locators** should I use? | [Locate UI elements reliably](#locate-ui-elements-reliably) | +| Should I change Scout's default **timeouts**? | [Use Scout's default timeouts](#use-scouts-default-timeouts) | +| How do I write good **page objects**? | [Page object tips](#page-object-tips) | +| My test keeps failing — should I add **retries**? | [Don't use manual retry loops — fix the source code](#dont-use-manual-retry-loops) | +| Should I **contribute** my page object to Scout? | [Contribute to Scout when possible](#contribute-to-scout-when-possible) | + +**API tests** + +| Question | Section | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Which **fixture** should I use? | [Validate endpoints with `apiClient`](#validate-endpoints-with-apiclient-for-readable-and-scoped-tests) | +| What should I **assert**? | [Don't just verify the status code, validate the response body](#dont-just-verify-the-status-code-validate-the-response-body) | + +--- + +## UI & API tests [ui-and-api-tests] + +Best practices that apply to both UI and API tests. + +### Design tests with a cloud-first mindset [design-tests-with-a-cloud-first-mindset] + +Scout is deployment-agnostic: write once, run locally and on Elastic Cloud. + +- Tag suites with [deployment tags](./deployment-tags.md) and use `--grep` to target environments. +- Prefer portable assumptions: don’t depend on “special” Cloud deployment tweaks for correctness. + +### Run tests multiple times to catch flakiness [use-the-flaky-test-runner-to-catch-flaky-tests-early] + +When you add new tests, fix flakes, or make significant changes, run the same tests multiple times to catch flakiness early. A good starting point is **20–50 runs**. + +Prefer doing this locally first (faster feedback), and use the Flaky Test Runner in CI when needed. + +For how to reproduce flakiness locally and in CI (including `--grep` guidance), see [Debug flaky tests](./debugging.md#scout-debugging-flaky-tests). + +```bash +/flaky scoutConfig:: +``` + +### Keep test suites independent [keep-test-suites-independent] + +- Keep **one top-level suite** per file (`test.describe`). +- Avoid nested `describe` blocks. Use `test.step` for structure inside a test. +- Don’t rely on test file execution order (it’s [not guaranteed](https://playwright.dev/docs/test-parallel#control-test-order)). + +### Organize test suites by role and user flow [organize-test-suites-by-role-and-user-flow] + +Prefer “one role + one flow per file”. Put shared login/navigation in `beforeEach`. + +```ts +// dashboard_viewer.spec.ts +test.beforeEach(async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsViewer(); + await pageObjects.dashboard.goto(); +}); + +test('can see dashboard', async ({ page }) => { + // assertions... +}); +``` + +### Use a global setup hook for one-time setup [move-repeated-one-time-setup-operations-to-a-global-setup-hook] + +If many files share the same “one-time” work (archives, API calls, settings), move it to a [global setup hook](./global-setup-hook.md). + +```ts +globalSetupHook('Load shared test data (if needed)', async ({ esArchiver, log }) => { + log.debug('[setup] loading archives (only if indexes do not exist)...'); + await esArchiver.loadIfNeeded(MY_ARCHIVE); +}); +``` + +### Keep cleanup in hooks [put-cleanup-code-in-hooks-not-in-the-test-body] + +Cleanup in the test body doesn’t run after a failure. Prefer `afterEach` / `afterAll`. + +```ts +test.afterEach(async ({ esClient, log }) => { + try { + await esClient.indices.delete({ index: testIndexName }); + } catch (e: any) { + log.debug(`Index cleanup failed: ${e.message}`); + } +}); +``` + +### Use constants for shared test values [use-constants-for-shared-test-values] + +If a value is reused across suites (archive paths, fixed time ranges, endpoints, common headers), extract it into a shared `constants.ts` file. This reduces duplication and typos, and makes updates safer. + +```ts +// test/scout/ui/constants.ts +export const LENS_BASIC_TIME_RANGE = { + from: 'Sep 22, 2015 @ 00:00:00.000', + to: 'Sep 23, 2015 @ 00:00:00.000', +}; + +export const DASHBOARD_SAVED_SEARCH_ARCHIVE = + 'src/platform/test/functional/fixtures/kbn_archiver/dashboard/current/kibana'; + +export const DASHBOARD_DEFAULT_INDEX_TITLE = 'logstash-*'; + +// test/scout/api/constants.ts +export const COMMON_HEADERS = { + 'kbn-xsrf': 'some-xsrf-token', + 'x-elastic-internal-origin': 'kibana', + 'Content-Type': 'application/json;charset=UTF-8', +} as const; +``` + +### Test with minimal permissions [test-with-minimal-permissions-avoid-admin-when-possible] + +Avoid `admin` unless there’s no alternative. Minimal permissions catch real permission bugs and keep tests realistic. + +See [browser authentication](./browser-auth.md) and [API authentication](./api-auth.md). + +```ts +await browserAuth.loginWithCustomRole('logs_analyst', { + elasticsearch: { + indices: [{ names: ['logs-*'], privileges: ['read'] }], + }, + kibana: [{ spaces: ['*'], base: [], feature: { discover: ['read'] } }], +}); +``` + +--- + +## UI tests [ui-tests] + +Best practices specific to UI tests. + +### Prefer parallel runs [run-tests-in-parallel-whenever-possible] + +Default to [parallel UI suites](./parallelism.md) when possible. Parallel workers share the same Kibana/ES deployment, but run in isolated Spaces. + +| Run in **parallel** | Run **sequentially** | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| UI tests (most suites) | API tests | +| Suites that can share pre-ingested data (often via the [global setup hook](./global-setup-hook.md)) | Suites requiring a “clean” Elasticsearch state | + +### Use `test.step` for multi-step flows [use-teststep-for-multi-step-flows] + +Use `test.step()` to structure a multi-step flow while keeping one browser context (faster, clearer reporting). + +```ts +test('navigates through pages', async ({ pageObjects }) => { + await test.step('go to Dashboards', async () => { + await pageObjects.navigation.clickDashboards(); + }); + + await test.step('go to Overview', async () => { + await pageObjects.navigation.clickOverview(); + }); +}); +``` + +### Test behavior, not data correctness [focus-ui-tests-on-behavior-not-data-correctness] + +Keep UI tests focused on: + +- layout and rendering +- navigation and interaction +- “does this feature work” at a user level + +Validate data correctness and edge cases with API/unit tests instead. + +```ts +// basic render checks: table is visible, expected headers exist +await expect(page.testSubj.locator('datasetQualityTable-loaded')).toBeVisible(); +await expect(page.testSubj.locator('datasetQualityTable').locator('th')).toContainText([ + 'Dataset', + 'Last Activity', +]); +``` + +### Prefer APIs for setup and teardown [prefer-kibana-apis-over-ui-for-setup-and-teardown] + +Setup/teardown via UI is slow and brittle. Prefer Kibana APIs and fixtures. + +```ts +test.beforeEach(async ({ uiSettings, kbnClient }) => { + await uiSettings.setDefaultTime({ from: startTime, to: endTime }); + await kbnClient.importExport.load(DATA_VIEW_ARCHIVE_PATH); +}); +``` + +### Skip onboarding with `addInitScript` [skip-onboarding-flows-with-addinitscript] + +If a page has onboarding/getting-started state, set localStorage before navigation. + +```ts +test.beforeEach(async ({ page, browserAuth, pageObjects }) => { + await browserAuth.loginAsViewer(); + await page.addInitScript(() => { + window.localStorage.setItem('gettingStartedVisited', 'true'); + }); + await pageObjects.homepage.goto(); +}); +``` + +### Use Playwright auto-waiting [leverage-playwright-auto-waiting] + +Playwright actions and [web-first assertions](https://playwright.dev/docs/best-practices#use-web-first-assertions) already wait/retry. + +- Avoid “pre-waits” like `waitForSelector()` before `click()`/`fill()` unless the **next step** depends on a new UI state (see below). +- Avoid manual `waitFor()` before assertions like `toBeVisible()`—they already retry. + +```ts +await page.testSubj.click('myButton'); +await expect(page.testSubj.locator('successToast')).toBeVisible(); +``` + +### Don't use manual retry loops [dont-use-manual-retry-loops] + +If an action fails, don’t wrap it in a retry loop. Playwright already waits for actionability; repeated failures usually point to an app issue (unstable DOM, non-unique selectors, re-render bugs). + +If you need retries to make a test pass, fix the component or make your waiting/locators explicit and stable. + +### Locate UI elements reliably [locate-ui-elements-reliably] + +Prefer stable `data-test-subj` attributes accessed via `page.testSubj`. + +```ts +// verbose +await page.click('[data-test-subj="myButton"]'); + +// preferred +await page.testSubj.click('myButton'); +``` + +If `data-test-subj` is missing, prefer adding one to source code. If that’s not possible, use `getByRole` **inside a scoped container**: + +```ts +await page.testSubj.locator('confirmDeleteModal').getByRole('button', { name: 'Delete' }).click(); +``` + +Avoid `getByText` for primary selectors; text changes and translations make it fragile. + +### Use Scout's default timeouts [use-scouts-default-timeouts] + +Scout configures Playwright timeouts ([source](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-scout/src/playwright/config/create_config.ts)). Prefer defaults. + +- Don’t override suite-level timeouts/retries with `test.describe.configure()` unless you have a strong reason. +- If you increase a timeout for one operation, keep it well below the test timeout and leave a short rationale. + +```ts +await expect(editor).toBeVisible(); // default timeout + +// justified: report generation can be slow +await expect(downloadBtn).toBeEnabled({ timeout: 30_000 }); +``` + +### Wait when the next step depends on it [wait-for-ui-updates-when-the-next-action-requires-it] + +Don’t “wait everywhere”. Add explicit waits when an action triggers UI work that the next step depends on. + +```ts +await page.gotoApp('sample/page/here'); +await page.testSubj.waitForSelector('mainContent', { state: 'visible' }); +``` + +### Wait for complex UI to finish rendering [wait-for-complex-components-to-fully-render] + +Tables/maps/visualizations can appear before data is rendered. Prefer waiting on an explicit “loaded” signal (ideally exposed by the component). + +In source code, use a dynamic `data-test-subj`: + +```tsx + +``` + +In tests, wait for the loaded state: + +```ts +await expect(page.testSubj.locator('myTable-loaded')).toBeVisible(); +``` + +For Kibana Maps, `data-render-complete="true"` is often the right “ready” signal. + +### Page object tips [page-object-tips] + +These tips complement the dedicated docs on [page objects](./page-objects.md). + +#### Use existing page objects to interact with the Kibana UI [use-existing-page-objects-to-interact-with-the-kibana-ui] + +Prefer existing page objects (and their methods) over rebuilding EUI interactions in test files. + +```ts +await pageObjects.datePicker.setAbsoluteRange({ + from: 'Sep 19, 2015 @ 06:31:44.000', + to: 'Sep 23, 2015 @ 18:31:44.000', +}); +``` + +#### Abstract common operations in page object methods [abstract-common-operations-in-page-object-methods] + +Create methods for repeated flows (and make them wait for readiness). + +```ts +async openNewDashboard() { + await this.page.testSubj.click('newItemButton'); + await this.page.testSubj.waitForSelector('emptyDashboardWidget', { state: 'visible' }); +} +``` + +#### Keep assertions explicit in tests, not hidden in page objects [keep-assertions-explicit-in-tests-not-hidden-in-page-objects] + +Prefer explicit `expect()` in the test file so reviewers can see intent and failure modes. + +```ts +await pageObjects.indexManagement.clickCreateIndexSaveButton(); +await expect(page.testSubj.locator('indicesTable')).toContainText(testIndexName); +``` + +#### Use EUI wrappers as class fields in page objects [use-eui-wrappers-as-class-fields-in-page-objects] + +If you must interact with EUI internals, use wrappers from Scout to keep that complexity out of tests. + +```ts +import { EuiComboBoxWrapper, ScoutPage } from '@kbn/scout'; + +export class StreamsAppPage { + public readonly fieldComboBox: EuiComboBoxWrapper; + + constructor(private readonly page: ScoutPage) { + this.fieldComboBox = new EuiComboBoxWrapper(this.page, 'fieldSelectorComboBox'); + } + + async selectField(value: string) { + await this.fieldComboBox.selectSingleOption(value); + } +} +``` + +### Contribute to Scout when possible [contribute-to-scout-when-possible] + +If you build a helper that will benefit other tests, consider upstreaming it: + +- **Reusable across many plugins/teams**: contribute to `@kbn/scout` +- **Reusable but solution-scoped**: contribute to the relevant solution Scout package +- **Plugin-specific**: keep it in your plugin’s `test/scout` tree + +For the full guidance, see [Scout](../scout.md#contribute-to-scout-when-possible). + +--- + +## API tests [api-tests] + +Best practices specific to API tests. + +### Validate endpoints with `apiClient` [validate-endpoints-with-apiclient-for-readable-and-scoped-tests] + +Use the right fixture for the right purpose: + +| Fixture | Use for | +| ----------------------------- | -------------------------------------------------------------------------------- | +| `apiClient` | The endpoint under test (with scoped credentials from [API auth](./api-auth.md)) | +| `apiServices` | Setup/teardown and side effects | +| `kbnClient`, `esClient`, etc. | Lower-level setup when `apiServices` doesn’t have a suitable helper | + +Prefer tests that read like “call endpoint X as role Y, assert outcome”. + +```ts +apiTest.beforeAll(async ({ requestAuth, apiServices }) => { + await apiServices.myFeature.createTestData(); + viewerCredentials = await requestAuth.getApiKeyForViewer(); +}); + +apiTest('returns data for viewer', async ({ apiClient }) => { + const { body, statusCode } = await apiClient.get('api/my-feature/data', { + headers: { ...COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + }); + + expect(statusCode).toBe(200); + expect(body.items).toHaveLength(3); +}); +``` + +This pattern validates both endpoint behavior and the [permission model](#test-with-minimal-permissions-avoid-admin-when-possible). + +### Validate the response body (not just status) [dont-just-verify-the-status-code-validate-the-response-body] + +Status code assertions are necessary but not sufficient—also validate shape and key fields. + +```ts +apiTest('returns autocomplete definitions', async ({ apiClient }) => { + const { body, statusCode } = await apiClient.get('api/console/api_server', { + headers: { ...COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + }); + + expect(statusCode).toBe(200); + expect(body).toMatchObject({ + es: { + endpoints: expect.any(Object), + globals: expect.any(Object), + name: 'es', + }, + }); +}); +``` diff --git a/docs/extend/scout/browser-auth.md b/docs/extend/scout/browser-auth.md new file mode 100644 index 0000000000000..0c78cd39b6bb8 --- /dev/null +++ b/docs/extend/scout/browser-auth.md @@ -0,0 +1,58 @@ +--- +navigation_title: Browser auth +--- + +# Browser authentication in Scout [scout-browser-auth] + +Use the `browserAuth` fixture to authenticate **UI tests**. Scout uses **SAML**, so the same approach works across deployment types. + +## Log in with `browserAuth` [scout-browser-auth-login] + +Available methods: + +| Method | Description | +| ------------------------------------- | --------------------------------------------------------------------------- | +| `loginAsViewer()` | Read-only flows using the built-in `viewer` role | +| `loginAsPrivilegedUser()` | Resolves to `editor`, or `developer` for Elasticsearch serverless projects | +| `loginAsAdmin()` | Admin-only behavior (full access); avoid unless required | +| `loginAs(role)` | Log in with a specific built-in role by name (must exist in the deployment) | +| `loginWithCustomRole(roleDescriptor)` | Log in with a specific set of Kibana and Elasticsearch privileges | + +```ts +test.beforeEach(async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsViewer(); + await pageObjects.dashboard.goto(); +}); +``` + +::::::{note} +Local runs can create on-demand identities via a trusted mock IdP. Cloud runs authenticate using pre-provisioned users (internal provisioning details live in [internal AppEx QA documentation](https://docs.elastic.dev/appex-qa/create-cloud-users)). +:::::: + +## Custom roles [scout-browser-auth-custom-role] + +Use `loginWithCustomRole()` to test permission boundaries with least privilege: + +```ts +await browserAuth.loginWithCustomRole({ + kibana: [{ spaces: ['*'], base: [], feature: { discover: ['read'] } }], + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, +}); +``` + +## Reuse role helpers [scout-browser-auth-extend] + +If the same login/role is needed across many tests, extend `browserAuth` in your solution/package/plugin fixtures instead of repeating role descriptors. + +Examples in the repo: + +- [Security](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/packages/kbn-scout-security/src/playwright/fixtures/test/browser_auth/index.ts) +- [APM](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/apm/test/scout/ui/fixtures/index.ts) + +## Best practices [scout-browser-auth-best-practices] + +- Avoid `admin` unless you’re explicitly testing admin-only behavior. +- Prefer `loginAsPrivilegedUser()` for suites that run across multiple environments. +- Keep custom roles minimal and document why they exist. diff --git a/docs/extend/scout/core-concepts.md b/docs/extend/scout/core-concepts.md new file mode 100644 index 0000000000000..9a0a87e5b78d9 --- /dev/null +++ b/docs/extend/scout/core-concepts.md @@ -0,0 +1,15 @@ +--- +navigation_title: Core concepts +--- + +# Core Scout concepts [scout-core-concepts] + +These pages explain Scout’s building blocks and how they fit together: + +- [Fixtures](./fixtures.md) +- [Page objects](./page-objects.md) +- [API services](./api-services.md) +- [Parallelism](./parallelism.md) +- [Global setup hook](./global-setup-hook.md) +- [Deployment tags](./deployment-tags.md) +- [Skip tests](./skip-tests.md) diff --git a/docs/extend/scout/debugging.md b/docs/extend/scout/debugging.md new file mode 100644 index 0000000000000..0dc994d86eacd --- /dev/null +++ b/docs/extend/scout/debugging.md @@ -0,0 +1,79 @@ +--- +navigation_title: Debugging +--- + +# Debug Scout test runs [scout-debugging] + +This page lists the fastest ways to debug Scout tests locally and in CI. + +## Local runs [scout-debugging-local] + +- If your tests use the `log` fixture, messages are printed in local console output. +- For more verbose output, set `SCOUT_LOG_LEVEL=debug`. + +### Open the HTML report [open-the-scout-report] + +After a run, Playwright generates an HTML report. The console output includes the report path. To open the latest report: + +```bash +npx playwright show-report /test/scout/ui/output/reports +``` + +::::::{note} +**CI runs**: in Buildkite, the Playwright HTML report is typically available under the job’s **Artifacts**. +:::::: + +### Playwright UI mode [playwright-ui-mode] + +[UI Mode](https://playwright.dev/docs/test-ui-mode) lets you run and debug tests interactively. + +```bash +npx playwright test \ + --config /test/scout/ui/playwright.config.ts \ + --project local \ + --ui \ + --grep @-- +``` + +The `--grep` value should match the suite tags you use. See [Deployment tags](./deployment-tags.md). + +## Debug flaky tests [scout-debugging-flaky-tests] + +When you add new tests, fix flakes, or make significant changes, run the same tests multiple times (recommended: **20–50** runs). See [Best practices](./best-practices.md#use-the-flaky-test-runner-to-catch-flaky-tests-early). + +### Repeat the same test locally [scout-debugging-flaky-tests-local] + +To reproduce flakiness locally, you can run the same test multiple times with Playwright’s `--repeat-each`. + +**Grepping is key**: always pass `--grep` with the test title or tag that matches the target environment you’re running against, otherwise you may run suites that aren’t compatible with your chosen `--project`. + +Example (repeat a single spec 30 times): + +```bash +npx playwright test dashboard_search_by_value.spec.ts \ + --project mki \ + --grep @cloud-serverless-search \ + --config src/platform/plugins/shared/dashboard/test/scout/ui/parallel.playwright.config.ts \ + --repeat-each 30 +``` + +`--project mki` runs against **cloud serverless**, so you’ll typically want a `@cloud-serverless-` grep (for example `@cloud-serverless-search`). For local runs (`--project local`), use `@local-...` tags. + +If you’re unsure what to use for `--grep`, check the tags on the `test.describe(...)` block (see [Deployment tags](./deployment-tags.md)). + +### Run the Flaky Test Runner (CI) [scout-debugging-flaky-tests-ci] + +There are two common ways to trigger the Flaky Test Runner (Elasticians only): + +- **UI**: open `https://ci-stats.kibana.dev/trigger_flaky_test_runner` and follow the prompts. +- **GitHub PR comment**: post a comment on your pull request: + +```bash +/flaky scoutConfig:: +``` + +Example: + +```bash +/flaky scoutConfig:src/platform/plugins/shared/dashboard/test/scout/ui/parallel.playwright.config.ts:30 +``` diff --git a/docs/extend/scout/deployment-tags.md b/docs/extend/scout/deployment-tags.md new file mode 100644 index 0000000000000..b555136701764 --- /dev/null +++ b/docs/extend/scout/deployment-tags.md @@ -0,0 +1,101 @@ +--- +navigation_title: Deployment tags +--- + +# Deployment tags [scout-deployment-tags] + +Deployment tags declare **where a test suite is expected to run**. Add them to every `test.describe()` (or `apiTest.describe()` / `spaceTest.describe()`), then use `--grep` when running tests to target a specific environment. + +Tags follow this shape: + +- `@--` + +Where: + +- **location**: `local` or `cloud` +- **arch**: `stateful` or `serverless` +- **domain**: `classic`, `search`, `observability_complete`, `security_complete`, … + +## Use the `tags` helper [scout-deployment-tags-using] + +Prefer the `tags` helper exported by Scout instead of writing raw strings. It returns arrays of Playwright tags you can use directly or combine. + +```ts +import { test, tags } from '@kbn/scout'; + +test.describe('My suite', { tag: tags.deploymentAgnostic }, () => { + test('works', async () => { + // ... + }); +}); +``` + +Combine multiple targets by spreading: + +```ts +test.describe('My suite', { tag: [...tags.stateful.classic, ...tags.serverless.search] }, () => { + // ... +}); +``` + +## Common shortcuts [scout-deployment-tags-shortcuts] + +Unless stated otherwise, these helpers include **both** `@local-*` and `@cloud-*` targets. + +### Cross-cutting helpers [scout-deployment-tags-cross-cutting] + +| Helper | What it targets | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tags.deploymentAgnostic` | **Recommended if your test can run (almost) everywhere**: includes stateful + serverless `*.complete`. Excludes serverless Workplace AI projects (as they don't have a stateful counterpart). | +| `tags.performance` | Performance tests (`@perf`) | + +### Stateful (by domain) [scout-deployment-tags-stateful] + +| Helper | Domain | +| ----------------------------- | -------------------- | +| `tags.stateful.all` | All stateful targets | +| `tags.stateful.classic` | Classic | +| `tags.stateful.search` | Search | +| `tags.stateful.observability` | Observability | +| `tags.stateful.security` | Security | + +### Serverless (by solution) [scout-deployment-tags-serverless] + +#### All serverless targets [scout-deployment-tags-serverless-all] + +| Helper | What it targets | +| --------------------- | ---------------------- | +| `tags.serverless.all` | All serverless targets | + +#### Search [scout-deployment-tags-serverless-search] + +| Helper | Project type | +| ------------------------ | ------------ | +| `tags.serverless.search` | Search | + +#### Observability [scout-deployment-tags-serverless-observability] + +| Helper | Project type | +| ----------------------------------------------- | ------------------------------- | +| `tags.serverless.observability.complete` | Observability (complete) | +| `tags.serverless.observability.logs_essentials` | Observability (logs_essentials) | + +#### Security [scout-deployment-tags-serverless-security] + +| Helper | Project type | +| ------------------------------------- | --------------------- | +| `tags.serverless.security.complete` | Security (complete) | +| `tags.serverless.security.essentials` | Security (essentials) | +| `tags.serverless.security.ease` | Security (ease) | + +#### Workplace AI [scout-deployment-tags-serverless-workplaceai] + +| Helper | Project type | +| ----------------------------- | ------------ | +| `tags.serverless.workplaceai` | Workplace AI | + +For the authoritative list (and the exact tag strings), see `src/platform/packages/shared/kbn-scout/src/playwright/tags.ts` or just rely on editor autocomplete. + +::::::{note} +Use tags to **include** suites where they make sense, instead of skipping suites after the fact. +:::::: diff --git a/docs/extend/scout/fixtures.md b/docs/extend/scout/fixtures.md new file mode 100644 index 0000000000000..963bb613211e1 --- /dev/null +++ b/docs/extend/scout/fixtures.md @@ -0,0 +1,75 @@ +--- +navigation_title: Fixtures +--- + +# Fixtures [scout-fixtures] + +Fixtures are Scout’s reusable building blocks for authentication, clients, data setup, and shared helpers. They’re similar in spirit to FTR services, but follow Playwright’s fixture model. + +## Quick usage [scout-fixtures-usage] + +```ts +test.describe('My suite', { tag: tags.deploymentAgnostic }, () => { + test.beforeAll(async ({ kbnClient }) => { + await kbnClient.importExport.load('path/to/archive'); + }); + + test.beforeEach(async ({ browserAuth }) => { + await browserAuth.loginAsViewer(); + }); +}); +``` + +## Fixture scope [scout-fixtures-scope] + +- **Worker-scoped** fixtures live for the lifetime of a Playwright worker. +- **Test-scoped** fixtures are created per test. + +Use worker scope for expensive setup when tests can safely share it. + +## Core Scout fixtures [core-scout-fixtures] + +These are provided by `@kbn/scout` and are also available when using solution Scout packages. + +| Worker-scoped | What it’s for | +| ------------- | ---------------------------------------------------------- | +| `apiClient` | Supertest-based HTTP client for endpoint validation | +| `apiServices` | Higher-level API helpers (setup/teardown/verification) | +| `config` | Test server configuration | +| `esArchiver` | Load ES archives | +| `esClient` | Elasticsearch client | +| `kbnClient` | Kibana API client | +| `requestAuth` | Role-scoped API key helper (see [API auth](./api-auth.md)) | +| `log` | Logger for fixtures and tests | +| `samlAuth` | SAML auth helper for interactive sessions (API tests) | + +| Test-scoped | What it’s for | +| ------------- | ----------------------------------------------------------------- | +| `browserAuth` | Browser login via cookies (see [Browser auth](./browser-auth.md)) | +| `pageObjects` | Registered [page objects](./page-objects.md) | +| `page` | Playwright `Page` extended by Scout helpers | + +::::::{note} +Availability varies by test type (UI vs API). When in doubt, rely on editor autocomplete for the fixture list available in your test. +:::::: + +## Create plugin/solution fixtures [create-a-new-fixture] + +Add fixtures under your test tree: + +- UI fixtures: `/test/scout/ui/fixtures` +- API fixtures: `/test/scout/api/fixtures` +- Shared: `/test/scout/common/fixtures` + +Typically you’ll create a `fixtures/index.ts` entry point that **extends** Scout’s base `test` (UI) and/or `apiTest` (API), then import that in your spec files: + +```ts +// UI test spec: /test/scout/ui/tests/my_suite.spec.ts +import { test } from '../fixtures'; + +test('uses plugin fixtures', async ({ pageObjects }) => { + // ... +}); +``` + +If a fixture would be broadly useful, consider contributing it to `@kbn/scout` (platform-wide) or your solution Scout package. diff --git a/docs/extend/scout/getting-started.md b/docs/extend/scout/getting-started.md new file mode 100644 index 0000000000000..8ee905c932b31 --- /dev/null +++ b/docs/extend/scout/getting-started.md @@ -0,0 +1,12 @@ +--- +navigation_title: Getting started +--- + +# Getting started with Scout [scout-getting-started] + +Start here to set up Scout in a plugin/package, run tests, and debug failures. + +- [Set up Scout in your plugin or package](./setup-plugin.md) +- [Run Scout tests](./run-tests.md) +- [Debug Scout test runs](./debugging.md) +- [Best practices for Scout tests](./best-practices.md) diff --git a/docs/extend/scout/global-setup-hook.md b/docs/extend/scout/global-setup-hook.md new file mode 100644 index 0000000000000..c192a5af2fb78 --- /dev/null +++ b/docs/extend/scout/global-setup-hook.md @@ -0,0 +1,65 @@ +--- +navigation_title: Global setup hook +--- + +# Global setup hook [scout-global-setup-hook] + +Use a global setup hook to run code **once** before any tests start (even with multiple workers). This is most useful for [parallel suites](./parallelism.md), where you want shared data/setup to exist before workers begin. It is also supported by non-parallel test suites. + +**Common uses**: + +- Load Elasticsearch archives with `esArchiver` +- Run one-time API setup with `apiServices` +- Apply shared Kibana settings via `kbnClient` + +::::::{note} +Scout doesn’t currently have a global teardown hook. Most environments are ephemeral and are shut down after the run. +:::::: + +## Enable it [enable-global-setup-hook] + +### 1. Turn it on in your config [global-setup-config] + +Set `runGlobalSetup: true` in your Playwright config: + +```ts +import { createPlaywrightConfig } from '@kbn/scout'; + +export default createPlaywrightConfig({ + testDir: './parallel_tests', + workers: 2, + runGlobalSetup: true, +}); +``` + +### 2. Create `global.setup.ts` [global-setup-file] + +Add `global.setup.ts` inside the `testDir` folder. Scout will discover and run it automatically. + +```text +test/scout/ui/ +└── parallel_tests/ + ├── global.setup.ts + └── some_suite.spec.ts +``` + +### 3. Write setup code [global-setup-code] + +Example: load an ES archive once: + +```ts +import { globalSetupHook } from '@kbn/scout'; + +globalSetupHook('Load test data', async ({ esArchiver, log }) => { + log.info('[setup] loading ES archive (only if needed)...'); + await esArchiver.loadIfNeeded('x-pack/platform/test/fixtures/es_archives/ml/farequote'); +}); +``` + +::::::{warning} +The global setup hook only has access to **worker-scoped** fixtures. It cannot use test-scoped fixtures like `page`, `browserAuth`, or `pageObjects`. +:::::: + +## Run tests [global-setup-run-tests] + +Run tests as usual via [Run Scout tests](./run-tests.md). The global setup hook will execute first—check console logs to verify it ran successfully. diff --git a/docs/extend/scout/page-objects.md b/docs/extend/scout/page-objects.md new file mode 100644 index 0000000000000..2cc46047cb579 --- /dev/null +++ b/docs/extend/scout/page-objects.md @@ -0,0 +1,95 @@ +--- +navigation_title: Page objects +--- + +# Page objects [scout-page-objects] + +Page objects wrap UI interactions (navigation, clicking, filling forms) so tests read like user workflows and stay maintainable as the UI evolves. + +::::::{tip} +Keep page objects focused on **UI interactions**. Don’t hide API setup/teardown inside page objects—use [API services](./api-services.md) or [fixtures](./fixtures.md) instead. +:::::: + +For practical tips, see [best practices](./best-practices.md#page-object-tips). + +## Usage [scout-page-objects-usage] + +Page objects are exposed through the `pageObjects` fixture and are lazy-initialized: + +```ts +test.beforeEach(async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsViewer(); + await pageObjects.discover.goto(); +}); +``` + +## Where they live [scout-page-objects-where] + +- Shared page objects: `@kbn/scout` and solution Scout packages (`/src/playwright/page_objects`) +- Plugin-local page objects: `/test/scout/ui/fixtures/page_objects` + +## Create a plugin page object [create-a-page-object] + +Create a class that takes `ScoutPage` and exposes locators + actions: + +```ts +import { ScoutPage } from '@kbn/scout'; + +export class NewPage { + constructor(private readonly page: ScoutPage) {} + + async goto() { + await this.page.gotoApp('sample/app/name'); + } +} +``` + +## Register a plugin page object [register-plugin-page-object] + +To make your page object available as `pageObjects.newPage`, register it in your plugin fixtures. + +### 1. Register it in `fixtures/page_objects/index.ts` + +```ts +import type { PageObjects, ScoutPage } from '@kbn/scout'; +import { createLazyPageObject } from '@kbn/scout'; +import { NewPage } from './new_page'; + +export interface MyPluginPageObjects extends PageObjects { + newPage: NewPage; +} + +export function extendPageObjects(pageObjects: PageObjects, page: ScoutPage): MyPluginPageObjects { + return { + ...pageObjects, + newPage: createLazyPageObject(NewPage, page), + }; +} +``` + +### 2. Wire it into your plugin `test` fixture + +In `/test/scout/ui/fixtures/index.ts`, extend Scout’s `test` so `pageObjects` uses your extended type: + +```ts +import { test as base } from '@kbn/scout'; +import type { ScoutPage, ScoutTestFixtures, ScoutWorkerFixtures } from '@kbn/scout'; + +import type { MyPluginPageObjects } from './page_objects'; +import { extendPageObjects } from './page_objects'; + +export interface MyPluginTestFixtures extends ScoutTestFixtures { + pageObjects: MyPluginPageObjects; +} + +export const test = base.extend({ + pageObjects: async ( + { pageObjects, page }: { pageObjects: MyPluginPageObjects; page: ScoutPage }, + use: (pageObjects: MyPluginPageObjects) => Promise + ) => { + await use(extendPageObjects(pageObjects, page)); + }, +}); +``` + +Now your specs can use `pageObjects.newPage` without importing the page object class directly. diff --git a/docs/extend/scout/parallelism.md b/docs/extend/scout/parallelism.md new file mode 100644 index 0000000000000..206e69fc1062e --- /dev/null +++ b/docs/extend/scout/parallelism.md @@ -0,0 +1,71 @@ +--- +navigation_title: Parallelism +--- + +# Parallelism [scout-parallelism] + +Scout supports parallel execution using Playwright workers. Parallel runs are usually faster, but they require good isolation and predictable setup. + +::::::{note} +Parallelism happens at the **file** level: Playwright runs test files in parallel workers. Tests in the same file still run in order. +:::::: + +## How it works [scout-parallelism-how] + +- All workers share the same Kibana + Elasticsearch deployment. +- Each worker gets an isolated Kibana space via the `scoutSpace` fixture (used with `spaceTest`). +- The space is cleaned up when the worker finishes. + +## When to use parallel vs sequential [scout-parallelism-differences] + +- **Parallel**: UI suites that can share pre-ingested data and isolate state per space. +- **Sequential**: suites that require a “clean” cluster state or need global mutations that aren’t space-scoped. + +## Enable parallel UI suites [enable-parallel-tests] + +### 1. Create a parallel config [parallel-config-workers] + +Add a config that points at a `parallel_tests/` directory and sets `workers`: + +```ts +import { createPlaywrightConfig } from '@kbn/scout'; + +export default createPlaywrightConfig({ + testDir: './parallel_tests', + workers: 2, + runGlobalSetup: true, +}); +``` + +### 2. Pre-ingest shared data (recommended) [parallel-global-setup] + +Use a [global setup hook](./global-setup-hook.md) to load shared data once before workers start. + +### 3. Use `spaceTest` [parallel-spaceTest] + +Use `spaceTest` to access `scoutSpace`: + +```ts +import { spaceTest, tags } from '@kbn/scout'; + +spaceTest.describe('My parallel suite', { tag: tags.deploymentAgnostic }, () => { + spaceTest.beforeAll(async ({ scoutSpace }) => { + // space-scoped setup (saved objects, ui settings, ...) + }); + + spaceTest('does something', async ({ pageObjects, browserAuth }) => { + await browserAuth.loginAsViewer(); + // ... + }); +}); +``` + +## API tests and parallelism [api-tests-and-parallelism] + +Parallel execution for API tests is not currently supported. + +## Parallel best practices [scout-parallelism-best-practices] + +- Load shared ES data once (global setup) and avoid mutating it in tests. +- Clean up space-scoped changes (saved objects / UI settings) in `afterAll`. +- If a suite can’t be isolated, keep it sequential. diff --git a/docs/extend/scout/run-tests.md b/docs/extend/scout/run-tests.md new file mode 100644 index 0000000000000..ce3ce66df62a3 --- /dev/null +++ b/docs/extend/scout/run-tests.md @@ -0,0 +1,223 @@ +--- +navigation_title: Run tests +--- + +# Run Scout tests [scout-run-tests] + +:::::::{tip} +The commands below work the same way for both UI and API tests. +::::::: + +## Local runs [scout-run-tests-local] + +Scout requires Kibana and Elasticsearch to be running before running tests against a **local deployment**. + +::::::{stepper} + +:::::{step} Start servers once + +Start the Kibana and Elasticsearch servers once: + +```bash +node scripts/scout.js start-server \ + --arch \ + --domain +``` + +::::: + +:::::{step} Run tests as often as you'd like (in a separate terminal) + +And then run tests how often you'd like against the same test servers: + +```bash +npx playwright test --config /test/scout/ui/playwright.config.ts \ + --project local \ + --grep @-- +``` + +- Use `--project local` to target your locally running Kibana/Elasticsearch processes. +- Use `--grep` to filter by tag (for example `@local-stateful-classic`). If you omit `--grep`, Playwright will run all suites in the config, including ones that may not be compatible with your target. + +We recommend checking out Playwright's [**UI mode**](./debugging.md#playwright-ui-mode) (use `--ui`). + +::::: +:::::::: + +### Alternative: one command to start servers + run tests [scout-run-tests-cli] + +```bash +node scripts/scout.js run-tests \ + --arch \ + --domain \ + --config /test/scout/ui/playwright.config.ts +``` + +When Scout starts Kibana and Elasticsearch locally, it saves the server configuration to `.scout/servers/local.json` and later reads it when running tests. + +### Run a subset with `--testFiles` [scout-run-tests-testFiles] + +Directory: + +```bash +node scripts/scout.js run-tests \ + --arch \ + --domain \ + --testFiles /test/scout/ui/tests/some_dir +``` + +Comma-separated file list: + +```bash +node scripts/scout.js run-tests \ + --arch \ + --domain \ + --testFiles , +``` + +:::::::{warning} +All `--testFiles` paths must fall under the same Scout root (for example, `scout/ui/tests` vs `scout/ui/parallel_tests`) so Scout can discover the right config. +::::::: + +## Run tests on Elastic Cloud [scout-run-tests-cloud] + +Follow these steps to run your Scout tests on a real ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on Elastic Cloud Hosted') **Elastic Cloud project or deployment**. + +::::::{tip} +**QAF** (internal, Elasticians-only tool) provides a `qaf kibana scout run-config` command to help you run Scout tests using a QAF-registered project or deployment. Great for CI workflows, but also works locally. Check out [Run Scout tests with QAF](https://docs.elastic.dev/appex-qa/qaf/guides/run-scout-tests-with-qaf) (Elasticians only). +:::::: + +::::::{stepper} + +:::::{step} Create Elastic Cloud users + +Follow our [internal guide to provision internal users](https://docs.elastic.dev/appex-qa/create-cloud-users) (Elasticians only) to then populate the `/.ftr/role_users.json` file. + +::::: + +:::::{step} Create Elastic Cloud project or deployment + +Use the Elastic Cloud UI or [create them with QAF](https://docs.elastic.dev/appex-qa/kibana-cloud-testing#create-an-elastic-cloud-deployment-or-project) (internal guide, Elasticians only). + +::::: + +:::::{step} Tell Scout about your new project or deployment and run tests + +Create and fill out the relevant file in `/.scout/servers`: `cloud_ech.json` for ECH deployments and `cloud_mki.json` for MKI projects. + +Follow the instructions below: + +:::::{tab-set} + +::::{tab-item} ECH (stateful) + +Open `/.scout/servers/cloud_ech.json`: + +```json +{ + "serverless": false, + "isCloud": true, + "cloudHostName": "", + "cloudUsersFilePath": ".ftr/role_users.json", + "hosts": { + "kibana": "", + "elasticsearch": "" + }, + "auth": { + "username": "", + "password": "" + } +} +``` + +- `cloudHostName`: the Cloud environment hostname (for example `console.qa.cld.elstc.co` or `cloud.elastic.co`) +- `cloudUsersFilePath`: credentials for Cloud role users (often `/.ftr/role_users.json`) + +#### Run tests with `--project ech`: + +```bash +npx playwright test --config /test/scout/ui/playwright.config.ts \ + --project ech \ + --grep @cloud-stateful- +``` + +Example `--grep` values: `@cloud-stateful-classic`, `@cloud-stateful-search`. + +Alternatively, run tests with the **Scout CLI**: + +```bash +node scripts/scout.js run-tests \ + --arch stateful \ + --domain \ + --location cloud \ + --config /test/scout/ui/playwright.config.ts +``` + +:::: + +::::{tab-item} MKI (serverless) + +Open `/.scout/servers/cloud_mki.json`: + +```json +{ + "serverless": true, + "projectType": "es", + "isCloud": true, + "cloudHostName": "", + "cloudUsersFilePath": ".ftr/role_users.json", + "hosts": { + "kibana": "", + "elasticsearch": "" + }, + "auth": { + "username": "testing-internal", + "password": "" + } +} +``` + +- `projectType` values: `es`, `security`, `oblt`, `workplaceai` +- `cloudHostName`: the Cloud environment hostname (for example `console.qa.cld.elstc.co` or `cloud.elastic.co`) +- More information on how to get the `` in the info box below + +#### Run tests with `--project mki`: + +```bash +npx playwright test --config /test/scout/ui/playwright.config.ts \ + --project mki \ + --grep @cloud-serverless- +``` + +Alternatively, run tests with the **Scout CLI**: + +```bash +node scripts/scout.js run-tests \ + --arch serverless \ + --domain \ + --location cloud \ + --config /test/scout/ui/playwright.config.ts +``` + +:::::::{note} +Internal (Elasticians): `testing-internal` is an operator user with `superuser` privileges plus additional [operator privileges](https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/operator-privileges). + +To retrieve its password, call the `_reset-internal-credentials` Elastic Cloud API endpoint (this resets the credential and returns a new password): + +```bash +curl -XPOST \ + -H "Authorization: ApiKey $API_KEY" \ + "${CLOUD_ENV_URL}/api/v1/serverless/projects/elasticsearch/${PROJECT_ID}/_reset-internal-credentials" +``` + +- `API_KEY`: create in the Elastic Cloud UI (Organization → API keys) +- `CLOUD_ENV_URL`: base URL of your Cloud environment (for example `https://console.qa.cld.elstc.co`) +- `PROJECT_ID`: serverless project ID from the Cloud UI + +::::::: + +:::: + +::::: + +::::: diff --git a/docs/extend/scout/setup-plugin.md b/docs/extend/scout/setup-plugin.md new file mode 100644 index 0000000000000..9944a36feda24 --- /dev/null +++ b/docs/extend/scout/setup-plugin.md @@ -0,0 +1,89 @@ +--- +navigation_title: Set up plugin +--- + +# Set up Scout in your plugin or package [scout-setup-plugin] + +This page shows the **minimum setup** to add Scout tests to a plugin/package. For choosing the right import (`@kbn/scout` vs solution packages), see [Scout packages](../scout.md#scout-packages). + +## Guided setup with the Scout CLI [scout-setup-cli] + +Generate a working scaffold (folders, configs, and sample tests): + +```bash +node scripts/scout.js generate +``` + +Then, [enable your plugin or package](#enable-scout-tests-in-ci) in the CI. + +## Manual setup [scout-setup-manual] + +### 1. Create the folder layout [scout-setup-folders] + +Create `test/scout`: + +```text +your-plugin/ +└── test/ + └── scout/ + ├── ui/ # UI tests (optional) + ├── api/ # API tests (optional) + └── common/ # shared code (optional) +``` + +### 2. Add Playwright config(s) [scout-setup-config] + +Create `playwright.config.ts` under `test/scout/ui` and/or `test/scout/api`: + +```ts +import { createPlaywrightConfig } from '@kbn/scout'; + +export default createPlaywrightConfig({ + testDir: './tests', +}); +``` + +::::::{important} +Name the file exactly `playwright.config.ts` so Scout tooling can discover it. +:::::: + +Then create the `tests/` directory next to the config. + +### 3. (Optional) Add a parallel UI config [scout-setup-parallel-config] + +If your UI suites can be isolated, add `parallel.playwright.config.ts` under `test/scout/ui` and point it at `parallel_tests/`: + +```ts +import { createPlaywrightConfig } from '@kbn/scout'; + +export default createPlaywrightConfig({ + testDir: './parallel_tests', + workers: 2, +}); +``` + +::::::{important} +Name the file exactly `parallel.playwright.config.ts` so Scout tooling can discover it. +:::::: + +See [Parallelism](./parallelism.md) and [Global setup hook](./global-setup-hook.md) for recommended parallel patterns. + +### 4. Enable tests in CI + +Finally, [enable Scout test runs in the CI](#enable-scout-tests-in-ci) for your plugin or package. + +## Enable Scout tests in CI [enable-scout-tests-in-ci] + +To enable Scout CI for your plugin/package, add it to `.buildkite/scout_ci_config.yml`: + +```yaml +plugins: + enabled: + - + disabled: + +packages: + enabled: + - + disabled: +``` diff --git a/docs/extend/scout/skip-tests.md b/docs/extend/scout/skip-tests.md new file mode 100644 index 0000000000000..e0c0cf1ef6a84 --- /dev/null +++ b/docs/extend/scout/skip-tests.md @@ -0,0 +1,84 @@ +--- +navigation_title: Skip tests +--- + +# Skip tests in Scout [scout-skip-tests] + +There are multiple ways to ensure a Scout test or test suite does not run at all, or runs only in certain environments. + +:::::{tip} +Best practices when skipping tests: + +- Skipping is usually temporary—re-enable as soon as possible. +- Create a tracking issue in the Kibana repo (label: `skipped-test`) and link it in a comment next to the skip. +- AppEx QA maintains internal documentation (for Elasticians only) on [tracking skipped tests](https://docs.elastic.dev/appex-qa/track-skipped-tests). + ::::: + +## Skip tests in all test runs [skip-tests-everywhere] + +Scout uses Playwright, which provides `test.skip()` and `test.describe.skip()` to skip tests or suites in **all** environments. + +If you prefer to control which environments your tests run in, use [deployment tags](./deployment-tags.md) instead. + +### Skip a test suite [skip-suite] + +```ts +import { test, tags } from '@kbn/scout'; + +// Add a comment to explain why this suite is skipped +test.describe.skip('test suite', { tag: tags.stateful.classic }, () => { + test('test', async ({ page }) => { + // this test will be skipped + }); +}); +``` + +### Skip a single test [skip-test] + +```ts +import { test, tags } from '@kbn/scout'; + +test.describe('test suite', { tag: tags.stateful.classic }, () => { + // Add a comment to explain why this test is skipped + test.skip('first test', async ({ page }) => { + // this test will be skipped + }); + + test('second test', async ({ page }) => { + // this test will run + }); +}); +``` + +### Using `test.fixme()` [skip-fixme] + +Playwright also provides `test.fixme()` as an alternative to `test.skip()`. It behaves the same way but semantically indicates the test is broken and needs to be fixed: + +```ts +import { test, tags } from '@kbn/scout'; + +test.describe('test suite', { tag: tags.stateful.classic }, () => { + // Add a comment to explain what needs to be fixed + test.fixme('broken test', async ({ page }) => { + // this test will be skipped and marked as "fixme" + }); +}); +``` + +## Skip tests by location, architecture, or domain [skip-tests-by-env] + +Instead of skipping entirely, control which environments suites run in using deployment tags (`@{location}-{arch}-{domain}`). + +For example, to run only on stateful classic environments: + +```ts +import { test, tags } from '@kbn/scout'; + +test.describe('Stateful classic feature', { tag: tags.stateful.classic }, () => { + test('my test', async ({ page }) => { + // this test only runs in stateful classic environments + }); +}); +``` + +See [Deployment tags](./deployment-tags.md) for shortcuts and patterns. diff --git a/docs/extend/scout/ui-testing.md b/docs/extend/scout/ui-testing.md new file mode 100644 index 0000000000000..3590b9cec8a84 --- /dev/null +++ b/docs/extend/scout/ui-testing.md @@ -0,0 +1,11 @@ +--- +navigation_title: UI testing +--- + +# UI testing with Scout [scout-ui-testing] + +Use these pages when writing and maintaining Scout UI tests: + +- [Write Scout UI tests](./write-ui-tests.md) +- [Browser authentication](./browser-auth.md) +- [Accessibility (a11y) checks](./a11y-checks.md) diff --git a/docs/extend/scout/write-api-tests.md b/docs/extend/scout/write-api-tests.md new file mode 100644 index 0000000000000..4202b117d6b47 --- /dev/null +++ b/docs/extend/scout/write-api-tests.md @@ -0,0 +1,34 @@ +--- +navigation_title: Write API tests +--- + +# Write Scout API tests [scout-write-api-tests] + +Scout API tests validate HTTP endpoints with realistic scoped credentials. + +:::::{important} +[Set up your plugin or package](./setup-plugin.md) first. +::::: + +## Recommended structure [api-test-suite-anatomy] + +1. **Prepare** with higher-privilege helpers (`apiServices`, `kbnClient`, `esArchiver`, …) +2. **Authenticate** with `requestAuth` (or `samlAuth` for `internal/*`) +3. **Call the endpoint under test** with `apiClient` + the scoped headers +4. **Assert** status + response body, and verify side effects when needed + +See [best practices for API tests](./best-practices.md#api-tests). + +Example test ([Console](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/console/test/scout/api/tests/spec_definitions.spec.ts)). + +## Save the test file [save-api-test-file] + +API tests live under `/test/scout/api/tests` and must end with `.spec.ts`. + +## Next steps [api-tests-next] + +- [API authentication](./api-auth.md) +- [Best practices](./best-practices.md) +- [Fixtures](./fixtures.md) +- [Run tests](./run-tests.md) and [Debugging](./debugging.md) +- [Parallelism notes](./parallelism.md#api-tests-and-parallelism) diff --git a/docs/extend/scout/write-ui-tests.md b/docs/extend/scout/write-ui-tests.md new file mode 100644 index 0000000000000..db2eb9cdb603b --- /dev/null +++ b/docs/extend/scout/write-ui-tests.md @@ -0,0 +1,34 @@ +--- +navigation_title: Write UI tests +--- + +# Write Scout UI tests [scout-write-ui-tests] + +Scout UI tests are Playwright tests that use Scout fixtures and page objects for readable, maintainable flows. + +:::::{important} +[Set up your plugin or package](./setup-plugin.md) first. +::::: + +## A good starting pattern [scout-write-ui-tests-pattern] + +- Authenticate with `browserAuth` in `beforeEach` +- Navigate and interact via `pageObjects` +- Use `test.step` for multi-step flows you want to read in reports + +Example test ([APM](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/settings/anomaly_detection.spec.ts)). + +## Save the test file [scout-write-ui-tests-save] + +- Sequential UI tests: `/test/scout/ui/tests` +- Parallel UI tests: `/test/scout/ui/parallel_tests` + +Spec files must end with `.spec.ts`. + +## Next steps [scout-write-ui-tests-next] + +- [Browser authentication](./browser-auth.md) +- [Best practices](./best-practices.md) +- [Page objects](./page-objects.md) +- [Fixtures](./fixtures.md) +- [Run tests](./run-tests.md) and [Debugging](./debugging.md) diff --git a/docs/extend/testing-kibana-plugin.md b/docs/extend/testing-kibana-plugin.md index e25ccfbde31a8..efa8cdb73005a 100644 --- a/docs/extend/testing-kibana-plugin.md +++ b/docs/extend/testing-kibana-plugin.md @@ -7,7 +7,9 @@ mapped_pages: ## Writing tests [_writing_tests] -Learn about [recommended testing approaches ](/extend/development-tests.md). +Learn about [recommended testing approaches](/extend/development-tests.md). + +For modern UI and API end-to-end tests in the {{kib}} repo, see [Scout](/extend/scout.md). ## Mock {{kib}} Core services in tests [_mock_kib_core_services_in_tests] diff --git a/docs/extend/toc.yml b/docs/extend/toc.yml index 3b8c1242b1043..a0e556109a477 100644 --- a/docs/extend/toc.yml +++ b/docs/extend/toc.yml @@ -53,6 +53,32 @@ toc: - file: kibana-issue-reporting.md - file: pr-review.md - file: kibana-linting.md + - file: scout.md + children: + - file: scout/getting-started.md + children: + - file: scout/setup-plugin.md + - file: scout/run-tests.md + - file: scout/debugging.md + - file: scout/best-practices.md + - file: scout/core-concepts.md + children: + - file: scout/fixtures.md + - file: scout/page-objects.md + - file: scout/api-services.md + - file: scout/parallelism.md + - file: scout/global-setup-hook.md + - file: scout/deployment-tags.md + - file: scout/skip-tests.md + - file: scout/ui-testing.md + children: + - file: scout/write-ui-tests.md + - file: scout/browser-auth.md + - file: scout/a11y-checks.md + - file: scout/api-testing.md + children: + - file: scout/write-api-tests.md + - file: scout/api-auth.md - file: external-plugin-development.md children: - file: plugin-tooling.md @@ -70,8 +96,6 @@ toc: - file: kibana-dashboard-plugin.md - file: kibana-expressions-plugin.md - file: uiactions-plugin.md - - file: dashboard-enhanced-plugin.md - - file: enhanced-embeddables-plugin.md - file: translations-plugin.md - file: dependencies-versions.md - file: development-telemetry.md diff --git a/docs/redirects.yml b/docs/redirects.yml index 0f3ecf33c03be..888e237c38425 100644 --- a/docs/redirects.yml +++ b/docs/redirects.yml @@ -2,5 +2,7 @@ redirects: 'extend/development-documentation.md': 'docs-content://extend/contribute/index.md' 'extend/saved-objects-service.md': 'extend/saved-objects.md' 'extend/sharing-saved-objects.md': 'extend/saved-objects/share.md' + 'extend/dashboard-enhanced-plugin.md': 'extend/plugin-list.md' + 'extend/enhanced-embeddables-plugin.md': 'extend/plugin-list.md' 'reference/osquery-exported-fields.md': 'integration-docs://reference/osquery-intro.md' 'reference/osquery-manager-prebuilt-packs.md': 'docs-content://solutions/security/investigate/osquery.md' diff --git a/docs/reference/configuration-reference/banner-settings.md b/docs/reference/configuration-reference/banner-settings.md index c205bf1c898e3..a9606e6d8dea6 100644 --- a/docs/reference/configuration-reference/banner-settings.md +++ b/docs/reference/configuration-reference/banner-settings.md @@ -20,21 +20,21 @@ Banners are a [subscription feature](https://www.elastic.co/subscriptions). :::: -`xpack.banners.placement` +`xpack.banners.placement` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : Set to `top` to display a banner above the Elastic header. Defaults to `disabled`. -`xpack.banners.textContent` +`xpack.banners.textContent` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : The text to display inside the banner, either plain text or Markdown. -`xpack.banners.textColor` +`xpack.banners.textColor` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : The color for the banner text. Defaults to `#8A6A0A`. -`xpack.banners.linkColor` {applies_to}`stack: ga 9.1` +`xpack.banners.linkColor` {applies_to}`stack: ga 9.1` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : The color for the banner link text. Defaults to `#0B64DD`. -`xpack.banners.backgroundColor` +`xpack.banners.backgroundColor` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : The color of the banner background. Defaults to `#FFF9E8`. -`xpack.banners.disableSpaceBanners` +`xpack.banners.disableSpaceBanners` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : If true, per-space banner overrides will be disabled. Defaults to `false`. diff --git a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md index 5690f7a21507b..095723640d486 100644 --- a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md +++ b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md @@ -4,6 +4,7 @@ * [Google Drive](/reference/connectors-kibana/google-drive-action-type.md): Search and access files and folders in Google Drive. * [Notion](/reference/connectors-kibana/notion-action-type.md): Explore content and databases in Notion. * [Sharepoint online](/reference/connectors-kibana/sharepoint-online-action-type.md): Search across SharePoint sites, pages, and content using the Microsoft Graph API. +* [Slack (v2)](/reference/connectors-kibana/slack-v2-action-type.md): Search and send messages via Slack. **Threat intelligence** * [AbuseIPDB](/reference/connectors-kibana/abuseipdb-action-type.md): Check IP reputation and report abusive IPs. @@ -11,4 +12,4 @@ * [GreyNoise](/reference/connectors-kibana/greynoise-action-type.md): Detect and classify Internet scanning noise. * [Shodan](/reference/connectors-kibana/shodan-action-type.md): Perform Internet-wide asset discovery and vulnerability scanning. * [URLVoid](/reference/connectors-kibana/urlvoid-action-type.md): Check domain and URL reputation using multi-engine scanning. -* [VirusTotal](/reference/connectors-kibana/virustotal-action-type.md): Perform file scanning, URL analysis, and threat intelligence lookups. \ No newline at end of file +* [VirusTotal](/reference/connectors-kibana/virustotal-action-type.md): Perform file scanning, URL analysis, and threat intelligence lookups. diff --git a/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md b/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md index 3803ca52e96a8..5b70d09fb938b 100644 --- a/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md +++ b/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md @@ -1,4 +1,4 @@ * [Cases](/reference/connectors-kibana/cases-action-type.md): Add alerts to [Cases](docs-content://explore-analyze/alerts-cases/cases.md). * [Index](/reference/connectors-kibana/index-action-type.md): Index data into Elasticsearch. * [Observability AI Assistant](/reference/connectors-kibana/obs-ai-assistant-action-type.md): Send alerts to the AI Assistant. -* [ServerLog](/reference/connectors-kibana/server-log-action-type.md): Add a message to a Kibana log. \ No newline at end of file +* [ServerLog](/reference/connectors-kibana/server-log-action-type.md): Add a message to a Kibana log. diff --git a/docs/reference/connectors-kibana/slack-v2-action-type.md b/docs/reference/connectors-kibana/slack-v2-action-type.md new file mode 100644 index 0000000000000..03ed5225f14e6 --- /dev/null +++ b/docs/reference/connectors-kibana/slack-v2-action-type.md @@ -0,0 +1,79 @@ +--- +navigation_title: "Slack (v2)" +mapped_pages: + - https://www.elastic.co/guide/en/kibana/current/slack-v2-action-type.html +applies_to: + stack: preview 9.4 + serverless: preview +--- + +# Slack (v2) connector [slack-v2-action-type] + +The Slack (v2) connector enables workflow-driven Slack automation: search Slack messages, resolve public channel IDs, and send messages to Slack public channels using the Slack Web API. + +## Create connectors in {{kib}} [define-slack-v2-ui] + +You can create connectors in **{{stack-manage-app}} > {{connectors-ui}}**. + +### Connector configuration [slack-v2-connector-configuration] + +Slack (v2) connectors have the following configuration properties: + +Temporary Slack user token +: A Slack **user token** (for example, `xoxp-...`). This is a **temporary** MVP authentication method. Treat it as sensitive and rotate it if exposed. + +## Test connectors [slack-v2-action-configuration] + +You can test connectors as you're creating or editing the connector in {{kib}}. The test verifies connectivity by calling Slack `auth.test`. + +The Slack (v2) connector has the following actions: + +Search messages +: Search for messages in Slack. + - **query** (required): Slack search query string. + - **inChannel** (optional): Adds `in:` to the query. + - **fromUser** (optional): Adds `from:<@UserID>` or `from:username` to the query. + - **after** (optional): Adds `after:` to the query (for example, `2026-02-10`). + - **before** (optional): Adds `before:` to the query (for example, `2026-02-10`). + - **sort** (optional): Sort order, `score` or `timestamp`. + - **sortDir** (optional): Sort direction, `asc` or `desc`. + - **count** (optional): Results to return (1-20). Slack returns up to 20 results per page. + - **cursor** (optional): Pagination cursor (use `response_metadata.next_cursor` from a previous call). + - **includeContextMessages** (optional): Include contextual messages. Defaults to `true`. + - **includeBots** (optional): Include bot messages. Defaults to `false`. + - **includeMessageBlocks** (optional): Include Block Kit blocks. Defaults to `true`. + - **raw** (optional): If `true`, return the full raw Slack response (verbose). + +Resolve channel ID +: Resolve a Slack conversation ID (`C...` for public channels, `G...` for private channels) from a human channel name (for example, `#general`). + - **name** (required): Channel name (with or without `#`). + - **types** (optional): Conversation types to search. Defaults to `public_channel`. + - **match** (optional): `exact` (default) or `contains`. + - **excludeArchived** (optional): Exclude archived channels. Defaults to `true`. + - **cursor** (optional): Pagination cursor to resume a previous scan. + - **limit** (optional): Channels per page (1-1000). Defaults to `1000`. + - **maxPages** (optional): Max pages to scan before giving up. Defaults to `10`. + +Send message +: Send a message to a Slack conversation ID. + - **channel** (required): Conversation ID (for example, `C123...`). Use **Resolve channel ID** first if you only have a channel name. + - **text** (required): Message text. + - **threadTs** (optional): Reply in a thread (timestamp of the parent message). + - **unfurlLinks** (optional): Enable unfurling of primarily text-based content. + - **unfurlMedia** (optional): Enable unfurling of media content. + +## Connector networking configuration [slack-v2-connector-networking-configuration] + +Use the [Action configuration settings](/reference/configuration-reference/alerting-settings.md#action-settings) to customize connector networking configurations, such as proxies, certificates, or TLS settings. If you use [`xpack.actions.allowedHosts`](/reference/configuration-reference/alerting-settings.md#action-settings), make sure `slack.com` is included. + +## Get API credentials [slack-v2-api-credentials] + +To use the Slack (v2) connector, you need a Slack app and a Slack **user token**. + +1. Create a Slack app and install it to your workspace. +2. Add **User Token Scopes** for the actions you intend to use: + - **resolve channel ID**: `channels:read` + - **send message (public channels)**: `chat:write` + - **search messages**: `search:read.public`, `search:read.private`, `search:read.im`, `search:read.mpim`, `search:read.files` +3. Copy the **User OAuth Token** (for example, `xoxp-...`) and paste it into the connector configuration. + diff --git a/docs/reference/toc.yml b/docs/reference/toc.yml index df16677313b95..5964a77936d6b 100644 --- a/docs/reference/toc.yml +++ b/docs/reference/toc.yml @@ -81,6 +81,7 @@ toc: - file: connectors-kibana/notion-action-type.md - file: connectors-kibana/sharepoint-online-action-type.md - file: connectors-kibana/shodan-action-type.md + - file: connectors-kibana/slack-v2-action-type.md - file: connectors-kibana/urlvoid-action-type.md - file: connectors-kibana/virustotal-action-type.md - file: connectors-kibana/pre-configured-connectors.md diff --git a/docs/settings-gen/readme.md b/docs/settings-gen/readme.md index 52ae119c119f4..305eb53f644b6 100644 --- a/docs/settings-gen/readme.md +++ b/docs/settings-gen/readme.md @@ -31,7 +31,16 @@ groups: description: | REQUIRED Multiline string. Can include tables, lists, code examples, etc. - # state: OPTIONAL One of deprecated/hidden/tech-preview + # applies_to: MANDATORY applicability metadata + # Supports docs-builder applies_to syntax. + # Replace "ga" with the correct availability information: "preview", "beta", "ga", "deprecated", "removed", "unavailable" are accepted values + # Only specify a version for the "stack" key; multiple values are accepted for this key, for example "stack: preview 9.4, ga 9.5, removed 9.8" + # + # applies_to: + # - "stack: ga 9.2" + # - "ess: ga" + # - "self: ga" + # # deprecation_details: "" OPTIONAL # note: "" OPTIONAL # tip: "" OPTIONAL @@ -43,11 +52,80 @@ groups: # - option: OPTIONAL # description: "" OPTIONAL # type: OPTIONAL ONe of static/dynamic - # platforms: OPTIONAL, list each supported platform # - cloud # - serverless # - self-managed + # settings: OPTIONAL, nested settings list + # Child settings inherit applies_to from the parent unless overridden. + # - setting: "[n].url" + # description: | + # REQUIRED # example: | OPTIONAL Multiline string. Can include tables, lists, code examples, etc. ``` + +## Example + +The following example shows a fully populated document with page metadata, group metadata, nested settings, and multiple `applies_to` statements. + +```yaml +--- +product: Kibana +collection: Example settings collection +id: example-settings +page_description: | + This page demonstrates the full settings documentation schema. + + Settings descriptions can include inline applies annotations, for example: + {applies_to}`stack: ga 9.2` and {applies_to}`ess: ga`. + +groups: + - group: Example group + id: example-group + description: | + These settings are examples for documentation structure and applicability tagging. + example: | + ```yaml + my.parent.setting: + child: value + ``` + + settings: + - setting: my.parent.setting + id: my-parent-setting + description: | + Parent setting with nested child settings. + datatype: string + default: "" + applies_to: + stack: ga 9.2 + ess: ga + self: ga + + settings: + - setting: "[n].url" + description: | + Child setting inheriting the parent's applicability. + datatype: string + + - setting: "[n].serverlessOnly" + description: | + Child setting overriding applicability using the inline list form. + datatype: bool + default: false + applies_to: + - "serverless: ga" + + - setting: my.deprecated.setting + description: | + This setting is deprecated. + datatype: bool + default: false + applies_to: + - "stack: deprecated 9.3+" + - "ess: ga" + - "self: ga" + deprecation_details: "Deprecated starting in 9.3." +--- +``` diff --git a/examples/flyout_system/public/components/_flyout_with_component.tsx b/examples/flyout_system/public/components/_flyout_with_component.tsx index 872bbc505428e..87fef43662ba7 100644 --- a/examples/flyout_system/public/components/_flyout_with_component.tsx +++ b/examples/flyout_system/public/components/_flyout_with_component.tsx @@ -7,8 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useState } from 'react'; -import { css } from '@emotion/react'; +import React, { useCallback, useRef, useState } from 'react'; import { EuiButton, @@ -50,6 +49,11 @@ const SessionFlyout: React.FC = React.memo((props) => { const [isChildFlyoutAOpen, setIsChildFlyoutAOpen] = useState(false); const [isChildFlyoutBOpen, setIsChildFlyoutBOpen] = useState(false); + // Refs for manual focus management + const mainTriggerRef = useRef(null); + const childTriggerARef = useRef(null); + const childTriggerBRef = useRef(null); + // Handlers for "Open" buttons const handleOpenMainFlyout = () => { @@ -81,16 +85,33 @@ const SessionFlyout: React.FC = React.memo((props) => { setIsFlyoutOpen(false); setIsChildFlyoutAOpen(false); setIsChildFlyoutBOpen(false); + + // Return focus to main trigger button after closing main flyout + // TODO: clean this up if EUI adds internal support for returning focus to the trigger element on close + // https://github.com/elastic/eui/issues/9365 + setTimeout(() => { + mainTriggerRef.current?.focus(); + }, 100); }, [title]); const handleCloseChildFlyoutA = useCallback(() => { console.log('close child flyout A', title); // eslint-disable-line no-console setIsChildFlyoutAOpen(false); + + // Return focus to child trigger button after closing child flyout A + setTimeout(() => { + childTriggerARef.current?.focus(); + }, 100); }, [title]); const handleCloseChildFlyoutB = useCallback(() => { console.log('close child flyout B', title); // eslint-disable-line no-console setIsChildFlyoutBOpen(false); + + // Return focus to child trigger button after closing child flyout B + setTimeout(() => { + childTriggerBRef.current?.focus(); + }, 100); }, [title]); // Render @@ -119,7 +140,11 @@ const SessionFlyout: React.FC = React.memo((props) => { - + Open {title} @@ -205,10 +230,18 @@ const SessionFlyout: React.FC = React.memo((props) => { {childSize && ( <> - + Open child flyout A {' '} - + Open child flyout B @@ -318,6 +351,9 @@ const NonSessionFlyout: React.FC = React.memo(() => { const [flyoutOwnFocus, setFlyoutOwnFocus] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + // Refs for manual focus management + const triggerRef = useRef(null); + const handleOpenFlyout = () => { setIsFlyoutVisible(true); }; @@ -330,6 +366,11 @@ const NonSessionFlyout: React.FC = React.memo(() => { const flyoutOnClose = useCallback(() => { console.log('close non-session flyout'); // eslint-disable-line no-console setIsFlyoutVisible(false); + + // Return focus to trigger button after closing flyout + setTimeout(() => { + triggerRef.current?.focus(); + }, 100); }, []); return ( @@ -356,7 +397,7 @@ const NonSessionFlyout: React.FC = React.memo(() => { - + Open Non-session Flyout @@ -433,11 +474,6 @@ export const FlyoutWithComponent: React.FC = () => ( description: , }, ]} - css={css` - dt { - min-width: 25em; - } - `} /> @@ -456,11 +492,6 @@ export const FlyoutWithComponent: React.FC = () => ( description: , }, ]} - css={css` - dt { - min-width: 25em; - } - `} /> diff --git a/examples/flyout_system/public/components/_flyout_with_overlays.tsx b/examples/flyout_system/public/components/_flyout_with_overlays.tsx index 71d3b551b0e39..cb72e2597fdd7 100644 --- a/examples/flyout_system/public/components/_flyout_with_overlays.tsx +++ b/examples/flyout_system/public/components/_flyout_with_overlays.tsx @@ -8,7 +8,6 @@ */ import React, { useCallback, useRef, useState } from 'react'; -import { css } from '@emotion/react'; import { EuiButton, @@ -109,12 +108,21 @@ const FlyoutContent: React.FC = React.memo((props) => { const [isChildFlyoutAOpen, setIsChildFlyoutAOpen] = useState(false); const [isChildFlyoutBOpen, setIsChildFlyoutBOpen] = useState(false); + // Refs for manual focus management - return focus to child trigger buttons + const childTriggerARef = useRef(null); + const childTriggerBRef = useRef(null); + const handleCloseChildFlyoutA = useCallback(() => { if (childFlyoutRefA.current) { childFlyoutRefA.current.close(); childFlyoutRefA.current = null; setIsChildFlyoutAOpen(false); } + + // Return focus to child trigger button after closing child flyout A + setTimeout(() => { + childTriggerARef.current?.focus(); + }, 100); }, [childFlyoutRefA]); const handleCloseChildFlyoutB = useCallback(() => { if (childFlyoutRefB.current) { @@ -122,6 +130,11 @@ const FlyoutContent: React.FC = React.memo((props) => { childFlyoutRefB.current = null; setIsChildFlyoutBOpen(false); } + + // Return focus to child trigger button after closing child flyout B + setTimeout(() => { + childTriggerBRef.current?.focus(); + }, 100); }, [childFlyoutRefB]); const openChildFlyoutA = useCallback(() => { @@ -141,6 +154,11 @@ const FlyoutContent: React.FC = React.memo((props) => { console.log('close child flyout', title); // eslint-disable-line no-console childFlyoutRefA.current = null; setIsChildFlyoutAOpen(false); + + // Return focus to child trigger button after closing child flyout A + setTimeout(() => { + childTriggerARef.current?.focus(); + }, 100); }, } ); @@ -164,6 +182,11 @@ const FlyoutContent: React.FC = React.memo((props) => { console.log('close child flyout B', title); // eslint-disable-line no-console childFlyoutRefB.current = null; setIsChildFlyoutBOpen(false); + + // Return focus to child trigger button after closing child flyout B + setTimeout(() => { + childTriggerBRef.current?.focus(); + }, 100); }, } ); @@ -222,10 +245,16 @@ const FlyoutContent: React.FC = React.memo((props) => { {childSize && ( <> - + {isChildFlyoutAOpen ? 'Close child flyout A' : 'Open child flyout A'} {' '} - + {isChildFlyoutBOpen ? 'Close child flyout B' : 'Open child flyout B'} @@ -254,6 +283,9 @@ const SessionFlyout: React.FC = React.memo((props) => { const childFlyoutRefA = useRef(null); const childFlyoutRefB = useRef(null); + // Ref for manual focus management - return focus to trigger button + const triggerRef = useRef(null); + // Callbacks for state synchronization const mainFlyoutOnActive = useCallback(() => { console.log('activate main flyout', title); // eslint-disable-line no-console @@ -265,6 +297,11 @@ const SessionFlyout: React.FC = React.memo((props) => { flyoutRef.current = null; setIsFlyoutOpen(false); } + + // Return focus to trigger button after closing main flyout + setTimeout(() => { + triggerRef.current?.focus(); + }, 100); }, []); const openFlyout = useCallback(() => { @@ -331,7 +368,7 @@ const SessionFlyout: React.FC = React.memo((props) => { - + Open {title} @@ -349,6 +386,9 @@ const NonSessionFlyout: React.FC = React.memo( const [flyoutOwnFocus, setFlyoutOwnFocus] = useState(false); const flyoutRef = useRef(null); + // Ref for manual focus management - return focus to trigger button + const triggerRef = useRef(null); + const openFlyout = useCallback(() => { // Create a handler that will be called to close the flyout const handleClose = () => { @@ -357,6 +397,11 @@ const NonSessionFlyout: React.FC = React.memo( flyoutRef.current = null; } setIsFlyoutOpen(false); + + // Return focus to trigger button after closing flyout + setTimeout(() => { + triggerRef.current?.focus(); + }, 100); }; const ref = overlays.openFlyout( @@ -422,7 +467,7 @@ const NonSessionFlyout: React.FC = React.memo( - + Open Non-session Flyout @@ -468,11 +513,6 @@ export const FlyoutWithOverlays: React.FC = ({ overlays description: , }, ]} - css={css` - dt { - min-width: 25em; - } - `} /> @@ -491,11 +531,6 @@ export const FlyoutWithOverlays: React.FC = ({ overlays description: , }, ]} - css={css` - dt { - min-width: 25em; - } - `} /> diff --git a/fleet_packages.json b/fleet_packages.json index 6eb8b6416c58b..961ace64dd220 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -30,7 +30,7 @@ }, { "name": "elastic_agent", - "version": "2.7.1" + "version": "2.7.2" }, { "name": "endpoint", diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index fdc201411a5ab..0ffeaacbc61d9 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -3598,6 +3598,13 @@ paths: required: true schema: type: string + - description: If true, removes the tool from agents that use it and then deletes it. If false and any agent uses the tool, the request returns 409 Conflict with the list of agents. + in: query + name: force + required: false + schema: + default: false + type: boolean responses: '200': content: @@ -22341,6 +22348,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -23343,6 +23364,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -24127,6 +24162,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -24890,6 +24939,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -25891,6 +25954,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -26767,6 +26844,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -27436,21 +27527,30 @@ paths: required: true schema: type: string - - in: query + - description: If true, returns the policy as a downloadable file + in: query name: download required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for standalone agents + in: query name: standalone required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for Kubernetes deployment + in: query name: kubernetes required: false schema: type: boolean + - description: If provided, returns the policy at the specified revision. Cannot be used with standalone or kubernetes flags. + in: query + name: revision + required: false + schema: + type: number responses: '200': content: @@ -27523,21 +27623,30 @@ paths: required: true schema: type: string - - in: query + - description: If true, returns the policy as a downloadable file + in: query name: download required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for standalone agents + in: query name: standalone required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for Kubernetes deployment + in: query name: kubernetes required: false schema: type: boolean + - description: If provided, returns the policy at the specified revision. Cannot be used with standalone or kubernetes flags. + in: query + name: revision + required: false + schema: + type: number responses: '200': content: @@ -59605,6 +59714,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -63257,6 +63379,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -64321,6 +64665,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -67973,6 +68330,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -68778,6 +69357,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -72430,6 +73022,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -73306,6 +74120,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -76958,6 +77785,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -77763,6 +78812,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -81749,6 +82811,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -86149,6 +87433,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -108183,6 +109689,11 @@ components: id: 6724a474-cbba-41ef-a1aa-66aebf0879e2 query: select * from uptime; saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 user_id: elastic type: object properties: {} diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 91056356d996f..359c8c1f52bb7 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -3669,6 +3669,13 @@ paths: required: true schema: type: string + - description: If true, removes the tool from agents that use it and then deletes it. If false and any agent uses the tool, the request returns 409 Conflict with the list of agents. + in: query + name: force + required: false + schema: + default: false + type: boolean responses: '200': content: @@ -24914,6 +24921,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -25916,6 +25937,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -26700,6 +26735,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -27463,6 +27512,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -28464,6 +28527,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -29340,6 +29417,20 @@ paths: type: string agents: type: number + agents_per_version: + items: + additionalProperties: false + type: object + properties: + count: + type: number + version: + type: string + required: + - version + - count + maxItems: 1000 + type: array data_output_id: nullable: true type: string @@ -30009,21 +30100,30 @@ paths: required: true schema: type: string - - in: query + - description: If true, returns the policy as a downloadable file + in: query name: download required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for standalone agents + in: query name: standalone required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for Kubernetes deployment + in: query name: kubernetes required: false schema: type: boolean + - description: If provided, returns the policy at the specified revision. Cannot be used with standalone or kubernetes flags. + in: query + name: revision + required: false + schema: + type: number responses: '200': content: @@ -30096,21 +30196,30 @@ paths: required: true schema: type: string - - in: query + - description: If true, returns the policy as a downloadable file + in: query name: download required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for standalone agents + in: query name: standalone required: false schema: type: boolean - - in: query + - description: If true, returns the policy formatted for Kubernetes deployment + in: query name: kubernetes required: false schema: type: boolean + - description: If provided, returns the policy at the specified revision. Cannot be used with standalone or kubernetes flags. + in: query + name: revision + required: false + schema: + type: number responses: '200': content: @@ -64059,6 +64168,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -67711,6 +67833,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -68775,6 +69119,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -72427,6 +72784,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -73232,6 +73811,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -76884,6 +77476,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -77760,6 +78574,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -81412,6 +82239,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -82217,6 +83266,19 @@ paths: type: number required: - kql + - type: object + properties: + esql: + additionalProperties: false + type: object + properties: + query: + description: Full ES|QL query. + type: string + required: + - query + required: + - esql type: array rules: items: @@ -86203,6 +87265,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -90603,6 +91887,228 @@ paths: - action - from - to + - allOf: + - description: Base processor options plus conditional execution + type: object + properties: + action: + enum: + - network_direction + type: string + customIdentifier: + description: Custom identifier to correlate this processor across outputs + minLength: 1 + type: string + description: + description: Human-readable notes about this processor step + type: string + destination_ip: + description: A non-empty string. + minLength: 1 + type: string + ignore_failure: + description: Continue pipeline execution if this processor fails + type: boolean + ignore_missing: + type: boolean + source_ip: + description: A non-empty string. + minLength: 1 + type: string + target_field: + description: A non-empty string. + minLength: 1 + type: string + where: + anyOf: + - anyOf: + - additionalProperties: false + description: A condition that compares a field to a value or range using an operator as the key. + type: object + properties: + contains: + anyOf: + - type: string + - type: number + - type: boolean + description: Contains comparison value. + endsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Ends-with comparison value. + eq: + anyOf: + - type: string + - type: number + - type: boolean + description: Equality comparison value. + field: + description: The document field to filter on. + minLength: 1 + type: string + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than comparison value. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: Greater-than-or-equal comparison value. + includes: + anyOf: + - type: string + - type: number + - type: boolean + description: Checks if multivalue field includes the value. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than comparison value. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: Less-than-or-equal comparison value. + neq: + anyOf: + - type: string + - type: number + - type: boolean + description: Inequality comparison value. + range: + additionalProperties: false + description: Range comparison values. + type: object + properties: + gt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + gte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lt: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + lte: + anyOf: + - type: string + - type: number + - type: boolean + description: A value that can be a string, number, or boolean. + startsWith: + anyOf: + - type: string + - type: number + - type: boolean + description: Starts-with comparison value. + required: + - field + - additionalProperties: false + description: A condition that checks for the existence or non-existence of a field. + type: object + properties: + exists: + description: Indicates whether the field exists or not. + type: boolean + field: + description: The document field to check. + minLength: 1 + type: string + required: + - field + description: A basic filter condition, either unary or binary. + - additionalProperties: false + description: A logical AND that groups multiple conditions. + type: object + properties: + and: + description: An array of conditions. All sub-conditions must be true for this condition to be true. + items: {} + type: array + required: + - and + - additionalProperties: false + description: A logical OR that groups multiple conditions. + type: object + properties: + or: + description: An array of conditions. At least one sub-condition must be true for this condition to be true. + items: {} + type: array + required: + - or + - additionalProperties: false + description: A logical NOT that negates a condition. + type: object + properties: + not: + description: A condition that negates another condition. + required: + - not + - additionalProperties: false + description: A condition that always evaluates to false. + type: object + properties: + never: + additionalProperties: false + description: An empty object. This condition never matches. + type: object + properties: {} + required: + - never + - additionalProperties: false + description: A condition that always evaluates to true. Useful for catch-all scenarios, but use with caution as partitions are ordered. + type: object + properties: + always: + additionalProperties: false + description: An empty object. This condition always matches. + type: object + properties: {} + required: + - always + description: Conditional expression controlling whether this processor runs + required: + - action + - source_ip + - destination_ip + - anyOf: + - additionalProperties: false + type: object + properties: + internal_networks: + items: + type: string + type: array + required: + - internal_networks + - additionalProperties: false + type: object + properties: + internal_networks_field: + description: A non-empty string. + minLength: 1 + type: string + required: + - internal_networks_field - additionalProperties: false description: Manual ingest pipeline wrapper around native Elasticsearch processors type: object @@ -118946,6 +120452,11 @@ components: id: 6724a474-cbba-41ef-a1aa-66aebf0879e2 query: select * from uptime; saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 user_id: elastic type: object properties: {} diff --git a/package.json b/package.json index cc7c2c5cbe3f9..4fd35d9e39418 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "**/baseline-browser-mapping": "2.9.14", "**/chokidar": "3.5.3", "**/d3-scale/**/d3-color": "npm:@elastic/kibana-d3-color@2.0.1", - "**/fast-xml-parser": "5.3.4", + "**/fast-xml-parser": "5.3.6", "**/globule/minimatch": "3.1.2", "**/hoist-non-react-statics": "3.3.2", "**/isomorphic-fetch/node-fetch": "2.7.0", @@ -498,6 +498,7 @@ "@kbn/core-user-settings-server-internal": "link:src/core/packages/user-settings/server-internal", "@kbn/core-user-settings-server-mocks": "link:src/core/packages/user-settings/server-mocks", "@kbn/cps": "link:src/platform/plugins/shared/cps", + "@kbn/cps-server-utils": "link:src/platform/packages/shared/kbn-cps-server-utils", "@kbn/cps-utils": "link:src/platform/packages/shared/kbn-cps-utils", "@kbn/cross-cluster-replication-plugin": "link:x-pack/platform/plugins/private/cross_cluster_replication", "@kbn/crypto": "link:src/platform/packages/shared/kbn-crypto", @@ -508,7 +509,6 @@ "@kbn/custom-integrations-plugin": "link:src/platform/plugins/shared/custom_integrations", "@kbn/dashboard-agent-common": "link:x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common", "@kbn/dashboard-agent-plugin": "link:x-pack/platform/plugins/shared/dashboard_agent", - "@kbn/dashboard-enhanced-plugin": "link:x-pack/platform/plugins/shared/dashboard_enhanced", "@kbn/dashboard-markdown": "link:src/platform/plugins/shared/dashboard_markdown", "@kbn/dashboard-plugin": "link:src/platform/plugins/shared/dashboard", "@kbn/dashboards-selector": "link:src/platform/packages/shared/dashboards/dashboards-selector", @@ -578,7 +578,6 @@ "@kbn/elasticsearch-client-plugin": "link:src/platform/test/plugin_functional/plugins/elasticsearch_client_plugin", "@kbn/elasticsearch-client-xpack-plugin": "link:x-pack/platform/test/plugin_api_integration/plugins/elasticsearch_client", "@kbn/embeddable-alerts-table-plugin": "link:x-pack/platform/plugins/shared/embeddable_alerts_table", - "@kbn/embeddable-enhanced-plugin": "link:x-pack/platform/plugins/shared/embeddable_enhanced", "@kbn/embeddable-examples-plugin": "link:examples/embeddable_examples", "@kbn/embeddable-plugin": "link:src/platform/plugins/shared/embeddable", "@kbn/embedded-lens-example-plugin": "link:x-pack/examples/embedded_lens_example", @@ -611,6 +610,7 @@ "@kbn/esql-ux-example-plugin": "link:examples/esql_ux_example", "@kbn/esql-validation-example-plugin": "link:examples/esql_validation_example", "@kbn/eui-provider-dev-warning": "link:src/platform/test/plugin_functional/plugins/eui_provider_dev_warning", + "@kbn/eval-kql": "link:src/platform/packages/shared/kbn-eval-kql", "@kbn/event-annotation-common": "link:src/platform/packages/shared/kbn-event-annotation-common", "@kbn/event-annotation-components": "link:src/platform/packages/shared/kbn-event-annotation-components", "@kbn/event-annotation-listing-plugin": "link:src/platform/plugins/private/event_annotation_listing", @@ -655,7 +655,6 @@ "@kbn/fleet-plugin": "link:x-pack/platform/plugins/shared/fleet", "@kbn/flot-charts": "link:src/platform/packages/shared/kbn-flot-charts", "@kbn/flyout-system-example-plugin": "link:examples/flyout_system", - "@kbn/flyout-ui": "link:src/platform/packages/shared/kbn-flyout-ui", "@kbn/foo-plugin": "link:x-pack/platform/test/ui_capabilities/common/plugins/foo_plugin", "@kbn/fs": "link:x-pack/platform/packages/shared/kbn-fs", "@kbn/ftr-apis-plugin": "link:src/platform/plugins/private/ftr_apis", @@ -919,6 +918,7 @@ "@kbn/response-ops-rule-form": "link:x-pack/platform/packages/shared/response-ops/rule_form", "@kbn/response-ops-rule-params": "link:x-pack/platform/packages/shared/response-ops/rule_params", "@kbn/response-ops-rules-apis": "link:x-pack/platform/packages/shared/response-ops/rules-apis", + "@kbn/response-ops-scheduling-types": "link:x-pack/platform/packages/shared/response-ops/scheduling-types", "@kbn/response-stream-plugin": "link:examples/response_stream", "@kbn/restorable-state": "link:src/platform/packages/shared/kbn-restorable-state", "@kbn/rison": "link:src/platform/packages/shared/kbn-rison", @@ -989,13 +989,11 @@ "@kbn/security-plugin-types-public": "link:x-pack/platform/packages/shared/security/plugin_types_public", "@kbn/security-plugin-types-server": "link:x-pack/platform/packages/shared/security/plugin_types_server", "@kbn/security-role-management-model": "link:x-pack/platform/packages/private/security/role_management_model", - "@kbn/security-solution-common": "link:src/platform/packages/shared/kbn-security-solution-common", "@kbn/security-solution-connectors": "link:x-pack/solutions/security/packages/connectors", "@kbn/security-solution-distribution-bar": "link:x-pack/solutions/security/packages/distribution-bar", "@kbn/security-solution-ess": "link:x-pack/solutions/security/plugins/security_solution_ess", "@kbn/security-solution-features": "link:x-pack/solutions/security/packages/features", "@kbn/security-solution-fixtures-plugin": "link:x-pack/platform/test/cases_api_integration/common/plugins/security_solution", - "@kbn/security-solution-flyout": "link:src/platform/packages/shared/kbn-security-solution-flyout", "@kbn/security-solution-navigation": "link:x-pack/solutions/security/packages/navigation", "@kbn/security-solution-plugin": "link:x-pack/solutions/security/plugins/security_solution", "@kbn/security-solution-serverless": "link:x-pack/solutions/security/plugins/security_solution_serverless", @@ -1044,6 +1042,7 @@ "@kbn/share-examples-plugin": "link:examples/share_examples", "@kbn/share-plugin": "link:src/platform/plugins/shared/share", "@kbn/shared-svg": "link:src/platform/packages/shared/kbn-shared-svg", + "@kbn/shared-ux-ai-components": "link:src/platform/packages/shared/shared-ux/ai-components", "@kbn/shared-ux-avatar-solution": "link:src/platform/packages/shared/shared-ux/avatar/solution", "@kbn/shared-ux-button-exit-full-screen": "link:src/platform/packages/shared/shared-ux/button/exit_full_screen", "@kbn/shared-ux-button-toolbar": "link:src/platform/packages/shared/shared-ux/button_toolbar", @@ -1326,7 +1325,7 @@ "diff": "8.0.3", "dom-to-image-more": "3.6.0", "dompurify": "3.3.0", - "dotenv": "17.2.3", + "dotenv": "17.2.4", "elastic-apm-node": "4.15.0", "email-addresses": "5.0.0", "eventsource-parser": "3.0.6", @@ -1759,6 +1758,7 @@ "@kbn/streamlang-tests": "link:x-pack/platform/packages/shared/kbn-streamlang-tests", "@kbn/styled-components-mapping-cli": "link:packages/kbn-styled-components-mapping-cli", "@kbn/synthetics-e2e": "link:x-pack/solutions/observability/plugins/synthetics/e2e", + "@kbn/synthetics-forge": "link:x-pack/solutions/observability/packages/kbn-synthetics-forge", "@kbn/synthetics-private-location": "link:x-pack/packages/kbn-synthetics-private-location", "@kbn/synthtrace": "link:src/platform/packages/shared/kbn-synthtrace", "@kbn/synthtrace-client": "link:src/platform/packages/shared/kbn-synthtrace-client", @@ -1885,7 +1885,7 @@ "@types/node": "22.19.0", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.14", - "@types/nodemailer": "7.0.4", + "@types/nodemailer": "7.0.6", "@types/normalize-path": "3.0.2", "@types/nunjucks": "3.2.6", "@types/object-hash": "3.0.6", diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index ed45a4b75fdd8..30ba403d98e2d 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -34,7 +34,6 @@ module.exports = { /x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]agent_policy[\/\\]components[\/\\]agent_policy_advanced_fields[\/\\]custom_fields[\/\\]index.tsx/, /x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]agent_policy[\/\\]components[\/\\]agent_policy_create_inline.tsx/, /x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]agent_policy[\/\\]components[\/\\]agent_policy_form.tsx/, - /x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]agent_policy[\/\\]components[\/\\]agent_policy_yaml_flyout.tsx/, /x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]agent_policy[\/\\]create_package_policy_page[\/\\]components[\/\\]steps[\/\\]components[\/\\]dataset_component.tsx/, /x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]agent_policy[\/\\]create_package_policy_page[\/\\]components[\/\\]steps[\/\\]components[\/\\]package_policy_input_panel.tsx/, /x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]agent_policy[\/\\]create_package_policy_page[\/\\]components[\/\\]steps[\/\\]components[\/\\]package_policy_input_stream.tsx/, diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index 5c38c259d56d4..703a63b0c09b7 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -86,6 +86,7 @@ "snoozeSchedule.skipRecurrences", "tags", "throttle", + "uiamApiKey", "updatedAt", "updatedBy" ], @@ -97,7 +98,8 @@ ], "api_key_pending_invalidation": [ "apiKeyId", - "createdAt" + "createdAt", + "uiamApiKey" ], "api_key_to_invalidate": [ "apiKeyId", diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index ba6c35fef95dd..bdd0c9cba858e 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -290,6 +290,9 @@ "throttle": { "type": "keyword" }, + "uiamApiKey": { + "type": "binary" + }, "updatedAt": { "type": "date" }, @@ -328,6 +331,9 @@ }, "createdAt": { "type": "date" + }, + "uiamApiKey": { + "type": "binary" } } }, diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/alert/10.9.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/alert/10.9.0.json new file mode 100644 index 0000000000000..975af4cbdd151 --- /dev/null +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/alert/10.9.0.json @@ -0,0 +1,165 @@ +{ + "10.8.0": [ + { + "name": "string", + "enabled": "boolean", + "consumer": "string", + "tags": "array", + "alertTypeId": "string", + "apiKeyOwner": "string?|null", + "apiKey": "string?|null", + "apiKeyCreatedByUser": "boolean?|null", + "createdBy": "string?|null", + "updatedBy": "string?|null", + "updatedAt": "string", + "createdAt": "string", + "muteAll": "boolean", + "mutedInstanceIds": "array", + "throttle": "string?|null", + "revision": "number", + "running": "boolean?|null", + "legacyId": "string?|null", + "scheduledTaskId": "string?|null", + "isSnoozedUntil": "string?|null", + "snoozeSchedule": "array?", + "actions": "array", + "notifyWhen": "onActionGroupChange?|onActiveAlert?|onThrottleInterval?|null", + "lastRun": "object?|null", + "nextRun": "string?|null", + "params": "record", + "typeVersion": "number?", + "flapping": "object?|null", + "schedule": { + "interval": "string" + }, + "meta": { + "versionApiKeyLastmodified": "string?" + }, + "executionStatus": { + "status": "ok|active|error|pending|unknown|warning", + "lastExecutionDate": "string", + "lastDuration": "number?", + "error": "object?|null", + "warning": "object?|null" + }, + "mapped_params": { + "risk_score": "number?", + "severity": "string?" + }, + "alertDelay": { + "active": "number" + }, + "artifacts": { + "dashboards": "array?", + "investigation_guide": { + "blob": "string" + } + }, + "monitoring": { + "run": { + "history": "array", + "calculated_metrics": { + "p50": "number?", + "p95": "number?", + "p99": "number?", + "success_ratio": "number" + }, + "last_run": { + "timestamp": "string", + "metrics": { + "duration": "number?", + "total_search_duration_ms": "number?|null", + "total_indexing_duration_ms": "number?|null", + "total_alerts_detected": "number?|null", + "total_alerts_created": "number?|null", + "gap_duration_s": "number?|null", + "gap_range": "object?|null" + } + } + } + } + } + ], + "10.9.0": [ + { + "name": "string", + "enabled": "boolean", + "consumer": "string", + "tags": "array", + "alertTypeId": "string", + "apiKeyOwner": "string?|null", + "apiKey": "string?|null", + "apiKeyCreatedByUser": "boolean?|null", + "createdBy": "string?|null", + "updatedBy": "string?|null", + "updatedAt": "string", + "createdAt": "string", + "muteAll": "boolean", + "mutedInstanceIds": "array", + "throttle": "string?|null", + "revision": "number", + "running": "boolean?|null", + "legacyId": "string?|null", + "scheduledTaskId": "string?|null", + "isSnoozedUntil": "string?|null", + "snoozeSchedule": "array?", + "actions": "array", + "notifyWhen": "onActionGroupChange?|onActiveAlert?|onThrottleInterval?|null", + "lastRun": "object?|null", + "nextRun": "string?|null", + "params": "record", + "typeVersion": "number?", + "flapping": "object?|null", + "uiamApiKey": "string?|null", + "schedule": { + "interval": "string" + }, + "meta": { + "versionApiKeyLastmodified": "string?" + }, + "executionStatus": { + "status": "ok|active|error|pending|unknown|warning", + "lastExecutionDate": "string", + "lastDuration": "number?", + "error": "object?|null", + "warning": "object?|null" + }, + "mapped_params": { + "risk_score": "number?", + "severity": "string?" + }, + "alertDelay": { + "active": "number" + }, + "artifacts": { + "dashboards": "array?", + "investigation_guide": { + "blob": "string" + } + }, + "monitoring": { + "run": { + "history": "array", + "calculated_metrics": { + "p50": "number?", + "p95": "number?", + "p99": "number?", + "success_ratio": "number" + }, + "last_run": { + "timestamp": "string", + "metrics": { + "duration": "number?", + "total_search_duration_ms": "number?|null", + "total_indexing_duration_ms": "number?|null", + "total_alerts_detected": "number?|null", + "total_alerts_created": "number?|null", + "gap_duration_s": "number?|null", + "gap_range": "object?|null" + } + } + } + } + } + ] +} diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/api_key_pending_invalidation/10.2.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/api_key_pending_invalidation/10.2.0.json new file mode 100644 index 0000000000000..ece77fe7b2a20 --- /dev/null +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/api_key_pending_invalidation/10.2.0.json @@ -0,0 +1,15 @@ +{ + "10.1.0": [ + { + "apiKeyId": "string", + "createdAt": "string" + } + ], + "10.2.0": [ + { + "apiKeyId": "string", + "createdAt": "string", + "uiamApiKey": "string?" + } + ] +} diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/api_key_to_invalidate/10.2.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/api_key_to_invalidate/10.2.0.json new file mode 100644 index 0000000000000..ece77fe7b2a20 --- /dev/null +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/api_key_to_invalidate/10.2.0.json @@ -0,0 +1,15 @@ +{ + "10.1.0": [ + { + "apiKeyId": "string", + "createdAt": "string" + } + ], + "10.2.0": [ + { + "apiKeyId": "string", + "createdAt": "string", + "uiamApiKey": "string?" + } + ] +} diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_scout_failures.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_scout_failures.ts index cf57f89e9f1b8..c0e1cf9b48ffd 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_scout_failures.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_scout_failures.ts @@ -18,6 +18,7 @@ export interface ScoutTestFailureExtended extends TestFailure { location: string; duration: number; owners: string; + errorMessage?: string; file?: string; kibanaModule?: { id: string; @@ -105,6 +106,7 @@ export async function getScoutFailures(reportPath: string): Promise { time: '2018-01-01T01:00:00Z', likelyIrrelevant: false, id: 'test-id-123', - target: 'serverless=es', + target: 'local-serverless-observability_complete', location: '/path/to/test.ts', duration: 5000, owners: 'team:test', @@ -216,7 +216,7 @@ describe('updateFailureIssue()', () => { "calls": Array [ Array [ 5678, - "New failure for \\"serverless=es\\" target: [kibana-on-merge - main](https://build-url)", + "New failure for \\"local-serverless-observability_complete\\" target: [kibana-on-merge - main](https://build-url)", ], ], "results": Array [ @@ -228,6 +228,147 @@ describe('updateFailureIssue()', () => { } `); }); + + it('does not include new error message when error.message is missing', async () => { + const api = new GithubApi(); + + await updateFailureIssue( + 'https://build-url', + { + classname: 'scout.suite', + name: 'scout test', + github: { + htmlUrl: 'https://github.com/issues/9101', + number: 9101, + nodeId: 'ijkl', + body: dedent` + # existing issue body + + \`\`\` + Previous error message + \`\`\` + + " + `, + }, + }, + api, + 'main', + 'kibana-on-merge', + { + classname: 'scout.suite', + name: 'scout test', + failure: 'new error stack trace', + time: '2018-01-01T01:00:00Z', + likelyIrrelevant: false, + id: 'test-id-456', + target: 'local-serverless-observability_complete', + location: '/path/to/test.ts', + duration: 5000, + owners: 'team:test', + } + ); + + const comment = api.addIssueComment.mock.calls[0][1] as string; + expect(comment).toContain('New failure for "local-serverless-observability_complete" target'); + expect(comment).not.toContain('New error message'); + }); + + it('does not include new error message when error.message matches issue body', async () => { + const api = new GithubApi(); + + await updateFailureIssue( + 'https://build-url', + { + classname: 'scout.suite', + name: 'scout test', + github: { + htmlUrl: 'https://github.com/issues/1112', + number: 1112, + nodeId: 'mnop', + body: dedent` + # existing issue body + + \`\`\` + TimeoutError: locator.click: Timeout 10000ms exceeded. + at /path/to/test.ts:42:10 + at async Runner.run (/node_modules/runner.js:100:5) + \`\`\` + + " + `, + }, + }, + api, + 'main', + 'kibana-on-merge', + { + classname: 'scout.suite', + name: 'scout test', + failure: + 'TimeoutError: locator.click: Timeout 10000ms exceeded.\n at /path/to/test.ts:42:10', + errorMessage: 'TimeoutError: locator.click: Timeout 10000ms exceeded.', + time: '2018-01-01T01:00:00Z', + likelyIrrelevant: false, + id: 'test-id-1112', + target: 'local-serverless-observability_complete', + location: '/path/to/test.ts', + duration: 5000, + owners: 'team:test', + } + ); + + const comment = api.addIssueComment.mock.calls[0][1] as string; + expect(comment).toContain('New failure for "local-serverless-observability_complete" target'); + expect(comment).not.toContain('New error message'); + }); + + it('includes new error message when error.message changed', async () => { + const api = new GithubApi(); + + await updateFailureIssue( + 'https://build-url', + { + classname: 'scout.suite', + name: 'scout test', + github: { + htmlUrl: 'https://github.com/issues/1213', + number: 1213, + nodeId: 'qrst', + body: dedent` + # existing issue body + + \`\`\` + Previous error message + \`\`\` + + " + `, + }, + }, + api, + 'main', + 'kibana-on-merge', + { + classname: 'scout.suite', + name: 'scout test', + failure: 'new error stack trace', + errorMessage: 'TimeoutError: locator.click: Timeout 10000ms exceeded.', + time: '2018-01-01T01:00:00Z', + likelyIrrelevant: false, + id: 'test-id-1213', + target: 'local-serverless-observability_complete', + location: '/path/to/test.ts', + duration: 5000, + owners: 'team:test', + } + ); + + const comment = api.addIssueComment.mock.calls[0][1] as string; + expect(comment).toContain('New failure for "local-serverless-observability_complete" target'); + expect(comment).toContain('New error message'); + expect(comment).toContain('TimeoutError: locator.click: Timeout 10000ms exceeded.'); + }); }); describe('createFailureIssue() - Scout failures', () => { @@ -243,7 +384,7 @@ describe('createFailureIssue() - Scout failures', () => { time: '2018-01-01T01:00:00Z', likelyIrrelevant: false, id: 'test-id-123', - target: 'stateful', + target: 'local-serverless-observability_complete', location: '/path/to/test.ts', duration: 5000, owners: 'team:test', @@ -266,7 +407,7 @@ describe('createFailureIssue() - Scout failures', () => { | Field | Value | |-------|-------| | Test ID | test-id-123 | - | Target | stateful | + | Target | local-serverless-observability_complete | | Location | /path/to/test.ts | | Duration | 5.00s | | Module | N/A | @@ -344,7 +485,7 @@ describe('createFailureIssue() - Scout failures', () => { time: '2018-01-01T01:00:00Z', likelyIrrelevant: false, id: 'test-id-789', - target: 'stateful', + target: 'local-serverless-observability_complete', location: '/path/to/test.ts', duration: 2000, owners: 'team:test', @@ -379,7 +520,7 @@ describe('createFailureIssue() - Scout failures', () => { time: '2018-01-01T01:00:00Z', likelyIrrelevant: false, id: 'test-id-789', - target: 'stateful', + target: 'local-serverless-observability_complete', location: '/path/to/test.ts', duration: 2000, owners: 'team:test', diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failure.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failure.ts index 8bf8389ab823a..ee17d2b25acd8 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failure.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failure.ts @@ -26,6 +26,15 @@ function truncateFailureBody(failure: string, maxCharacters: number = 8192): str ].join('\n'); } +function getFailureBodyFromIssueBody(body: string): string | undefined { + const match = body.match(/```[\r\n]+([\s\S]*?)[\r\n]+```/); + if (!match) { + return undefined; + } + + return match[1].trim(); +} + function createFTRTitle(failure: TestFailure, prependTitle: string): string { if (prependTitle && prependTitle.trim() !== '') { return `Failing test: ${prependTitle} ${failure.classname} - ${failure.name}`; @@ -186,11 +195,48 @@ function createScoutComment( failure: ScoutTestFailureExtended, buildUrl: string, branch: string, - pipeline: string + pipeline: string, + newErrorMessage?: string ): string { - return `New failure for "${failure.target}" target: [${ + const base = `New failure for "${failure.target}" target: [${ pipeline || 'CI Build' } - ${branch}](${buildUrl})`; + if (!newErrorMessage) { + /* + * If there's a failure with the same error message as before, just post a comment + * with pipeline link and failure target. + * + * Example: + * + * New failure for "local-serverless-observability_complete" target: [kibana-on-merge - main](https://buildkite.com/elastic/kibana-on-merge/builds/123456) + */ + return base; + } + + /* + * If there's a new error message, include it in the comment. This provides more + * context on how the failure has changed since the issue was opened or last updated. + * + * Example: + * + * New failure for "local-serverless-observability_complete" target: [kibana-on-merge - main](https://buildkite.com/elastic/kibana-on-merge/builds/123456) + * + * New error message: + * ``` + * Error: expect(locator).toBeEnabled() failed + * + * Locator: locator('notExist') + * Expected: enabled + * Timeout: 10000ms + * Error: element(s) not found + * + * Call log: + * - Expect "toBeEnabled" with timeout 10000ms + * - waiting for locator('notExist') + * ``` + */ + + return `${base}\n\nNew error message:\n\`\`\`\n${newErrorMessage}\n\`\`\``; } async function updateFTRFailureIssue( @@ -228,7 +274,16 @@ async function updateScoutFailureIssue( await api.editIssueBodyAndEnsureOpen(issue.github.number, newBody); - const commentText = createScoutComment(failure, buildUrl, branch, pipeline); + const previousFailureBody = getFailureBodyFromIssueBody(issue.github.body); + let newErrorMessage: string | undefined; + if (failure.errorMessage && previousFailureBody) { + const currentErrorMsg = truncateFailureBody(failure.errorMessage).trim(); + if (!previousFailureBody.includes(currentErrorMsg)) { + newErrorMessage = currentErrorMsg; + } + } + + const commentText = createScoutComment(failure, buildUrl, branch, pipeline, newErrorMessage); await api.addIssueComment(issue.github.number, commentText); return { newBody, newCount }; diff --git a/packages/kbn-mock-idp-plugin/server/plugin.ts b/packages/kbn-mock-idp-plugin/server/plugin.ts index 879e1acfccb53..a9822ad4ec9cd 100644 --- a/packages/kbn-mock-idp-plugin/server/plugin.ts +++ b/packages/kbn-mock-idp-plugin/server/plugin.ts @@ -357,6 +357,52 @@ export const plugin: PluginInitializer = as } } ); + + router.post( + { + path: '/mock_idp/uiam/convert_api_keys', + validate: { + body: schema.object({ + keys: schema.arrayOf( + schema.object({ + key: schema.string(), + }), + { minSize: 1 } + ), + }), + }, + options: { authRequired: 'optional' }, + security: { authz: { enabled: false, reason: 'Mock IDP plugin for testing' } }, + }, + async (context, request, response) => { + try { + const { keys } = request.body; + const [ + { + security: { authc }, + }, + ] = await core.getStartServices(); + + const result = await authc.apiKeys.uiam?.convert({ keys }); + + if (!result) { + return response.badRequest({ + body: { message: 'Failed to convert API keys' }, + }); + } + + return response.ok({ + body: result, + }); + } catch (err) { + logger.error(`Failed to convert API keys via UIAM: ${err}`, err); + return response.customError({ + statusCode: 500, + body: { message: err.message }, + }); + } + } + ); }, start() {}, stop() {}, diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 3b6f1bddb4ca7..c7165dadeb4f8 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -32,7 +32,6 @@ pageLoadAssetSize: customIntegrations: 11715 dashboard: 20000 dashboardAgent: 5135 - dashboardEnhanced: 11651 dashboardMarkdown: 4994 data: 494550 dataQuality: 11469 @@ -51,9 +50,8 @@ pageLoadAssetSize: discoverShared: 2322 elasticAssistant: 338870 elasticAssistantSharedState: 4881 - embeddable: 14545 + embeddable: 16638 embeddableAlertsTable: 6524 - embeddableEnhanced: 4488 enterpriseSearch: 37566 entityStore: 71717 esql: 19247 @@ -122,7 +120,7 @@ pageLoadAssetSize: newsfeed: 11369 noDataPage: 1749 observability: 107797 - observabilityAgentBuilder: 7077 + observabilityAgentBuilder: 9172 observabilityAIAssistant: 61180 observabilityAIAssistantApp: 18012 observabilityAiAssistantManagement: 7126 @@ -185,12 +183,12 @@ pageLoadAssetSize: transform: 16515 triggersActionsUi: 112000 uiActions: 35278 - uiActionsEnhanced: 19272 + uiActionsEnhanced: 17373 unifiedDocViewer: 14513 unifiedSearch: 19500 upgradeAssistant: 6898 uptime: 48171 - urlDrilldown: 18801 + urlDrilldown: 6812 urlForwarding: 7349 usageCollection: 5655 ux: 8376 diff --git a/packages/kbn-ts-type-check-cli/run_type_check_cli.ts b/packages/kbn-ts-type-check-cli/run_type_check_cli.ts index 6a45ef8110e2e..ceaf25335b713 100644 --- a/packages/kbn-ts-type-check-cli/run_type_check_cli.ts +++ b/packages/kbn-ts-type-check-cli/run_type_check_cli.ts @@ -21,6 +21,7 @@ import { archiveTSBuildArtifacts } from './src/archive/archive_ts_build_artifact import { restoreTSBuildArtifacts } from './src/archive/restore_ts_build_artifacts'; import { LOCAL_CACHE_ROOT } from './src/archive/constants'; import { isCiEnvironment } from './src/archive/utils'; +import { normalizeProjectPath } from './src/normalize_project_path'; const rel = (from: string, to: string) => { const path = Path.relative(from, to); @@ -133,7 +134,7 @@ run( log.verbose('Skipping TypeScript cache restore because --with-archive was not provided.'); } - const projectFilter = flagsReader.path('project'); + const projectFilter = normalizeProjectPath(flagsReader.path('project'), log); const projects = TS_PROJECTS.filter( (p) => !p.isTypeCheckDisabled() && (!projectFilter || p.path === projectFilter) diff --git a/packages/kbn-ts-type-check-cli/src/normalize_project_path.test.ts b/packages/kbn-ts-type-check-cli/src/normalize_project_path.test.ts new file mode 100644 index 0000000000000..2c12d601a94e1 --- /dev/null +++ b/packages/kbn-ts-type-check-cli/src/normalize_project_path.test.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Path from 'path'; +import { REPO_ROOT } from '@kbn/repo-info'; + +import { normalizeProjectPath } from './normalize_project_path'; + +describe('normalizeProjectPath', () => { + it('returns undefined when project path is not set', () => { + const warning = jest.fn(); + expect(normalizeProjectPath(undefined, { warning })).toBeUndefined(); + expect(warning).not.toHaveBeenCalled(); + }); + + it('returns the same project path when tsconfig.json is passed', () => { + const warning = jest.fn(); + const projectPath = '/repo/packages/foo/tsconfig.json'; + + expect(normalizeProjectPath(projectPath, { warning })).toBe(projectPath); + expect(warning).not.toHaveBeenCalled(); + }); + + it('normalizes tsconfig.type_check.json to tsconfig.json and logs a warning', () => { + const warning = jest.fn(); + const projectPath = Path.resolve(REPO_ROOT, 'packages/foo/tsconfig.type_check.json'); + const normalizedProjectPath = Path.resolve(REPO_ROOT, 'packages/foo/tsconfig.json'); + + expect(normalizeProjectPath(projectPath, { warning })).toBe(normalizedProjectPath); + expect(warning).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledWith( + `Received --project=packages/foo/tsconfig.type_check.json. tsconfig.type_check.json is generated by the type-check script; using packages/foo/tsconfig.json instead.` + ); + }); +}); diff --git a/packages/kbn-ts-type-check-cli/src/normalize_project_path.ts b/packages/kbn-ts-type-check-cli/src/normalize_project_path.ts new file mode 100644 index 0000000000000..c7ceab0338f78 --- /dev/null +++ b/packages/kbn-ts-type-check-cli/src/normalize_project_path.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Path from 'path'; +import { REPO_ROOT } from '@kbn/repo-info'; +import type { SomeDevLog } from '@kbn/some-dev-log'; + +const TYPE_CHECK_CONFIG_FILENAME = 'tsconfig.type_check.json'; +const PROJECT_CONFIG_FILENAME = 'tsconfig.json'; + +const formatPathForLog = (path: string) => { + if (!Path.isAbsolute(path)) { + return path; + } + + const relativeToRepoRoot = Path.relative(REPO_ROOT, path); + if (!relativeToRepoRoot.startsWith('..') && !Path.isAbsolute(relativeToRepoRoot)) { + return relativeToRepoRoot; + } + + return path; +}; + +/** + * Normalizes the `--project` value passed to `scripts/type_check`. + * + * The type-check CLI expects a source `tsconfig.json`. If a generated + * `tsconfig.type_check.json` path is passed instead, this rewrites it to the + * sibling `tsconfig.json` and logs a warning explaining the correction. + */ +export const normalizeProjectPath = ( + projectPath: string | undefined, + log: Pick +) => { + if (!projectPath || Path.basename(projectPath) !== TYPE_CHECK_CONFIG_FILENAME) { + return projectPath; + } + + const normalizedPath = Path.resolve(Path.dirname(projectPath), PROJECT_CONFIG_FILENAME); + const formattedProjectPath = formatPathForLog(projectPath); + const formattedNormalizedPath = formatPathForLog(normalizedPath); + + log.warning( + `Received --project=${formattedProjectPath}. ${TYPE_CHECK_CONFIG_FILENAME} is generated by the type-check script; using ${formattedNormalizedPath} instead.` + ); + + return normalizedPath; +}; diff --git a/snyk_mcp/triage/risk_assessment.md b/snyk_mcp/triage/risk_assessment.md new file mode 100644 index 0000000000000..333f1fcf56ef1 --- /dev/null +++ b/snyk_mcp/triage/risk_assessment.md @@ -0,0 +1,20 @@ +Comment for issue https://api.github.com/repos/elastic/security/issues/0: + + - **Triage:** High severity vulnerability (CVE-2025-11362) in pdfmake@0.2.15 used by Kibana's screenshotting plugin for PDF report generation. The vulnerability allows attackers to cause application crashes through repeated redirect URLs in file embedding. Since Kibana processes user-controlled content when generating PDF reports from dashboards and visualizations, this poses a significant risk. The vulnerability affects production functionality used for exporting dashboards and reports. + - **Risk response:** mitigate + - **Upgrade paths:** + - pdfmake@0.2.15 -> pdfmake@0.3.0-beta.17 + + +Comment for issue https://api.github.com/repos/elastic/security/issues/0: +### Security statement +```yaml + + cve: CVE-2025-11362 + status: future update + statement: Kibana is affected by this issue. pdfmake@0.2.15 is used in the screenshotting plugin for PDF report generation functionality. The vulnerability allows attackers to cause application crashes through repeated redirect URLs in file embedding, which can be triggered when processing user-controlled content during PDF export operations from dashboards and visualizations. pdfmake will be updated to version 0.3.0-beta.17 or higher as part of Kibana standard maintenance practices in a future Kibana version. + product: kibana + dependency: pdfmake + +``` + diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 1cc467f2f59fb..73c044fe4ede4 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -16,9 +16,12 @@ import { isKibanaDistributable } from '@kbn/repo-info'; import { readKeystore } from '../keystore/lib/read_keystore'; import { compileConfigStack } from './compile_config_stack'; import { getConfigFromFiles } from '@kbn/config'; +import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; const DEV_MODE_PATH = '@kbn/cli-dev-mode'; const DEV_MODE_SUPPORTED = canRequire(DEV_MODE_PATH); +const DEV_UTILS_PATH = '@kbn/dev-utils'; +const DEV_UTILS_SUPPORTED = canRequire(DEV_UTILS_PATH); const MOCK_IDP_PLUGIN_PATH = '@kbn/mock-idp-plugin/common'; const MOCK_IDP_PLUGIN_SUPPORTED = canRequire(MOCK_IDP_PLUGIN_PATH); @@ -440,9 +443,16 @@ function tryConfigureServerlessSamlProvider(rawConfig, opts, extraCliOptions) { }); } - if (opts.uiam) { + if (opts.uiam && DEV_UTILS_SUPPORTED) { + // Ensure the key/cert pair is loaded dynamically to exclude it from the production build. + // eslint-disable-next-line import/no-dynamic-require + const { KBN_CERT_PATH, KBN_KEY_PATH } = require(DEV_UTILS_PATH); + console.info('Kibana will be configured to support UIAM.'); lodashSet(rawConfig, 'xpack.security.uiam.enabled', true); + lodashSet(rawConfig, 'xpack.security.uiam.ssl.certificate', KBN_CERT_PATH); + lodashSet(rawConfig, 'xpack.security.uiam.ssl.key', KBN_KEY_PATH); + lodashSet(rawConfig, 'xpack.security.uiam.ssl.verificationMode', 'none'); lodashSet(rawConfig, 'mockIdpPlugin.uiam.enabled', true); if (!_.has(rawConfig, 'xpack.security.uiam.url')) { diff --git a/src/cli/serve/serve.test.js b/src/cli/serve/serve.test.js index eaf50f2fa8b83..d6f6be02effb3 100644 --- a/src/cli/serve/serve.test.js +++ b/src/cli/serve/serve.test.js @@ -8,7 +8,7 @@ */ import { applyConfigOverrides } from './serve'; -import { kibanaDevServiceAccount } from '@kbn/dev-utils'; +import { KBN_CERT_PATH, KBN_KEY_PATH, kibanaDevServiceAccount } from '@kbn/dev-utils'; describe('applyConfigOverrides', () => { it('merges empty objects to an empty config', () => { @@ -126,7 +126,7 @@ describe('applyConfigOverrides', () => { cloud: { organization_id: 'org1234567890', projects_url: '', - serverless: { project_id: 'abcde1234567890' }, + serverless: { project_id: 'abcdef12345678901234567890123456' }, }, security: { authc: { @@ -147,7 +147,12 @@ describe('applyConfigOverrides', () => { uiam: { enabled: true, sharedSecret: 'Dw7eRt5yU2iO9pL3aS4dF6gH8jK0lZ1xC2vB3nM4qW5=', - url: 'http://localhost:8080', + url: 'https://localhost:8443', + ssl: { + certificate: KBN_CERT_PATH, + key: KBN_KEY_PATH, + verificationMode: 'none', + }, }, }, }, diff --git a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx index e621c9bd976de..3dc5f6ad6fe43 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx @@ -231,8 +231,10 @@ export class ChromeService { } public stop() { + this.navControls.stop(); this.navLinks.stop(); this.projectNavigation.stop(); + this.sidebar.stop(); this.stop$.next(); } } diff --git a/src/core/packages/chrome/browser-internal/src/services/nav_controls/nav_controls_service.test.ts b/src/core/packages/chrome/browser-internal/src/services/nav_controls/nav_controls_service.test.ts index 90cf583c530f1..f74e3e83cb437 100644 --- a/src/core/packages/chrome/browser-internal/src/services/nav_controls/nav_controls_service.test.ts +++ b/src/core/packages/chrome/browser-internal/src/services/nav_controls/nav_controls_service.test.ts @@ -10,7 +10,7 @@ import { take } from 'rxjs'; import { NavControlsService } from './nav_controls_service'; -describe('RecentlyAccessed#start()', () => { +describe('NavControlsService#start()', () => { const getStart = () => { return new NavControlsService().start(); }; diff --git a/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.test.ts b/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.test.ts index 9a9f5a2b3b47a..ff70f15dfaf5a 100644 --- a/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.test.ts +++ b/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.test.ts @@ -46,7 +46,7 @@ const availableApps: ReadonlyMap = new Map([ { id: 'chromelessApp', order: 20, - title: 'Chromless App', + title: 'Chromeless App', chromeless: true, mount: () => () => undefined, }, @@ -110,7 +110,7 @@ describe('NavLinksService', () => { ).not.toContain('chromelessApp'); }); - it('does not include `inaccesible` applications', async () => { + it('does not include `inaccessible` applications', async () => { expect( await lastValueFrom( start.getNavLinks$().pipe( diff --git a/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.ts b/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.ts index 23edebc23c1ab..e53e96e619b17 100644 --- a/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.ts +++ b/src/core/packages/chrome/browser-internal/src/services/nav_links/nav_links_service.ts @@ -43,7 +43,8 @@ export class NavLinksService { return navLinks; }, []) ); - }) + }), + takeUntil(this.stop$) ) .subscribe((navlinks) => { navLinks$.next(navlinks); diff --git a/src/core/packages/chrome/browser-internal/src/side_effects/csp_warning.tsx b/src/core/packages/chrome/browser-internal/src/side_effects/csp_warning.tsx index 2df4490bbdbf4..8418c1d370ebe 100644 --- a/src/core/packages/chrome/browser-internal/src/side_effects/csp_warning.tsx +++ b/src/core/packages/chrome/browser-internal/src/side_effects/csp_warning.tsx @@ -28,14 +28,19 @@ export function showCspWarningIfNeeded({ return; } - getNotifications().then((notifications) => { - notifications.toasts.addWarning({ - title: mountReactNode( - - ), + getNotifications() + .then((notifications) => { + notifications.toasts.addWarning({ + title: mountReactNode( + + ), + }); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.warn('Failed to show CSP warning toast', e); }); - }); } diff --git a/src/core/packages/chrome/browser-internal/src/side_effects/handle_eui_fullscreen_changes.ts b/src/core/packages/chrome/browser-internal/src/side_effects/handle_eui_fullscreen_changes.ts index d91bb2d0ebb04..8f4b3a99f2cb4 100644 --- a/src/core/packages/chrome/browser-internal/src/side_effects/handle_eui_fullscreen_changes.ts +++ b/src/core/packages/chrome/browser-internal/src/side_effects/handle_eui_fullscreen_changes.ts @@ -60,7 +60,7 @@ export function handleEuiFullScreenChanges({ }); }); - mutationObserver.observe(body, { attributes: true }); + mutationObserver.observe(body, { attributes: true, attributeFilter: ['class'] }); stop$.pipe(take(1)).subscribe(() => { mutationObserver.disconnect(); diff --git a/src/core/packages/chrome/browser-internal/src/ui/breadcrumb_utils.ts b/src/core/packages/chrome/browser-internal/src/ui/breadcrumb_utils.ts new file mode 100644 index 0000000000000..8baadb4d402ea --- /dev/null +++ b/src/core/packages/chrome/browser-internal/src/ui/breadcrumb_utils.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import classNames from 'classnames'; +import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; + +/** Maps raw ChromeBreadcrumb[] to EUI-compatible breadcrumb props */ +export function prepareBreadcrumbs(breadcrumbs: ChromeBreadcrumb[]) { + const crumbs = breadcrumbs.length === 0 ? [{ text: 'Kibana' } as ChromeBreadcrumb] : breadcrumbs; + + return crumbs.map((breadcrumb, i) => { + const isLast = i === crumbs.length - 1; + const { deepLinkId, ...rest } = breadcrumb; + + return { + ...rest, + href: isLast ? undefined : breadcrumb.href, + onClick: isLast ? undefined : breadcrumb.onClick, + 'data-test-subj': classNames( + 'breadcrumb', + deepLinkId && `breadcrumb-deepLinkId-${deepLinkId}`, + breadcrumb['data-test-subj'], + i === 0 && 'first', + isLast && 'last' + ), + }; + }); +} diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx index fd3a30caac903..e5bd7de4712c4 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header.test.tsx @@ -122,11 +122,8 @@ describe('Header', () => { ); component.update(); expect(component.find('HeaderExtension').length).toBe(2); - expect( - component.find('HeaderExtension').at(0).getDOMNode().querySelector('.my-extension1') - ).toBeTruthy(); - expect( - component.find('HeaderExtension').at(1).getDOMNode().querySelector('.my-extension2') - ).toBeTruthy(); + const rootNode = component.getDOMNode(); + expect(rootNode.querySelector('.my-extension1')).toBeTruthy(); + expect(rootNode.querySelector('.my-extension2')).toBeTruthy(); }); }); diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx index d12f21aac3013..6d65f83d0dfc7 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header.tsx @@ -17,10 +17,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import classnames from 'classnames'; -import React, { createRef, useState, useMemo } from 'react'; +import React, { createRef, useState } from 'react'; import type { Observable } from 'rxjs'; -import { map, EMPTY } from 'rxjs'; -import useObservable from 'react-use/lib/useObservable'; + import type { HttpStart } from '@kbn/core-http-browser'; import type { InternalApplicationStart } from '@kbn/core-application-browser-internal'; import type { @@ -49,6 +48,7 @@ import { HeaderActionMenu, useHeaderActionMenuMounter } from './header_action_me import { BreadcrumbsWithExtensionsWrapper } from './breadcrumbs_with_extensions'; import { HeaderMenuButton } from './header_menu_button'; import { HeaderPageAnnouncer } from './header_page_announcer'; +import { useHasAppMenuConfig } from '../use_has_app_menu_config'; export interface HeaderProps { kibanaVersion: string; @@ -95,14 +95,7 @@ export function Header({ const [navId] = useState(htmlIdGenerator()()); const headerActionMenuMounter = useHeaderActionMenuMounter(application.currentActionMenu$); - const hasBeta$ = useMemo( - () => - observables.appMenu$?.pipe( - map((config) => !!config && !!config.items && config.items.length > 0) - ) ?? EMPTY, - [observables.appMenu$] - ); - const hasBetaConfig = useObservable(hasBeta$, false); + const hasBetaConfig = useHasAppMenuConfig(observables.appMenu$); const toggleCollapsibleNavRef = createRef void }>(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header_badge.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header_badge.tsx index 39a4c32e98e86..b5068251051ed 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header_badge.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header_badge.tsx @@ -7,77 +7,34 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { Component } from 'react'; -import type * as Rx from 'rxjs'; +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; import { EuiBetaBadge } from '@elastic/eui'; import type { ChromeBadge } from '@kbn/core-chrome-browser'; interface Props { - badge$: Rx.Observable; + badge$: Observable; } -interface State { - badge: ChromeBadge | undefined; -} - -export class HeaderBadge extends Component { - private subscription?: Rx.Subscription; - - constructor(props: Props) { - super(props); - - this.state = { badge: undefined }; - } +export const HeaderBadge = ({ badge$ }: Props) => { + const badge = useObservable(badge$, undefined); - public componentDidMount() { - this.subscribe(); + if (badge == null) { + return null; } - public componentDidUpdate(prevProps: Props) { - if (prevProps.badge$ === this.props.badge$) { - return; - } - - this.unsubscribe(); - this.subscribe(); - } - - public componentWillUnmount() { - this.unsubscribe(); - } - - public render() { - if (this.state.badge == null) { - return null; - } - - return ( -
({ alignSelf: 'center', marginRight: euiTheme.size.s })}> - -
- ); - } - - private subscribe() { - this.subscription = this.props.badge$.subscribe((badge) => { - this.setState({ - badge, - }); - }); - } - - private unsubscribe() { - if (this.subscription) { - this.subscription.unsubscribe(); - this.subscription = undefined; - } - } -} + return ( +
({ alignSelf: 'center', marginRight: euiTheme.size.s })}> + +
+ ); +}; diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header_breadcrumbs.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header_breadcrumbs.tsx index a1c5f90d91e2d..405d11f28dedc 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header_breadcrumbs.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header_breadcrumbs.tsx @@ -8,11 +8,11 @@ */ import { EuiHeaderBreadcrumbs } from '@elastic/eui'; -import classNames from 'classnames'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; +import { prepareBreadcrumbs } from '../breadcrumb_utils'; interface Props { breadcrumbs$: Observable; @@ -20,29 +20,7 @@ interface Props { export function HeaderBreadcrumbs({ breadcrumbs$ }: Props) { const breadcrumbs = useObservable(breadcrumbs$, []); - let crumbs = breadcrumbs; - - if (breadcrumbs.length === 0) { - crumbs = [{ text: 'Kibana' }]; - } - - crumbs = crumbs.map((breadcrumb, i) => { - const isLast = i === breadcrumbs.length - 1; - const { deepLinkId, ...rest } = breadcrumb; - - return { - ...rest, - href: isLast ? undefined : breadcrumb.href, - onClick: isLast ? undefined : breadcrumb.onClick, - 'data-test-subj': classNames( - 'breadcrumb', - deepLinkId && `breadcrumb-deepLinkId-${deepLinkId}`, - breadcrumb['data-test-subj'], - i === 0 && 'first', - isLast && 'last' - ), - }; - }); + const crumbs = prepareBreadcrumbs(breadcrumbs); return ; } diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header_extension.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header_extension.tsx index 297402b8c93c0..17a84ed21b36a 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header_extension.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header_extension.tsx @@ -8,7 +8,7 @@ */ import { css } from '@emotion/react'; -import React from 'react'; +import React, { useRef, useEffect } from 'react'; import type { MountPoint } from '@kbn/core-mount-utils-browser'; interface Props { @@ -17,57 +17,28 @@ interface Props { containerClassName?: string; } -export class HeaderExtension extends React.Component { - private readonly ref = React.createRef(); - private unrender?: () => void; - - public componentDidMount() { - this.renderExtension(); - } - - public componentDidUpdate(prevProps: Props) { - if (this.props.extension === prevProps.extension) { - return; - } - - this.unrenderExtension(); - this.renderExtension(); - } - - public componentWillUnmount() { - this.unrenderExtension(); - } - - public render() { - return ( -
- ); - } - - private renderExtension() { - if (!this.ref.current) { - throw new Error(' mounted without ref'); - } - - if (this.props.extension) { - this.unrender = this.props.extension(this.ref.current); - } - } - - private unrenderExtension() { - if (this.unrender) { - this.unrender(); - this.unrender = undefined; - } - } -} +export const HeaderExtension = ({ extension, display, containerClassName }: Props) => { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current || !extension) return; + const unrender = extension(ref.current); + return () => { + unrender?.(); + }; + }, [extension]); + + return ( +
+ ); +}; diff --git a/src/core/packages/chrome/browser-internal/src/ui/header/header_help_menu.tsx b/src/core/packages/chrome/browser-internal/src/ui/header/header_help_menu.tsx index 2929948ed3005..51eaf97d9f65f 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/header/header_help_menu.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/header/header_help_menu.tsx @@ -7,12 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { Component, Fragment } from 'react'; -import type { Observable, Subscription } from 'rxjs'; -import { combineLatest } from 'rxjs'; +import React, { Fragment, useState, useCallback, useMemo } from 'react'; +import type { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { EuiButtonEmptyProps, WithEuiThemeProps } from '@elastic/eui'; +import type { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiButtonEmpty, EuiFlexGroup, @@ -23,15 +22,16 @@ import { EuiPopoverTitle, EuiSpacer, EuiPopoverFooter, - withEuiTheme, + useEuiTheme, } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; import type { InternalApplicationStart } from '@kbn/core-application-browser-internal'; import type { ChromeHelpExtension, ChromeGlobalHelpExtensionMenuLink, + ChromeHelpMenuLink, } from '@kbn/core-chrome-browser'; -import type { ChromeHelpMenuLink } from '@kbn/core-chrome-browser/src'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import { css } from '@emotion/react'; @@ -79,193 +79,125 @@ interface Props { isServerless: boolean; } -interface State { - isOpen: boolean; - helpExtension?: ChromeHelpExtension; - helpSupportUrl: string; - globalHelpExtensionMenuLinks: ChromeGlobalHelpExtensionMenuLink[]; - defaultContentLinks: ChromeHelpMenuLink[]; -} +const createCustomLink = ( + index: number, + text: React.ReactNode, + addSpacer?: boolean, + buttonProps?: EuiButtonEmptyProps +) => { + return ( + + + {text} + + {addSpacer && } + + ); +}; -class HelpMenu extends Component { - private subscription?: Subscription; +export const HeaderHelpMenu = ({ + navigateToUrl, + globalHelpExtensionMenuLinks$, + helpExtension$, + helpSupportUrl$, + defaultContentLinks$, + kibanaVersion, + kibanaDocLink, + docLinks, + isServerless, +}: Props) => { + const { euiTheme } = useEuiTheme(); + const [isOpen, setIsOpen] = useState(false); + + const helpExtension = useObservable(helpExtension$, undefined); + const helpSupportUrl = useObservable(helpSupportUrl$, ''); + const globalHelpExtensionMenuLinks = useObservable(globalHelpExtensionMenuLinks$, []); + const providedDefaultContentLinks = useObservable(defaultContentLinks$, []); + + const defaultContentLinks = useMemo( + () => + providedDefaultContentLinks.length === 0 + ? buildDefaultContentLinks({ kibanaDocLink, docLinks, helpSupportUrl }) + : providedDefaultContentLinks, + [providedDefaultContentLinks, kibanaDocLink, docLinks, helpSupportUrl] + ); - constructor(props: Props & WithEuiThemeProps) { - super(props); + const closeMenu = useCallback(() => setIsOpen(false), []); + const toggleMenu = useCallback(() => setIsOpen((prev) => !prev), []); - this.state = { - isOpen: false, - helpExtension: undefined, - helpSupportUrl: '', - globalHelpExtensionMenuLinks: [], - defaultContentLinks: [], - }; - } + const helpExtensionContent = helpExtension?.content; + const helpExtensionMount = useCallback( + (domNode: HTMLDivElement) => { + const unmount = helpExtensionContent?.(domNode, { hideHelpMenu: closeMenu }); + return unmount ?? (() => {}); + }, + [helpExtensionContent, closeMenu] + ); - public componentDidMount() { - this.subscription = combineLatest( - this.props.helpExtension$, - this.props.helpSupportUrl$, - this.props.globalHelpExtensionMenuLinks$, - this.props.defaultContentLinks$ - ).subscribe( - ([helpExtension, helpSupportUrl, globalHelpExtensionMenuLinks, defaultContentLinks]) => { - this.setState({ - helpExtension, - helpSupportUrl, - globalHelpExtensionMenuLinks, - defaultContentLinks: - defaultContentLinks.length === 0 - ? buildDefaultContentLinks({ ...this.props, helpSupportUrl }) - : defaultContentLinks, - }); + const createOnClickHandler = useCallback( + (href: string) => (event: React.MouseEvent) => { + if (!isModifiedOrPrevented(event) && event.button === 0) { + event.preventDefault(); + closeMenu(); + navigateToUrl(href); } - ); - } - - public componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - this.subscription = undefined; - } - } - - public render() { - const { kibanaVersion, theme } = this.props; - - const defaultContent = this.renderDefaultContent(); - const globalCustomContent = this.renderGlobalCustomContent(); - const customContent = this.renderCustomContent(); - - const euiThemePadding = css` - padding: ${theme.euiTheme.size.s}; - `; - - const button = ( - - - - ); - - return ( - - - - -

- -

-
- {!this.props.isServerless && ( - - - - )} -
-
- -
- {globalCustomContent} - {defaultContent} - {customContent && ( - <> - - {customContent} - - )} -
-
- ); - } - - private renderDefaultContent() { - const { defaultContentLinks } = this.state; - - return ( - - {defaultContentLinks.map(({ href, title, onClick: _onClick, dataTestSubj }, i) => { - const isLast = i === defaultContentLinks.length - 1; + }, + [closeMenu, navigateToUrl] + ); - if (href && _onClick) { - throw new Error( - 'Only one of `href` and `onClick` should be provided for the help menu link.' - ); - } + const euiThemePadding = css` + padding: ${euiTheme.size.s}; + `; - const hrefProps = href ? { href, target: '_blank' } : {}; - const onClick = () => { - if (!_onClick) return; - _onClick(); - this.closeMenu(); - }; + const defaultContent = ( + + {defaultContentLinks.map(({ href, title, onClick: _onClick, dataTestSubj }, idx) => { + const isLast = idx === defaultContentLinks.length - 1; - return ( - - - {title} - - {!isLast && } - + if (href && _onClick) { + throw new Error( + 'Only one of `href` and `onClick` should be provided for the help menu link.' ); - })} - - ); - } + } - private renderGlobalCustomContent() { - const { navigateToUrl } = this.props; - const { globalHelpExtensionMenuLinks } = this.state; + const hrefProps = href ? { href, target: '_blank' } : {}; + const onClick = () => { + if (!_onClick) return; + _onClick(); + closeMenu(); + }; + + return ( + + + {title} + + {!isLast && } + + ); + })} + + ); - return globalHelpExtensionMenuLinks - .sort((a, b) => b.priority - a.priority) - .map((link, index) => { - const { linkType, content: text, href, external, ...rest } = link; - return createCustomLink(index, text, true, { - href, - onClick: external ? undefined : this.createOnClickHandler(href, navigateToUrl), - ...rest, - }); + const globalCustomContent = globalHelpExtensionMenuLinks + .sort((a, b) => b.priority - a.priority) + .map((link, index) => { + const { linkType, content: text, href, external, ...rest } = link; + return createCustomLink(index, text, true, { + href, + onClick: external ? undefined : createOnClickHandler(href), + ...rest, }); - } + }); - private renderCustomContent() { - const { helpExtension } = this.state; - if (!helpExtension) { - return null; - } - const { navigateToUrl } = this.props; + let customContent: React.ReactNode = null; + if (helpExtension) { const { appName, links, content } = helpExtension; const customLinks = @@ -293,7 +225,7 @@ class HelpMenu extends Component { const { linkType, content: text, href, external, ...rest } = link; return createCustomLink(index, text, addSpacer, { href, - onClick: this.createOnClickHandler(href, navigateToUrl), + onClick: createOnClickHandler(href), ...rest, }); } @@ -302,7 +234,7 @@ class HelpMenu extends Component { } }); - return ( + customContent = ( <>

{appName}

@@ -311,52 +243,72 @@ class HelpMenu extends Component { {content && ( <> {customLinks && } - content(domNode, { hideHelpMenu: this.closeMenu })} - /> + )} ); } - private onMenuButtonClick = () => { - this.setState({ - isOpen: !this.state.isOpen, - }); - }; - - private closeMenu = () => { - this.setState({ - isOpen: false, - }); - }; - - private createOnClickHandler(href: string, navigate: Props['navigateToUrl']) { - return (event: React.MouseEvent) => { - if (!isModifiedOrPrevented(event) && event.button === 0) { - event.preventDefault(); - this.closeMenu(); - navigate(href); - } - }; - } -} + const button = ( + + + + ); -const createCustomLink = ( - index: number, - text: React.ReactNode, - addSpacer?: boolean, - buttonProps?: EuiButtonEmptyProps -) => { return ( - - - {text} - - {addSpacer && } - + + + + +

+ +

+
+ {!isServerless && ( + + + + )} +
+
+ +
+ {globalCustomContent} + {defaultContent} + {customContent && ( + <> + + {customContent} + + )} +
+
); }; - -export const HeaderHelpMenu = withEuiTheme(HelpMenu); diff --git a/src/core/packages/chrome/browser-internal/src/ui/loading_indicator.tsx b/src/core/packages/chrome/browser-internal/src/ui/loading_indicator.tsx index 28b91a5714a03..01d485367b0cf 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/loading_indicator.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/loading_indicator.tsx @@ -10,11 +10,12 @@ import { Global, css } from '@emotion/react'; import { EuiLoadingSpinner, EuiProgress, EuiIcon, EuiImage } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import classNames from 'classnames'; -import type { Subscription } from 'rxjs'; import type { HttpStart } from '@kbn/core-http-browser'; +const DEBOUNCE_DELAY_MS = 250; + export interface LoadingIndicatorProps { loadingCount$: ReturnType; showAsBar?: boolean; @@ -23,114 +24,100 @@ export interface LoadingIndicatorProps { valueAmount?: string | number; } -export class LoadingIndicator extends React.Component { - public static defaultProps = { showAsBar: false }; - - private loadingCountSubscription?: Subscription; - - state = { - visible: false, - }; +export const LoadingIndicator = ({ + loadingCount$, + showAsBar = false, + customLogo, + maxAmount, + valueAmount, +}: LoadingIndicatorProps) => { + const [visible, setVisible] = useState(false); + const timerRef = useRef>(); - private timer: any; - private increment = 1; - - componentDidMount() { - this.loadingCountSubscription = this.props.loadingCount$.subscribe((count) => { - if (this.increment > 1) { - clearTimeout(this.timer); - } - this.increment += this.increment; - this.timer = setTimeout(() => { - this.setState({ - visible: count > 0, - }); - }, 250); + useEffect(() => { + const subscription = loadingCount$.subscribe((count) => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setVisible(count > 0); + }, DEBOUNCE_DELAY_MS); }); - } - componentWillUnmount() { - if (this.loadingCountSubscription) { - clearTimeout(this.timer); - this.loadingCountSubscription.unsubscribe(); - this.loadingCountSubscription = undefined; - } - } + return () => { + clearTimeout(timerRef.current); + subscription.unsubscribe(); + }; + }, [loadingCount$]); - render() { - const className = classNames(!this.state.visible && 'kbnLoadingIndicator-hidden'); - const indicatorHiddenCss = !this.state.visible - ? css({ - visibility: 'hidden', - animationPlayState: 'paused', - }) - : undefined; + const className = classNames(!visible && 'kbnLoadingIndicator-hidden'); + const indicatorHiddenCss = !visible + ? css({ + visibility: 'hidden', + animationPlayState: 'paused', + }) + : undefined; - const testSubj = this.state.visible - ? 'globalLoadingIndicator' - : 'globalLoadingIndicator-hidden'; + const testSubj = visible ? 'globalLoadingIndicator' : 'globalLoadingIndicator-hidden'; - const ariaLabel = i18n.translate('core.ui.loadingIndicatorAriaLabel', { - defaultMessage: 'Loading content', - }); + const ariaLabel = i18n.translate('core.ui.loadingIndicatorAriaLabel', { + defaultMessage: 'Loading content', + }); - const logoImage = this.props.customLogo ? ( - - ) : ( - - ); + const logoImage = customLogo ? ( + + ) : ( + + ); - const logo = this.state.visible ? ( - - ) : ( - logoImage - ); + const logo = visible ? ( + + ) : ( + logoImage + ); - return ( - <> - + + {!showAsBar ? ( + logo + ) : ( + - {!this.props.showAsBar ? ( - logo - ) : ( - - )} - - ); - } -} + )} + + ); +}; diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/app_menu.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/app_menu.tsx index 24a59db12d7db..66294cc8766eb 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/app_menu.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/app_menu.tsx @@ -8,15 +8,15 @@ */ import type { Observable } from 'rxjs'; -import { map, EMPTY } from 'rxjs'; +import { EMPTY } from 'rxjs'; import { useEuiTheme, type UseEuiTheme } from '@elastic/eui'; import type { MountPoint } from '@kbn/core-mount-utils-browser'; import React, { useMemo } from 'react'; -import useObservable from 'react-use/lib/useObservable'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { HeaderAppMenu } from '../header/header_app_menu'; import { HeaderActionMenu, useHeaderActionMenuMounter } from '../header/header_action_menu'; +import { useHasAppMenuConfig } from '../use_has_app_menu_config'; interface AppMenuBarProps { // TODO: get rid of observable @@ -47,13 +47,7 @@ export const AppMenuBar = ({ appMenuActions$, appMenu$ }: AppMenuBarProps) => { const styles = useAppMenuBarStyles(euiTheme); - const hasBeta$ = useMemo( - () => - appMenu$?.pipe(map((config) => !!config && !!config.items && config.items.length > 0)) ?? - EMPTY, - [appMenu$] - ); - const hasBetaConfig = useObservable(hasBeta$, false); + const hasBetaConfig = useHasAppMenuConfig(appMenu$); if (!headerActionMenuMounter.mount && !hasBetaConfig) return null; diff --git a/src/core/packages/chrome/browser-internal/src/ui/project/breadcrumbs.tsx b/src/core/packages/chrome/browser-internal/src/ui/project/breadcrumbs.tsx index 4fe527e91d311..e9111eba0d415 100644 --- a/src/core/packages/chrome/browser-internal/src/ui/project/breadcrumbs.tsx +++ b/src/core/packages/chrome/browser-internal/src/ui/project/breadcrumbs.tsx @@ -8,12 +8,12 @@ */ import { EuiBreadcrumbs } from '@elastic/eui'; -import classNames from 'classnames'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; +import { prepareBreadcrumbs } from '../breadcrumb_utils'; interface Props { breadcrumbs$: Observable; @@ -21,29 +21,7 @@ interface Props { export function Breadcrumbs({ breadcrumbs$ }: Props) { const breadcrumbs = useObservable(breadcrumbs$, []); - let crumbs = breadcrumbs; - - if (breadcrumbs.length === 0) { - crumbs = [{ text: 'Kibana' }]; - } - - crumbs = crumbs.map((breadcrumb, i) => { - const isLast = i === breadcrumbs.length - 1; - const { deepLinkId, ...rest } = breadcrumb; - - return { - ...rest, - href: isLast ? undefined : breadcrumb.href, - onClick: isLast ? undefined : breadcrumb.onClick, - 'data-test-subj': classNames( - 'breadcrumb', - deepLinkId && `breadcrumb-deepLinkId-${deepLinkId}`, - breadcrumb['data-test-subj'], - i === 0 && 'first', - isLast && 'last' - ), - }; - }); + const crumbs = prepareBreadcrumbs(breadcrumbs); return ( | undefined +): boolean { + const hasConfig$ = useMemo( + () => + appMenu$?.pipe(map((config) => !!config && !!config.items && config.items.length > 0)) ?? + EMPTY, + [appMenu$] + ); + return useObservable(hasConfig$, false); +} diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx index cd2f8f6e9df7c..2de4e749ec493 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx @@ -116,6 +116,13 @@ const projectModeBackgroundStyles = (euiThemeContext: UseEuiTheme) => { // https://github.com/elastic/eui/issues/8820 const globalTempHackStyles = (_euiTheme: UseEuiTheme['euiTheme'], chromeStyle: ChromeStyle) => css` .kbnBody { + // adjust position of the classic side-navigation + .euiFlyout.euiCollapsibleNav { + ${logicalCSS('top', layoutVar('application.top', '0px'))}; + ${logicalCSS('left', layoutVar('application.left', '0px'))}; + ${logicalCSS('bottom', layoutVar('application.bottom', '0px'))}; + } + // overlay mask "belowHeader" should only cover the application area .euiOverlayMask[data-relative-to-header='below'] { ${logicalCSS('top', layoutVar('application.top', '0px'))}; diff --git a/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_service.ts b/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_service.ts index 08c455936281f..41838bf96659d 100644 --- a/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_service.ts +++ b/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_service.ts @@ -67,6 +67,10 @@ export class SidebarService { }; } + stop() { + this.state.stop(); + } + @bind @memoize private getApp( diff --git a/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_state_service.ts b/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_state_service.ts index 17881d16d65ff..97fd01e3a889f 100644 --- a/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_state_service.ts +++ b/src/core/packages/chrome/sidebar/sidebar-internal/src/services/sidebar_state_service.ts @@ -121,4 +121,8 @@ export class SidebarStateService { getCurrentAppId(): SidebarAppId | null { return this.currentAppId$.getValue(); } + + stop() { + window.removeEventListener('resize', this.handleWindowResize); + } } diff --git a/src/core/packages/elasticsearch/server-internal/README.md b/src/core/packages/elasticsearch/server-internal/README.md index 38df445ccf465..ab7611e0a3a60 100644 --- a/src/core/packages/elasticsearch/server-internal/README.md +++ b/src/core/packages/elasticsearch/server-internal/README.md @@ -1,3 +1,27 @@ # @kbn/core-elasticsearch-server-internal This package contains the internal types and implementation for Core's server-side elasticsearch service. + +## ElasticsearchService + +The `ElasticsearchService` is one of the Core services (instantiated in `@src/core/packages/root/server-internal/src/server.ts`) that provides connectivity to Elasticsearch for Kibana. + +### Purpose +It manages the lifecycle of Elasticsearch clients, ensures the connection is healthy and compatible, and exposes APIs for plugins to interact with Elasticsearch. + +### Key Features +- **Client Management**: Creates and manages `ClusterClient` instances (e.g., `data` client), providing both internal-user and request-scoped access. +- **Connection Health**: Periodically polls Elasticsearch nodes to verify version compatibility (`esNodesCompatibility$`) and calculates the overall service status (`status$`). +- **Preboot & Setup**: + - Loads configuration (`elasticsearch.hosts`, `username`/`password` or `serviceAccountToken`, etc.). + - Initializes the `AgentManager` for HTTP agent reuse. + - Registers analytics context providers. +- **Startup Checks**: + - Validates the connection to Elasticsearch. + - Verifies that inline scripting is enabled on the cluster. + - Fetches and exposes cluster capabilities. +- **Cross-Project Search (CPS) Handling**: + - Configures the `CpsRequestHandler` based on the `cps.cpsEnabled` configuration flag (read from `coreContext.configService`). + - **Behavior based on `cpsEnabled`**: + - **Enabled (`true`)**: The `CpsRequestHandler` injects `project_routing: '_alias:_origin'` into requests if `project_routing` is missing (unless it's a PIT request, where it is stripped). + - **Disabled (`false`)**: The `CpsRequestHandler` strictly strips any `project_routing` parameter from request bodies to prevent unintended cross-project queries. diff --git a/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.test.ts b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.test.ts new file mode 100644 index 0000000000000..506b3bf2d3591 --- /dev/null +++ b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { TransportRequestParams } from '@elastic/elasticsearch'; +import { CpsRequestHandler } from './cps_request_handler'; + +const LOCAL_PROJECT_ROUTING = '_alias:_origin'; + +describe('CpsRequestHandler', () => { + describe('when CPS is enabled', () => { + const onRequest = new CpsRequestHandler(true).onRequest; + + it('injects default project_routing into body', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + onRequest({ scoped: false }, params, {}); + }); + + it('does not inject when API does not support project_routing', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_cat/indices', + meta: { name: 'cat/indices', acceptedParams: [] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect(params.body).toBeUndefined(); + }); + + it('does not override project_routing already present in body', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + body: { project_routing: 'custom-value' }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBe('custom-value'); + }); + + it('does not inject project_routing for PIT-based searches', () => { + const params: TransportRequestParams = { + method: 'POST', + path: '/_search', + body: { pit: { id: 'abc123' } }, + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + }); + + it('strips project_routing from body for PIT-based searches', () => { + const params: TransportRequestParams = { + method: 'POST', + path: '/_search', + body: { pit: { id: 'abc123' }, project_routing: 'should-be-removed' }, + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + expect((params.body as Record)?.pit).toEqual({ id: 'abc123' }); + }); + + it('preserves existing body fields when injecting', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + body: { query: { match_all: {} } }, + }; + + onRequest({ scoped: true }, params, {}); + + expect(params.body).toEqual({ + query: { match_all: {} }, + project_routing: LOCAL_PROJECT_ROUTING, + }); + }); + }); + + describe('when CPS is disabled', () => { + const onRequest = new CpsRequestHandler(false).onRequest; + + it('does not inject project_routing', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + }; + + onRequest({ scoped: true }, params, {}); + + expect(params.body).toBeUndefined(); + }); + + it('strips project_routing from body', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_search', + meta: { name: 'search', acceptedParams: ['project_routing'] }, + body: { query: { match_all: {} }, project_routing: 'should-be-removed' }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + expect((params.body as Record)?.query).toEqual({ match_all: {} }); + }); + + it('strips project_routing even when API does not support it', () => { + const params: TransportRequestParams = { + method: 'GET', + path: '/_bulk', + meta: { name: 'bulk', acceptedParams: [] }, + body: { project_routing: 'should-be-stripped' }, + }; + + onRequest({ scoped: true }, params, {}); + + expect((params.body as Record)?.project_routing).toBeUndefined(); + }); + }); +}); diff --git a/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.ts b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.ts new file mode 100644 index 0000000000000..1fa3d7435596a --- /dev/null +++ b/src/core/packages/elasticsearch/server-internal/src/cps_request_handler.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { set } from '@kbn/safer-lodash-set'; +import { isPlainObject } from 'lodash'; +import type { OnRequestHandler } from '@kbn/core-elasticsearch-client-server-internal'; + +const LOCAL_PROJECT_ROUTING = '_alias:_origin'; + +/** @internal */ +export class CpsRequestHandler { + constructor(private readonly cpsEnabled: boolean) {} + + public readonly onRequest: OnRequestHandler = (_ctx, params, _options) => { + const body = isPlainObject(params.body) ? (params.body as Record) : undefined; + + if (this.cpsEnabled) { + if (this.shouldApplyProjectRouting(params.meta?.acceptedParams)) + if (body?.pit) { + // The project_routing is set by the openPit API, and thus part of the PIT context. + this.stripProjectRouting(body); + } else { + this.injectProjectRouting(params, body); + } + } else { + this.stripProjectRouting(body); + } + }; + + private stripProjectRouting(body: Record | undefined): void { + if (body?.project_routing != null) { + delete body.project_routing; + } + } + + private injectProjectRouting( + params: Parameters[1], + body: Record | undefined + ): void { + if (!body?.project_routing) { + set(params, 'body.project_routing', LOCAL_PROJECT_ROUTING); + } + } + + private shouldApplyProjectRouting(acceptedParams: string[] | undefined): boolean { + return Boolean(acceptedParams?.includes('project_routing')); + } +} diff --git a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts index d7393a53bdf38..7ba95e3117e2f 100644 --- a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts +++ b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.test.ts @@ -26,7 +26,7 @@ import { } from './elasticsearch_service.test.mocks'; import type { NodesVersionCompatibility } from './version_check/ensure_es_version'; -import { BehaviorSubject, firstValueFrom, of } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, of, throwError } from 'rxjs'; import { first, concatMap } from 'rxjs'; import { REPO_ROOT } from '@kbn/repo-info'; import { Env } from '@kbn/config'; @@ -89,7 +89,10 @@ beforeEach(() => { verificationMode: 'none', }, }); - configService.atPath.mockReturnValue(mockConfig$); + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + return new BehaviorSubject({}); + }); const logger = loggingSystemMock.create(); coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; @@ -519,167 +522,83 @@ describe('#stop', () => { }); describe('CPS onRequest handler', () => { - describe('in non-serverless mode', () => { - it('does not pass onRequest handler to ClusterClient', async () => { - await elasticsearchService.setup(setupDeps); + it('passes onRequest to ClusterClient in non-serverless mode', async () => { + await elasticsearchService.setup(setupDeps); - expect(MockClusterClient).toHaveBeenCalledWith( - expect.objectContaining({ - onRequest: undefined, - }) - ); - }); + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); }); - describe('in serverless mode', () => { - let serverlessEnv: Env; - let serverlessCoreContext: CoreContext; - let serverlessElasticsearchService: ElasticsearchService; - - beforeEach(() => { - serverlessEnv = Env.createDefault( - REPO_ROOT, - getEnvOptions({ cliArgs: { serverless: true } }) - ); - const logger = loggingSystemMock.create(); - serverlessCoreContext = { - coreId: Symbol(), - env: serverlessEnv, - logger, - configService: configService as any, - }; - serverlessElasticsearchService = new ElasticsearchService(serverlessCoreContext); + it('passes onRequest to ClusterClient in serverless mode when CPS is enabled', async () => { + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + if (path === 'cps') return new BehaviorSubject({ cpsEnabled: true }); + return new BehaviorSubject({}); }); - - afterEach(async () => { - await serverlessElasticsearchService?.stop(); + const serverlessEnv = Env.createDefault( + REPO_ROOT, + getEnvOptions({ cliArgs: { serverless: true } }) + ); + const serverlessService = new ElasticsearchService({ + coreId: Symbol(), + env: serverlessEnv, + logger: loggingSystemMock.create(), + configService: configService as any, }); + await serverlessService.setup(setupDeps); - describe('onRequest handler behavior', () => { - type OnRequestHandler = (ctx: { scoped: boolean }, params: any, options?: any) => void; - let onRequestHandler: OnRequestHandler; - const LOCAL_PROJECT_ROUTING = '_alias:_origin'; - - const setCpsEnabled = (enabled: boolean) => { - // Access private property for testing - (serverlessElasticsearchService as any).cpsEnabled = enabled; - }; - - beforeEach(async () => { - MockClusterClient.mockClear(); - await serverlessElasticsearchService.setup(setupDeps); - onRequestHandler = MockClusterClient.mock.calls[0][0].onRequest; - }); - - it('injects project_routing for unscoped requests', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: false }, params, options); - - expect((params as any).body?.project_routing).toBe(LOCAL_PROJECT_ROUTING); - }); - - it('does not inject project_routing when CPS is disabled', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - }; - - onRequestHandler({ scoped: true }, params as any, options); - - expect((params as any).body?.project_routing).toBeUndefined(); - }); - - it('does not inject project_routing when API does not support it', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_cat/indices', - meta: { acceptedParams: [] }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect((params as any).body?.project_routing).toBeUndefined(); - }); - - it('does not inject project_routing when it is already set', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - body: { project_routing: 'custom-value' }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect((params as any).body?.project_routing).toBe('custom-value'); - }); - - it('does not inject project_routing for PIT requests', () => { - const options: any = {}; - const params = { - method: 'POST', - path: '/_search', - body: { pit: { id: 'abc123' } }, - meta: { acceptedParams: ['project_routing'] }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect((params as any).body?.project_routing).toBeUndefined(); - }); - - it('injects project_routing when all conditions are met', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - body: { project_routing: LOCAL_PROJECT_ROUTING }, - }; - - setCpsEnabled(true); - - onRequestHandler({ scoped: true }, params, options); - - expect(params.body.project_routing).toBe(LOCAL_PROJECT_ROUTING); - }); - - it('preserves existing param body when injecting project_routing', () => { - const options: any = {}; - const params = { - method: 'GET', - path: '/_search', - meta: { acceptedParams: ['project_routing'] }, - body: { field1: 'value1', project_routing: LOCAL_PROJECT_ROUTING }, - }; + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); + await serverlessService.stop(); + }); - setCpsEnabled(true); + it('passes onRequest to ClusterClient in serverless mode when CPS is disabled', async () => { + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + if (path === 'cps') return new BehaviorSubject({ cpsEnabled: false }); + return new BehaviorSubject({}); + }); + const serverlessEnv = Env.createDefault( + REPO_ROOT, + getEnvOptions({ cliArgs: { serverless: true } }) + ); + const serverlessService = new ElasticsearchService({ + coreId: Symbol(), + env: serverlessEnv, + logger: loggingSystemMock.create(), + configService: configService as any, + }); + await serverlessService.setup(setupDeps); - onRequestHandler({ scoped: true }, params, options); + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); + await serverlessService.stop(); + }); - expect(params.body).toEqual({ - field1: 'value1', - project_routing: LOCAL_PROJECT_ROUTING, - }); - }); + it('treats cpsEnabled as false when atPath("cps") observable errors', async () => { + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') return mockConfig$; + if (path === 'cps') return throwError(() => new Error('cps config unavailable')); + return new BehaviorSubject({}); }); + const serverlessEnv = Env.createDefault( + REPO_ROOT, + getEnvOptions({ cliArgs: { serverless: true } }) + ); + const serverlessService = new ElasticsearchService({ + coreId: Symbol(), + env: serverlessEnv, + logger: loggingSystemMock.create(), + configService: configService as any, + }); + await serverlessService.setup(setupDeps); + + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining({ onRequest: expect.any(Function) }) + ); + await serverlessService.stop(); }); }); diff --git a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts index 5ec3cebdd3623..3a013e35cef53 100644 --- a/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts +++ b/src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts @@ -8,7 +8,6 @@ */ import type { Observable } from 'rxjs'; -import { set } from '@kbn/safer-lodash-set'; import { map, takeUntil, firstValueFrom, Subject } from 'rxjs'; import type { Logger } from '@kbn/logging'; @@ -25,13 +24,8 @@ import type { ElasticsearchClientConfig, ElasticsearchCapabilities, } from '@kbn/core-elasticsearch-server'; -import { - ClusterClient, - AgentManager, - type OnRequestHandler, -} from '@kbn/core-elasticsearch-client-server-internal'; +import { ClusterClient, AgentManager } from '@kbn/core-elasticsearch-client-server-internal'; -import { isPlainObject } from 'lodash'; import type { InternalSecurityServiceSetup } from '@kbn/core-security-server-internal'; import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; import type { ElasticsearchConfigType } from './elasticsearch_config'; @@ -49,6 +43,7 @@ import { isInlineScriptingEnabled } from './is_scripting_enabled'; import { mergeConfig } from './merge_config'; import { type ClusterInfo, getClusterInfo$ } from './get_cluster_info'; import { getElasticsearchCapabilities } from './get_capabilities'; +import { CpsRequestHandler } from './cps_request_handler'; export interface SetupDeps { analytics: AnalyticsServiceSetup; @@ -64,6 +59,7 @@ export class ElasticsearchService private readonly log: Logger; private readonly config$: Observable; private readonly isServerless: boolean; + private cpsRequestHandler!: CpsRequestHandler; private stop$ = new Subject(); private kibanaVersion: string; private authHeaders?: IAuthHeadersStorage; @@ -73,7 +69,6 @@ export class ElasticsearchService private clusterInfo$?: Observable; private unauthorizedErrorHandler?: UnauthorizedErrorHandler; private agentManager?: AgentManager; - private cpsEnabled = false; private security?: InternalSecurityServiceSetup; constructor(private readonly coreContext: CoreContext) { @@ -106,6 +101,16 @@ export class ElasticsearchService const config = await firstValueFrom(this.config$); + // TODO we should find a better method to determine whether the underlying ES is CPS-capable. + const cpsEnabled = this.isServerless + ? ( + await firstValueFrom( + this.coreContext.configService.atPath<{ cpsEnabled?: boolean }>('cps') + ).catch(() => ({ cpsEnabled: false })) + ).cpsEnabled ?? false + : false; + this.cpsRequestHandler = new CpsRequestHandler(cpsEnabled); + const agentManager = this.getAgentManager(config); this.authHeaders = deps.http.authRequestHeaders; @@ -153,10 +158,6 @@ export class ElasticsearchService getAgentsStats: agentManager.getAgentsStats.bind(agentManager), }, publicBaseUrl: config.publicBaseUrl, - setCpsFeatureFlag: (enabled) => { - this.cpsEnabled = enabled; - this.log.info(`CPS feature flag set to ${enabled}`); - }, }; } @@ -251,7 +252,7 @@ export class ElasticsearchService getUnauthorizedErrorHandler: () => this.unauthorizedErrorHandler, agentFactoryProvider: this.getAgentManager(baseConfig), kibanaVersion: this.kibanaVersion, - onRequest: this.getOnRequestHandler(), + onRequest: this.cpsRequestHandler?.onRequest, }); } @@ -263,25 +264,4 @@ export class ElasticsearchService } return this.agentManager; } - - private getOnRequestHandler(): OnRequestHandler | undefined { - if (!this.isServerless) return undefined; - - return (ctx, params, options) => { - // Note: this.cpsEnabled may be set at a later point in time - if (!this.cpsEnabled) return; - const body = params.body; - if ( - isPlainObject(body) && - ((body as Record).project_routing != null || - (body as Record).pit != null) - ) - return; - - const acceptedParams = params.meta?.acceptedParams; - const apiSupportsProjectRouting = acceptedParams?.includes('project_routing') ?? false; - if (!apiSupportsProjectRouting) return; - set(params, 'body.project_routing', '_alias:_origin'); - }; - } } diff --git a/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts b/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts index b8fae0b88c663..b1256939f7f9b 100644 --- a/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts +++ b/src/core/packages/elasticsearch/server-mocks/src/elasticsearch_service.mock.ts @@ -65,7 +65,6 @@ const createPrebootContractMock = () => { const createSetupContractMock = () => { const setupContract: MockedElasticSearchServiceSetup = lazyObject({ setUnauthorizedErrorHandler: jest.fn(), - setCpsFeatureFlag: jest.fn(), legacy: { config$: new BehaviorSubject({} as ElasticsearchConfig), }, diff --git a/src/core/packages/elasticsearch/server/src/contracts.ts b/src/core/packages/elasticsearch/server/src/contracts.ts index a3a26ef740656..60f31d315964a 100644 --- a/src/core/packages/elasticsearch/server/src/contracts.ts +++ b/src/core/packages/elasticsearch/server/src/contracts.ts @@ -97,17 +97,6 @@ export interface ElasticsearchServiceSetup { */ readonly publicBaseUrl?: string; - - /** - * Sets the CPS feature flag in the Elasticsearch service. - * This should only be called from the CPS plugin. - * - * @example - * ```ts - * core.elasticsearch.setCpsFeatureFlag(true); - * ``` - */ - setCpsFeatureFlag: (enabled: boolean) => void; } /** diff --git a/src/core/packages/execution-context/browser-internal/README.md b/src/core/packages/execution-context/browser-internal/README.md index 9b90469b5581a..0c9ba4d32706f 100644 --- a/src/core/packages/execution-context/browser-internal/README.md +++ b/src/core/packages/execution-context/browser-internal/README.md @@ -1,3 +1,9 @@ # @kbn/core-execution-context-browser-internal This package contains the internal types and implementation for Core's browser-side execution context service. + +## Pointers + +- `x-kbn-context` is attached to `core.http.fetch(...)` requests in `src/core/packages/http/browser-internal/src/fetch.ts`. +- The header value is URI-encoded JSON produced by `src/core/packages/execution-context/browser-internal/src/execution_context_container.ts`. +- `HttpFetchOptions.context` is merged with the current global context via `executionContext.withGlobalContext(...)` before serialization. diff --git a/src/core/packages/execution-context/browser/README.md b/src/core/packages/execution-context/browser/README.md index cb58171bb89cf..54da7782344f7 100644 --- a/src/core/packages/execution-context/browser/README.md +++ b/src/core/packages/execution-context/browser/README.md @@ -1,3 +1,67 @@ # @kbn/core-execution-context-browser -This package contains the public types for Core's browser-side execution context service. +Browser-side execution context helps Kibana associate **HTTP requests** with a meaningful “where did this come from?” context (app/page/entity). Core includes this context in the `x-kbn-context` header on `core.http.fetch(...)` requests so the server can propagate it (for example into Elasticsearch `x-opaque-id` for slow log tracing). + +This package contains the **public contract types** for `core.executionContext` in the browser. The common data shape is `KibanaExecutionContext` from [`@kbn/core-execution-context-common`](../common). + +## How to set it (recommended) + +Use the `useExecutionContext` hook from `@kbn/kibana-react-plugin/public` in your React components. + +```ts +import type { KibanaExecutionContext } from '@kbn/core-execution-context-common'; +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; + +const ctx: KibanaExecutionContext = { + type: 'application', + name: 'discover', + page: 'sessionView', + id: discoverSessionId, +}; + +useExecutionContext(core.executionContext, ctx); +``` + +Notes: +- `page` should be a **stable logical unit** (don’t embed ids in `page`; use `id` for identifiers). +- The hook clears on unmount; Core also clears the context when the current app changes. + +## Nested context with `child` (embeddables/components) + +If you already have a parent context (e.g. from an app), add a child context for a nested component: + +```ts +import type { KibanaExecutionContext } from '@kbn/core-execution-context-common'; + +const parent: KibanaExecutionContext = { + type: 'application', + name: 'dashboard', + page: 'view', + id: dashboardId, +}; + +const ctx: KibanaExecutionContext = { + ...parent, + child: { type: 'visualization', name: embeddableType, id: embeddableId }, +}; +``` + +This pattern is used in e.g. ML embeddables: [`use_embeddable_execution_context.ts`](../../../../../x-pack/platform/plugins/shared/ml/public/embeddables/common/use_embeddable_execution_context.ts). + +## Per-request context (for a single `http.fetch`) + +You can attach a per-request context that will be merged with the current global context: + +```ts +await core.http.fetch('/api/my_plugin/do_something', { + context: { type: 'myPlugin', name: 'doSomething', id: entityId }, +}); +``` + +## Space id (`space`) + +`KibanaExecutionContext.space` is the active space id. In the browser, the Spaces plugin keeps it in sync by calling `core.executionContext.set({ space })`, so most plugins shouldn’t need to set it themselves. + +## Keep it small + +The `x-kbn-context` header is URI-encoded JSON and is size-limited. Keep context values small and avoid sensitive data (especially in `description` and `meta`). diff --git a/src/core/packages/execution-context/common/README.md b/src/core/packages/execution-context/common/README.md index 7d48a29358eda..9c51838168dd1 100644 --- a/src/core/packages/execution-context/common/README.md +++ b/src/core/packages/execution-context/common/README.md @@ -1,3 +1,17 @@ # @kbn/core-execution-context-common This package contains the common types for Core's execution context. + +## `KibanaExecutionContext` (field reference) + +`KibanaExecutionContext` is a small object describing where work originates. It is used by the browser to populate the `x-kbn-context` header, and by the server to enrich Elasticsearch `x-opaque-id`. + +- **`type`**: high-level category (e.g. `application`, `dashboard`, `visualization`, `task`). +- **`name`**: public name of an app/feature/subsystem (e.g. `discover`, `lens`, `taskManager`). +- **`space`**: current space id (when applicable). +- **`page`**: stable logical unit like a page/tab/route segment (avoid embedding ids; put those in `id`). +- **`id`**: identifier for the current entity (dashboard id, rule id, saved object id, etc.). +- **`description`**: human-readable description (avoid large values and sensitive data). +- **`url`**: browser URL or server endpoint/task URL (avoid unique identifiers if not needed). +- **`meta`**: optional extra structured details (keep small; avoid sensitive data). +- **`child`**: nested context spawned from the current one (embeddables/sub-operations); can be chained. diff --git a/src/core/packages/execution-context/server-internal/README.md b/src/core/packages/execution-context/server-internal/README.md index 409492850893e..035264ec54e7b 100644 --- a/src/core/packages/execution-context/server-internal/README.md +++ b/src/core/packages/execution-context/server-internal/README.md @@ -2,3 +2,9 @@ This package contains the internal types and implementation for Core's server-side execution context service. +## Pointers + +- Core HTTP installs request execution context from the `x-kbn-context` header in `src/core/packages/http/server-internal/src/http_server.ts`. +- `AsyncLocalStorage` storage and the `withContext(...)` implementation live in `src/core/packages/execution-context/server-internal/src/execution_context_service.ts`. +- `x-kbn-context` parsing and the compact context string used for Elasticsearch `x-opaque-id` live in `src/core/packages/execution-context/server-internal/src/execution_context_container.ts`. +- Config: `execution_context.enabled` in `src/core/packages/execution-context/server-internal/src/execution_context_config.ts`. diff --git a/src/core/packages/execution-context/server/README.md b/src/core/packages/execution-context/server/README.md index 017676c5d10c3..94005f8d3b27a 100644 --- a/src/core/packages/execution-context/server/README.md +++ b/src/core/packages/execution-context/server/README.md @@ -1,4 +1,43 @@ # @kbn/core-execution-context-server -This package contains the public types for Core's server-side execution context service. +Server-side execution context helps Kibana correlate **server work** with a meaningful “origin” (app/page/entity/background task). Core uses it to enrich Elasticsearch `x-opaque-id`, which is useful for tracing expensive searches in Elasticsearch slow logs. +This package contains the **public contract types** for `core.executionContext` on the server. The common data shape is `KibanaExecutionContext` from [`@kbn/core-execution-context-common`](../common). + +## Request handling (typical case) + +For HTTP requests initiated from the browser, Core’s HTTP layer parses the incoming `x-kbn-context` header and installs it into request-scoped async context. In a normal route handler you generally **do not need to do anything**—Elasticsearch client calls will automatically include the derived `x-opaque-id`. + +For more context on how `x-opaque-id` is used to trace slow searches, see Elastic docs: [`Trace an Elasticsearch query in Kibana`](https://www.elastic.co/docs/troubleshoot/kibana/trace-elasticsearch-query-to-the-origin-in-kibana). + +## Background/server-only work (use `withContext`) + +For work that doesn’t originate from an incoming browser request (task manager, background sync, scheduled jobs, etc.), wrap your work with `core.executionContext.withContext(...)`: + +```ts +import type { KibanaExecutionContext } from '@kbn/core-execution-context-common'; + +const ctx: KibanaExecutionContext = { + type: 'task', + name: 'myPlugin', + id: taskId, + page: 'run', + description: 'sync something', +}; + +return core.executionContext.withContext(ctx, async () => { + // Any Elasticsearch calls made from here will include the derived x-opaque-id + await esClient.asInternalUser.ping(); +}); +``` + +## Debugging + +To see stored execution context in Kibana logs, enable debug logging for the `execution_context` logger: + +```yml +logging: + loggers: + - name: execution_context + level: debug +``` diff --git a/src/core/packages/plugins/server-internal/src/plugin.test.ts b/src/core/packages/plugins/server-internal/src/plugin.test.ts index 79f053b9a1f78..6b4c488783161 100644 --- a/src/core/packages/plugins/server-internal/src/plugin.test.ts +++ b/src/core/packages/plugins/server-internal/src/plugin.test.ts @@ -634,7 +634,7 @@ test('`stop` cleans up the plugin container', async () => { }); describe('#getConfigSchema()', () => { - it('reads config schema from plugin', () => { + it('reads config schema from plugin', async () => { const pluginSchema = schema.any(); const configDescriptor = { schema: pluginSchema, @@ -661,10 +661,10 @@ describe('#getConfigSchema()', () => { }), }); - expect(plugin.getConfigDescriptor()).toBe(configDescriptor); + expect(await plugin.getConfigDescriptor()).toBe(configDescriptor); }); - it('returns null if config definition not specified', () => { + it('returns null if config definition not specified', async () => { jest.doMock(join('plugin-with-no-definition', 'server'), () => ({}), { virtual: true }); const manifest = createPluginManifest(); const opaqueId = Symbol(); @@ -680,10 +680,10 @@ describe('#getConfigSchema()', () => { nodeInfo, }), }); - expect(plugin.getConfigDescriptor()).toBe(null); + expect(await plugin.getConfigDescriptor()).toBe(null); }); - it('returns null for plugins without a server part', () => { + it('returns null for plugins without a server part', async () => { const manifest = createPluginManifest({ server: false }); const opaqueId = Symbol(); const plugin = new PluginWrapper({ @@ -698,10 +698,10 @@ describe('#getConfigSchema()', () => { nodeInfo, }), }); - expect(plugin.getConfigDescriptor()).toBe(null); + expect(await plugin.getConfigDescriptor()).toBe(null); }); - it('throws if plugin contains invalid schema', () => { + it('throws if plugin contains invalid schema', async () => { jest.doMock( join('plugin-invalid-schema', 'server'), () => ({ @@ -727,7 +727,7 @@ describe('#getConfigSchema()', () => { nodeInfo, }), }); - expect(() => plugin.getConfigDescriptor()).toThrowErrorMatchingInlineSnapshot( + await expect(() => plugin.getConfigDescriptor()).rejects.toThrowErrorMatchingInlineSnapshot( `"Configuration schema expected to be an instance of Type"` ); }); diff --git a/src/core/packages/plugins/server-internal/src/plugin.ts b/src/core/packages/plugins/server-internal/src/plugin.ts index bd9e7f2525809..4c13ff6ab76d4 100644 --- a/src/core/packages/plugins/server-internal/src/plugin.ts +++ b/src/core/packages/plugins/server-internal/src/plugin.ts @@ -107,7 +107,7 @@ export class PluginWrapper< public async init() { this.log.debug('Initializing plugin'); - this.definition = this.getPluginDefinition(); + this.definition = await this.getPluginDefinition(); this.instance = await this.createPluginInstance(); if (!('plugin' in this.definition || 'module' in this.definition)) { @@ -196,11 +196,11 @@ export class PluginWrapper< this.container = undefined; } - public getConfigDescriptor(): PluginConfigDescriptor | null { + public async getConfigDescriptor(): Promise { if (!this.manifest.server) { return null; } - const definition = this.getPluginDefinition(); + const definition = await this.getPluginDefinition(); if (!definition.config) { this.log.debug(`Plugin "${this.name}" does not export "config" (${this.path}).`); return null; @@ -213,8 +213,10 @@ export class PluginWrapper< return config; } - protected getPluginDefinition(): PluginDefinition { - return require(join(this.path, 'server')) ?? {}; + protected async getPluginDefinition(): Promise< + PluginDefinition + > { + return (await import(join(this.path, 'server'))) ?? {}; } protected async createPluginInstance() { diff --git a/src/core/packages/plugins/server-internal/src/plugin_context.ts b/src/core/packages/plugins/server-internal/src/plugin_context.ts index 93ca5f0b75804..8aefb7d6dd3f2 100644 --- a/src/core/packages/plugins/server-internal/src/plugin_context.ts +++ b/src/core/packages/plugins/server-internal/src/plugin_context.ts @@ -221,7 +221,6 @@ export function createPluginSetupContext({ legacy: deps.elasticsearch.legacy, publicBaseUrl: deps.elasticsearch.publicBaseUrl, setUnauthorizedErrorHandler: deps.elasticsearch.setUnauthorizedErrorHandler, - setCpsFeatureFlag: deps.elasticsearch.setCpsFeatureFlag, }, executionContext: { withContext: deps.executionContext.withContext, diff --git a/src/core/packages/plugins/server-internal/src/plugins_service.ts b/src/core/packages/plugins/server-internal/src/plugins_service.ts index 7c1f9ef700c02..ec7e92ab2e894 100644 --- a/src/core/packages/plugins/server-internal/src/plugins_service.ts +++ b/src/core/packages/plugins/server-internal/src/plugins_service.ts @@ -271,33 +271,35 @@ export class PluginsService const plugins = await firstValueFrom(plugin$.pipe(toArray())); // Register config descriptors and deprecations - for (const plugin of plugins) { - const configDescriptor = plugin.getConfigDescriptor(); - if (configDescriptor) { - this.pluginConfigDescriptors.set(plugin.name, configDescriptor); - if (configDescriptor.deprecations) { - this.coreContext.configService.addDeprecationProvider( - plugin.configPath, - configDescriptor.deprecations - ); - } - if (configDescriptor.exposeToUsage) { - this.pluginConfigUsageDescriptors.set( - Array.isArray(plugin.configPath) ? plugin.configPath.join('.') : plugin.configPath, - getFlattenedObject(configDescriptor.exposeToUsage) - ); - } - if (configDescriptor.dynamicConfig) { - const configKeys = Object.entries(getFlattenedObject(configDescriptor.dynamicConfig)) - .filter(([, value]) => value === true) - .map(([key]) => key); - if (configKeys.length > 0) { - this.coreContext.configService.addDynamicConfigPaths(plugin.configPath, configKeys); + await Promise.all( + plugins.map(async (plugin) => { + const configDescriptor = await plugin.getConfigDescriptor(); + if (configDescriptor) { + this.pluginConfigDescriptors.set(plugin.name, configDescriptor); + if (configDescriptor.deprecations) { + this.coreContext.configService.addDeprecationProvider( + plugin.configPath, + configDescriptor.deprecations + ); } + if (configDescriptor.exposeToUsage) { + this.pluginConfigUsageDescriptors.set( + Array.isArray(plugin.configPath) ? plugin.configPath.join('.') : plugin.configPath, + getFlattenedObject(configDescriptor.exposeToUsage) + ); + } + if (configDescriptor.dynamicConfig) { + const configKeys = Object.entries(getFlattenedObject(configDescriptor.dynamicConfig)) + .filter(([, value]) => value === true) + .map(([key]) => key); + if (configKeys.length > 0) { + this.coreContext.configService.addDynamicConfigPaths(plugin.configPath, configKeys); + } + } + this.coreContext.configService.setSchema(plugin.configPath, configDescriptor.schema); } - this.coreContext.configService.setSchema(plugin.configPath, configDescriptor.schema); - } - } + }) + ); const config = await firstValueFrom(this.config$); const enableAllPlugins = config.shouldEnableAllPlugins; diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts index aa088e35fa74c..796bb754645af 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts +++ b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts @@ -141,6 +141,51 @@ function renderTestCases( expect(data.legacyMetadata.uiSettings.user).toEqual(userSettings); // user settings are injected }); + it('renders page with light color-scheme when dark mode is disabled', async () => { + getSettingValueMock.mockImplementation((settingName: string) => { + if (settingName === 'theme:darkMode') { + return false; + } + return settingName; + }); + + const [render] = await getRender(); + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + + expect(dom('meta[name="color-scheme"]').attr('content')).toBe('light'); + }); + + it('renders page with dark color-scheme when dark mode is enabled', async () => { + getSettingValueMock.mockImplementation((settingName: string) => { + if (settingName === 'theme:darkMode') { + return true; + } + return settingName; + }); + + const [render] = await getRender(); + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + + expect(dom('meta[name="color-scheme"]').attr('content')).toBe('dark'); + }); + + it('renders page with dual color-scheme when dark mode is set to system', async () => { + getSettingValueMock.mockImplementation((settingName: string) => { + if (settingName === 'theme:darkMode') { + return 'system'; + } + return settingName; + }); + + const [render] = await getRender(); + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + + expect(dom('meta[name="color-scheme"]').attr('content')).toBe('light dark'); + }); + it('renders "core" page with global settings', async () => { const userSettings = { 'foo:bar': { userValue: true } }; uiSettings.globalClient.getUserProvided.mockResolvedValue(userSettings); diff --git a/src/core/packages/rendering/server-internal/src/views/template.tsx b/src/core/packages/rendering/server-internal/src/views/template.tsx index 29e7ddcbdbde1..c0efb1997cd31 100644 --- a/src/core/packages/rendering/server-internal/src/views/template.tsx +++ b/src/core/packages/rendering/server-internal/src/views/template.tsx @@ -37,6 +37,7 @@ export const Template: FunctionComponent = ({ const title = customBranding.pageTitle ?? 'Elastic'; const favIcon = customBranding.faviconSVG ?? `${uiPublicUrl}/favicons/favicon.svg`; const favIconPng = customBranding.faviconPNG ?? `${uiPublicUrl}/favicons/favicon.png`; + const colorScheme = darkMode === 'system' ? 'light dark' : darkMode ? 'dark' : 'light'; const logo = customBranding.logo ? ( logo ) : ( @@ -54,7 +55,7 @@ export const Template: FunctionComponent = ({ - + {/* Inject EUI reset and global styles before all other component styles */} diff --git a/src/core/packages/security/server-internal/src/security_route_handler_context.ts b/src/core/packages/security/server-internal/src/security_route_handler_context.ts index 7eae4ad66034f..77a05c3b9db5e 100644 --- a/src/core/packages/security/server-internal/src/security_route_handler_context.ts +++ b/src/core/packages/security/server-internal/src/security_route_handler_context.ts @@ -43,6 +43,7 @@ export class CoreSecurityRouteHandlerContext implements SecurityRequestHandlerCo grant: (grantUiamApiKeyParams) => uiam.grant(this.request, grantUiamApiKeyParams), invalidate: (invalidateUiamApiKeyParams) => uiam.invalidate(this.request, invalidateUiamApiKeyParams), + convert: (convertUiamApiKeyParams) => uiam.convert(convertUiamApiKeyParams), } : null, }, diff --git a/src/core/packages/security/server-internal/src/utils/convert_security_api.test.ts b/src/core/packages/security/server-internal/src/utils/convert_security_api.test.ts index 39ba5d35a0c41..61008103e7287 100644 --- a/src/core/packages/security/server-internal/src/utils/convert_security_api.test.ts +++ b/src/core/packages/security/server-internal/src/utils/convert_security_api.test.ts @@ -29,6 +29,7 @@ describe('convertSecurityApi', () => { uiam: { grant: jest.fn(), invalidate: jest.fn(), + convert: jest.fn(), }, }, }, diff --git a/src/core/packages/security/server-mocks/src/api_keys.mock.ts b/src/core/packages/security/server-mocks/src/api_keys.mock.ts index 81a404e3d1f2d..6cbee3c22246d 100644 --- a/src/core/packages/security/server-mocks/src/api_keys.mock.ts +++ b/src/core/packages/security/server-mocks/src/api_keys.mock.ts @@ -24,6 +24,7 @@ export const apiKeysMock = { uiam: { grant: jest.fn(), invalidate: jest.fn(), + convert: jest.fn(), }, }), }; diff --git a/src/core/packages/security/server-mocks/src/security_service.mock.ts b/src/core/packages/security/server-mocks/src/security_service.mock.ts index 4f83be3ef2f1a..f42d08c51bb5c 100644 --- a/src/core/packages/security/server-mocks/src/security_service.mock.ts +++ b/src/core/packages/security/server-mocks/src/security_service.mock.ts @@ -100,6 +100,7 @@ const createRequestHandlerContextMock = () => { uiam: { grant: jest.fn(), invalidate: jest.fn(), + convert: jest.fn(), }, }), }), diff --git a/src/core/packages/security/server/src/authentication/api_keys/index.ts b/src/core/packages/security/server/src/authentication/api_keys/index.ts index d0f53a92da150..ae64a0725911d 100644 --- a/src/core/packages/security/server/src/authentication/api_keys/index.ts +++ b/src/core/packages/security/server/src/authentication/api_keys/index.ts @@ -36,6 +36,11 @@ export type { UiamAPIKeysWithContextType, GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams, + ConvertUiamAPIKeyParams, + ConvertUiamAPIKeyResult, + ConvertUiamAPIKeyResultSuccess, + ConvertUiamAPIKeyResultFailed, + ConvertUiamAPIKeysResponse, } from './uiam'; export interface APIKeysType extends NativeAPIKeysType { diff --git a/src/core/packages/security/server/src/authentication/api_keys/uiam/index.ts b/src/core/packages/security/server/src/authentication/api_keys/uiam/index.ts index abac865f26151..e9b1c9d198611 100644 --- a/src/core/packages/security/server/src/authentication/api_keys/uiam/index.ts +++ b/src/core/packages/security/server/src/authentication/api_keys/uiam/index.ts @@ -11,5 +11,10 @@ export type { UiamAPIKeysType, GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams, + ConvertUiamAPIKeyParams, + ConvertUiamAPIKeyResult, + ConvertUiamAPIKeyResultSuccess, + ConvertUiamAPIKeyResultFailed, + ConvertUiamAPIKeysResponse, } from './uiam_api_keys'; export type { UiamAPIKeysWithContextType } from './uiam_api_keys_context'; diff --git a/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys.ts b/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys.ts index f8c309c1a2fd7..7a6986aec6139 100644 --- a/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys.ts +++ b/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys.ts @@ -38,6 +38,14 @@ export interface UiamAPIKeysType { request: KibanaRequest, params: InvalidateUiamAPIKeyParams ): Promise; + + /** + * Converts Elasticsearch API keys into UIAM API keys. + * + * @param params The parameters containing the keys to convert. + * @returns A promise that resolves to a response containing per-key success/failure results, or null if the license is not enabled. + */ + convert(params: ConvertUiamAPIKeyParams): Promise; } /** @@ -64,3 +72,56 @@ export interface InvalidateUiamAPIKeyParams { */ id: string; } + +/** + * Parameters for converting Elasticsearch API keys into UIAM API keys. + */ +export interface ConvertUiamAPIKeyParams { + /** + * The Elasticsearch API keys to convert. + */ + keys: Array<{ + /** The base64-encoded Elasticsearch API key value. */ + key: string; + }>; +} + +/** + * A successful result from converting an Elasticsearch API key into a UIAM API key. + */ +export interface ConvertUiamAPIKeyResultSuccess { + status: 'success'; + id: string; + key: string; + description: string; + organization_id: string; + internal: boolean; + role_assignments: Record; + creation_date: string; + expiration_date: string | null; +} + +/** + * A failed result from converting an Elasticsearch API key into a UIAM API key. + */ +export interface ConvertUiamAPIKeyResultFailed { + status: 'failed'; + code: string; + message: string; + resource: string | null; + type: string; +} + +/** + * A single result entry from the convert API keys operation; either success or failure. + */ +export type ConvertUiamAPIKeyResult = + | ConvertUiamAPIKeyResultSuccess + | ConvertUiamAPIKeyResultFailed; + +/** + * Response from the UIAM convert API keys operation. + */ +export interface ConvertUiamAPIKeysResponse { + results: ConvertUiamAPIKeyResult[]; +} diff --git a/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys_context.ts b/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys_context.ts index 0bfb67037ad8b..d34ed153902a9 100644 --- a/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys_context.ts +++ b/src/core/packages/security/server/src/authentication/api_keys/uiam/uiam_api_keys_context.ts @@ -8,7 +8,12 @@ */ import type { GrantAPIKeyResult, InvalidateAPIKeyResult } from '../api_keys'; -import type { GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams } from './uiam_api_keys'; +import type { + ConvertUiamAPIKeyParams, + ConvertUiamAPIKeysResponse, + GrantUiamAPIKeyParams, + InvalidateUiamAPIKeyParams, +} from './uiam_api_keys'; /** * Public UIAM API Keys service exposed through core context to manage @@ -33,4 +38,12 @@ export interface UiamAPIKeysWithContextType { * @throws {Error} If the request does not contain an authorization header or if the credential is not a UIAM credential. */ invalidate(params: InvalidateUiamAPIKeyParams): Promise; + + /** + * Converts Elasticsearch API keys into UIAM API keys. + * + * @param params The parameters containing the keys to convert. + * @returns A promise that resolves to a response containing per-key success/failure results, or null if the license is not enabled. + */ + convert(params: ConvertUiamAPIKeyParams): Promise; } diff --git a/src/core/packages/security/server/src/authentication/index.ts b/src/core/packages/security/server/src/authentication/index.ts index de9e50c9d3813..35fcc7da68248 100644 --- a/src/core/packages/security/server/src/authentication/index.ts +++ b/src/core/packages/security/server/src/authentication/index.ts @@ -30,6 +30,11 @@ export type { UiamAPIKeysWithContextType, GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams, + ConvertUiamAPIKeyParams, + ConvertUiamAPIKeyResult, + ConvertUiamAPIKeyResultSuccess, + ConvertUiamAPIKeyResultFailed, + ConvertUiamAPIKeysResponse, } from './api_keys'; export { HTTPAuthorizationHeader } from './http_authentication'; diff --git a/src/core/packages/ui-settings/server-internal/src/settings/announcements.ts b/src/core/packages/ui-settings/server-internal/src/settings/announcements.ts index 9778f2242b1c7..2b6efc8df7600 100644 --- a/src/core/packages/ui-settings/server-internal/src/settings/announcements.ts +++ b/src/core/packages/ui-settings/server-internal/src/settings/announcements.ts @@ -26,7 +26,7 @@ export const getAnnouncementsSettings = (): Record => defaultMessage: 'This setting is deprecated and will be removed in Kibana 10.0. Use the global setting "Hide announcements" instead.', }), - docLinksKey: 'uiSettings', + docLinksKey: 'generalSettings', }, schema: schema.boolean(), }, diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 34b60b3d30cff..764f963514562 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -60,10 +60,10 @@ describe('checking migration metadata changes on all registered SO types', () => "action": "f57d48f1be0e7895817648ecc89db8a5b594edc5185ad7c60b5ab743c960311b", "action_task_params": "6751dc8a4707a432bc9b90f5a025f183aefc84bca5ec26c29ce6939b24ea81e4", "ad_hoc_run_params": "9c372f2a8f8b468e9b699a6df633c7f14fab7f13216c9ec160813e75bae56098", - "alert": "119624b6025ea6794d2c33e2b41c2e4730d10446430b285691f7638ee6787af5", + "alert": "ab52f596c3499231d37ab4c0ee346010789a9a0b9d64d61a6631986e1e62b2aa", "alerting_rule_template": "a26521005d8a51af336ec95a2097c4bd073980c050e3c675cec3851acff78fd9", - "api_key_pending_invalidation": "b5a0fe007bff147bbb0ef7d0393c976f777ccb470359090d79890a769baf3c68", - "api_key_to_invalidate": "5add5ee737ccc61cc16bbf68423d634d1354971f20926b5ff465a2a853d1723a", + "api_key_pending_invalidation": "c1c0f5cbb1175a7d25c762b290d9d46c04557e4a8ae6a2c7bf77b8fd99b2146d", + "api_key_to_invalidate": "424d96e6dabbb6eef0618a2c23c24dca5005c650ea70f3f5fc80f978c0bd329c", "apm-custom-dashboards": "b4ac5df21cce9c4d5165fb529a05f170d91b4d9de932335b13f7932a83b8f34c", "apm-indices": "0dcb582b6349a0b8a351cbfbd269ca3819e501dad1e5407643ebbeed325aa9d2", "apm-server-schema": "2b5b7272a49356d054d8072bb530d70e4ad4eb03d2dc36116214140111908784", @@ -264,8 +264,9 @@ describe('checking migration metadata changes on all registered SO types', () => "ad_hoc_run_params|10.1.0: 2189eaa92bd66dfcc4ea2e5ec0e63ead9341a96eddd671def942ffd51bb2be0e", "==========================================================================================", "alert|global: 8365bd1a75d780902feb5f272ed0d6c430d3d63f", - "alert|mappings: 9a3a22a2bc7734d2ae1448ca90b305c9e730c456", + "alert|mappings: bd90b2061275655e3dad670cf7d9c3aaace56c6b", "alert|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "alert|10.9.0: 2d45844f7ac6873a16c579f4bee9086e8059a2ec52803a3ab94b990795bf1aa2", "alert|10.8.0: 91693eaf0ae2bf94683360e53be98aaf77da29b4d5b6ed3839db40b7c13a4a33", "alert|10.7.0: 7def9b639ae95ec88acf990d8ba9df2d517294b243241d6fa5bd61ae980e1f2c", "alert|10.6.0: 320eda57bb8e4d268264fe105975074b65bab0e6a8d47acd489c8274fa0f3a19", @@ -301,13 +302,15 @@ describe('checking migration metadata changes on all registered SO types', () => "alerting_rule_template|10.1.0: 5d4e755ad4a43932d14339869246b5c49059ce9cd4079ab2571f2edd9213baea", "===============================================================================================", "api_key_pending_invalidation|global: 95b04002ba51622fd4512312dc80f09c2176999c", - "api_key_pending_invalidation|mappings: 6690f4f2a071feda5eec8353cdb23c0f1624910a", + "api_key_pending_invalidation|mappings: 3ad0b4c69b863b2dcd4b1a6818470bbceba628ba", "api_key_pending_invalidation|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "api_key_pending_invalidation|10.2.0: e3ada22c2aede055379460b80c59c23298d74ddcfc7516df857ae506806ae4f1", "api_key_pending_invalidation|10.1.0: b236f8ecc31c1f82ccf0c183399d6d2a9bf778c1d45d70ec763583939c82a29c", "=====================================================================================================", "api_key_to_invalidate|global: 32dfc316c06e96b4edefee819aa3e0bf7365241d", "api_key_to_invalidate|mappings: fa87c3b4528dcec61462709d2575ac13ba86397e", "api_key_to_invalidate|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "api_key_to_invalidate|10.2.0: 298d41047ddc72b2b0f9ddc112595f693ba75b0a48959752dc99e80e4f09744a", "api_key_to_invalidate|10.1.0: b236f8ecc31c1f82ccf0c183399d6d2a9bf778c1d45d70ec763583939c82a29c", "==============================================================================================", "apm-custom-dashboards|global: f72017061cda1a43792af034d019b3a766eacd7a", @@ -1323,10 +1326,10 @@ describe('checking migration metadata changes on all registered SO types', () => "action": "10.1.0", "action_task_params": "10.2.0", "ad_hoc_run_params": "10.3.0", - "alert": "10.8.0", + "alert": "10.9.0", "alerting_rule_template": "10.3.0", - "api_key_pending_invalidation": "10.1.0", - "api_key_to_invalidate": "10.1.0", + "api_key_pending_invalidation": "10.2.0", + "api_key_to_invalidate": "10.2.0", "apm-custom-dashboards": "10.1.0", "apm-indices": "10.1.0", "apm-server-schema": "10.1.0", @@ -1478,10 +1481,10 @@ describe('checking migration metadata changes on all registered SO types', () => "action": "10.1.0", "action_task_params": "10.2.0", "ad_hoc_run_params": "10.3.0", - "alert": "10.8.0", + "alert": "10.9.0", "alerting_rule_template": "10.3.0", - "api_key_pending_invalidation": "10.1.0", - "api_key_to_invalidate": "10.1.0", + "api_key_pending_invalidation": "10.2.0", + "api_key_to_invalidate": "10.2.0", "apm-custom-dashboards": "10.1.0", "apm-indices": "10.1.0", "apm-server-schema": "10.1.0", diff --git a/src/core/server/integration_tests/elasticsearch/project_routing_non_serverless.test.ts b/src/core/server/integration_tests/elasticsearch/project_routing_non_serverless.test.ts new file mode 100644 index 0000000000000..ee03e984cf849 --- /dev/null +++ b/src/core/server/integration_tests/elasticsearch/project_routing_non_serverless.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * @jest-environment node + */ + +import { createTestServers } from '@kbn/core-test-helpers-kbn-server'; + +const LOCAL_PROJECT_ROUTING = '_alias:_origin'; + +describe('project_routing on non-serverless', () => { + it('Kibana removes project_routing information from requests when not serverless', async () => { + // When not serverless, ElasticsearchService sets cpsEnabled to false (elasticsearch_service.ts), + // so CpsRequestHandler strips project_routing. The cps plugin config only exposes cpsEnabled + // for the serverless offering (offeringBasedSchema), so are not allowed to enable it here. + const { startES, startKibana } = createTestServers({ + adjustTimeout: (timeout: number) => jest.setTimeout(timeout), + }); + const esServer = await startES(); + const kibanaServer = await startKibana(); + const esClient = kibanaServer.coreStart.elasticsearch.client.asInternalUser; + + try { + const response = await esClient.search({ + index: '.kibana', + size: 0, + body: { + // @ts-expect-error - project_routing is a valid body parameter + project_routing: LOCAL_PROJECT_ROUTING, + }, + }); + expect(response.hits.hits).toBeDefined(); + } finally { + await kibanaServer.stop(); + await esServer.stop(); + } + }); +}); diff --git a/src/core/server/integration_tests/elasticsearch/cps_project_routing.test.ts b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_cps.test.ts similarity index 99% rename from src/core/server/integration_tests/elasticsearch/cps_project_routing.test.ts rename to src/core/server/integration_tests/elasticsearch/project_routing_serverless_cps.test.ts index 301f18f320153..304e646b1c25b 100644 --- a/src/core/server/integration_tests/elasticsearch/cps_project_routing.test.ts +++ b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_cps.test.ts @@ -54,7 +54,7 @@ const SORTED_COUNTS_DESC = [...TEST_DOCUMENTS] * * @see src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts */ -describe('CPS project_routing on serverless ES', () => { +describe('project_routing on serverless CPS', () => { let serverlessES: TestServerlessESUtils; let serverlessKibana: TestServerlessKibanaUtils; let client: ElasticsearchClient; diff --git a/src/core/server/integration_tests/elasticsearch/project_routing_serverless_non_cps.test.ts b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_non_cps.test.ts new file mode 100644 index 0000000000000..2f19dc6101b48 --- /dev/null +++ b/src/core/server/integration_tests/elasticsearch/project_routing_serverless_non_cps.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * @jest-environment node + */ + +import type { + TestServerlessESUtils, + TestServerlessKibanaUtils, +} from '@kbn/core-test-helpers-kbn-server'; +import { createTestServerlessInstances } from '@kbn/core-test-helpers-kbn-server'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { systemIndicesSuperuser } from '@kbn/test'; + +const SYSTEM_INDEX = '.kibana'; +const TEST_INDEX = 'cps-routing-integration-test'; +const ALL_PROJECT_ROUTING = '_alias:*'; + +/** + * Integration tests for CPS (Cross-Project Search) project_routing parameter. + * + * It's important that we properly strip the `project_routing` parameter. Incorrect + * injection can cause 400 errors for requests that should otherwise pass, and risk + * of breaking requests to ES at a large scale. The integration tests send real requests + * to a real ES server to empirically verify that such errors do not happen. + * + * These tests start a serverless ES instance with CPS disabled (`enableCPS: false`). + * + * @see src/core/packages/elasticsearch/server-internal/src/elasticsearch_service.ts + */ +describe('project_routing on serverless non-CPS', () => { + let serverlessES: TestServerlessESUtils; + let serverlessKibana: TestServerlessKibanaUtils; + let client: ElasticsearchClient; + + beforeAll(async () => { + const { startES } = serverlessInstances(false); + serverlessES = await startES(); + }); + + describe(`'cps' plugin disabled`, () => { + beforeAll(async () => { + const { startKibana } = serverlessInstances(false); + serverlessKibana = await startKibana(); + client = serverlessKibana.coreStart.elasticsearch.client.asInternalUser; + await client.indices.create({ index: TEST_INDEX }); + }); + + afterAll(async () => { + await client?.indices.delete({ index: TEST_INDEX }).catch(() => {}); + await serverlessKibana?.stop(); + }); + + it('does NOT inject project_routing', async () => { + await expect( + client.search({ index: SYSTEM_INDEX, query: { match_all: {} } }) + ).resolves.not.toThrow(); + + await expect( + client.search({ index: TEST_INDEX, query: { match_all: {} } }) + ).resolves.not.toThrow(); + }); + + it('strips project_routing', async () => { + await expect( + client.search({ + index: SYSTEM_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).resolves.not.toThrow(); + + await expect( + client.search({ + index: TEST_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).resolves.not.toThrow(); + }); + }); + + describe(`'cps' plugin enabled`, () => { + beforeAll(async () => { + const { startKibana } = serverlessInstances(true); + serverlessKibana = await startKibana(); + client = serverlessKibana.coreStart.elasticsearch.client.asInternalUser; + await client.indices.create({ index: TEST_INDEX }); + }); + + afterAll(async () => { + await client?.indices.delete({ index: TEST_INDEX }).catch(() => {}); + await serverlessKibana?.stop(); + }); + + it('injects project_routing and requests fail', async () => { + await expect( + client.search({ index: SYSTEM_INDEX, query: { match_all: {} } }) + ).rejects.toThrow(); + + await expect( + client.search({ index: TEST_INDEX, query: { match_all: {} } }) + ).rejects.toThrow(); + }); + + it('does NOT strip project_routing and requests fail', async () => { + await expect( + client.search({ + index: SYSTEM_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).rejects.toThrow(); + + await expect( + client.search({ + index: TEST_INDEX, + query: { match_all: {} }, + // @ts-expect-error - project_routing is a valid body parameter + body: { project_routing: ALL_PROJECT_ROUTING }, + }) + ).rejects.toThrow(); + }); + }); + + afterAll(async () => { + await serverlessES?.stop(); + }); +}); + +const serverlessInstances = (cpsPlugin: boolean) => { + return createTestServerlessInstances({ + adjustTimeout: (timeout: number) => jest.setTimeout(timeout), + enableCPS: false, + // Match `yarn es serverless --projectType observability ...` + projectType: 'oblt', + // Required to apply the UIAM/serverless ES args block (mock IDP/project metadata). + kibanaUrl: 'http://localhost:5601/', + // Setup-only: use superuser so tests can create temp indices. + kibana: { + settings: { + ...(cpsPlugin && { + cps: { + enabled: true, + cpsEnabled: true, + }, + }), + elasticsearch: { + username: systemIndicesSuperuser.username, + password: systemIndicesSuperuser.password, + }, + }, + }, + }); +}; diff --git a/src/core/test-helpers/kbn-server/src/create_serverless_root.ts b/src/core/test-helpers/kbn-server/src/create_serverless_root.ts index b041706cb8aa7..04e0ba2e9087d 100644 --- a/src/core/test-helpers/kbn-server/src/create_serverless_root.ts +++ b/src/core/test-helpers/kbn-server/src/create_serverless_root.ts @@ -106,7 +106,10 @@ export function createTestServerlessInstances({ if (enableCPS) { if (!kibana.settings) kibana.settings = {}; - set(kibana.settings, 'cps.cpsEnabled', true); + const hasCpsKey = kibana.settings && 'cps' in kibana.settings; + if (!hasCpsKey) { + set(kibana.settings, 'cps.cpsEnabled', enableCPS); + } // Match the default `yarn es serverless --uiam` setup, but allow tests to override // auth by pre-setting `elasticsearch.username/password` (e.g. use `system_indices_superuser`). const existingEsSettings = (kibana.settings as any).elasticsearch ?? {}; diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index fcded3e3a3591..1cc43f8db0be4 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -52,7 +52,7 @@ export async function runDockerGenerator( */ if (flags.baseImage === 'wolfi') baseImageName = - 'docker.elastic.co/wolfi/chainguard-base:latest@sha256:844d6d4ac44e829e2fdb12e881d73c910867f932eca6b0892735d43e3d5235f8'; + 'docker.elastic.co/wolfi/chainguard-base:latest@sha256:568df5fb0e237c3a2139399d4bd8e80994f24321fa8469aec565b8566704e83a'; let imageFlavor = ''; if (flags.baseImage === 'wolfi' && !flags.serverless && !flags.cloud) imageFlavor += `-wolfi`; diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/mocks/team_assign_mock.txt b/src/dev/code_coverage/ingest_coverage/__tests__/mocks/team_assign_mock.txt index 986f1cc0b2bf5..e1a4be5670a57 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/mocks/team_assign_mock.txt +++ b/src/dev/code_coverage/ingest_coverage/__tests__/mocks/team_assign_mock.txt @@ -1,36 +1,3 @@ -x-pack/platform/plugins/shared/dashboard_enhanced/public/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/mocks.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/plugin.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/public/services/index.ts kibana-app -x-pack/platform/plugins/shared/dashboard_enhanced/scripts/storybook.js kibana-app x-pack/platform/plugins/private/discover_enhanced/common/config.ts kibana-app x-pack/platform/plugins/private/discover_enhanced/common/index.ts kibana-app x-pack/platform/plugins/private/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts kibana-app diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 00d9507e3c2a9..9b8345cedc04b 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -24,7 +24,6 @@ export const storybookAliases = { classic_stream_flyout: 'x-pack/platform/packages/shared/kbn-classic-stream-flyout/.storybook', custom_icons: 'src/platform/packages/shared/kbn-custom-icons/.storybook', custom_integrations: 'src/platform/plugins/shared/custom_integrations/storybook', - dashboard_enhanced: 'x-pack/platform/plugins/shared/dashboard_enhanced/.storybook', dashboard: 'src/platform/plugins/shared/dashboard/.storybook', data: 'src/platform/plugins/shared/data/.storybook', discover: 'src/platform/plugins/shared/discover/.storybook', @@ -53,6 +52,7 @@ export const storybookAliases = { 'src/platform/packages/private/kbn-language-documentation/.storybook', lists: 'x-pack/solutions/security/plugins/lists/.storybook', management: 'src/platform/packages/shared/kbn-management/storybook/config', + metrics_data_access: 'x-pack/solutions/observability/plugins/metrics_data_access/.storybook', observability_ai_assistant_app: 'x-pack/solutions/observability/plugins/observability_ai_assistant_app/.storybook', observability_ai_assistant: diff --git a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx index fb16c7f67423f..770abda1a0938 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx @@ -54,9 +54,8 @@ import { v4 as uuidv4 } from 'uuid'; import { createPortal } from 'react-dom'; import useObservable from 'react-use/lib/useObservable'; import useLocalStorage from 'react-use/lib/useLocalStorage'; -import { firstValueFrom, of } from 'rxjs'; import { QuerySource } from '@kbn/esql-types'; -import { useCanCreateLookupIndex, useLookupIndexCommand } from './lookup_join'; +import { useLookupIndexCommand } from './lookup_join'; import { useFieldsBrowser } from './resource_browser/use_fields_browser'; import { EditorFooter } from './editor_footer'; import { QuickSearchVisor } from './editor_visor'; @@ -159,6 +158,7 @@ const ESQLEditorInternal = function ESQLEditor({ const editorRef = useRef(); const editorModelUriRef = useRef(undefined); const containerRef = useRef(null); + const suppressSuggestionsRef = useRef(false); const editorCommandDisposables = useRef( new WeakMap() @@ -479,6 +479,10 @@ const ESQLEditorInternal = function ESQLEditor({ const triggerSuggestions = useCallback(() => { setTimeout(() => { + if (suppressSuggestionsRef.current) { + suppressSuggestionsRef.current = false; + return; + } editorRef.current?.trigger(undefined, 'editor.action.triggerSuggest', {}); }, 0); }, []); @@ -664,8 +668,6 @@ const ESQLEditorInternal = function ESQLEditor({ return { cache: fn.cache, memoizedHistoryStarredItems: fn }; }, []); - const canCreateLookupIndex = useCanCreateLookupIndex(); - // Extract source command and build minimal query with cluster prefixes const minimalQuery = useMemo(() => { const prefix = code.match(/\b(FROM|TS)\b/i)?.[1]?.toUpperCase(); @@ -714,11 +716,6 @@ const ESQLEditorInternal = function ESQLEditor({ }); useEsqlEditorActionsRegistration(editorActions); - const isResourceBrowserEnabled = useCallback(async () => { - const currentApp = await firstValueFrom(application?.currentAppId$ ?? of(undefined)); - return Boolean(enableResourceBrowser && currentApp === 'discover'); - }, [application?.currentAppId$, enableResourceBrowser]); - const esqlCallbacks = useEsqlCallbacks({ core, data, @@ -727,7 +724,6 @@ const ESQLEditorInternal = function ESQLEditor({ esqlService, histogramBarTarget, activeSolutionId: activeSolutionId ?? undefined, - canCreateLookupIndex, minimalQueryRef, abortControllerRef, dataSourcesCache, @@ -738,7 +734,7 @@ const ESQLEditorInternal = function ESQLEditor({ memoizedHistoryStarredItems, favoritesClient, getJoinIndicesCallback, - isResourceBrowserEnabled, + enableResourceBrowser, }); const { @@ -761,6 +757,7 @@ const ESQLEditorInternal = function ESQLEditor({ editorRef, editorModel, openIndicesBrowser, + suppressSuggestionsRef, }); const { diff --git a/src/platform/packages/private/kbn-esql-editor/src/resource_browser/use_can_suggest_resource_browser.ts b/src/platform/packages/private/kbn-esql-editor/src/resource_browser/use_can_suggest_resource_browser.ts new file mode 100644 index 0000000000000..8f726fb3d31a3 --- /dev/null +++ b/src/platform/packages/private/kbn-esql-editor/src/resource_browser/use_can_suggest_resource_browser.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useCallback } from 'react'; +import { firstValueFrom, of } from 'rxjs'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { ESQLEditorDeps } from '../types'; + +export const useCanSuggestResourceBrowser = (enableResourceBrowser: boolean) => { + const { + services: { application }, + } = useKibana(); + + return useCallback(async () => { + const currentApp = await firstValueFrom(application?.currentAppId$ ?? of(undefined)); + return Boolean(enableResourceBrowser && currentApp === 'discover'); + }, [application?.currentAppId$, enableResourceBrowser]); +}; diff --git a/src/platform/packages/private/kbn-esql-editor/src/resource_browser/use_resource_browser_badge.tsx b/src/platform/packages/private/kbn-esql-editor/src/resource_browser/use_resource_browser_badge.tsx index 7a811ff51a1e8..7a87410949bcc 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/resource_browser/use_resource_browser_badge.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/resource_browser/use_resource_browser_badge.tsx @@ -20,12 +20,14 @@ interface UseSourcesBadgeParams { editorRef: MutableRefObject; editorModel: MutableRefObject; openIndicesBrowser: (options?: { openedFrom?: IndicesBrowserOpenMode }) => void; + suppressSuggestionsRef: MutableRefObject; } export const useSourcesBadge = ({ editorRef, editorModel, openIndicesBrowser, + suppressSuggestionsRef, }: UseSourcesBadgeParams) => { const { euiTheme } = useEuiTheme(); const decorationsRef = useRef(undefined); @@ -127,11 +129,17 @@ export const useSourcesBadge = ({ firstSupportedCommand.range.endColumn + 1 ); editor.setPosition(positionAfterCommand); - editor.revealPosition(positionAfterCommand); + + suppressSuggestionsRef.current = true; + openIndicesBrowser({ openedFrom: IndicesBrowserOpenMode.Badge }); + + // Remove focus from the editor immediately so there is no visible + // focus state while the popover mounts and takes over. + (editor.getDomNode()?.ownerDocument.activeElement as HTMLElement)?.blur(); } }, - [editorModel, editorRef, openIndicesBrowser] + [editorModel, editorRef, openIndicesBrowser, suppressSuggestionsRef] ); return { diff --git a/src/platform/packages/private/kbn-esql-editor/src/use_esql_callbacks.ts b/src/platform/packages/private/kbn-esql-editor/src/use_esql_callbacks.ts index c2a90d6e34f0f..480eacd842887 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/use_esql_callbacks.ts +++ b/src/platform/packages/private/kbn-esql-editor/src/use_esql_callbacks.ts @@ -30,6 +30,8 @@ import { clearCacheWhenOld } from './helpers'; import { getHistoryItems } from './history_local_storage'; import type { ESQLEditorDeps } from './types'; import type { StarredQueryMetadata } from './editor_footer/esql_starred_queries_service'; +import { useCanCreateLookupIndex } from './lookup_join'; +import { useCanSuggestResourceBrowser } from './resource_browser/use_can_suggest_resource_browser'; type MemoizedFn = (...args: TArgs) => { timestamp: number; @@ -68,7 +70,6 @@ interface UseEsqlCallbacksParams { esqlService?: ESQLEditorDeps['esql']; histogramBarTarget: number; activeSolutionId?: Parameters[2]; - canCreateLookupIndex: ESQLCallbacks['canCreateLookupIndex']; minimalQueryRef: MutableRefObject; abortControllerRef: MutableRefObject; dataSourcesCache: MapCache; @@ -79,7 +80,7 @@ interface UseEsqlCallbacksParams { memoizedHistoryStarredItems: MemoizedHistoryStarredItems; favoritesClient: FavoritesClient; getJoinIndicesCallback: Required['getJoinIndices']; - isResourceBrowserEnabled: () => Promise; + enableResourceBrowser: boolean; } export const useEsqlCallbacks = ({ @@ -90,7 +91,6 @@ export const useEsqlCallbacks = ({ esqlService, histogramBarTarget, activeSolutionId, - canCreateLookupIndex, minimalQueryRef, abortControllerRef, dataSourcesCache, @@ -101,7 +101,7 @@ export const useEsqlCallbacks = ({ memoizedHistoryStarredItems, favoritesClient, getJoinIndicesCallback, - isResourceBrowserEnabled, + enableResourceBrowser, }: UseEsqlCallbacksParams): ESQLCallbacks => { const getSources = useCallback(async () => { clearCacheWhenOld(dataSourcesCache, minimalQueryRef.current); @@ -212,6 +212,9 @@ export const useEsqlCallbacks = ({ const isServerless = Boolean(esqlService?.isServerless); + const canCreateLookupIndex = useCanCreateLookupIndex(); + const canSuggestResourceBrowser = useCanSuggestResourceBrowser(enableResourceBrowser); + const getKqlSuggestions = useCallback( async (kqlQuery: string, cursorPositionInKql: number) => { const hasQuerySuggestions = kql?.autocomplete?.hasQuerySuggestions('kuery'); @@ -264,7 +267,7 @@ export const useEsqlCallbacks = ({ canCreateLookupIndex, isServerless, getKqlSuggestions, - isResourceBrowserEnabled, + canSuggestResourceBrowser, }), [ getSources, @@ -285,7 +288,7 @@ export const useEsqlCallbacks = ({ canCreateLookupIndex, isServerless, getKqlSuggestions, - isResourceBrowserEnabled, + canSuggestResourceBrowser, ] ); }; diff --git a/src/platform/packages/private/kbn-mock-idp-utils/src/constants.ts b/src/platform/packages/private/kbn-mock-idp-utils/src/constants.ts index 230fc1f032da8..43e4eeee368f6 100644 --- a/src/platform/packages/private/kbn-mock-idp-utils/src/constants.ts +++ b/src/platform/packages/private/kbn-mock-idp-utils/src/constants.ts @@ -32,16 +32,16 @@ export const MOCK_IDP_UIAM_COSMOS_DB_URL = process.env.MOCK_IDP_UIAM_COSMOS_DB_URL || 'https://localhost:8081'; export const MOCK_IDP_UIAM_ORGANIZATION_ID = 'org1234567890'; -export const MOCK_IDP_UIAM_PROJECT_ID = 'abcde1234567890'; +export const MOCK_IDP_UIAM_PROJECT_ID = 'abcdef12345678901234567890123456'; // Sometimes it is useful or required to point local UIAM service clients, or clients operating within the same Docker // network (i.e., Elasticsearch), to a different UIAM service URL. For example, http://host.docker.internal:8080 can be // used to route requests through the host network, making it easier to capture traffic with a network analyzer running // on the host. export const MOCK_IDP_UIAM_SERVICE_INTERNAL_URL = - process.env.MOCK_IDP_UIAM_SERVICE_INTERNAL_URL || 'http://uiam:8080'; + process.env.MOCK_IDP_UIAM_SERVICE_INTERNAL_URL || 'https://uiam:8443'; export const MOCK_IDP_UIAM_SERVICE_URL = - process.env.MOCK_IDP_UIAM_SERVICE_URL || 'http://localhost:8080'; + process.env.MOCK_IDP_UIAM_SERVICE_URL || 'https://localhost:8443'; export const MOCK_IDP_REALM_NAME = 'cloud-saml-kibana'; export const MOCK_IDP_REALM_TYPE = 'saml'; diff --git a/src/platform/packages/private/kbn-scout-reporting/src/registry/manifest.ts b/src/platform/packages/private/kbn-scout-reporting/src/registry/manifest.ts index 589e47473fe61..dd42cd0c7896a 100644 --- a/src/platform/packages/private/kbn-scout-reporting/src/registry/manifest.ts +++ b/src/platform/packages/private/kbn-scout-reporting/src/registry/manifest.ts @@ -24,7 +24,6 @@ export const getGitSHA1ForPath = async (p: string) => { export interface ScoutConfigManifest { path: string; exists: boolean; - lastModified: string; sha1: string; tests: { id: string; diff --git a/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.test.ts b/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.test.ts index 574b633c5dbb9..462580625a98a 100644 --- a/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.test.ts +++ b/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.test.ts @@ -18,7 +18,6 @@ jest.mock('fast-glob'); const dummyManifestProps = { exists: false, - lastModified: new Date(0).toISOString(), sha1: '000000000000000-000000000000000', tests: [], }; @@ -83,7 +82,6 @@ describe('test_config module', () => { ); const scoutRoot = path.join(moduleRoot, 'test/scout'); const validManifestContent = { - lastModified: '2025-12-03T19:04:17.097Z', sha1: 'b72df4fa5abc546e5f21e6c2f6eaaaa523755720', tests: [ { diff --git a/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.ts b/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.ts index e7c0219c6b663..cd66835178d43 100644 --- a/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.ts +++ b/src/platform/packages/private/kbn-scout-reporting/src/registry/test_config.ts @@ -83,7 +83,6 @@ export const testConfig = { } } else { manifestFileData = { - lastModified: new Date(0).toISOString(), sha1: '000000000000000-000000000000000', tests: [], }; diff --git a/src/platform/packages/private/kbn-scout-reporting/src/reporting/playwright/manifest_updater/playwright_reporter.ts b/src/platform/packages/private/kbn-scout-reporting/src/reporting/playwright/manifest_updater/playwright_reporter.ts index 93f2c42423080..8728f59685beb 100644 --- a/src/platform/packages/private/kbn-scout-reporting/src/reporting/playwright/manifest_updater/playwright_reporter.ts +++ b/src/platform/packages/private/kbn-scout-reporting/src/reporting/playwright/manifest_updater/playwright_reporter.ts @@ -65,7 +65,6 @@ export class ScoutManifestUpdater implements Reporter { this.scoutConfig.manifest.path, JSON.stringify( { - lastModified: new Date().toISOString(), sha1: await getGitSHA1ForPath(path.dirname(this.scoutConfig.path)), tests: this.scoutConfig.manifest.tests, }, diff --git a/src/platform/packages/private/kbn-validate-oas/src/oas_error_baseline.json b/src/platform/packages/private/kbn-validate-oas/src/oas_error_baseline.json index 9cd563c328125..87d7c9f3b65a2 100644 --- a/src/platform/packages/private/kbn-validate-oas/src/oas_error_baseline.json +++ b/src/platform/packages/private/kbn-validate-oas/src/oas_error_baseline.json @@ -1,4 +1,4 @@ { - "./oas_docs/output/kibana.yaml": 1241, - "./oas_docs/output/kibana.serverless.yaml": 1134 + "./oas_docs/output/kibana.yaml": 1235, + "./oas_docs/output/kibana.serverless.yaml": 1128 } \ No newline at end of file diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/index.ts index 2230b9b741d5b..0d445965f3006 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/index.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/index.ts @@ -26,7 +26,7 @@ export type { // Hooks. export { useContentListItems, useContentListState } from './src/state'; export type { ContentListQueryData } from './src/state'; -export { useContentListSort, useContentListSearch } from './src/features'; +export { useContentListSort, useContentListSearch, useContentListSelection } from './src/features'; export { useContentListPagination } from './src/features'; // State. @@ -46,6 +46,7 @@ export type { UseContentListPaginationReturn, SearchConfig, UseContentListSearchReturn, + UseContentListSelectionReturn, } from './src/features'; export type { ActiveFilters, diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.tsx index 87ab1dd63c0c9..cd761bc4e72c7 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.tsx @@ -76,13 +76,15 @@ export const ContentListProvider = ({ const queryKeyScope = queryKeyScopeProp ?? `${id}-listing`; // Resolve feature support flags. + // Selection is disabled when explicitly set to `false` or when the list is read-only. const supports: ContentListSupports = useMemo( () => ({ sorting: features.sorting !== false, pagination: features.pagination !== false, search: features.search !== false, + selection: features.selection !== false && !isReadOnly, }), - [features.sorting, features.pagination, features.search] + [features.sorting, features.pagination, features.search, features.selection, isReadOnly] ); // Create context value. diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/index.ts index 3ccbe4208f566..54f7977487d4e 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/index.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/index.ts @@ -22,3 +22,7 @@ export { useContentListPagination } from './pagination'; // Search feature. export type { SearchConfig, UseContentListSearchReturn } from './search'; export { useContentListSearch } from './search'; + +// Selection feature. +export type { UseContentListSelectionReturn } from './selection'; +export { useContentListSelection } from './selection'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/index.ts new file mode 100644 index 0000000000000..95eecf7cc1ea2 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { UseContentListSelectionReturn } from './types'; +export { useContentListSelection } from './use_content_list_selection'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/types.ts new file mode 100644 index 0000000000000..aea2ae275cd3f --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ContentListItem } from '../../item'; + +/** + * Return type for the {@link useContentListSelection} hook. + */ +export interface UseContentListSelectionReturn { + /** IDs of currently selected items. */ + selectedIds: string[]; + /** Currently selected items resolved from the loaded items list. */ + selectedItems: ContentListItem[]; + /** Number of currently selected items. */ + selectedCount: number; + /** Whether a specific item is selected. */ + isSelected: (id: string) => boolean; + /** + * Replace the selection with the given items. + * Typically called by `EuiBasicTable`'s `onSelectionChange` callback. + */ + setSelection: (items: ContentListItem[]) => void; + /** Clear all selected items. */ + clearSelection: () => void; + /** Whether selection is supported (enabled via features, not read-only). */ + isSupported: boolean; +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/use_content_list_selection.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/use_content_list_selection.test.tsx new file mode 100644 index 0000000000000..5e0a533ca96a7 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/use_content_list_selection.test.tsx @@ -0,0 +1,263 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { ContentListProvider } from '../../context'; +import type { FindItemsResult, FindItemsParams } from '../../datasource'; +import { useContentListSelection } from './use_content_list_selection'; + +const mockItems = [ + { id: '1', title: 'Dashboard A' }, + { id: '2', title: 'Dashboard B' }, + { id: '3', title: 'Dashboard C' }, +]; + +describe('useContentListSelection', () => { + const mockFindItems = jest.fn( + async (_params: FindItemsParams): Promise => ({ + items: mockItems, + total: mockItems.length, + }) + ); + + const createWrapper = (options?: { selectionDisabled?: boolean; isReadOnly?: boolean }) => { + const { selectionDisabled, isReadOnly } = options ?? {}; + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initial state', () => { + it('returns empty selection by default', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + expect(result.current.selectedIds).toEqual([]); + expect(result.current.selectedItems).toEqual([]); + expect(result.current.selectedCount).toBe(0); + }); + + it('returns `isSupported` true by default', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + expect(result.current.isSupported).toBe(true); + }); + + it('returns `isSupported` false when selection is disabled', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper({ selectionDisabled: true }), + }); + + expect(result.current.isSupported).toBe(false); + }); + + it('returns `isSupported` false when `isReadOnly` is true', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper({ isReadOnly: true }), + }); + + expect(result.current.isSupported).toBe(false); + }); + }); + + describe('setSelection', () => { + it('updates `selectedIds` and `selectedCount`', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSelection([mockItems[0], mockItems[2]]); + }); + + expect(result.current.selectedIds).toEqual(['1', '3']); + expect(result.current.selectedCount).toBe(2); + }); + + it('resolves `selectedItems` from the loaded items list', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSelection([mockItems[0], mockItems[2]]); + }); + + expect(result.current.selectedItems).toEqual([ + expect.objectContaining({ id: '1', title: 'Dashboard A' }), + expect.objectContaining({ id: '3', title: 'Dashboard C' }), + ]); + }); + + it('is a no-op when selection is disabled', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper({ selectionDisabled: true }), + }); + + act(() => { + result.current.setSelection([mockItems[0]]); + }); + + expect(result.current.selectedIds).toEqual([]); + expect(result.current.selectedCount).toBe(0); + }); + + it('is a no-op when `isReadOnly` is true', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper({ isReadOnly: true }), + }); + + act(() => { + result.current.setSelection([mockItems[0]]); + }); + + expect(result.current.selectedIds).toEqual([]); + expect(result.current.selectedCount).toBe(0); + }); + + it('replaces the previous selection entirely', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSelection([mockItems[0], mockItems[1]]); + }); + + expect(result.current.selectedIds).toEqual(['1', '2']); + + act(() => { + result.current.setSelection([mockItems[2]]); + }); + + expect(result.current.selectedIds).toEqual(['3']); + }); + }); + + describe('clearSelection', () => { + it('clears all selected items', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSelection([mockItems[0], mockItems[1]]); + }); + + expect(result.current.selectedCount).toBe(2); + + act(() => { + result.current.clearSelection(); + }); + + expect(result.current.selectedIds).toEqual([]); + expect(result.current.selectedCount).toBe(0); + }); + + it('is a no-op when selection is disabled', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper({ selectionDisabled: true }), + }); + + // Should not throw. + act(() => { + result.current.clearSelection(); + }); + + expect(result.current.selectedIds).toEqual([]); + }); + }); + + describe('isSelected', () => { + it('returns true for selected items', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSelection([mockItems[0], mockItems[2]]); + }); + + expect(result.current.isSelected('1')).toBe(true); + expect(result.current.isSelected('3')).toBe(true); + }); + + it('returns false for unselected items', () => { + const { result } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSelection([mockItems[0]]); + }); + + expect(result.current.isSelected('2')).toBe(false); + expect(result.current.isSelected('3')).toBe(false); + }); + }); + + describe('function stability', () => { + it('provides stable `setSelection` reference across renders', () => { + const { result, rerender } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + const firstSetSelection = result.current.setSelection; + rerender(); + const secondSetSelection = result.current.setSelection; + + expect(firstSetSelection).toBe(secondSetSelection); + }); + + it('provides stable `clearSelection` reference across renders', () => { + const { result, rerender } = renderHook(() => useContentListSelection(), { + wrapper: createWrapper(), + }); + + const firstClearSelection = result.current.clearSelection; + rerender(); + const secondClearSelection = result.current.clearSelection; + + expect(firstClearSelection).toBe(secondClearSelection); + }); + }); + + describe('error handling', () => { + it('throws when used outside provider', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useContentListSelection()); + }).toThrow( + 'ContentListContext is missing. Ensure your component is wrapped with ContentListProvider.' + ); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/use_content_list_selection.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/use_content_list_selection.ts new file mode 100644 index 0000000000000..ab0e43d5b348f --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/selection/use_content_list_selection.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useCallback, useMemo } from 'react'; +import type { ContentListItem } from '../../item'; +import { useContentListState } from '../../state/use_content_list_state'; +import { useContentListConfig } from '../../context'; +import { CONTENT_LIST_ACTIONS } from '../../state/types'; +import type { UseContentListSelectionReturn } from './types'; + +/** + * Hook to access and control item selection. + * + * Provides the current selection state and functions to modify it. + * When selection is disabled (via `features.selection: false` or `isReadOnly`), + * `setSelection` and `clearSelection` become no-ops and `isSupported` returns `false`. + * + * @throws Error if used outside `ContentListProvider`. + * @returns Object containing selection state, mutation functions, and support flag. + * + * @example + * ```tsx + * const { selectedItems, selectedCount, clearSelection, isSupported } = useContentListSelection(); + * + * if (!isSupported) return null; + * + * return ( + *
+ * {selectedCount} items selected + * + *
+ * ); + * ``` + */ +export const useContentListSelection = (): UseContentListSelectionReturn => { + const { supports } = useContentListConfig(); + const { state, dispatch } = useContentListState(); + + const { selectedIds } = state.selection; + const { items } = state; + + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + // Only resolves against the currently loaded `items` array. Selection is + // automatically cleared by the reducer when search, filters, sort, or + // pagination change, so stale cross-page IDs should not accumulate. + const selectedItems = useMemo( + () => items.filter((item) => selectedIdSet.has(item.id)), + [items, selectedIdSet] + ); + + const isSelected = useCallback((id: string) => selectedIdSet.has(id), [selectedIdSet]); + + const setSelection = useCallback( + (selectedItemsList: ContentListItem[]) => { + if (!supports.selection) { + return; + } + dispatch({ + type: CONTENT_LIST_ACTIONS.SET_SELECTION, + payload: { ids: selectedItemsList.map((item) => item.id) }, + }); + }, + [dispatch, supports.selection] + ); + + const clearSelection = useCallback(() => { + if (!supports.selection) { + return; + } + dispatch({ type: CONTENT_LIST_ACTIONS.CLEAR_SELECTION }); + }, [dispatch, supports.selection]); + + return { + selectedIds, + selectedItems, + selectedCount: selectedIds.length, + isSelected, + setSelection, + clearSelection, + isSupported: supports.selection, + }; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/types.ts index 0b8aa29e43d0a..eabe99c674548 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/types.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/types.ts @@ -21,6 +21,13 @@ export interface ContentListFeatures { pagination?: PaginationConfig | boolean; /** Search configuration. */ search?: SearchConfig | boolean; + /** + * Selection configuration. + * When `true` (default), row selection checkboxes are shown and bulk + * actions are enabled. Set to `false` to disable selection entirely. + * Selection is automatically disabled when `isReadOnly` is `true`. + */ + selection?: boolean; } /** @@ -76,4 +83,6 @@ export interface ContentListSupports { pagination: boolean; /** Whether search is supported. */ search: boolean; + /** Whether item selection and bulk actions are supported. */ + selection: boolean; } diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/index.ts index e64f9957ee869..0789d3bcc8345 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/index.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/index.ts @@ -10,7 +10,7 @@ export { ContentListStateProvider } from './state_provider'; export { useContentListState, ContentListStateContext } from './use_content_list_state'; export { useContentListItems } from './use_content_list_items'; -export { reducer } from './state_reducer'; +export { reducer, DEFAULT_SELECTION } from './state_reducer'; export type { ContentListState, ContentListAction, diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_provider.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_provider.tsx index 455eb7710ac03..898ae458ed3c3 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_provider.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_provider.tsx @@ -17,7 +17,7 @@ import { isSortingConfig, isPaginationConfig, isSearchConfig } from '../features import { DEFAULT_PAGE_SIZE } from '../features/pagination'; import { getPersistedPageSize } from '../features/pagination'; import type { PaginationConfig } from '../features/pagination'; -import { reducer } from './state_reducer'; +import { reducer, DEFAULT_SELECTION } from './state_reducer'; import { useContentListItemsQuery } from '../query'; /** @@ -52,7 +52,7 @@ const resolveInitialPageSize = ( * Internal provider component that manages the runtime state of the content list. * * This provider: - * - Manages client-controlled state (search, filters, sort, pagination) via reducer. + * - Manages client-controlled state (search, filters, sort, pagination, selection) via reducer. * - Uses React Query for data fetching with caching and deduplication. * - Combines client state with query data for a unified state interface. * @@ -88,13 +88,14 @@ export const ContentListStateProvider = ({ children }: ContentListStateProviderP return undefined; }, [search]); - // Initial client state (search, filters, sort, page). + // Initial client state (search, filters, sort, page, selection). const initialClientState: ContentListClientState = useMemo( () => ({ search: { queryText: initialSearch ?? '' }, filters: { ...DEFAULT_FILTERS, search: initialSearch }, sort: initialSort, page: { index: 0, size: initialPageSize }, + selection: { ...DEFAULT_SELECTION }, }), [initialSort, initialPageSize, initialSearch] ); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.test.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.test.ts index a1dbbbc58cac8..50247386a1629 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.test.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { reducer } from './state_reducer'; +import { reducer, DEFAULT_SELECTION } from './state_reducer'; import { CONTENT_LIST_ACTIONS, DEFAULT_FILTERS } from './types'; import type { ContentListClientState, ContentListAction } from './types'; @@ -15,7 +15,7 @@ describe('state_reducer', () => { /** * Creates initial client state for testing. * - * Note: The reducer only manages client-controlled state (search, filters, sort, pagination). + * Note: The reducer only manages client-controlled state (search, filters, sort, pagination, selection). * Query data (items, isLoading, error) is managed by React Query directly. */ const createInitialState = ( @@ -25,6 +25,7 @@ describe('state_reducer', () => { filters: DEFAULT_FILTERS, sort: { field: 'updatedAt', direction: 'desc' }, page: { index: 0, size: 20 }, + selection: { ...DEFAULT_SELECTION }, ...overrides, }); @@ -68,6 +69,20 @@ describe('state_reducer', () => { expect(newState.filters).toEqual({ search: 'test query' }); }); + + it('clears selection when sort changes', () => { + const initialState = createInitialState({ + selection: { selectedIds: ['1', '2'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SORT, + payload: { field: 'title', direction: 'asc' }, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual([]); + }); }); describe('SET_PAGE_INDEX', () => { @@ -110,6 +125,95 @@ describe('state_reducer', () => { expect(newState.filters).toEqual({ search: 'test query' }); expect(newState.sort).toEqual({ field: 'title', direction: 'asc' }); }); + + it('clears selection when page index changes', () => { + const initialState = createInitialState({ + selection: { selectedIds: ['1', '2'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_PAGE_INDEX, + payload: { index: 2 }, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual([]); + }); + }); + + describe('SET_SELECTION', () => { + it('sets selected IDs', () => { + const initialState = createInitialState(); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SELECTION, + payload: { ids: ['1', '3'] }, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual(['1', '3']); + }); + + it('replaces existing selection', () => { + const initialState = createInitialState({ + selection: { selectedIds: ['1', '2'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SELECTION, + payload: { ids: ['3'] }, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual(['3']); + }); + + it('preserves sort and filters when setting selection', () => { + const initialState = createInitialState({ + filters: { search: 'test query' }, + sort: { field: 'title', direction: 'asc' }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SELECTION, + payload: { ids: ['1'] }, + }; + + const newState = reducer(initialState, action); + + expect(newState.filters).toEqual({ search: 'test query' }); + expect(newState.sort).toEqual({ field: 'title', direction: 'asc' }); + }); + }); + + describe('CLEAR_SELECTION', () => { + it('clears all selected IDs', () => { + const initialState = createInitialState({ + selection: { selectedIds: ['1', '2', '3'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.CLEAR_SELECTION, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual([]); + }); + + it('preserves sort and filters when clearing selection', () => { + const initialState = createInitialState({ + filters: { search: 'test query' }, + sort: { field: 'title', direction: 'asc' }, + selection: { selectedIds: ['1'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.CLEAR_SELECTION, + }; + + const newState = reducer(initialState, action); + + expect(newState.filters).toEqual({ search: 'test query' }); + expect(newState.sort).toEqual({ field: 'title', direction: 'asc' }); + }); }); describe('SET_SEARCH', () => { @@ -181,6 +285,20 @@ describe('state_reducer', () => { expect(newState.search.queryText).toBe(''); expect(newState.filters).toEqual({ search: undefined }); }); + + it('clears selection when search changes', () => { + const initialState = createInitialState({ + selection: { selectedIds: ['1', '2'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SEARCH, + payload: { queryText: 'dashboard', filters: { search: 'dashboard' } }, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual([]); + }); }); describe('CLEAR_FILTERS', () => { @@ -240,6 +358,21 @@ describe('state_reducer', () => { expect(newState.page.index).toBe(0); expect(newState.page.size).toBe(20); }); + + it('clears selection when filters are cleared', () => { + const initialState = createInitialState({ + filters: { search: 'test' }, + search: { queryText: 'test' }, + selection: { selectedIds: ['1', '2'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.CLEAR_FILTERS, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual([]); + }); }); describe('SET_PAGE_SIZE', () => { @@ -282,6 +415,20 @@ describe('state_reducer', () => { expect(newState.filters).toEqual({ search: 'test query' }); expect(newState.sort).toEqual({ field: 'title', direction: 'asc' }); }); + + it('clears selection when page size changes', () => { + const initialState = createInitialState({ + selection: { selectedIds: ['1', '2'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_PAGE_SIZE, + payload: { size: 50 }, + }; + + const newState = reducer(initialState, action); + + expect(newState.selection.selectedIds).toEqual([]); + }); }); describe('SET_SORT resets page index', () => { @@ -327,6 +474,7 @@ describe('state_reducer', () => { const initialState = createInitialState(); const originalSort = initialState.sort; const originalFilters = initialState.filters; + const originalSelection = initialState.selection; reducer(initialState, { type: CONTENT_LIST_ACTIONS.SET_SORT, @@ -335,6 +483,32 @@ describe('state_reducer', () => { expect(initialState.sort).toBe(originalSort); expect(initialState.filters).toBe(originalFilters); + expect(initialState.selection).toBe(originalSelection); + }); + + it('returns a new state object for SET_SELECTION', () => { + const initialState = createInitialState(); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SELECTION, + payload: { ids: ['1'] }, + }; + + const newState = reducer(initialState, action); + + expect(newState).not.toBe(initialState); + }); + + it('returns a new state object for CLEAR_SELECTION', () => { + const initialState = createInitialState({ + selection: { selectedIds: ['1'] }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.CLEAR_SELECTION, + }; + + const newState = reducer(initialState, action); + + expect(newState).not.toBe(initialState); }); it('returns a new state object for SET_SEARCH', () => { diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.ts index 841ce3d50d363..af9511f6e75ab 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.ts @@ -10,12 +10,22 @@ import type { ContentListClientState, ContentListAction } from './types'; import { CONTENT_LIST_ACTIONS, DEFAULT_FILTERS } from './types'; +/** + * Default selection state. + */ +export const DEFAULT_SELECTION = { + selectedIds: [] as string[], +}; + /** * State reducer for client-controlled state. * - * Handles user-driven state mutations (search, filters, sort, pagination). + * Handles user-driven state mutations (search, filters, sort, pagination, selection). * Query data (items, loading, error) is managed by React Query directly. * + * Selection is cleared whenever search, filters, sort, or pagination change so that + * `selectedIds` never references items the user can no longer see. + * * @param state - Current client state. * @param action - Action to apply. * @returns New client state. @@ -30,8 +40,8 @@ export const reducer = ( ...state, search: { queryText: action.payload.queryText }, filters: action.payload.filters, - // Reset to first page when search changes to avoid stale page offsets. page: { ...state.page, index: 0 }, + selection: { ...DEFAULT_SELECTION }, }; case CONTENT_LIST_ACTIONS.CLEAR_FILTERS: @@ -39,8 +49,8 @@ export const reducer = ( ...state, filters: { ...DEFAULT_FILTERS }, search: { queryText: '' }, - // Reset to first page when filters are cleared to avoid stale page offsets. page: { ...state.page, index: 0 }, + selection: { ...DEFAULT_SELECTION }, }; case CONTENT_LIST_ACTIONS.SET_SORT: @@ -50,20 +60,36 @@ export const reducer = ( field: action.payload.field, direction: action.payload.direction, }, - // Reset to first page when sort changes to avoid stale page offsets. page: { ...state.page, index: 0 }, + selection: { ...DEFAULT_SELECTION }, }; case CONTENT_LIST_ACTIONS.SET_PAGE_INDEX: return { ...state, page: { ...state.page, index: action.payload.index }, + selection: { ...DEFAULT_SELECTION }, }; case CONTENT_LIST_ACTIONS.SET_PAGE_SIZE: return { ...state, page: { index: 0, size: action.payload.size }, + selection: { ...DEFAULT_SELECTION }, + }; + + case CONTENT_LIST_ACTIONS.SET_SELECTION: + return { + ...state, + selection: { + selectedIds: action.payload.ids, + }, + }; + + case CONTENT_LIST_ACTIONS.CLEAR_SELECTION: + return { + ...state, + selection: { ...DEFAULT_SELECTION }, }; default: diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/types.ts index 7bac3ecaffe82..9f2a70a91506a 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/types.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/types.ts @@ -8,8 +8,8 @@ */ import type { Dispatch } from 'react'; -import type { ContentListItem } from '../item'; import type { ActiveFilters } from '../datasource'; +import type { ContentListItem } from '../item'; /** * Action type constants for state reducer. @@ -25,6 +25,8 @@ export const CONTENT_LIST_ACTIONS = { SET_PAGE_INDEX: 'SET_PAGE_INDEX', /** Set page size. */ SET_PAGE_SIZE: 'SET_PAGE_SIZE', + SET_SELECTION: 'SET_SELECTION', + CLEAR_SELECTION: 'CLEAR_SELECTION', } as const; /** @@ -72,6 +74,11 @@ export interface ContentListClientState { /** Current number of items per page. */ size: number; }; + /** Selection state - IDs of currently selected items. */ + selection: { + /** IDs of selected items. */ + selectedIds: string[]; + }; } /** @@ -141,7 +148,9 @@ export type ContentListAction = | ClearFiltersAction | SetSortAction | { type: typeof CONTENT_LIST_ACTIONS.SET_PAGE_INDEX; payload: { index: number } } - | { type: typeof CONTENT_LIST_ACTIONS.SET_PAGE_SIZE; payload: { size: number } }; + | { type: typeof CONTENT_LIST_ACTIONS.SET_PAGE_SIZE; payload: { size: number } } + | { type: typeof CONTENT_LIST_ACTIONS.SET_SELECTION; payload: { ids: string[] } } + | { type: typeof CONTENT_LIST_ACTIONS.CLEAR_SELECTION }; /** * Context value provided by `ContentListStateProvider`. diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/index.ts index b4b6da1d48579..1cbd32546ea6f 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/index.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/index.ts @@ -43,5 +43,8 @@ export { } from './src/action'; export type { ActionNamespace, ActionProps } from './src/action'; +// Selection hook. +export { useSelection, type UseSelectionReturn } from './src/hooks'; + // Empty state. export { EmptyState, type EmptyStateProps } from './src/empty_state'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/moon.yml b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/moon.yml index bb0b4762cb055..d9d4838346859 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/moon.yml +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/moon.yml @@ -23,6 +23,7 @@ dependsOn: - '@kbn/content-list-mock-data' - '@kbn/i18n' - '@kbn/i18n-react' + - '@kbn/content-list-toolbar' tags: - shared-browser - package diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/delete/delete_action.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/delete/delete_action.test.tsx index cfc35ff87e188..8732e0a665aa7 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/delete/delete_action.test.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/delete/delete_action.test.tsx @@ -19,7 +19,7 @@ const defaultContext: ActionBuilderContext = { }, isReadOnly: false, entityName: 'dashboard', - supports: { sorting: true, pagination: true, search: true }, + supports: { sorting: true, pagination: true, search: true, selection: true }, }; describe('delete action builder', () => { diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/edit/edit_action.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/edit/edit_action.test.tsx index ceef3e137d6df..270537316531e 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/edit/edit_action.test.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/action/edit/edit_action.test.tsx @@ -17,7 +17,7 @@ const defaultContext: ActionBuilderContext = { }, isReadOnly: false, entityName: 'dashboard', - supports: { sorting: true, pagination: true, search: true }, + supports: { sorting: true, pagination: true, search: true, selection: true }, }; describe('edit action builder', () => { diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/actions/actions_builder.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/actions/actions_builder.test.tsx index 51c3c1870e971..61e6e26510db5 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/actions/actions_builder.test.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/actions/actions_builder.test.tsx @@ -26,7 +26,7 @@ const defaultContext: ColumnBuilderContext = { }, isReadOnly: false, entityName: 'dashboard', - supports: { sorting: true, pagination: true, search: true }, + supports: { sorting: true, pagination: true, search: true, selection: true }, }; describe('actions column builder', () => { diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/name/name_builder.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/name/name_builder.test.tsx index 37fbcf58bb9c1..6f331f8c39cba 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/name/name_builder.test.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/name/name_builder.test.tsx @@ -20,7 +20,7 @@ const defaultContext: ColumnBuilderContext = { itemConfig: undefined, isReadOnly: false, entityName: 'dashboard', - supports: { sorting: true, pagination: true, search: false }, + supports: { sorting: true, pagination: true, search: false, selection: true }, }; describe('name column builder', () => { @@ -65,7 +65,7 @@ describe('name column builder', () => { it('forces sortable false when sorting is unsupported', () => { const context: ColumnBuilderContext = { ...defaultContext, - supports: { sorting: false, pagination: true, search: false }, + supports: { sorting: false, pagination: true, search: false, selection: true }, }; const result = buildNameColumn({}, context); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/updated_at/updated_at_builder.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/updated_at/updated_at_builder.test.tsx index 2ae389acb5cc4..9b7aa30c8c893 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/updated_at/updated_at_builder.test.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/column/updated_at/updated_at_builder.test.tsx @@ -22,7 +22,7 @@ const defaultContext: ColumnBuilderContext = { itemConfig: undefined, isReadOnly: false, entityName: 'dashboard', - supports: { sorting: true, pagination: true, search: true }, + supports: { sorting: true, pagination: true, search: true, selection: true }, }; describe('updated at column builder', () => { @@ -67,7 +67,7 @@ describe('updated at column builder', () => { it('forces sortable false when sorting is unsupported', () => { const context: ColumnBuilderContext = { ...defaultContext, - supports: { sorting: false, pagination: true, search: true }, + supports: { sorting: false, pagination: true, search: true, selection: true }, }; const result = buildUpdatedAtColumn({}, context); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.stories.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.stories.tsx index 43d95ec22bedd..61c340ec7fe6c 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.stories.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.stories.tsx @@ -29,6 +29,7 @@ import { useContentListSearch, useContentListPagination, useContentListConfig, + useContentListSelection, } from '@kbn/content-list-provider'; import type { ContentListItem, @@ -36,6 +37,7 @@ import type { FindItemsParams, FindItemsResult, } from '@kbn/content-list-provider'; +import { ContentListToolbar } from '@kbn/content-list-toolbar'; import { MOCK_DASHBOARDS, createMockFindItems } from '@kbn/content-list-mock-data/storybook'; import { ContentListTable } from './content_list_table'; @@ -58,6 +60,8 @@ const createStoryFindItems = (options?: { }) => { const { items = MOCK_DASHBOARDS, delay = 0, isEmpty = false } = options ?? {}; + const availableItems = items; + return async (params: FindItemsParams): Promise => { // Simulate network delay. if (delay > 0) { @@ -70,7 +74,7 @@ const createStoryFindItems = (options?: { } // Use mock findItems for sorting logic. - const mockFindItems = createMockFindItems({ items }); + const mockFindItems = createMockFindItems({ items: availableItems }); const result = await mockFindItems({ searchQuery: params.searchQuery, filters: {}, @@ -107,6 +111,8 @@ interface StateDiagnosticPanelProps { showActions?: boolean; /** Whether custom actions (Share, Archive) are mixed in alongside presets. */ showCustomActions?: boolean; + /** Whether selection is enabled. */ + showSelection?: boolean; } /** @@ -118,16 +124,24 @@ const StateDiagnosticPanel = ({ showTypeColumn = true, showActions = false, showCustomActions = false, + showSelection = false, }: StateDiagnosticPanelProps) => { const [isOpen, setIsOpen] = useState(defaultOpen); const { items, totalItems, isLoading, isFetching, error } = useContentListItems(); const { field: sortField, direction: sortDirection } = useContentListSort(); const { search } = useContentListSearch(); const pagination = useContentListPagination(); + const { selectedIds, selectedCount } = useContentListSelection(); const config = useContentListConfig(); // Generate JSX code based on current configuration. const tableJsx = useMemo(() => { + const parts: string[] = []; + + if (showSelection) { + parts.push(''); + } + const columnChildren: string[] = []; // Name column with optional description. @@ -163,15 +177,17 @@ const StateDiagnosticPanel = ({ columnChildren.push(` \n${actionLines.join('\n')}\n `); } - return ` + parts.push(` ${columnChildren.join('\n')} -`; - }, [showDescription, showTypeColumn, showActions, showCustomActions]); +`); + + return parts.join('\n'); + }, [showDescription, showTypeColumn, showActions, showCustomActions, showSelection]); return ( <> - + + {selectedCount > 0 && ( + + {selectedCount} selected + + )} {isLoading && ( Loading… @@ -222,6 +243,16 @@ ${columnChildren.join('\n')} )} + + + Selection:{' '} + {config.supports.selection ? ( + Enabled + ) : ( + Disabled + )} + + @@ -231,7 +262,7 @@ ${columnChildren.join('\n')}

Sort

- + {JSON.stringify({ field: sortField, direction: sortDirection }, null, 2)} @@ -239,7 +270,7 @@ ${columnChildren.join('\n')}

Search

- + {JSON.stringify({ search, isFetching }, null, 2)} @@ -248,7 +279,7 @@ ${columnChildren.join('\n')}

Pagination

- + {JSON.stringify( { pageIndex: pagination.pageIndex, @@ -261,11 +292,25 @@ ${columnChildren.join('\n')} )} + {showSelection && ( + + +

Selection

+
+ + {JSON.stringify( + { selectedCount, selectedIds: selectedIds.slice(0, 5) }, + null, + 2 + )} + +
+ )}

Table JSX

- + {tableJsx}
@@ -323,6 +368,7 @@ interface PlaygroundArgs { showTypeColumn: boolean; showActions: boolean; showCustomActions: boolean; + showSelection: boolean; hasClickableRows: boolean; showDiagnostics: boolean; } @@ -333,7 +379,7 @@ const { Column, Action } = ContentListTable; /** * Wrapper component for the Playground story. - * Handles stable prop references via useMemo. + * Handles stable prop references via `useMemo`. */ const PlaygroundStoryWrapper = ({ args }: { args: PlaygroundArgs }) => { const [toasts, setToasts] = useState([]); @@ -361,6 +407,10 @@ const PlaygroundStoryWrapper = ({ args }: { args: PlaygroundArgs }) => { return { findItems }; }, [args.isLoading, args.hasItems]); + // Use a ref to keep `getHref` stable while still logging to the actions panel. + const entityNameRef = useRef(args.entityName); + entityNameRef.current = args.entityName; + const itemConfig = useMemo(() => { const config: ContentListItemConfig = {}; @@ -370,9 +420,6 @@ const PlaygroundStoryWrapper = ({ args }: { args: PlaygroundArgs }) => { if (args.showActions) { config.getEditUrl = (item) => `#/${args.entityName}/${item.id}/edit`; - // NOTE: `Action.Delete` is currently a no-op stub. This handler exists so - // `buildDeleteAction` sees `onDelete` and renders the trash icon. The toast - // below won't fire until the Delete Orchestration PR wires the actual flow. config.onDelete = async (items) => { await new Promise((resolve) => setTimeout(resolve, 500)); const names = items.map((item) => item.title).join(', '); @@ -404,7 +451,7 @@ const PlaygroundStoryWrapper = ({ args }: { args: PlaygroundArgs }) => { ); // Key forces re-mount when configuration changes. - const key = `${args.hasItems}-${args.isLoading}-${args.isReadOnly}-${args.hasPagination}-${args.showActions}`; + const key = `${args.hasItems}-${args.isLoading}-${args.isReadOnly}-${args.hasPagination}-${args.showActions}-${args.showSelection}`; return ( <> @@ -420,59 +467,65 @@ const PlaygroundStoryWrapper = ({ args }: { args: PlaygroundArgs }) => { initialSort: { field: 'title', direction: 'asc' }, }, pagination: args.hasPagination ? { initialPageSize: 10 } : (false as const), + selection: args.showSelection, }} > - - - - {args.showTypeColumn && ( - {item.type ?? 'unknown'}} + + + + + + + {args.showTypeColumn && ( + {item.type ?? 'unknown'}} + /> + )} + {args.showActions && ( + + + {args.showCustomActions && ( + <> + + + + )} + + + )} + + {args.showDiagnostics && ( + )} - {args.showActions && ( - - - {args.showCustomActions && ( - <> - - - - )} - - - )} - - {args.showDiagnostics && ( - - )} +
@@ -492,6 +545,7 @@ export const Table: PlaygroundStory = { showTypeColumn: false, showActions: true, showCustomActions: false, + showSelection: true, hasClickableRows: true, showDescription: true, entityName: 'dashboard', @@ -564,6 +618,12 @@ export const Table: PlaygroundStory = { table: { category: 'Actions' }, if: { arg: 'showActions' }, }, + showSelection: { + control: 'boolean', + description: + 'Enable row selection with checkboxes. Shows a delete button in the toolbar that clears the selection when clicked.', + table: { category: 'Selection' }, + }, hasClickableRows: { control: 'boolean', description: 'Make row titles clickable links.', diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.tsx index d9c6d36dd7be1..9b5eb261a87f1 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/content_list_table.tsx @@ -13,7 +13,7 @@ import { EuiBasicTable } from '@elastic/eui'; import { useContentListItems, type ContentListItem } from '@kbn/content-list-provider'; import { Column as BaseColumn, NameColumn, UpdatedAtColumn, ActionsColumn } from './column'; import { Action as BaseAction, EditAction, DeleteAction } from './action'; -import { useColumns, useSorting } from './hooks'; +import { useColumns, useSorting, useSelection } from './hooks'; import { EmptyState } from './empty_state'; /** @@ -69,7 +69,12 @@ export const getRowId = (id: string): string => `content-list-table-row-${id}`; * ContentListTable - Table renderer for content listings. * * Integrates with EUI's EuiBasicTable and ContentListProvider for state management. - * Supports configurable columns via compound components, and empty states. + * Supports configurable columns via compound components, row selection with + * checkboxes, and empty states. + * + * Selection checkboxes are automatically added when `features.selection` is enabled + * (the default). Selection state is managed by the provider and accessible to the + * toolbar for bulk actions via {@link useContentListSelection}. * * @example Basic usage (defaults to Name column) * ```tsx @@ -90,6 +95,14 @@ export const getRowId = (id: string): string => `content-list-table-row-${id}`; * * * ``` + * + * @example With selection and toolbar + * ```tsx + * + * + * + * + * ``` */ const ContentListTableComponent = ({ title, @@ -105,6 +118,7 @@ const ContentListTableComponent = ({ const columns = useColumns(children); const { sorting, onChange } = useSorting(); + const { selection } = useSelection(); const isTableEmpty = !loading && !error && items.length === 0; @@ -124,6 +138,7 @@ const ContentListTableComponent = ({ loading={loading} onChange={onChange} sorting={sorting} + selection={selection} tableLayout={tableLayout} data-test-subj={dataTestSubj} /> diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/index.ts index 81231cd630859..dcb1a0ed04802 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/index.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/index.ts @@ -9,3 +9,4 @@ export { useColumns } from './use_columns'; export { useSorting } from './use_sorting'; +export { useSelection, type UseSelectionReturn } from './use_selection'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/use_selection.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/use_selection.test.tsx new file mode 100644 index 0000000000000..fc9462647902c --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/use_selection.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { ContentListProvider } from '@kbn/content-list-provider'; +import type { FindItemsResult, FindItemsParams, ContentListItem } from '@kbn/content-list-provider'; +import { useSelection } from './use_selection'; + +const mockItems: ContentListItem[] = [ + { id: '1', title: 'Dashboard A' }, + { id: '2', title: 'Dashboard B' }, +]; + +describe('useSelection', () => { + const mockFindItems = jest.fn( + async (_params: FindItemsParams): Promise => ({ + items: mockItems, + total: mockItems.length, + }) + ); + + const createWrapper = (options?: { selectionDisabled?: boolean; isReadOnly?: boolean }) => { + const { selectionDisabled, isReadOnly } = options ?? {}; + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when selection is supported', () => { + it('returns a selection config object', () => { + const { result } = renderHook(() => useSelection(), { + wrapper: createWrapper(), + }); + + expect(result.current.selection).toBeDefined(); + expect(result.current.selection).toHaveProperty('onSelectionChange'); + expect(result.current.selection).toHaveProperty('selected'); + expect(result.current.selection).toHaveProperty('selectable'); + }); + + it('returns `selectable` function that always returns true', () => { + const { result } = renderHook(() => useSelection(), { + wrapper: createWrapper(), + }); + + const { selectable } = result.current.selection!; + expect(selectable!(mockItems[0])).toBe(true); + expect(selectable!(mockItems[1])).toBe(true); + }); + + it('provides `selected` array for controlled selection', () => { + const { result } = renderHook(() => useSelection(), { + wrapper: createWrapper(), + }); + + // Initially empty since no items are selected. + expect(result.current.selection!.selected).toEqual([]); + }); + }); + + describe('when selection is disabled', () => { + it('returns `undefined` when selection feature is disabled', () => { + const { result } = renderHook(() => useSelection(), { + wrapper: createWrapper({ selectionDisabled: true }), + }); + + expect(result.current.selection).toBeUndefined(); + }); + + it('returns `undefined` when in read-only mode', () => { + const { result } = renderHook(() => useSelection(), { + wrapper: createWrapper({ isReadOnly: true }), + }); + + expect(result.current.selection).toBeUndefined(); + }); + }); + + describe('stability', () => { + it('returns a stable selection config across re-renders', () => { + const { result, rerender } = renderHook(() => useSelection(), { + wrapper: createWrapper(), + }); + + const firstSelection = result.current.selection; + rerender(); + const secondSelection = result.current.selection; + + expect(firstSelection).toBe(secondSelection); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/use_selection.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/use_selection.ts new file mode 100644 index 0000000000000..2faf908b39e87 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/src/hooks/use_selection.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useMemo } from 'react'; +import type { EuiTableSelectionType } from '@elastic/eui'; +import { + useContentListConfig, + useContentListSelection, + type ContentListItem, +} from '@kbn/content-list-provider'; + +/** + * Return type for the {@link useSelection} hook. + */ +export interface UseSelectionReturn { + /** + * Selection configuration for `EuiBasicTable`'s `selection` prop. + * Returns `undefined` when selection is not supported (e.g., read-only mode). + */ + selection?: EuiTableSelectionType; +} + +/** + * Hook to integrate content list selection with `EuiBasicTable`. + * + * Bridges the provider's selection state with `EuiBasicTable`'s `selection` prop + * using **controlled mode** (`selected`). This ensures programmatic selection + * changes (e.g., clearing after delete) are reflected in the table checkboxes. + * + * @returns Object containing the `selection` prop for `EuiBasicTable`. + */ +export const useSelection = (): UseSelectionReturn => { + const { supports } = useContentListConfig(); + const { selectedItems, setSelection } = useContentListSelection(); + + const selection: EuiTableSelectionType | undefined = useMemo(() => { + if (!supports.selection) { + return undefined; + } + + // TODO: Accept an optional `selectable` predicate from the provider config to + // support per-row selectability (e.g., permission-gated rows). + return { + onSelectionChange: setSelection, + selected: selectedItems, + selectable: () => true, + }; + }, [supports.selection, setSelection, selectedItems]); + + return { selection }; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/tsconfig.json b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/tsconfig.json index 18bba1bdf5172..7f5bbbc6abb75 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/tsconfig.json +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-table/tsconfig.json @@ -26,6 +26,7 @@ "@kbn/content-list-mock-data", "@kbn/i18n", "@kbn/i18n-react", + "@kbn/content-list-toolbar", ], "exclude": [ "target/**/*" diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/index.ts index 70573a82eac05..621e59877c936 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/index.ts +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/index.ts @@ -13,8 +13,11 @@ * Provides toolbar components for content list UIs, including filters and actions. */ -// Main component (includes `ContentListToolbar.Filters` namespace). +// Main component (includes `ContentListToolbar.Filters` and `ContentListToolbar.SelectionBar` namespaces). export { ContentListToolbar, type ContentListToolbarProps } from './src/content_list_toolbar'; // Filter declarative components for direct imports. export { Filters, SortFilter, type FiltersProps, type SortFilterProps } from './src/filters'; + +// Selection bar component for direct imports. +export { SelectionBar, type SelectionBarProps } from './src/selection_bar'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/content_list_toolbar.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/content_list_toolbar.tsx index 4a0ecb61c8c5b..aeeb0568007cd 100644 --- a/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/content_list_toolbar.tsx +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/content_list_toolbar.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { ReactNode } from 'react'; import { EuiSearchBar } from '@elastic/eui'; import type { EuiSearchBarOnChangeArgs } from '@elastic/eui'; @@ -15,6 +15,7 @@ import { useContentListConfig, useContentListSearch } from '@kbn/content-list-pr import { i18n } from '@kbn/i18n'; import { Filters } from './filters'; import { useFilters } from './hooks'; +import { SelectionBar } from './selection_bar'; /** * Props for the {@link ContentListToolbar} component. @@ -37,6 +38,10 @@ const defaultPlaceholder = i18n.translate( * Provides a toolbar with search and filter controls for content lists using `EuiSearchBar`. * Currently supports search and the Sort filter; additional filters will be added in subsequent PRs. * + * When items are selected in the table, a "Delete N entities" button appears in the + * toolbar's left tools area (via `EuiSearchBar`'s `toolsLeft`), matching the existing + * `TableListView` pattern. + * * **Smart Defaults**: When no children are provided, auto-renders filters * based on provider configuration. * @@ -65,7 +70,7 @@ const ContentListToolbarComponent = ({ children, 'data-test-subj': dataTestSubj = 'contentListToolbar', }: ContentListToolbarProps) => { - const { labels } = useContentListConfig(); + const { labels, supports } = useContentListConfig(); const { search, setSearch, isSupported: searchIsSupported } = useContentListSearch(); const filters = useFilters(children); @@ -81,6 +86,16 @@ const ContentListToolbarComponent = ({ [setSearch] ); + // Only include the selection bar when selection is supported to avoid + // running selection hooks unnecessarily in read-only or selection-disabled modes. + const toolsLeft = useMemo( + () => + supports.selection + ? [] + : undefined, + [supports.selection, dataTestSubj] + ); + return ( @@ -98,4 +114,7 @@ const ContentListToolbarComponent = ({ }; // Attach sub-components to `ContentListToolbar` namespace. -export const ContentListToolbar = Object.assign(ContentListToolbarComponent, { Filters }); +export const ContentListToolbar = Object.assign(ContentListToolbarComponent, { + Filters, + SelectionBar, +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/index.ts new file mode 100644 index 0000000000000..4f217d49c6cc7 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { SelectionBar, type SelectionBarProps } from './selection_bar'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/selection_bar.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/selection_bar.test.tsx new file mode 100644 index 0000000000000..841e9d8d0e304 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/selection_bar.test.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { ContentListProvider, useContentListSelection } from '@kbn/content-list-provider'; +import type { FindItemsResult, FindItemsParams, ContentListItem } from '@kbn/content-list-provider'; +import { SelectionBar } from './selection_bar'; + +const mockItems: ContentListItem[] = [ + { id: '1', title: 'Dashboard A' }, + { id: '2', title: 'Dashboard B' }, + { id: '3', title: 'Dashboard C' }, +]; + +/** + * Helper component that selects items before rendering the `SelectionBar`. + * Uses the `useContentListSelection` hook to set the selection programmatically. + */ +const SelectionBarWithSetup = ({ itemsToSelect }: { itemsToSelect: ContentListItem[] }) => { + const { setSelection, selectedCount } = useContentListSelection(); + + // Select items on first render. + React.useEffect(() => { + setSelection(itemsToSelect); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (selectedCount === 0) { + return null; + } + + return ; +}; + +describe('SelectionBar', () => { + const mockFindItems = jest.fn( + async (_params: FindItemsParams): Promise => ({ + items: mockItems, + total: mockItems.length, + }) + ); + + const createWrapper = () => { + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a delete button with item count and entity name', async () => { + const Wrapper = createWrapper(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Delete 2 dashboards')).toBeInTheDocument(); + }); + }); + + it('renders singular entity name for single selection', async () => { + const Wrapper = createWrapper(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Delete 1 dashboard')).toBeInTheDocument(); + }); + }); + + it('renders a danger button with trash icon', async () => { + const Wrapper = createWrapper(); + render( + + + + ); + + await waitFor(() => { + const button = screen.getByTestId('contentListSelectionBar-deleteButton'); + expect(button).toBeInTheDocument(); + // EUI uses Emotion CSS-in-JS; check for `danger` in the class string. + expect(button.className).toContain('danger'); + }); + }); + + it('returns null when no items are selected', () => { + const Wrapper = createWrapper(); + const { container } = render( + + + + ); + + expect(container.innerHTML).toBe(''); + }); + + it('has the correct test subject on the button', async () => { + const Wrapper = createWrapper(); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('contentListSelectionBar-deleteButton')).toBeInTheDocument(); + }); + }); + + it('clears the selection when the delete button is clicked', async () => { + const Wrapper = createWrapper(); + render( + + + + ); + + // Wait for the button to appear. + await waitFor(() => { + expect(screen.getByTestId('contentListSelectionBar-deleteButton')).toBeInTheDocument(); + }); + + // Click the delete button. + fireEvent.click(screen.getByTestId('contentListSelectionBar-deleteButton')); + + // The selection is cleared, so `SelectionBarWithSetup` renders null. + await waitFor(() => { + expect(screen.queryByTestId('contentListSelectionBar-deleteButton')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/selection_bar.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/selection_bar.tsx new file mode 100644 index 0000000000000..6fe8106f4ae58 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-toolbar/src/selection_bar/selection_bar.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo } from 'react'; +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useContentListConfig, useContentListSelection } from '@kbn/content-list-provider'; + +export interface SelectionBarProps { + /** Optional `data-test-subj` attribute for testing. */ + 'data-test-subj'?: string; +} + +/** + * Selection actions rendered as `toolsLeft` inside the `EuiSearchBar`. + * + * When items are selected, renders a danger button labelled + * "Delete {count} {entity}" matching the existing `TableListView` pattern. + * + * The button currently **clears the selection** as a placeholder action. + * Delete orchestration will be wired in a follow-up PR that adds + * provider-level delete support. + * + * Returns `null` when nothing is selected. + * + * @internal Rendered automatically by {@link ContentListToolbar}. + */ +export const SelectionBar = ({ + 'data-test-subj': dataTestSubj = 'contentListSelectionBar', +}: SelectionBarProps) => { + const { labels } = useContentListConfig(); + const { selectedCount, clearSelection } = useContentListSelection(); + + const buttonLabel = useMemo( + () => + i18n.translate('contentManagement.contentList.toolbar.selectionBar.deleteButton', { + defaultMessage: 'Delete {itemCount} {entityName}', + values: { + itemCount: selectedCount, + entityName: selectedCount === 1 ? labels.entity : labels.entityPlural, + }, + }), + [selectedCount, labels.entity, labels.entityPlural] + ); + + if (selectedCount === 0) { + return null; + } + + return ( + + {buttonLabel} + + ); +}; diff --git a/src/platform/packages/shared/controls/controls-schemas/src/legacy_types.ts b/src/platform/packages/shared/controls/controls-schemas/src/legacy_types.ts index 5e9ada1c2652c..73b70c26ef245 100644 --- a/src/platform/packages/shared/controls/controls-schemas/src/legacy_types.ts +++ b/src/platform/packages/shared/controls/controls-schemas/src/legacy_types.ts @@ -44,13 +44,13 @@ export type LegacyStoredOptionsListExplicitInput = LegacyStoredDataControlState hideExists?: boolean; hideSort?: boolean; }; - exclude?: boolean; - existsSelected?: boolean; - runPastTimeout?: boolean; - searchTechnique?: string; - selectedOptions?: Array; - singleSelect?: boolean; - sort?: { by: string; direction: string }; + exclude?: boolean | null; + existsSelected?: boolean | null; + runPastTimeout?: boolean | null; + searchTechnique?: string | null; + selectedOptions?: Array | null; + singleSelect?: boolean | null; + sort?: { by: string; direction: string } | null; }; export type LegacyStoredRangeSliderExplicitInput = LegacyStoredDataControlState & { diff --git a/src/platform/packages/shared/kbn-alerting-types/rule_types.ts b/src/platform/packages/shared/kbn-alerting-types/rule_types.ts index 031b9f04b5318..7042f092948ba 100644 --- a/src/platform/packages/shared/kbn-alerting-types/rule_types.ts +++ b/src/platform/packages/shared/kbn-alerting-types/rule_types.ts @@ -247,6 +247,7 @@ export interface Rule { apiKey: string | null; apiKeyOwner: string | null; apiKeyCreatedByUser?: boolean | null; + uiamApiKey?: string | null; throttle?: string | null; muteAll: boolean; notifyWhen?: RuleNotifyWhenType | null; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts index 2cb2406a265cc..a961a2b5f436e 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts @@ -20,3 +20,4 @@ export * from './specs/urlvoid/urlvoid'; export * from './specs/virustotal/virustotal'; export * from './specs/jina/jina_reader'; export * from './specs/sharepoint_online/sharepoint_online'; +export * from './specs/slack/slack'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts index e9e9ac665521b..bee6151b6c550 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts @@ -86,4 +86,8 @@ export const ConnectorIconsMap: Map< () => import(/* webpackChunkName: "connectorIconGoogleDrive" */ './specs/google_drive/icon') ), ], + [ + '.slack2', + lazy(() => import(/* webpackChunkName: "connectorIconSlack2" */ './specs/slack/icon')), + ], ]); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/icon/index.tsx b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/icon/index.tsx new file mode 100644 index 0000000000000..cf950909c1d5f --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/icon/index.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiIcon } from '@elastic/eui'; +import type { ConnectorIconProps } from '../../../types'; +import slackIcon from './slack.svg'; + +export default (props: ConnectorIconProps) => { + return ; +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/icon/slack.svg b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/icon/slack.svg new file mode 100644 index 0000000000000..552efad2883a6 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/icon/slack.svg @@ -0,0 +1 @@ + diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/slack.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/slack.test.ts new file mode 100644 index 0000000000000..1fe985ae4c66f --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/slack.test.ts @@ -0,0 +1,397 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ActionContext } from '../../connector_spec'; +import { Slack } from './slack'; + +describe('Slack', () => { + const mockClient = { + get: jest.fn(), + post: jest.fn(), + }; + + const mockContext = { + client: mockClient, + log: { debug: jest.fn(), error: jest.fn() }, + } as unknown as ActionContext; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(Slack).toBeDefined(); + }); + + it('should have correct metadata', () => { + expect(Slack.metadata.id).toBe('.slack2'); + expect(Slack.metadata.displayName).toBe('Slack (v2)'); + expect(Slack.metadata.minimumLicense).toBe('enterprise'); + expect(Slack.metadata.supportedFeatureIds).toContain('workflows'); + }); + + it('should use bearer auth type', () => { + expect(Slack.auth).toBeDefined(); + expect(Slack.auth?.types.length).toBeGreaterThanOrEqual(1); + const types = (Slack.auth?.types as Array).map((t) => + typeof t === 'string' ? t : t.type + ); + expect(types).toContain('bearer'); + }); + + describe('searchMessages action', () => { + it('should search messages with required query', async () => { + const mockResponse = { + data: { + ok: true, + results: { messages: [] }, + response_metadata: { next_cursor: '' }, + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + const result = await Slack.actions.searchMessages.handler(mockContext, { query: 'hello' }); + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://slack.com/api/assistant.search.context', + expect.objectContaining({ + query: 'hello', + content_types: ['messages'], + }), + expect.any(Object) + ); + expect(result).toEqual({ + ok: true, + query: 'hello', + total: 0, + response_metadata: { next_cursor: '' }, + matches: [], + }); + }); + + it('should include optional parameters', async () => { + const mockResponse = { + data: { + ok: true, + results: { messages: [] }, + response_metadata: { next_cursor: '' }, + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + await Slack.actions.searchMessages.handler(mockContext, { + query: 'test', + sort: 'timestamp', + sortDir: 'desc', + count: 20, + cursor: 'abc', + includeContextMessages: true, + includeBots: false, + includeMessageBlocks: true, + inChannel: 'general', + fromUser: '@U123', + after: '2026-01-01', + before: '2026-02-01', + }); + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://slack.com/api/assistant.search.context', + expect.objectContaining({ + query: 'test in:general from:@U123 after:2026-01-01 before:2026-02-01', + sort: 'timestamp', + sort_dir: 'desc', + cursor: 'abc', + include_context_messages: true, + include_bots: false, + include_message_blocks: true, + }), + expect.any(Object) + ); + }); + + it('should return raw Slack response when raw=true', async () => { + const mockResponse = { + data: { + ok: true, + results: { messages: [{ content: 'hi' }] }, + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + const result = await Slack.actions.searchMessages.handler(mockContext, { + query: 'hello', + raw: true, + }); + + expect(result).toEqual(mockResponse.data); + }); + + it('should throw error when Slack API returns error', async () => { + const mockResponse = { data: { ok: false, error: 'invalid_auth' } }; + mockClient.post.mockResolvedValue(mockResponse); + + await expect( + Slack.actions.searchMessages.handler(mockContext, { query: 'test' }) + ).rejects.toThrow('Slack searchMessages error: invalid_auth'); + }); + }); + + describe('resolveChannelId action', () => { + it('should resolve a channel id by exact name match', async () => { + const mockResponse = { + data: { + ok: true, + channels: [ + { id: 'C1', name: 'general', is_archived: false }, + { id: 'C2', name: 'random', is_archived: false }, + ], + response_metadata: { next_cursor: 'next123' }, + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = await Slack.actions.resolveChannelId.handler(mockContext, { + name: '#general', + }); + + expect(mockClient.get).toHaveBeenCalledWith('https://slack.com/api/conversations.list', { + params: { + types: 'public_channel', + exclude_archived: true, + limit: 1000, + }, + }); + expect(result).toEqual({ + ok: true, + found: true, + id: 'C1', + name: 'general', + source: 'conversations.list', + pagesFetched: 1, + nextCursor: 'next123', + }); + }); + + it('should paginate until found (contains match)', async () => { + mockClient.get + .mockResolvedValueOnce({ + data: { + ok: true, + channels: [{ id: 'C9', name: 'zzz' }], + response_metadata: { next_cursor: 'c2' }, + }, + }) + .mockResolvedValueOnce({ + data: { + ok: true, + channels: [{ id: 'C7', name: 'alerts-prod' }], + response_metadata: { next_cursor: '' }, + }, + }); + + const result = await Slack.actions.resolveChannelId.handler(mockContext, { + name: 'alerts', + match: 'contains', + maxPages: 5, + }); + + expect(mockClient.get).toHaveBeenCalledTimes(2); + expect(mockClient.get).toHaveBeenNthCalledWith( + 1, + 'https://slack.com/api/conversations.list', + { + params: { types: 'public_channel', exclude_archived: true, limit: 1000 }, + } + ); + expect(mockClient.get).toHaveBeenNthCalledWith( + 2, + 'https://slack.com/api/conversations.list', + { + params: { types: 'public_channel', exclude_archived: true, limit: 1000, cursor: 'c2' }, + } + ); + expect(result).toMatchObject({ + ok: true, + found: true, + id: 'C7', + source: 'conversations.list', + pagesFetched: 2, + }); + }); + }); + + describe('sendMessage action', () => { + it('should send message with required parameters', async () => { + const mockResponse = { + data: { + ok: true, + channel: 'C123', + ts: '1234567890.123456', + message: { + text: 'Hello from Kibana', + user: 'U123', + type: 'message', + }, + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + const result = await Slack.actions.sendMessage.handler(mockContext, { + channel: 'C123', + text: 'Hello from Kibana', + }); + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://slack.com/api/chat.postMessage', + { + channel: 'C123', + text: 'Hello from Kibana', + }, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + } + ); + expect(result).toEqual(mockResponse.data); + }); + + it('should send threaded reply', async () => { + const mockResponse = { + data: { + ok: true, + channel: 'C123', + ts: '1234567890.123457', + message: { + text: 'Reply message', + thread_ts: '1234567890.123456', + }, + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + await Slack.actions.sendMessage.handler(mockContext, { + channel: 'C123', + text: 'Reply message', + threadTs: '1234567890.123456', + }); + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://slack.com/api/chat.postMessage', + { + channel: 'C123', + text: 'Reply message', + thread_ts: '1234567890.123456', + }, + expect.any(Object) + ); + }); + + it('should include unfurl options', async () => { + const mockResponse = { + data: { + ok: true, + channel: 'C123', + ts: '1234567890.123456', + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + await Slack.actions.sendMessage.handler(mockContext, { + channel: 'C123', + text: 'Check out https://example.com', + unfurlLinks: true, + unfurlMedia: false, + }); + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://slack.com/api/chat.postMessage', + { + channel: 'C123', + text: 'Check out https://example.com', + unfurl_links: true, + unfurl_media: false, + }, + expect.any(Object) + ); + }); + + it('should throw error when Slack API returns error', async () => { + const mockResponse = { + data: { + ok: false, + error: 'channel_not_found', + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + await expect( + Slack.actions.sendMessage.handler(mockContext, { + channel: 'invalid-channel', + text: 'Hello', + }) + ).rejects.toThrow('Slack sendMessage error: channel_not_found'); + }); + }); + + describe('test handler', () => { + it('should return success when API is accessible', async () => { + const mockResponse = { + data: { + ok: true, + url: 'https://myteam.slack.com/', + team: 'My Team', + user: 'testbot', + team_id: 'T123', + user_id: 'U123', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + if (!Slack.test) { + throw new Error('Test handler not defined'); + } + const result = await Slack.test.handler(mockContext); + + expect(mockClient.get).toHaveBeenCalledWith('https://slack.com/api/auth.test'); + expect(result.ok).toBe(true); + expect(result.message).toContain('My Team'); + }); + + it('should return failure when Slack API returns error', async () => { + const mockResponse = { + data: { + ok: false, + error: 'invalid_auth', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + if (!Slack.test) { + throw new Error('Test handler not defined'); + } + const result = await Slack.test.handler(mockContext); + + expect(result.ok).toBe(false); + expect(result.message).toContain('invalid_auth'); + }); + + it('should return failure on network error', async () => { + mockClient.get.mockRejectedValue(new Error('Network timeout')); + + if (!Slack.test) { + throw new Error('Test handler not defined'); + } + const result = await Slack.test.handler(mockContext); + + expect(result.ok).toBe(false); + expect(result.message).toBe('Network timeout'); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/slack.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/slack.ts new file mode 100644 index 0000000000000..6970ef79a5e21 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/slack.ts @@ -0,0 +1,818 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; +import { z } from '@kbn/zod/v4'; +import type { AxiosError, AxiosResponse } from 'axios'; +import type { ConnectorSpec, ActionContext } from '../../connector_spec'; +import type { SlackAssistantSearchContextResponse, SlackErrorFields } from './types'; + +const SLACK_API_BASE = 'https://slack.com/api'; +const ENABLE_TEMPORARY_MANUAL_TOKEN_AUTH = true; // Temporary: remove once OAuth support is unblocked. +const SLACK_CONVERSATION_TYPES = ['public_channel', 'private_channel', 'im', 'mpim'] as const; + +// Slack API/connector constants (avoid magic numbers) +const SLACK_MAX_SEARCH_RESULTS_PER_PAGE = 20; +const SLACK_SEARCH_DEFAULT_COUNT = SLACK_MAX_SEARCH_RESULTS_PER_PAGE; +const SLACK_MAX_CONVERSATIONS_LIST_LIMIT = 1000; +const SLACK_DEFAULT_CONVERSATIONS_LIST_LIMIT = SLACK_MAX_CONVERSATIONS_LIST_LIMIT; +const SLACK_DEFAULT_RESOLVE_CHANNEL_MAX_PAGES = 10; +const SLACK_MAX_RESOLVE_CHANNEL_MAX_PAGES = 100; + +const SLACK_RETRY_DEFAULT_BASE_DELAY_MS = 1000; +const SLACK_RETRY_JITTER_MAX_MS = 250; +const SLACK_RETRY_MAX_DELAY_MS = 60_000; +const SLACK_RETRY_EXPONENT_CAP = 6; +const SLACK_MAX_RETRIES = 5; + +// Tiny async sleep helper +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const SlackSearchMessagesInputSchema = z.object({ + query: z + .string() + .min(1) + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.query.description', + { + defaultMessage: + 'Search query to find messages (supports Slack search operators; see optional constraint fields)', + } + ) + ), + inChannel: z + .string() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.inChannel.description', + { + defaultMessage: + 'Optional Slack search constraint. Adds `in:` to the query.', + } + ) + ), + fromUser: z + .string() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.fromUser.description', + { + defaultMessage: + 'Optional Slack search constraint. Adds `from:<@UserID>` or `from:username` to the query.', + } + ) + ), + after: z + .string() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.after.description', + { + defaultMessage: + 'Optional Slack search constraint. Adds `after:` to the query (e.g. 2026-02-10).', + } + ) + ), + before: z + .string() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.before.description', + { + defaultMessage: + 'Optional Slack search constraint. Adds `before:` to the query (e.g. 2026-02-10).', + } + ) + ), + sort: z + .enum(['score', 'timestamp']) + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.sort.description', + { + defaultMessage: 'Sort order: score (relevance) or timestamp', + } + ) + ), + sortDir: z + .enum(['asc', 'desc']) + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.sortDir.description', + { defaultMessage: 'Sort direction' } + ) + ), + count: z + .number() + .int() + .min(1) + .max(SLACK_MAX_SEARCH_RESULTS_PER_PAGE) + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.count.description', + { + defaultMessage: `Number of results to return (1-${SLACK_MAX_SEARCH_RESULTS_PER_PAGE}). Slack returns up to ${SLACK_MAX_SEARCH_RESULTS_PER_PAGE} results per page.`, + } + ) + ), + cursor: z + .string() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.cursor.description', + { + defaultMessage: + 'Pagination cursor to fetch the next page of results (use response_metadata.next_cursor from a previous call).', + } + ) + ), + includeContextMessages: z + .boolean() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.includeContextMessages.description', + { + defaultMessage: + 'Include contextual messages (messages before/after the matched message, or thread context). Defaults to true.', + } + ) + ), + includeBots: z + .boolean() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.includeBots.description', + { + defaultMessage: 'Include bot-authored messages. Defaults to false.', + } + ) + ), + includeMessageBlocks: z + .boolean() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.includeMessageBlocks.description', + { + defaultMessage: + 'Include Block Kit blocks in message results (useful for extracting mentions/links). Defaults to true.', + } + ) + ), + raw: z + .boolean() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.input.raw.description', + { + defaultMessage: + 'Return the full raw Slack API response instead of a compact, LLM-friendly result.', + } + ) + ), +}); +type SlackSearchMessagesInput = z.infer; + +const SlackResolveChannelIdInputSchema = z.object({ + name: z + .string() + .min(1) + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.input.name.description', + { + defaultMessage: + 'Channel name to resolve (e.g. "general" or "#general"). Returns the matching conversation ID (C.../G...).', + } + ) + ), + types: z + .array(z.enum(SLACK_CONVERSATION_TYPES)) + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.input.types.description', + { + defaultMessage: + 'Conversation types to search. Defaults to public_channel. Valid: public_channel, private_channel, im, mpim.', + } + ) + ), + match: z + .enum(['exact', 'contains']) + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.input.match.description', + { + defaultMessage: + 'How to match the channel name. exact is fastest/most precise. contains can help when you only know part of the name.', + } + ) + ), + excludeArchived: z + .boolean() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.input.excludeArchived.description', + { defaultMessage: 'Exclude archived channels (default true)' } + ) + ), + cursor: z + .string() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.input.cursor.description', + { defaultMessage: 'Optional cursor to resume a previous scan (advanced). Usually omit.' } + ) + ), + limit: z + .number() + .int() + .min(1) + .max(SLACK_MAX_CONVERSATIONS_LIST_LIMIT) + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.input.limit.description', + { + defaultMessage: `Channels per page to request (1-${SLACK_MAX_CONVERSATIONS_LIST_LIMIT}). Defaults to ${SLACK_DEFAULT_CONVERSATIONS_LIST_LIMIT}.`, + } + ) + ), + maxPages: z + .number() + .int() + .min(1) + .max(SLACK_MAX_RESOLVE_CHANNEL_MAX_PAGES) + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.input.maxPages.description', + { + defaultMessage: `Maximum number of pages to scan before giving up. Defaults to ${SLACK_DEFAULT_RESOLVE_CHANNEL_MAX_PAGES}.`, + } + ) + ), +}); +type SlackResolveChannelIdInput = z.infer; + +const SlackSendMessageInputSchema = z.object({ + channel: z + .string() + .min(1) + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.sendMessage.input.channel.description', + { + defaultMessage: + 'Conversation ID to send the message to (e.g. C... for channels, G... for private channels, D... for DMs). Use resolveChannelId to discover channel IDs.', + } + ) + ), + text: z + .string() + .min(1) + .describe( + i18n.translate('core.kibanaConnectorSpecs.slack.actions.sendMessage.input.text.description', { + defaultMessage: 'The message text to send', + }) + ), + threadTs: z + .string() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.sendMessage.input.threadTs.description', + { + defaultMessage: 'Timestamp of another message to reply to (creates a threaded reply)', + } + ) + ), + unfurlLinks: z + .boolean() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.sendMessage.input.unfurlLinks.description', + { + defaultMessage: 'Whether to enable unfurling of primarily text-based content', + } + ) + ), + unfurlMedia: z + .boolean() + .optional() + .describe( + i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.sendMessage.input.unfurlMedia.description', + { + defaultMessage: 'Whether to enable unfurling of media content', + } + ) + ), +}); +type SlackSendMessageInput = z.infer; + +const isRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); + +function getHeader(headers: unknown, headerName: string): string | undefined { + if (!isRecord(headers)) return undefined; + const needle = headerName.toLowerCase(); + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() !== needle) continue; + if (typeof v === 'string') return v; + if (Array.isArray(v) && typeof v[0] === 'string') return v[0]; + } + return undefined; +} + +function getSlackErrorFields(responseData: unknown): SlackErrorFields { + if (!isRecord(responseData)) return {}; + return { + error: asString(responseData.error), + needed: asString(responseData.needed), + provided: asString(responseData.provided), + }; +} + +function formatSlackApiErrorMessage(params: { + action: string; + responseData?: unknown; + responseHeaders?: unknown; +}) { + const { action, responseData } = params; + const { error: slackError, needed, provided } = getSlackErrorFields(responseData); + const error = slackError ?? 'unknown_error'; + + const extras: string[] = []; + // Be careful about echoing back scope details in user-facing errors. We include only the minimum + // Slack-provided hints that help diagnose the failure without exposing token scope inventories. + if (needed) extras.push(`needed=${needed}`); + if (provided) extras.push(`provided=${provided}`); + + return extras.length > 0 + ? `Slack ${action} error: ${error} (${extras.join(', ')})` + : `Slack ${action} error: ${error}`; +} + +function getSlackRetryDelayMs(params: { + responseHeaders?: unknown; + attempt: number; + defaultBaseDelayMs?: number; +}) { + const { + responseHeaders, + attempt, + defaultBaseDelayMs = SLACK_RETRY_DEFAULT_BASE_DELAY_MS, + } = params; + const retryAfter = getHeader(responseHeaders, 'retry-after'); + const retryAfterSeconds = typeof retryAfter === 'string' ? Number(retryAfter) : NaN; + + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { + // Add a small jitter so multiple callers don't retry in lockstep. + const jitterMs = Math.floor(Math.random() * SLACK_RETRY_JITTER_MAX_MS); + return Math.min(SLACK_RETRY_MAX_DELAY_MS, Math.floor(retryAfterSeconds * 1000) + jitterMs); + } + + // Fallback exponential backoff with jitter. + const exp = Math.min(SLACK_RETRY_EXPONENT_CAP, Math.max(0, attempt)); // cap at 2^cap + const base = defaultBaseDelayMs * Math.pow(2, exp); + const jitterMs = Math.floor(Math.random() * SLACK_RETRY_JITTER_MAX_MS); + return Math.min(SLACK_RETRY_MAX_DELAY_MS, base + jitterMs); +} + +async function slackRequestWithRateLimitRetry(params: { + ctx: ActionContext; + action: string; + request: () => Promise>; + maxRetries?: number; +}): Promise> { + const { ctx, action, request, maxRetries = 3 } = params; + + // Total attempts = maxRetries + 1 (initial attempt + retries) + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await request(); + } catch (error) { + const err = error as AxiosError; + + const status = err.response?.status; + const slackError = getSlackErrorFields(err.response?.data).error; + const isRateLimited = + status === 429 || + slackError === 'ratelimited' || + (typeof err.message === 'string' && err.message.includes('ratelimited')); + + if (!isRateLimited || attempt === maxRetries) { + throw error; + } + + const delayMs = getSlackRetryDelayMs({ + responseHeaders: err.response?.headers, + attempt, + }); + ctx.log.debug( + `Slack ${action} rate limited (attempt ${ + attempt + 1 + }/${maxRetries}). Sleeping ${delayMs}ms before retry.` + ); + await sleep(delayMs); + } + } + + throw new Error(`Slack ${action} failed after ${maxRetries + 1} attempts`); +} + +/** + * Slack connector using OAuth2 Authorization Code flow (Slack OAuth v2), + * with an additional temporary bearer token option for local testing. + * + * Required Slack App scopes: + * MVP: + * - channels:read - to list channels/conversations (public/private/DMs depending on workspace + membership) + * - chat:write - for sending messages to public channels + * - search:read.public (and related granular scopes) - for searching messages (requires a user token) + * + * Optional (possible future usage): + * - groups:read - to list private channels (future) + * - im:read - to list DMs (future) + * - mpim:read - to list group DMs (future) + * - groups:history - to read private channel history (future) + * - im:history - to read DM history (future) + * - mpim:history - to read group DM history (future) + * - users:read,users:read.email - to support user-targeted lookups (not used in MVP) + */ +export const Slack: ConnectorSpec = { + metadata: { + id: '.slack2', + displayName: 'Slack (v2)', + description: i18n.translate('core.kibanaConnectorSpecs.slack.metadata.description', { + defaultMessage: 'List public channels and send messages to Slack channels', + }), + minimumLicense: 'enterprise', + supportedFeatureIds: ['workflows'], + }, + + auth: { + types: [ + ...(ENABLE_TEMPORARY_MANUAL_TOKEN_AUTH + ? ([ + { + type: 'bearer', + defaults: { + token: '', + }, + overrides: { + meta: { + token: { + sensitive: true, + label: i18n.translate( + 'core.kibanaConnectorSpecs.slack.auth.temporaryManualToken.label', + { + defaultMessage: 'Temporary Slack user token', + } + ), + helpText: i18n.translate( + 'core.kibanaConnectorSpecs.slack.auth.temporaryManualToken.helpText', + { + defaultMessage: + 'Temporary option for testing only. Paste a Slack user token (e.g. xoxp-...) here.', + } + ), + }, + }, + }, + }, + ] as const) + : []), + ], + }, + + // No additional configuration needed beyond OAuth credentials + schema: z.object({}), + + actions: { + // https://api.slack.com/methods/assistant.search.context + searchMessages: { + isTool: true, + description: i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.searchMessages.description', + { + defaultMessage: + 'Search Slack messages using the Real-time Search API (assistant.search.context)', + } + ), + input: SlackSearchMessagesInputSchema, + handler: async (ctx, input) => { + const typedInput: SlackSearchMessagesInput = SlackSearchMessagesInputSchema.parse(input); + + const queryParts: string[] = [typedInput.query]; + if (typedInput.inChannel) queryParts.push(`in:${typedInput.inChannel}`); + if (typedInput.fromUser) queryParts.push(`from:${typedInput.fromUser}`); + if (typedInput.after) queryParts.push(`after:${typedInput.after}`); + if (typedInput.before) queryParts.push(`before:${typedInput.before}`); + const finalQuery = queryParts.filter(Boolean).join(' '); + + const count = typedInput.count ?? SLACK_SEARCH_DEFAULT_COUNT; + const requestBody: Record = { + query: finalQuery, + channel_types: ['public_channel', 'private_channel', 'mpim', 'im'], + content_types: ['messages'], + include_context_messages: typedInput.includeContextMessages ?? true, + include_bots: typedInput.includeBots ?? false, + include_message_blocks: typedInput.includeMessageBlocks ?? true, + }; + if (typedInput.sort) requestBody.sort = typedInput.sort; + if (typedInput.sortDir) requestBody.sort_dir = typedInput.sortDir; + if (typedInput.cursor) requestBody.cursor = typedInput.cursor; + + try { + ctx.log.debug(`Slack searchMessages request`); + const response = + await slackRequestWithRateLimitRetry({ + ctx, + action: 'searchMessages', + maxRetries: SLACK_MAX_RETRIES, + request: () => + ctx.client.post(`${SLACK_API_BASE}/assistant.search.context`, requestBody, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }), + }); + + if (!response.data.ok) { + throw new Error( + formatSlackApiErrorMessage({ + action: 'searchMessages', + responseData: response.data, + responseHeaders: response.headers, + }) + ); + } + + if (typedInput.raw) { + return response.data; + } + + const messages = response.data.results?.messages ?? []; + const limitedMessages = messages.slice(0, Math.min(count, messages.length)); + + return { + ok: true, + query: finalQuery, + total: messages.length, + response_metadata: response.data.response_metadata, + matches: limitedMessages.map((m) => { + return { + ts: m.message_ts, + team: m.team_id, + text: m.content, + permalink: m.permalink, + channel: { id: m.channel_id, name: m.channel_name }, + sender: { userId: m.author_user_id, username: m.author_name }, + }; + }), + }; + } catch (error) { + const err = error as AxiosError; + ctx.log.error( + `Slack searchMessages failed: ${err.message}, Status: ${ + err.response?.status + }, Data: ${JSON.stringify(err.response?.data)}` + ); + throw error; + } + }, + }, + + // Helper for LLMs: resolve a channel ID (C.../G...) from a human name (e.g. "#general"). + // Deterministic (uses conversations.list). No caching to avoid cross-tenant/process state. + // https://api.slack.com/methods/conversations.list + resolveChannelId: { + isTool: true, + description: i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.resolveChannelId.description', + { + defaultMessage: + 'Resolve a Slack channel/conversation ID from a channel name (rate-limit-aware pagination)', + } + ), + input: SlackResolveChannelIdInputSchema, + handler: async (ctx, input) => { + const typedInput: SlackResolveChannelIdInput = + SlackResolveChannelIdInputSchema.parse(input); + + const nameNorm = typedInput.name.trim().replace(/^#/, '').toLowerCase(); + const types = + typedInput.types && typedInput.types.length > 0 + ? typedInput.types + : (['public_channel'] as Array<(typeof SLACK_CONVERSATION_TYPES)[number]>); + const match = typedInput.match ?? 'exact'; + const excludeArchived = typedInput.excludeArchived ?? true; + const limit = typedInput.limit ?? SLACK_DEFAULT_CONVERSATIONS_LIST_LIMIT; + const maxPages = typedInput.maxPages ?? SLACK_DEFAULT_RESOLVE_CHANNEL_MAX_PAGES; + + let cursor = typedInput.cursor; + let pagesFetched = 0; + + while (pagesFetched < maxPages) { + const params: Record = { + types: types.join(','), + exclude_archived: excludeArchived, + limit, + ...(cursor ? { cursor } : {}), + }; + + ctx.log.debug(`Slack resolveChannelId scan (page ${pagesFetched + 1})`); + const response = await slackRequestWithRateLimitRetry<{ + ok: boolean; + error?: string; + needed?: string; + provided?: string; + channels?: Array<{ id?: string; name?: string }>; + response_metadata?: { next_cursor?: string }; + }>({ + ctx, + action: 'resolveChannelId', + maxRetries: SLACK_MAX_RETRIES, + request: () => ctx.client.get(`${SLACK_API_BASE}/conversations.list`, { params }), + }); + + if (!response.data.ok) { + throw new Error( + formatSlackApiErrorMessage({ + action: 'resolveChannelId', + responseData: response.data, + responseHeaders: response.headers, + }) + ); + } + + const channels = response.data.channels ?? []; + const found = channels.find((c) => { + const cName = (c.name ?? '').toString().toLowerCase(); + if (!cName) return false; + return match === 'exact' ? cName === nameNorm : cName.includes(nameNorm); + }); + + if (found?.id) { + return { + ok: true, + found: true, + id: found.id, + name: found.name ?? nameNorm, + source: 'conversations.list', + pagesFetched: pagesFetched + 1, + nextCursor: response.data.response_metadata?.next_cursor, + }; + } + + const next = response.data.response_metadata?.next_cursor; + pagesFetched += 1; + if (!next) { + cursor = undefined; + break; + } + cursor = next; + } + + return { + ok: true, + found: false, + id: undefined, + name: nameNorm, + source: 'conversations.list', + pagesFetched, + nextCursor: cursor, + }; + }, + }, + + // https://api.slack.com/methods/chat.postMessage + sendMessage: { + isTool: true, + description: i18n.translate( + 'core.kibanaConnectorSpecs.slack.actions.sendMessage.description', + { + defaultMessage: 'Send a message to a Slack channel', + } + ), + input: SlackSendMessageInputSchema, + handler: async (ctx, input) => { + const typedInput: SlackSendMessageInput = SlackSendMessageInputSchema.parse(input); + + const payload: Record = { + channel: typedInput.channel, + text: typedInput.text, + }; + + if (typedInput.threadTs) { + payload.thread_ts = typedInput.threadTs; + } + if (typedInput.unfurlLinks !== undefined) { + payload.unfurl_links = typedInput.unfurlLinks; + } + if (typedInput.unfurlMedia !== undefined) { + payload.unfurl_media = typedInput.unfurlMedia; + } + + try { + ctx.log.debug(`Slack sendMessage request: channel=${typedInput.channel}`); + const response = await slackRequestWithRateLimitRetry({ + ctx, + action: 'sendMessage', + maxRetries: SLACK_MAX_RETRIES, + request: () => + ctx.client.post(`${SLACK_API_BASE}/chat.postMessage`, payload, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }), + }); + + if (!response.data.ok) { + throw new Error( + formatSlackApiErrorMessage({ + action: 'sendMessage', + responseData: response.data, + responseHeaders: response.headers, + }) + ); + } + + return response.data; + } catch (error) { + const err = error as AxiosError; + ctx.log.error( + `Slack sendMessage failed: ${err.message}, Status: ${ + err.response?.status + }, Data: ${JSON.stringify(err.response?.data)}` + ); + throw error; + } + }, + }, + }, + + test: { + description: i18n.translate('core.kibanaConnectorSpecs.slack.test.description', { + defaultMessage: 'Verifies Slack connection by checking API access', + }), + handler: async (ctx) => { + ctx.log.debug('Slack test handler'); + + try { + // Test connection by calling auth.test which validates the token + const response = await ctx.client.get(`${SLACK_API_BASE}/auth.test`); + + if (!response.data.ok) { + return { + ok: false, + message: formatSlackApiErrorMessage({ + action: 'test', + responseData: response.data, + responseHeaders: response.headers, + }), + }; + } + + const teamName = response.data.team || 'Unknown'; + return { + ok: true, + message: i18n.translate('core.kibanaConnectorSpecs.slack.test.successMessage', { + defaultMessage: 'Successfully connected to Slack workspace: {teamName}', + values: { teamName }, + }), + }; + } catch (error) { + const err = error as { message?: string }; + return { ok: false, message: err.message ?? 'Unknown error' }; + } + }, + }, +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/types.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/types.ts new file mode 100644 index 0000000000000..cd2457ff12645 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/slack/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// ============================================================================= +// Slack Web API response types (minimal shapes used by this connector spec) +// ============================================================================= + +export interface SlackAssistantSearchContextMessage { + author_name?: string; + author_user_id?: string; + team_id?: string; + channel_id?: string; + channel_name?: string; + message_ts?: string; + content?: string; + is_author_bot?: boolean; + permalink?: string; + blocks?: unknown; + context_messages?: unknown; +} + +export interface SlackAssistantSearchContextResponse { + ok: boolean; + error?: string; + needed?: string; + provided?: string; + results?: { + messages?: SlackAssistantSearchContextMessage[]; + files?: unknown[]; + channels?: unknown[]; + }; + response_metadata?: { next_cursor?: string }; +} + +export interface SlackErrorFields { + error?: string; + needed?: string; + provided?: string; +} diff --git a/src/platform/packages/shared/kbn-cps-server-utils/README.md b/src/platform/packages/shared/kbn-cps-server-utils/README.md new file mode 100644 index 0000000000000..4d0f97cc21775 --- /dev/null +++ b/src/platform/packages/shared/kbn-cps-server-utils/README.md @@ -0,0 +1,20 @@ +# @kbn/cps-server-utils + +Server-side Cross-Project Search (CPS) utilities. + +## `getSpaceNPRE` + +Returns the Named Project Routing Expression (NPRE) for a given space, using the convention `kibana_space_${spaceId}_default`. + +Accepts either a `spaceId` string or a `KibanaRequest` (from which the space is derived via the request URL path, without a dependency on the `spaces` plugin). + +```ts +import { getSpaceNPRE } from '@kbn/cps-server-utils'; + +// From a space ID string +getSpaceNPRE('my-space'); // 'kibana_space_my-space_default' +getSpaceNPRE(''); // 'kibana_space_default_default' + +// From a KibanaRequest (extracts space from the URL path) +getSpaceNPRE(request); // e.g. 'kibana_space_my-space_default' +``` diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/action_factory/index.ts b/src/platform/packages/shared/kbn-cps-server-utils/index.ts similarity index 89% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/action_factory/index.ts rename to src/platform/packages/shared/kbn-cps-server-utils/index.ts index 358152d4ac2c6..8b287ce8cfc30 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/action_factory/index.ts +++ b/src/platform/packages/shared/kbn-cps-server-utils/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './action_factory'; +export { getSpaceNPRE } from './src/get_space_npre'; diff --git a/src/platform/packages/shared/kbn-security-solution-common/jest.config.js b/src/platform/packages/shared/kbn-cps-server-utils/jest.config.js similarity index 87% rename from src/platform/packages/shared/kbn-security-solution-common/jest.config.js rename to src/platform/packages/shared/kbn-cps-server-utils/jest.config.js index a3854d3bffdf4..3b58292c01e3f 100644 --- a/src/platform/packages/shared/kbn-security-solution-common/jest.config.js +++ b/src/platform/packages/shared/kbn-cps-server-utils/jest.config.js @@ -10,5 +10,5 @@ module.exports = { preset: '@kbn/test/jest_node', rootDir: '../../../../..', - roots: ['/src/platform/packages/shared/kbn-security-solution-common'], + roots: ['/src/platform/packages/shared/kbn-cps-server-utils'], }; diff --git a/src/platform/packages/shared/kbn-cps-server-utils/kibana.jsonc b/src/platform/packages/shared/kbn-cps-server-utils/kibana.jsonc new file mode 100644 index 0000000000000..7f78db4117572 --- /dev/null +++ b/src/platform/packages/shared/kbn-cps-server-utils/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-server", + "id": "@kbn/cps-server-utils", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/kbn-security-solution-flyout/moon.yml b/src/platform/packages/shared/kbn-cps-server-utils/moon.yml similarity index 60% rename from src/platform/packages/shared/kbn-security-solution-flyout/moon.yml rename to src/platform/packages/shared/kbn-cps-server-utils/moon.yml index f485e465f1300..d7016eb59962a 100644 --- a/src/platform/packages/shared/kbn-security-solution-flyout/moon.yml +++ b/src/platform/packages/shared/kbn-cps-server-utils/moon.yml @@ -1,25 +1,27 @@ # This file is generated by the @kbn/moon package. Any manual edits will be erased! # To extend this, write your extensions/overrides to 'moon.extend.yml' -# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/security-solution-flyout' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/cps-server-utils' $schema: https://moonrepo.dev/schemas/project.json -id: '@kbn/security-solution-flyout' +id: '@kbn/cps-server-utils' type: unknown owners: - defaultOwner: '@elastic/security-threat-hunting-investigations' + defaultOwner: '@elastic/kibana-core' toolchain: default: node language: typescript project: - name: '@kbn/security-solution-flyout' - description: Moon project for @kbn/security-solution-flyout + name: '@kbn/cps-server-utils' + description: Moon project for @kbn/cps-server-utils channel: '' - owner: '@elastic/security-threat-hunting-investigations' + owner: '@elastic/kibana-core' metadata: - sourceRoot: src/platform/packages/shared/kbn-security-solution-flyout -dependsOn: [] + sourceRoot: src/platform/packages/shared/kbn-cps-server-utils +dependsOn: + - '@kbn/core-http-server' + - '@kbn/spaces-utils' tags: - - shared-browser + - shared-server - package - prod - group-platform @@ -27,8 +29,7 @@ tags: - jest-unit-tests fileGroups: src: - - '**/*.ts' - - '**/*.tsx' + - '**/*' - '!target/**/*' tasks: jest: diff --git a/src/platform/packages/shared/kbn-security-solution-flyout/package.json b/src/platform/packages/shared/kbn-cps-server-utils/package.json similarity index 74% rename from src/platform/packages/shared/kbn-security-solution-flyout/package.json rename to src/platform/packages/shared/kbn-cps-server-utils/package.json index cdee8c005167b..194ec935c35cc 100644 --- a/src/platform/packages/shared/kbn-security-solution-flyout/package.json +++ b/src/platform/packages/shared/kbn-cps-server-utils/package.json @@ -1,7 +1,7 @@ { - "name": "@kbn/security-solution-flyout", - "private": true, + "name": "@kbn/cps-server-utils", "version": "1.0.0", + "private": true, "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", "sideEffects": false -} \ No newline at end of file +} diff --git a/src/platform/packages/shared/kbn-cps-server-utils/src/get_space_npre.test.ts b/src/platform/packages/shared/kbn-cps-server-utils/src/get_space_npre.test.ts new file mode 100644 index 0000000000000..de76db5f56742 --- /dev/null +++ b/src/platform/packages/shared/kbn-cps-server-utils/src/get_space_npre.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { KibanaRequest } from '@kbn/core-http-server'; +import { getSpaceNPRE } from './get_space_npre'; + +const mockRequest = (pathname: string) => + ({ url: new URL(`http://localhost:5601${pathname}`) } as unknown as KibanaRequest); + +describe('getSpaceNPRE', () => { + describe('when called with a spaceId string', () => { + it('returns the NPRE for the given space', () => { + expect(getSpaceNPRE('my-space')).toBe('kibana_space_my-space_default'); + }); + + it('uses "default" when spaceId is an empty string', () => { + expect(getSpaceNPRE('')).toBe('kibana_space_default_default'); + }); + + it('returns the NPRE for the default space when spaceId is "default"', () => { + expect(getSpaceNPRE('default')).toBe('kibana_space_default_default'); + }); + }); + + describe('when called with a KibanaRequest', () => { + it('extracts the space from the request URL and returns the NPRE', () => { + expect(getSpaceNPRE(mockRequest('/s/my-space/api/foo'))).toBe( + 'kibana_space_my-space_default' + ); + }); + + it('returns the default space NPRE when the request URL has no space segment', () => { + expect(getSpaceNPRE(mockRequest('/api/foo'))).toBe('kibana_space_default_default'); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-cps-server-utils/src/get_space_npre.ts b/src/platform/packages/shared/kbn-cps-server-utils/src/get_space_npre.ts new file mode 100644 index 0000000000000..35fcb8781a04a --- /dev/null +++ b/src/platform/packages/shared/kbn-cps-server-utils/src/get_space_npre.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { KibanaRequest } from '@kbn/core-http-server'; +import { DEFAULT_SPACE_ID, getSpaceIdFromPath } from '@kbn/spaces-utils'; + +export function getSpaceNPRE(spaceIdOrRequest: string | KibanaRequest): string { + const spaceId = + typeof spaceIdOrRequest === 'string' + ? spaceIdOrRequest || DEFAULT_SPACE_ID + : getSpaceIdFromPath(spaceIdOrRequest.url.pathname).spaceId; + + return `kibana_space_${spaceId}_default`; +} diff --git a/src/platform/packages/shared/kbn-flyout-ui/tsconfig.json b/src/platform/packages/shared/kbn-cps-server-utils/tsconfig.json similarity index 57% rename from src/platform/packages/shared/kbn-flyout-ui/tsconfig.json rename to src/platform/packages/shared/kbn-cps-server-utils/tsconfig.json index 4d33b40eda45c..7cf571130aa4e 100644 --- a/src/platform/packages/shared/kbn-flyout-ui/tsconfig.json +++ b/src/platform/packages/shared/kbn-cps-server-utils/tsconfig.json @@ -4,10 +4,14 @@ "outDir": "target/types", }, "include": [ - "**/*.ts", + "**/*", + "../../../../../typings/**/*", + ], + "kbn_references": [ + "@kbn/core-http-server", + "@kbn/spaces-utils", ], "exclude": [ "target/**/*" - ], - "kbn_references": [] + ] } diff --git a/src/platform/packages/shared/kbn-dev-utils/certs/kibana.crt b/src/platform/packages/shared/kbn-dev-utils/certs/kibana.crt index ad4afb02212dc..871f414c5cefe 100644 --- a/src/platform/packages/shared/kbn-dev-utils/certs/kibana.crt +++ b/src/platform/packages/shared/kbn-dev-utils/certs/kibana.crt @@ -1,30 +1,29 @@ Bag Attributes friendlyName: kibana - localKeyID: 54 69 6D 65 20 31 36 39 35 34 38 32 34 31 34 33 39 35 -Key Attributes: -Bag Attributes - friendlyName: kibana - localKeyID: 54 69 6D 65 20 31 36 39 35 34 38 32 34 31 34 33 39 35 -subject=CN = kibana -issuer=CN = Elastic Certificate Tool Autogenerated CA + localKeyID: 54 69 6D 65 20 31 37 37 31 34 33 37 30 36 30 33 34 31 +subject=CN=kibana +issuer=CN=Elastic Certificate Tool Autogenerated CA -----BEGIN CERTIFICATE----- -MIIDYjCCAkqgAwIBAgIUZ2p8K7GMXGk6xwCS9S91BUl1JnAwDQYJKoZIhvcNAQEL -BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l -cmF0ZWQgQ0EwIBcNMjMwOTIzMTUyMDE0WhgPMjA3MzA5MTAxNTIwMTRaMBExDzAN -BgNVBAMTBmtpYmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMOU -r52dbZ5dY0BoP2p7CEnOpG+qHTNrOAqZO/OJfniPMtpGmwAMl3WZDca6u2XkV2KE -qQyevQ2ADk6G3o8S2RU8mO/+UweuCDF7LHuSdxEGTpucidZErmVhEGUOFosL5UeB -AtIDWxvWwgK+W9Yzt5IEN2HzNCZ6h0dOSk2r9EjVMG5yF4Q6kuqOYxBT7jxoaOtO -OCrgBRummtUga4T13WZ/ZIyyHpXj2+JD4YEmrDyoTa7NLaphv0hnVhHXYoYBI/c6 -2SwwAoBlmtDmlinwSACQ3o/8eLWk0tqkIP14rc3oFh3m7D2c3c2m2HXuyoSDMfGW -beG2IE1Q3idcGmeG3qsCAwEAAaOBjDCBiTAdBgNVHQ4EFgQUMOUM7w5jmIozDvnq -RpM779m5GigwHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wPAYDVR0R -BDUwM4IUaG9zdC5kb2NrZXIuaW50ZXJuYWyCCWxvY2FsaG9zdIIEZXMwM4IEZXMw -MoIEZXMwMTAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCxqvQYXSKqgpdl -SP4gXgwipAnYsoW9qkgWQODTvSBEzUdOWme0d3j7i2l6Ur/nVSv5YjkqAv1hf/yJ -Hrk9h+j29ZO/aQ/KDh5i/gTEUnPw3Bxbw47dfn23tjMWO7NCU1fr5HNztRsa/gQr -e9s07g25u/gTfTi9Fyu0lcRe3bXOLS/mFVcuC5oxuS65R9OlbIsiORkZ2EfwuNUf -wAAYOGPIjM2VlQCvBitefsd/SzRKHdxSPy6KSjkO6MGEGo87fr7B7Nx1qp1DVrK7 -q9XeP1Cuygjg9WTcnsvWvNw8CssyuFM6X/3tGjpPasXwLvNUoG2AairK2AYTWhvS -foE31cFg +MIIEBTCCAu2gAwIBAgIVAKqEEFDGSEs427xhISo7Je1FMv9rMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTI2MDIxODE3NTEwMFoYDzIwNzYwMjA2MTc1MTAwWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDn +WglNV6V9dB8F27upvq8rUT5hbhNrucawkvOJrcH0FP2pvinqHCboWRxMPO8P13Yd +xJ/3oD0gLQZYVF9Jp3jduU9Xss5p/J0MeyhB8p/DPwGIH8mWeDTWgmVGJqkmi0+x +wm4Me5h6W0OXjfuigS1pLRBM3LhfMSnYGuL5c5cVkFxSk/h7mjB+nPTMyqsa5sIc +/pZdJ+yELxnPXM27HaTutvzKc9qmq7aLk+4z6aHx/stTJIwMaavhqEX62xCFhZZV +xKLu8kc32X25lsym92E1eDviCeKcv9319TWe9CFHit40xOJHrLTirWDqrfVTXbIE +Fvus0UzM+qshEJt7iPQzAgMBAAGjggEtMIIBKTAdBgNVHQ4EFgQUOZVqGbUpsDWb +zODy6rn0QlE5F10wHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wgdsG +A1UdEQSB0zCB0IIEZXMwMYI8a2Ita2ItdWlhbS5wcm9qZWN0LWFiY2RlZjEyMzQ1 +Njc4OTAxMjM0NTY3ODkwMTIzNDU2LmtiLmxvY2FsggRlczAygl1FSURFTlQuYWJj +ZGVmMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTYucHJvamVjdC5lbGFzdGljc2Vh +cmNoLnR5cGUub3JnMTIzNDU2Nzg5MC5vcmdhbml6YXRpb26CBGVzMDOCCWxvY2Fs +aG9zdIIUaG9zdC5kb2NrZXIuaW50ZXJuYWwwCQYDVR0TBAIwADANBgkqhkiG9w0B +AQsFAAOCAQEABRwp6ooJzO/nzBO/J+GBV0+brtTgqSXg8hZ6+9BUCv2dNPV7U/io +TkZtY3mbK2cgq0B6Q2e/poiZtHW35Zuo87zYonSyQxqblML7C8lzFEM4qTAOzBAN +xzzbSciumz5p88x5ug/y+0VDB2BkyQ+24Pw2YxbuA7iHjOOmdcgScPfulAx4FImf +p2AZ/DeRdArTZAyI2pTKkOxvNRtToiBII2DdmHe7oYcfXFG02Z9vwz/o4YO+cLTs +8hMMrL9G1y56wsSWT+kh/j3QDzqMnNmQ+VeEjEvxET6TbHZqgRa4q2qkyS2ls+X8 +NHWZ5uNXYbfnZpDdZ7LyAvvNvkThWc87gw== -----END CERTIFICATE----- diff --git a/src/platform/packages/shared/kbn-dev-utils/certs/kibana.key b/src/platform/packages/shared/kbn-dev-utils/certs/kibana.key index a73331ab17874..37c24545903dc 100644 --- a/src/platform/packages/shared/kbn-dev-utils/certs/kibana.key +++ b/src/platform/packages/shared/kbn-dev-utils/certs/kibana.key @@ -1,27 +1,27 @@ -----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAw5SvnZ1tnl1jQGg/ansISc6kb6odM2s4Cpk784l+eI8y2kab -AAyXdZkNxrq7ZeRXYoSpDJ69DYAOTobejxLZFTyY7/5TB64IMXsse5J3EQZOm5yJ -1kSuZWEQZQ4WiwvlR4EC0gNbG9bCAr5b1jO3kgQ3YfM0JnqHR05KTav0SNUwbnIX -hDqS6o5jEFPuPGho6044KuAFG6aa1SBrhPXdZn9kjLIelePb4kPhgSasPKhNrs0t -qmG/SGdWEddihgEj9zrZLDACgGWa0OaWKfBIAJDej/x4taTS2qQg/XitzegWHebs -PZzdzabYde7KhIMx8ZZt4bYgTVDeJ1waZ4beqwIDAQABAoIBABaXTBm2n3zVaKt9 -3yVbhL+RwOitC6Zu0hBXVtdwoE0orUUNNsYwriYFQdQcqZzBXV6h2Cz/APNYQU7M -wVRhZvXPBBNkmw6eCZA9nAvCBULQKbBLypgXYtWO+qfRksUI4Lj7q+m6PYHfspVC -i7UYUDHrjsIfp3xyVsHjxy1lmVf4JFAANNHAQeYPR6l5bw/t5yA6qQ9vBtVRz9da -nCE2mQHAODXHMXdiIss4YA3a3uIuN54ud0/cWP/Pn2eM2MHsQYEKFISd7rZ81wlE -+9DmENkL3L9iK0Zx4xUTGGS8R8oM5jimUaa4PQLwoD6IolY0C33Ri6d4IK23AtOH -/+687lECgYEA0oJ2CPNCmbB4Sy7ry3PYZhxZk8qaS5dmYrmB+Z5wNu/mpC2/6M72 -Vkol3BG+5yPgAB4SW09TeRh7VczGu+8juZW1q/Hi1MinhwCfGOvWlIFD9+G4d/gF -5EgWaDwCkimUo218Wry88hCzWuUKyd4HS3PODE0v39Jt1eLSQFGbqEcCgYEA7dha -UBs/I0kPvrYmlDrEH0dJRyvaG5ODv2gqvPtURNqFNMWAZ7146ZTDe2IvZ5bRTikc -B+lwlkMb+yhPvoGX1o3EkOYUhdUcgzSU9nL+ynSoWeOqprJ9Q/al4rIEh8ebvbZI -RF0qvBU6Q8Qg6aA/pcTVHU0+fFe6GDYarWvarH0CgYEAoxYphfOYVGMwPucCDKQa -Mbmi+GnNMeUAkFmxxYam3xjq8aTz+dRlaiKVxDIHWSElCFJD3HPPcpCx9J3qFW1G -mx/OGIEUP8+YYnHr0C3eFz0yQBeih2cigWIL4gMj5sLKAfbvkYiJRWwE19V8jzox -IpZ8OnGONnPbXgoU43mWAz8CgYEA3qvrAYxAtBw2rWmC/Mt3yYDHzeX0MFUOxygS -uxLhdgTPKPSunnD4vlYUHXNyxhygn/hE0fNvAH6bt6up3MUfDjNzj+SX2iQGqZ+U -xpYqjAhjhKRso9v/Ap3r+CyJqUTrPdVmGvrOg3+sKL15wr/QVrXMf75NfcPz6a7d -kvaip1ECgYA/T6EllMlRBM7hMnhZlgt3Ccu/e6VcVzQxqp0EewHzw+N17LdoaWaB -0bw9AqTwLeEhf4s4vidB7pO8YGNkCOKkQ3gqA/pF21VtF4xBRP6iYFa9rEGrHsRU -as68JJ6Lk+gn0aJ0RJNRaOa8xeoA/YWYNfnCfiAHj82UzP5TT4L/BQ== +MIIEpQIBAAKCAQEA51oJTVelfXQfBdu7qb6vK1E+YW4Ta7nGsJLzia3B9BT9qb4p +6hwm6FkcTDzvD9d2HcSf96A9IC0GWFRfSad43blPV7LOafydDHsoQfKfwz8BiB/J +lng01oJlRiapJotPscJuDHuYeltDl437ooEtaS0QTNy4XzEp2Bri+XOXFZBcUpP4 +e5owfpz0zMqrGubCHP6WXSfshC8Zz1zNux2k7rb8ynPapqu2i5PuM+mh8f7LUySM +DGmr4ahF+tsQhYWWVcSi7vJHN9l9uZbMpvdhNXg74gninL/d9fU1nvQhR4reNMTi +R6y04q1g6q31U12yBBb7rNFMzPqrIRCbe4j0MwIDAQABAoIBAEfBllcF0094/9JK +WAeLHDu8RDPl92IXSfgkbCMM4LZ6+D54u/lf5/VzBiLjV2a7dmg/PzqH2c2sCWbG +LJPkvMlQm7pgvEMFVhSz8kWaIlFlrmzxJw5jlEfgQ9chUJ+i4AILgySeBSoWOn28 +TQsXM+WGU6LzZsAnuInNtQ6X+ol2GQklRQ2e/y9TI69WQ4OPaTP4VbyXnXsMrUoE +v6hE3AKwcXUkaZ2l+zZGS5CJ8AuVbOLYacJZUXIl6V5FYL3FxR7H4rieR0RhG2PY +RQv7NpCZmicSP3rTQ3IUlbq7I5G222+XtO4L5sOIuNA7+QsILS2i/t34L2kBovQd +mwWE+ekCgYEA/NAZ2XCEf3eqjxIyr93aQiVUhVIN6Q4sUQT2pzagVpb4k7j7/QId +ME8w7T2xXZuoJxGTZHKl42uwlMjN7e2rYVLUFbvABS9h11t2+boYsF+n9FjYmh2L +F6j5yN6/u7ibOSZSQGENMowZ8/t4DXNXs6htwemc+rtm7OcONa0z2EUCgYEA6kSs +i6S7YbsQO47ROTDlf7xnAbrZAH41KAslDkLXs+6tnuNTO45e+8K764dcPasdrDMX +twMzlVXmBCNLUxdSwk9pLxpse1pjH9nQq+SeE4MhnXto12Nzt2kA6xDFTO0Ywzav +ILn3cYUEShMUytu2pe/0R7EoCVxPUeU4C/iFzhcCgYEAxmrdnta8Zv6YkmmJ9pV8 +c2WxDGH2IO/KwFvQ6jPpa4xZ1DbfLxe1qPC+SbSdvAYq117n+3Iv2Gnw0RU46oAa +fevwII6WintBozBaFG2GawboXtJMTcjaHdu1D34jpUWiLhxxea2yGfXzeJXpB0V7 +k7mhSwv69J6YjV5avK4PfrECgYEAg/eHk2qKu/UaodJD/gmTXq+M/yZ4U5TE8PfG +OhBhXlTXrSe1nVkIHJ6IKZeo2HxqTLTDaS7+geNPnYkcR4Rd9GOzhvtFnP8/05Np +v0sb2TYHW6VHW/4EE4+tGr3pxvnQ9zb41GCuCV67GddB4Tx/2V4gp7oeKZe8fw+2 +0NeA2KUCgYEAwMqYbBi+5RpOhmImd+gHK2bMzJbxR6HHjwZ1BbmcCyrOfWsC6p16 +wgJTbgZV2LbBO9OtPQzbSKTnPoiRUiodRtPOikzkCyDN0FfD/SagCMu2fdlkOhHF +f17UItNbc0prYHeBC47KlanKE6/CkbwRmBYpWrx4wis2BHzoifIXIfU= -----END RSA PRIVATE KEY----- diff --git a/src/platform/packages/shared/kbn-dev-utils/certs/kibana.p12 b/src/platform/packages/shared/kbn-dev-utils/certs/kibana.p12 index ea88b940ed8d3..91dc03828a057 100644 Binary files a/src/platform/packages/shared/kbn-dev-utils/certs/kibana.p12 and b/src/platform/packages/shared/kbn-dev-utils/certs/kibana.p12 differ diff --git a/src/platform/packages/shared/kbn-es-query-server/src/stored_filter.ts b/src/platform/packages/shared/kbn-es-query-server/src/stored_filter.ts index 892bdbcdf68da..15f93ee4e8f65 100644 --- a/src/platform/packages/shared/kbn-es-query-server/src/stored_filter.ts +++ b/src/platform/packages/shared/kbn-es-query-server/src/stored_filter.ts @@ -44,7 +44,8 @@ export const storedFilterMetaSchema = schema.object( // This would require a more complex schema definition that can handle recursive types. // For now, we use `schema.any()` to allow flexibility in the params field. params: schema.maybe(schema.any()), - value: schema.maybe(schema.string()), + // Typing as any since value is undocumented subset of FilterMetaParams + value: schema.maybe(schema.any()), }, { unknowns: 'allow' } ); diff --git a/src/platform/packages/shared/kbn-es-query/src/filters/build_filters/types.ts b/src/platform/packages/shared/kbn-es-query/src/filters/build_filters/types.ts index f67a51342333e..742730a33cb69 100644 --- a/src/platform/packages/shared/kbn-es-query/src/filters/build_filters/types.ts +++ b/src/platform/packages/shared/kbn-es-query/src/filters/build_filters/types.ts @@ -10,7 +10,12 @@ import type { FilterStateStore } from '@kbn/es-query-constants'; import type { ExistsFilter } from './exists_filter'; import type { PhrasesFilter, PhrasesFilterMeta } from './phrases_filter'; -import type { PhraseFilter, PhraseFilterMeta, PhraseFilterMetaParams } from './phrase_filter'; +import type { + PhraseFilter, + PhraseFilterMeta, + PhraseFilterMetaParams, + PhraseFilterValue, +} from './phrase_filter'; import type { RangeFilter, RangeFilterMeta, RangeFilterParams } from './range_filter'; import type { MatchAllFilter, MatchAllFilterMeta } from './match_all_filter'; @@ -73,7 +78,7 @@ export type FilterMeta = { type?: string; key?: string; params?: FilterMetaParams; - value?: string; + value?: string | RangeFilterParams | PhraseFilterValue[]; }; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions diff --git a/src/platform/packages/shared/kbn-es/src/utils/docker.test.ts b/src/platform/packages/shared/kbn-es/src/utils/docker.test.ts index 2b21669dcf41a..cc84effe6ecb7 100644 --- a/src/platform/packages/shared/kbn-es/src/utils/docker.test.ts +++ b/src/platform/packages/shared/kbn-es/src/utils/docker.test.ts @@ -624,11 +624,13 @@ describe('resolveEsArgs()', () => { "--env", "serverless.project_type=elasticsearch_general_purpose", "--env", - "serverless.project_id=abcde1234567890", + "serverless.project_id=abcdef12345678901234567890123456", "--env", "serverless.universal_iam_service.enabled=true", "--env", - "serverless.universal_iam_service.url=http://uiam:8080", + "serverless.universal_iam_service.url=https://uiam:8443", + "--env", + "serverless.universal_iam_service.ssl.verification_mode=none", "--env", "ES_JAVA_OPTS=-Des.stateless.allow.index.refresh_interval.override=true", ] diff --git a/src/platform/packages/shared/kbn-es/src/utils/docker.ts b/src/platform/packages/shared/kbn-es/src/utils/docker.ts index 3aeeca5177867..73d72b24c2fd1 100644 --- a/src/platform/packages/shared/kbn-es/src/utils/docker.ts +++ b/src/platform/packages/shared/kbn-es/src/utils/docker.ts @@ -698,6 +698,7 @@ export function resolveEsArgs( esArgs.set('serverless.universal_iam_service.enabled', 'true'); esArgs.set('serverless.universal_iam_service.url', MOCK_IDP_UIAM_SERVICE_INTERNAL_URL); + esArgs.set('serverless.universal_iam_service.ssl.verification_mode', 'none'); } } diff --git a/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.test.ts b/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.test.ts index 21826ec9b30bb..37ad99a0ae4c0 100644 --- a/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.test.ts +++ b/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.test.ts @@ -27,6 +27,14 @@ jest.mock('undici', () => { }; }); +jest.mock('@kbn/dev-utils', () => { + return { + CA_CERT_PATH: '/some/path/ca.crt', + KBN_CERT_PATH: '/some/path/kibana.crt', + KBN_KEY_PATH: '/some/path/kibana.key', + }; +}); + // Import undici after mocking to get the mocked exports import * as undici from 'undici'; const mockUndiciFetch = jest.mocked(undici.fetch); @@ -94,7 +102,7 @@ describe(`#runUiamContainer()`, () => { "curl -sk http://127.0.0.1:8080/ready | grep -q \\"\\\\\\"overall\\\\\\": true\\"", "--name", "uiam-cosmosdb", - "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-EN20251223", + "docker.elastic.co/kibana-ci/uiam-azure-cosmos-emulator:latest-verified", "--protocol", "https", "--port", @@ -147,11 +155,23 @@ describe(`#runUiamContainer()`, () => { "/some_path/run_java_with_custom_ca.sh:/opt/jboss/container/java/run/run-java-with-custom-ca.sh:z", "--volume", "/some_path/uiam_cosmosdb.pfx:/tmp/uiam_cosmosdb.pfx:z", + "--volume", + "/some/path/ca.crt:/tmp/ca.crt:z", + "--volume", + "/some/path/kibana.key:/tmp/server.key:z", + "--volume", + "/some/path/kibana.crt:/tmp/server.crt:z", "-p", - "127.0.0.1:8080:8080", + "127.0.0.1:8443:8443", "--entrypoint", "/opt/jboss/container/java/run/run-java-with-custom-ca.sh", "--env", + "quarkus.tls.https.key-store.pem.0.cert=/tmp/server.crt", + "--env", + "quarkus.tls.https.key-store.pem.0.key=/tmp/server.key", + "--env", + "quarkus.tls.https.trust-store.pem.certs=/tmp/ca.crt", + "--env", "quarkus.http.ssl.certificate.key-store-provider=JKS", "--env", "quarkus.http.ssl.certificate.trust-store-provider=SUN", @@ -162,6 +182,8 @@ describe(`#runUiamContainer()`, () => { "--env", "quarkus.log.category.\\"org\\".level=INFO", "--env", + "quarkus.log.category.\\"co.elastic.cloud.uiam\\".level=DEBUG", + "--env", "quarkus.log.console.json.enabled=false", "--env", "quarkus.log.level=INFO", diff --git a/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.ts b/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.ts index d3f234ccf6839..6e7ceb49e24f8 100644 --- a/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.ts +++ b/src/platform/packages/shared/kbn-es/src/utils/docker_uiam.ts @@ -30,26 +30,24 @@ import { setTimeout as setTimeoutAsync } from 'timers/promises'; import { Agent } from 'undici'; import type { ArrayElement } from '@kbn/utility-types'; import { REPO_ROOT } from '@kbn/repo-info'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { SERVERLESS_UIAM_ENTRYPOINT_PATH, SERVERLESS_UIAM_CERTIFICATE_BUNDLE_PATH } from '../paths'; -const COSMOS_DB_EMULATOR_DOCKER_REGISTRY = 'mcr.microsoft.com'; -const COSMOS_DB_EMULATOR_DOCKER_REPO = `${COSMOS_DB_EMULATOR_DOCKER_REGISTRY}/cosmosdb/linux/azure-cosmos-emulator`; +const COSMOS_DB_EMULATOR_DOCKER_REGISTRY = 'docker.elastic.co'; +const COSMOS_DB_EMULATOR_DOCKER_REPO = `${COSMOS_DB_EMULATOR_DOCKER_REGISTRY}/kibana-ci/uiam-azure-cosmos-emulator`; -// Check new version at https://github.com/Azure/azure-cosmos-db-emulator-docker/releases. DON'T use the rolling -// `vnext-preview` image tag. -const COSMOS_DB_EMULATOR_DOCKER_LATEST_VERIFIED_TAG = 'vnext-EN20251223'; -export const COSMOS_DB_EMULATOR_DEFAULT_IMAGE = `${COSMOS_DB_EMULATOR_DOCKER_REPO}:${COSMOS_DB_EMULATOR_DOCKER_LATEST_VERIFIED_TAG}`; +export const COSMOS_DB_EMULATOR_DEFAULT_IMAGE = `${COSMOS_DB_EMULATOR_DOCKER_REPO}:latest-verified`; const UIAM_DOCKER_REGISTRY = 'docker.elastic.co'; const UIAM_DOCKER_PROMOTED_REPO = `${UIAM_DOCKER_REGISTRY}/kibana-ci/uiam`; -// Use the promoted :latest-verified image in CI, fall back to specific tag for local development export const UIAM_DEFAULT_IMAGE = `${UIAM_DOCKER_PROMOTED_REPO}:latest-verified`; const MAX_HEALTHCHECK_RETRIES = 30; const ENV_DEFAULTS = { UIAM_COSMOS_DB_UI_PORT: '8082', + UIAM_APP_LOGGING_LEVEL: 'DEBUG', UIAM_LOGGING_LEVEL: 'INFO', }; @@ -116,13 +114,26 @@ export const UIAM_CONTAINERS = [ '--volume', `${SERVERLESS_UIAM_CERTIFICATE_BUNDLE_PATH}:/tmp/uiam_cosmosdb.pfx:z`, + '--volume', + `${CA_CERT_PATH}:/tmp/ca.crt:z`, + '--volume', + `${KBN_KEY_PATH}:/tmp/server.key:z`, + '--volume', + `${KBN_CERT_PATH}:/tmp/server.crt:z`, '-p', - `127.0.0.1:${new URL(MOCK_IDP_UIAM_SERVICE_INTERNAL_URL)?.port}:8080`, // UIAM API port + `127.0.0.1:${new URL(MOCK_IDP_UIAM_SERVICE_INTERNAL_URL)?.port}:8443`, // UIAM API port '--entrypoint', '/opt/jboss/container/java/run/run-java-with-custom-ca.sh', + '--env', + 'quarkus.tls.https.key-store.pem.0.cert=/tmp/server.crt', + '--env', + 'quarkus.tls.https.key-store.pem.0.key=/tmp/server.key', + '--env', + 'quarkus.tls.https.trust-store.pem.certs=/tmp/ca.crt', + '--env', 'quarkus.http.ssl.certificate.key-store-provider=JKS', '--env', @@ -134,6 +145,8 @@ export const UIAM_CONTAINERS = [ '--env', `quarkus.log.category."org".level=${env.UIAM_LOGGING_LEVEL}`, '--env', + `quarkus.log.category."co.elastic.cloud.uiam".level=${env.UIAM_APP_LOGGING_LEVEL}`, + '--env', 'quarkus.log.console.json.enabled=false', '--env', `quarkus.log.level=${env.UIAM_LOGGING_LEVEL}`, diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/types.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/types.ts index f95b750a1d2ec..b77694e7c4829 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/types.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/types.ts @@ -367,30 +367,16 @@ export interface ValidationErrors { message: string; type: { value: string; availableFields: string }; }; - promqlMissingParam: { + promqlInvalidParam: { message: string; - type: { param: string }; - }; - promqlMissingParamValue: { - message: string; - type: { param: string }; - }; - promqlInvalidDateParam: { - message: string; - type: { param: string }; - }; - promqlInvalidStepParam: { - message: string; - type: {}; + type: { + reason: string; + }; }; promqlMutuallyExclusiveParams: { message: string; type: { param1: string; param2: string }; }; - promqlInvalidBucketsParam: { - message: string; - type: {}; - }; promqlMissingQuery: { message: string; type: {}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/autocomplete/resource_browser_suggestions.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/autocomplete/resource_browser_suggestions.ts index fac9de15570cd..276a242694f3c 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/autocomplete/resource_browser_suggestions.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/autocomplete/resource_browser_suggestions.ts @@ -21,8 +21,8 @@ export async function getIndicesBrowserSuggestion({ context?: ICommandContext; innerText?: string; }): Promise { - const isResourceBrowserEnabled = (await callbacks?.isResourceBrowserEnabled?.()) ?? false; - if (!isResourceBrowserEnabled || context?.isCursorInSubquery) { + const canSuggestResourceBrowser = (await callbacks?.canSuggestResourceBrowser?.()) ?? false; + if (!canSuggestResourceBrowser || context?.isCursorInSubquery) { return undefined; } diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/errors.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/errors.ts index 29b0ab3217012..7b9bfe19e724d 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/errors.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/errors.ts @@ -265,35 +265,11 @@ Expected one of: }), type: 'error', }; - case 'promqlMissingParam': + case 'promqlInvalidParam': return { - message: i18n.translate('kbn-esql-language.esql.validation.promqlMissingParam', { - defaultMessage: '[PROMQL] Missing required param "{param}"', - values: { param: out.param }, - }), - type: 'error', - }; - case 'promqlMissingParamValue': - return { - message: i18n.translate('kbn-esql-language.esql.validation.promqlMissingParamValue', { - defaultMessage: '[PROMQL] Missing value for "{param}"', - values: { param: out.param }, - }), - type: 'error', - }; - case 'promqlInvalidDateParam': - return { - message: i18n.translate('kbn-esql-language.esql.validation.promqlInvalidDateParam', { - defaultMessage: - '[PROMQL] Invalid {param} value. Use ISO 8601 with Z (e.g. 2024-01-15T10:00:00Z) or ?_tstart/?_tend', - values: { param: out.param }, - }), - type: 'error', - }; - case 'promqlInvalidStepParam': - return { - message: i18n.translate('kbn-esql-language.esql.validation.promqlInvalidStepParam', { - defaultMessage: '[PROMQL] Invalid step value', + message: i18n.translate('kbn-esql-language.esql.validation.promqlInvalidParam', { + defaultMessage: '[PROMQL] {reason}', + values: { reason: out.reason }, }), type: 'error', }; @@ -305,13 +281,6 @@ Expected one of: }), type: 'error', }; - case 'promqlInvalidBucketsParam': - return { - message: i18n.translate('kbn-esql-language.esql.validation.promqlInvalidBucketsParam', { - defaultMessage: '[PROMQL] Invalid buckets value. Must be a positive integer', - }), - type: 'error', - }; case 'promqlMissingQuery': return { message: i18n.translate('kbn-esql-language.esql.validation.promqlMissingQuery', { diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/promql.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/promql.ts index fa17de20c4900..6eed698e0c1c0 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/promql.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/promql.ts @@ -41,12 +41,19 @@ const getPromqlFunctionDeclaration = (fn: PromQLFunctionDefinition) => { /* Converts a PROMQL function definition into an autocomplete suggestion. */ const getPromqlFunctionSuggestion = (fn: PromQLFunctionDefinition): ISuggestionItem => { - const { description, examples, name, preview, signatures } = fn; + const { description, examples, name, preview, signatures, type } = fn; const detail = description; const docDetail = preview ? `**[${techPreviewLabel}]** ${detail}` : detail; const hasNoArguments = signatures.every((signature) => signature.params.length === 0); - const text = hasNoArguments ? `${name}() ` : `${name}($0) `; + + // Aggregations insert just the name (e.g. `sum `) without parens: a follow-up prompt lets the user pick `` or `()`. + const text = + type === PromQLFunctionDefinitionTypes.ACROSS_SERIES + ? `${name} ` + : hasNoArguments + ? `${name}() ` + : `${name}($0) `; return { label: name, diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/validation/function.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/validation/function.ts index dc10e41adbc18..6da93a02a392c 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/validation/function.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/validation/function.ts @@ -326,6 +326,14 @@ export function getPromqlFunctionArityCheck( return null; } +/* Treats instant_vector as compatible with range_vector (implicit range selector). */ +function isPromqlParamTypeCompatible( + expected: PromQLFunctionParamType, + actual: PromQLFunctionParamType +): boolean { + return expected === actual || (expected === 'range_vector' && actual === 'instant_vector'); +} + /* Filters signatures compatible by arity and argument types. */ export function getPromqlMatchingSignatures( signatures: PromQLSignature[], @@ -338,7 +346,10 @@ export function getPromqlMatchingSignatures( const matchingArity = filterByMatchingArity(signatures, argTypes.length); return matchingArity.filter(({ params }) => - argTypes.every((argType, idx) => params[idx]?.type === argType) + argTypes.every( + (argType, idx) => + params[idx]?.type && argType && isPromqlParamTypeCompatible(params[idx]!.type, argType) + ) ); } @@ -359,9 +370,14 @@ export function getPromqlSignatureMismatch( .slice(0, argCount) .map(({ name, type }) => `${name}=${type}`) .join(', '); - const mismatchIdx = argTypes.findIndex((argType, idx) => refParams[idx]?.type !== argType); + const mismatchIdx = argTypes.findIndex( + (argType, idx) => + !refParams[idx]?.type || + !argType || + !isPromqlParamTypeCompatible(refParams[idx]!.type, argType) + ); - return { required, mismatchIdx }; + return mismatchIdx >= 0 ? { required, mismatchIdx } : null; } /* Filters signatures whose required/max param count includes argCount. */ diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/complete_items.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/complete_items.ts index 7a6cc7bef3008..24d7122902e1a 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/complete_items.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/complete_items.ts @@ -231,6 +231,16 @@ export const promqlRangeSelectorItem: ISuggestionItem = withAutoSuggest({ category: SuggestionCategory.PROMQL_METRIC_QUALIFIER, }); +export const promqlOpenParensCompleteItem: ISuggestionItem = withAutoSuggest({ + label: '()', + text: '($0) ', + asSnippet: true, + kind: 'Snippet', + detail: i18n.translate('kbn-esql-language.esql.autocomplete.promql.addFunctionArguments', { + defaultMessage: 'Add function arguments', + }), +}); + export const byCompleteItem: ISuggestionItem = withAutoSuggest({ label: 'BY', text: 'BY ', @@ -634,7 +644,7 @@ export function createIndicesBrowserSuggestion( ): ISuggestionItem { return createResourceBrowserSuggestion({ label: i18n.translate('kbn-esql-language.esql.autocomplete.indicesBrowser.suggestionLabel', { - defaultMessage: 'Browse indices', + defaultMessage: 'Browse data sources', }), description: i18n.translate( 'kbn-esql-language.esql.autocomplete.indicesBrowser.suggestionDescription', diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/from/autocomplete.test.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/from/autocomplete.test.ts index cbafcae5dfa4b..4f1c05b23707d 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/from/autocomplete.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/from/autocomplete.test.ts @@ -70,10 +70,10 @@ describe('FROM Autocomplete', () => { ); }); describe('... ...', () => { - test('suggests Browse indices in empty source slots when enabled', async () => { + test('suggests Browse data sources in empty source slots when enabled', async () => { mockCallbacks = { ...mockCallbacks, - isResourceBrowserEnabled: jest.fn().mockResolvedValue(true), + canSuggestResourceBrowser: jest.fn().mockResolvedValue(true), }; const suggest = async (query: string) => { @@ -87,10 +87,10 @@ describe('FROM Autocomplete', () => { }; const initialSlotLabels = (await suggest('FROM /')).map((s) => s.label); - expect(initialSlotLabels).toContain('Browse indices'); + expect(initialSlotLabels).toContain('Browse data sources'); const afterCommaLabels = (await suggest('FROM index, /')).map((s) => s.label); - expect(afterCommaLabels).toContain('Browse indices'); + expect(afterCommaLabels).toContain('Browse data sources'); }); test('suggests visible indices on space', async () => { diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.test.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.test.ts index 1e358866c7d83..2636b67b19637 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.test.ts @@ -493,6 +493,22 @@ describe('inside query', () => { }); describe('aggregation functions (by clause)', () => { + test('suggests by after aggregation name before opening paren', async () => { + await expectPromqlSuggestions('PROMQL sum ', { + textsContain: [promqlByCompleteItem.text, '($0) '], + textsNotContain: [pipeCompleteItem.text], + }); + }); + + test('suggests expression items in second paren of pre-grouping form', async () => { + const numericFields = getFieldNamesByType(ESQL_NUMBER_TYPES, true); + + await expectPromqlSuggestions('PROMQL sum by (keywordField) (', { + labelsContain: ['abs', 'avg', ...numericFields], + labelsNotContain: [promqlByCompleteItem.label], + }); + }); + test('suggests by and pipe when cursor is at end of aggregation without space', async () => { await expectPromqlSuggestions('PROMQL sum(rate(http_requests_total[5m]))', { textsContain: [promqlByCompleteItem.text, pipeCompleteItem.text], @@ -597,6 +613,19 @@ describe('aggregation functions (by clause)', () => { }); }); + test('does not suggest by after complete pre-grouped aggregation', async () => { + await expectPromqlSuggestions('PROMQL sum by (keywordField) (rate(doubleField[5m])) ', { + textsNotContain: [promqlByCompleteItem.text], + textsContain: [pipeCompleteItem.text], + }); + }); + + test('wrapped aggregation functions keep cursor inside parens (pre-grouped form)', async () => { + await expectPromqlSuggestions('PROMQL sum by (keywordField) ', { + textsContain: ['(avg $0) '], + }); + }); + test('suggests by inside outer aggregation after trailing spaces', async () => { // cursor is after spaces but before closing paren of outer sum() // Should suggest 'by' for the completed inner avg() aggregation diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.ts index a6bd8971cafd3..08a33964bb67c 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/autocomplete.ts @@ -17,9 +17,10 @@ import { getPromqlLabelMatcherSuggestions, getPromqlOperatorSuggestions, getMetricTypesForSignature, + getPromqlParamTypesForFunction, } from '../../definitions/utils/promql'; import type { ICommandCallbacks, ISuggestionItem, ICommandContext } from '../types'; -import { ESQL_NUMBER_TYPES, ESQL_STRING_TYPES } from '../../definitions/types'; +import { ESQL_STRING_TYPES, type PromQLFunctionParamType } from '../../definitions/types'; import { assignCompletionItem, buildAddValuePlaceholder, @@ -29,6 +30,7 @@ import { pipeCompleteItem, promqlByCompleteItem, promqlLabelSelectorItem, + promqlOpenParensCompleteItem, promqlRangeSelectorItem, valuePlaceholderConstant, } from '../complete_items'; @@ -37,6 +39,7 @@ import { getPromqlParam, getUsedPromqlParamNames, isAfterCustomColumnAssignment, + getPreGroupedAggregationName, PromqlParamValueType, getPosition, getIndexAssignmentContext, @@ -72,7 +75,8 @@ export async function autocomplete( const pipeIndex = findPipeOutsideQuotes(query, commandStart); const commandText = query.substring(commandStart, pipeIndex === -1 ? query.length : pipeIndex); const position = getPosition(innerText, command, commandText); - const needsWrappedQuery = isAfterCustomColumnAssignment(innerCommandText); + const preGroupedAgg = getPreGroupedAggregationName(innerCommandText); + const shouldWrap = isAfterCustomColumnAssignment(innerCommandText) || !!preGroupedAgg; switch (position.type) { case 'after_command': { @@ -94,10 +98,7 @@ export async function autocomplete( const baseSuggestions = [ ...availableParamSuggestions, ...(columnSuggestion ? [columnSuggestion] : []), - ...(canSuggestColumn - ? buildFieldSuggestions(context, ESQL_NUMBER_TYPES, needsWrappedQuery ? 'wrap' : 'plain') - : []), - ...(canSuggestColumn ? wrapFunctionSuggestions(needsWrappedQuery) : []), + ...(canSuggestColumn ? buildVectorSuggestions(context, [], shouldWrap) : []), ]; const indexSuggestions = suggestForIndexAssignment( @@ -136,13 +137,10 @@ export async function autocomplete( case 'after_operator': { const signatureTypes = position.signatureTypes ?? []; - const metricTypes = getMetricTypesForSignature(signatureTypes); const canSuggestScalar = signatureTypes.length === 0 || signatureTypes.includes('scalar'); - const functionSuggestions = getPromqlFunctionSuggestions(signatureTypes); return [ - ...buildFieldSuggestions(context, metricTypes, needsWrappedQuery ? 'wrap' : 'plain'), - ...wrapFunctionSuggestions(needsWrappedQuery, functionSuggestions), + ...buildVectorSuggestions(context, signatureTypes, shouldWrap), ...(canSuggestScalar ? [buildAddValuePlaceholder('number')] : []), ]; } @@ -178,6 +176,18 @@ export async function autocomplete( } case 'inside_query': { + if (position.isAfterAggregationName) { + return [promqlByCompleteItem, promqlOpenParensCompleteItem]; + } + + if (preGroupedAgg) { + return buildVectorSuggestions( + context, + getPromqlParamTypesForFunction(preGroupedAgg, 0), + shouldWrap + ); + } + const insideQuerySuggestions: ISuggestionItem[] = [...getPromqlOperatorSuggestions()]; if (position.canAddGrouping) { @@ -205,25 +215,31 @@ export async function autocomplete( if (position.canSuggestCommaInFunctionArgs) { return [getCommaWithAutoSuggest()]; } - const signatureTypes = position.signatureTypes ?? []; - const types = getMetricTypesForSignature(signatureTypes); + const signatureTypes = position.signatureTypes ?? []; const expectsOnlyScalar = signatureTypes.length > 0 && signatureTypes.every((type) => type === 'scalar'); - const scalarValues = expectsOnlyScalar ? [buildAddValuePlaceholder('number')] : []; - const metrics = expectsOnlyScalar - ? [] - : buildFieldSuggestions(context, types, needsWrappedQuery ? 'wrap' : 'plain'); - - const functions = expectsOnlyScalar - ? [] - : wrapFunctionSuggestions(needsWrappedQuery, getPromqlFunctionSuggestions(signatureTypes)); + if (expectsOnlyScalar) { + return [buildAddValuePlaceholder('number')]; + } - return [...scalarValues, ...metrics, ...functions]; + return buildVectorSuggestions(context, signatureTypes, shouldWrap); } case 'after_query': { + if (position.isAfterAggregationName) { + return [promqlByCompleteItem, promqlOpenParensCompleteItem]; + } + + if (preGroupedAgg) { + return buildVectorSuggestions( + context, + getPromqlParamTypesForFunction(preGroupedAgg, 0), + shouldWrap + ); + } + const suggestions: ISuggestionItem[] = [...getPromqlOperatorSuggestions(), pipeCompleteItem]; if (position.canAddGrouping) { @@ -349,13 +365,22 @@ function suggestParamValues( return getDateLiterals(); } + const durationPlaceholder = { + ...valuePlaceholderConstant, + label: 'Insert duration', + text: '"${0:5m}"', + detail: 'Use units like s, m, h, d', + }; + if (param === PromqlParamName.Step) { + return [durationPlaceholder]; + } + + if (param === PromqlParamName.ScrapeInterval) { return [ { - ...valuePlaceholderConstant, - label: 'Insert duration', - text: '"${0:5m}"', - detail: 'Use units like s, m, h, d', + ...durationPlaceholder, + text: '"${0:1m}"', }, ]; } @@ -378,6 +403,20 @@ function suggestParamValues( // Field Suggestions // ============================================================================ +function buildVectorSuggestions( + context: ICommandContext | undefined, + signatureTypes: PromQLFunctionParamType[], + wrap: boolean +): ISuggestionItem[] { + const metricTypes = getMetricTypesForSignature(signatureTypes); + const functionSuggestions = getPromqlFunctionSuggestions(signatureTypes); + + return [ + ...buildFieldSuggestions(context, metricTypes, wrap ? 'wrap' : 'plain'), + ...wrapFunctionSuggestions(wrap, functionSuggestions), + ]; +} + /* Wraps function suggestions in parentheses when needed for column assignment syntax. */ function wrapFunctionSuggestions( wrap: boolean, @@ -387,10 +426,14 @@ function wrapFunctionSuggestions( return suggestions; } - return suggestions.map((suggestion) => ({ - ...suggestion, - text: `(${suggestion.text})`, - })); + return suggestions.map((suggestion) => { + const hasCursorPlaceholder = suggestion.text.includes('$0'); + const text = hasCursorPlaceholder + ? `(${suggestion.text})` + : `(${suggestion.text.trimEnd()} $0) `; + + return { ...suggestion, text }; + }); } function buildFieldSuggestions( diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/utils.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/utils.ts index 33554a14a7119..42656bbf2604c 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/utils.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/utils.ts @@ -18,6 +18,7 @@ import type { PromQLBinaryExpression, PromQLAstQueryExpression, PromQLFunction, + PromQLGrouping, PromQLLabel, PromQLSelector, } from '../../../embedded_languages/promql/types'; @@ -56,6 +57,7 @@ interface PromqlPosition { type: PromqlPositionType; currentParam?: string; // param name being edited (e.g. 'step' for after_param_equals) canAddGrouping?: boolean; // whether by/without can be appended to the the query + isAfterAggregationName?: boolean; // whether cursor is after agg name before opening args, e.g. `sum |` selector?: PromQLSelector; // selector node at cursor (for duration/label checks) canSuggestRangeSelector?: boolean; // whether [5m] range selector can be suggested isCompleteLabel?: boolean; // label at cursor is complete (suggest comma instead of new label) @@ -74,6 +76,13 @@ const PROMQL_BINARY_OPS_PATTERN = promqlOperatorDefinitions const PROMQL_TRAILING_BINARY_OP_REGEX = new RegExp(`(${PROMQL_BINARY_OPS_PATTERN})\\s*$`, 'i'); +// Pre-grouped aggregation detection (e.g. "sum by (labels) ") +const PROMQL_GROUPING_KEYWORDS: PromQLGrouping['name'][] = ['by', 'without']; +const PRE_GROUPED_AGG_REGEX = new RegExp( + `\\b(${IDENTIFIER_PATTERN})\\s+(?:${PROMQL_GROUPING_KEYWORDS.join('|')})\\s*\\([^)]*\\)\\s*$`, + 'i' +); + // Param zone detection const TRAILING_PARAM_NAME_REGEX = new RegExp(`(${IDENTIFIER_PATTERN})\\s*$`); const FUNCTION_ARG_START_TOKENS = ['(', ',']; @@ -157,36 +166,54 @@ function getQueryZonePosition( }; const parsed = parseQueryAst(); - // Computes canAddGrouping from the parsed AST - const computeCanAddGrouping = (): boolean => { + // Computes canAddGrouping and whether cursor is right after an aggregation name. + const computeGroupingContext = (): { + canAddGrouping: boolean; + isAfterAggregationName: boolean; + } => { if (!parsed) { - return false; + return { canAddGrouping: false, isAfterAggregationName: false }; } const { root, cursor } = parsed; const textBeforeCursor = querySlice!.text.slice(0, cursor).trimEnd(); const logicalCursor = textBeforeCursor.length; + const afterAggregationName = isAfterAggregationName(textBeforeCursor); if (logicalCursor === 0) { - return false; + return { canAddGrouping: false, isAfterAggregationName: false }; } const nearest = findNearestAggregation(root, logicalCursor); - return nearest?.location.max === logicalCursor - 1; + return { + canAddGrouping: nearest?.location.max === logicalCursor - 1 || afterAggregationName, + isAfterAggregationName: afterAggregationName, + }; }; // Zone 1: cursor past everything (including wrapper parens if any) if (cursorPosition > (queryBounds.wrappedEnd ?? queryBounds.queryEnd)) { + const groupingContext = + queryBounds.wrappedEnd === undefined + ? computeGroupingContext() + : { canAddGrouping: false, isAfterAggregationName: false }; + return { type: 'after_query', - canAddGrouping: queryBounds.wrappedEnd === undefined && computeCanAddGrouping(), + canAddGrouping: groupingContext.canAddGrouping, + isAfterAggregationName: groupingContext.isAfterAggregationName, }; } // Zone 2: cursor between inner expression and outer parens wrapper if (queryBounds.wrappedEnd !== undefined && cursorPosition > queryBounds.queryEnd) { - return { type: 'inside_query', canAddGrouping: computeCanAddGrouping() }; + const groupingContext = computeGroupingContext(); + return { + type: 'inside_query', + canAddGrouping: groupingContext.canAddGrouping, + isAfterAggregationName: groupingContext.isAfterAggregationName, + }; } // Inside query zone: delegate to cursor-first resolver @@ -354,6 +381,13 @@ function findNearestAggregation( return nearest; } +/** Returns true when cursor is after an aggregation function name before `(`, e.g. `sum |`. */ +function isAfterAggregationName(textBeforeCursor: string): boolean { + const trailingIdentifier = getTrailingIdentifier(textBeforeCursor.trimEnd()); + + return trailingIdentifier ? isPromqlAcrossSeriesFunction(trailingIdentifier) : false; +} + /** Resolves signature types by walking up to the enclosing function. */ function getSignatureTypesFromAncestors( text: string, @@ -414,9 +448,13 @@ function findSelectorArgPosition( } const selector = arg as PromQLSelector; + const selectorMetricName = selector.metric?.name; + const isFunctionLikeMetric = + !!selectorMetricName && !!getPromqlFunctionDefinition(selectorMetricName); // After metric, no labels/duration yet if ( + !isFunctionLikeMetric && selector.metric && !selector.labelMap && !selector.duration && @@ -636,9 +674,16 @@ function resolveSelectorPosition( signatureTypes: PromQLFunctionParamType[] ): PromqlPosition | undefined { const { metric, labelMap } = selector; + const metricName = metric?.name; + const isFunctionLikeMetric = !!metricName && !!getPromqlFunctionDefinition(metricName); // Cursor after metric, before labelMap → after_metric - if (metric && cursor > metric.location.max && (!labelMap || cursor < labelMap.location.min)) { + if ( + !isFunctionLikeMetric && + metric && + cursor > metric.location.max && + (!labelMap || cursor < labelMap.location.min) + ) { return { type: 'after_metric', selector }; } @@ -848,15 +893,21 @@ function resolveTopLevelPosition( textBeforeCursorArg?: string ): PromqlPosition { if (precomputedCanAddGrouping !== undefined) { - return { type: 'inside_query', canAddGrouping: precomputedCanAddGrouping }; + return { + type: 'inside_query', + canAddGrouping: precomputedCanAddGrouping, + isAfterAggregationName: isAfterAggregationName(textBeforeCursorArg ?? text.slice(0, cursor)), + }; } const textBeforeCursor = textBeforeCursorArg ?? text.slice(0, cursor).trimEnd(); const logicalCursor = textBeforeCursor.length; const nearest = findNearestAggregation(root, logicalCursor); - const canAddGrouping = logicalCursor > 0 && nearest?.location.max === logicalCursor - 1; + const afterAggregationName = isAfterAggregationName(textBeforeCursor); + const canAddGrouping = + logicalCursor > 0 && (nearest?.location.max === logicalCursor - 1 || afterAggregationName); - return { type: 'inside_query', canAddGrouping }; + return { type: 'inside_query', canAddGrouping, isAfterAggregationName: afterAggregationName }; } /** Gets the binary-expression node nearest to cursor, if present in match chain. */ @@ -1057,6 +1108,7 @@ function getQueryPosition( // Cursor on metric identifier: use after_metric when at or past metric end if ( + !getPromqlFunctionDefinition(selectorNode.metric?.name) && selectorNode.metric && cursor >= selectorNode.metric.location.max && (!selectorNode.labelMap || cursor < selectorNode.labelMap.location.min) @@ -1091,10 +1143,12 @@ function getQueryPosition( // canAddGrouping takes precedence over function args (matches old priority chain) const logicalCursor = textBeforeCursor.length; const nearestAgg = findNearestAggregation(root, logicalCursor); - const canAddGrouping = logicalCursor > 0 && nearestAgg?.location.max === logicalCursor - 1; + const afterAggregationName = isAfterAggregationName(textBeforeCursor); + const canAddGrouping = + logicalCursor > 0 && (nearestAgg?.location.max === logicalCursor - 1 || afterAggregationName); if (canAddGrouping || isAfterCompleteExpression(root, cursor)) { - return { type: 'inside_query', canAddGrouping }; + return { type: 'inside_query', canAddGrouping, isAfterAggregationName: afterAggregationName }; } // Function context @@ -1398,6 +1452,7 @@ export enum PromqlParamName { Start = 'start', End = 'end', Buckets = 'buckets', + ScrapeInterval = 'scrape_interval', } export interface PromqlParamDefinition { @@ -1434,6 +1489,11 @@ export const PROMQL_PARAMS: PromqlParamDefinition[] = [ description: 'Number of time buckets (alternative to step)', valueType: PromqlParamValueType.Static, }, + { + name: PromqlParamName.ScrapeInterval, + description: 'Scrape interval for implicit range selector window (e.g. 1m)', + valueType: PromqlParamValueType.Static, + }, ]; export const PROMQL_PARAM_NAMES: string[] = PROMQL_PARAMS.map(({ name }) => name); @@ -1557,6 +1617,13 @@ export function isAtValidColumnSuggestionPosition( // Column Assignment Helpers // ============================================================================ +export function getPreGroupedAggregationName(commandText: string): string | undefined { + const match = commandText.trimEnd().match(PRE_GROUPED_AGG_REGEX); + if (!match) return undefined; + + return isPromqlAcrossSeriesFunction(match[1]) ? match[1] : undefined; +} + /** Detects when the cursor is after a custom query assignment like "col0 =". */ export function isAfterCustomColumnAssignment(commandText: string): boolean { const trimmed = commandText.trimEnd(); diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.test.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.test.ts index d2c946fb77db2..7c6cc74db61b7 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.test.ts @@ -75,6 +75,13 @@ describe('PROMQL Validation', () => { test('valid buckets value', () => { promqlExpectErrors('PROMQL buckets=6 start=?_tstart end=?_tend (avg(doubleField))', []); }); + + test('invalid scrape_interval format', () => { + promqlExpectErrors( + 'PROMQL scrape_interval=abc start=?_tstart end=?_tend (rate(counterIntegerField))', + ['[PROMQL] Invalid scrape_interval value'] + ); + }); }); describe('query presence', () => { @@ -150,10 +157,7 @@ describe('PROMQL Validation', () => { [ 'unknown metric name without index reports unknown column', 'PROMQL step=5m (rate(bytes))', - [ - '[PROMQL] Argument types require (v=range_vector) for function "rate"', - 'Unknown column "bytes"', - ], + ['Unknown column "bytes"'], ], ])('%s', (_title, query, expected) => { promqlExpectErrors(query, expected); @@ -166,10 +170,11 @@ describe('PROMQL Validation', () => { ); }); - test('type mismatch: rate expects range vector', () => { - promqlExpectErrors('PROMQL step=5m start=?_tstart end=?_tend (rate(counterIntegerField))', [ - '[PROMQL] Argument types require (v=range_vector) for function "rate"', - ]); + test('rate accepts instant_vector (implicit range selector)', () => { + promqlExpectErrors( + 'PROMQL step=5m start=?_tstart end=?_tend (rate(counterIntegerField))', + [] + ); }); test('type mismatch: quantile expects scalar as first arg', () => { diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.ts index 049de1d7b76e2..821bef78dc888 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/promql/validate.ts @@ -51,9 +51,9 @@ import { // ISO 8601 with Z, optional milliseconds (e.g. 2024-01-15T10:00:00Z or ...00.000Z). const FORMAT_DATE_LITERAL_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; // Prometheus duration format (one or more number+unit segments). -const FORMAT_STEP_DURATION_REGEX = /^([0-9]+(ms|s|m|h|d|w|y))+$/; -// Catches split step values like "step= 1 m" which the parser drops from params. -const STEP_WITH_SPACES_REGEX = /\bstep\s*=\s*\d+\s+[a-z]+/i; +const FORMAT_DURATION_REGEX = /^([0-9]+(ms|s|m|h|d|w|y))+$/; +// Catches split duration param values like "step= 1 m" which the parser drops from params. +const DURATION_PARAM_WITH_SPACES_REGEX = /\b(step|scrape_interval)\s*=\s*\d+\s+[a-z]+/i; // Extracts "param = value" from the query field when the last param is mis-parsed. const PROMQL_QUERY_PARAM_VALUE_REGEX = new RegExp(`^\\s*(${IDENTIFIER_PATTERN})\\s*=\\s*(\\S*)`); @@ -72,11 +72,14 @@ export const validate = ( usedParams.add(param); } - if (STEP_WITH_SPACES_REGEX.test(command.text) && !paramValues.has(PromqlParamName.Step)) { + const durationSpaceMatch = command.text.match(DURATION_PARAM_WITH_SPACES_REGEX); + + if (durationSpaceMatch && !paramValues.has(durationSpaceMatch[1].toLowerCase())) { + const param = durationSpaceMatch[1].toLowerCase(); messages.push( getMessageFromId({ - messageId: 'promqlInvalidStepParam', - values: {}, + messageId: 'promqlInvalidParam', + values: { reason: `Invalid ${param} value` }, locations: command.location, }) ); @@ -99,10 +102,11 @@ export const validate = ( const hasEnd = usedParams.has(PromqlParamName.End); if (hasStart !== hasEnd) { + const param = hasStart ? PromqlParamName.End : PromqlParamName.Start; messages.push({ ...getMessageFromId({ - messageId: 'promqlMissingParam', - values: { param: hasStart ? PromqlParamName.End : PromqlParamName.Start }, + messageId: 'promqlInvalidParam', + values: { reason: `Missing required param "${param}"` }, locations: command.location, }), }); @@ -118,8 +122,8 @@ export const validate = ( if (value === '') { messages.push({ ...getMessageFromId({ - messageId: 'promqlMissingParamValue', - values: { param }, + messageId: 'promqlInvalidParam', + values: { reason: `Missing value for "${param}"` }, locations: entryLocation ?? location, }), }); @@ -133,22 +137,24 @@ export const validate = ( if (!isPlaceholder && !FORMAT_DATE_LITERAL_REGEX.test(normalized)) { messages.push({ ...getMessageFromId({ - messageId: 'promqlInvalidDateParam', - values: { param }, + messageId: 'promqlInvalidParam', + values: { + reason: `Invalid ${param} value. Use ISO 8601 with Z (e.g. 2024-01-15T10:00:00Z) or ?_tstart/?_tend`, + }, locations: location, }), }); } } - if (param === PromqlParamName.Step) { + if (param === PromqlParamName.Step || param === PromqlParamName.ScrapeInterval) { const normalized = stripQuotes(value); - if (!FORMAT_STEP_DURATION_REGEX.test(normalized)) { + if (!FORMAT_DURATION_REGEX.test(normalized)) { messages.push({ ...getMessageFromId({ - messageId: 'promqlInvalidStepParam', - values: {}, + messageId: 'promqlInvalidParam', + values: { reason: `Invalid ${param} value` }, locations: keyLocation ?? location, }), }); @@ -157,11 +163,12 @@ export const validate = ( if (param === PromqlParamName.Buckets) { const num = Number(value); + if (!Number.isInteger(num) || num <= 0) { messages.push({ ...getMessageFromId({ - messageId: 'promqlInvalidBucketsParam', - values: {}, + messageId: 'promqlInvalidParam', + values: { reason: 'Invalid buckets value. Must be a positive integer' }, locations: keyLocation ?? location, }), }); diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/timeseries/autocomplete.test.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/timeseries/autocomplete.test.ts index efe678bae0e75..1ae5681a44422 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/timeseries/autocomplete.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/timeseries/autocomplete.test.ts @@ -70,13 +70,13 @@ describe('TS Autocomplete', () => { return autocomplete(query, command, mockCallbacks, mockContext, cursorPosition); }; - test('suggests Browse indices in empty source slots when enabled', async () => { - mockCallbacks.isResourceBrowserEnabled = jest.fn().mockResolvedValue(true); + test('suggests Browse data sources in empty source slots when enabled', async () => { + mockCallbacks.canSuggestResourceBrowser = jest.fn().mockResolvedValue(true); const suggestions = await suggest('TS '); const labels = suggestions.map((s) => s.label); - expect(labels[0]).toEqual('Browse indices'); + expect(labels[0]).toEqual('Browse data sources'); }); test('can suggest timeseries indices (and aliases)', async () => { diff --git a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/types.ts b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/types.ts index e49bd5b3ba0fe..3bdc3a90a04f4 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/commands/registry/types.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/commands/registry/types.ts @@ -188,7 +188,7 @@ export interface ICommandCallbacks { canCreateLookupIndex?: (indexName: string) => Promise; isServerless?: boolean; getKqlSuggestions?: ESQLCallbacks['getKqlSuggestions']; - isResourceBrowserEnabled?: () => Promise; + canSuggestResourceBrowser?: () => Promise; } export interface ICommandContext { diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/autocomplete/autocomplete.ts b/src/platform/packages/shared/kbn-esql-language/src/language/autocomplete/autocomplete.ts index b9e5eb2d5a7d7..6d6d2245bb555 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/autocomplete/autocomplete.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/autocomplete/autocomplete.ts @@ -257,14 +257,14 @@ async function getSuggestionsWithinCommandExpression( ); const isInsideSubquery = astContext.isCursorInSubquery; // We only show resource browser suggestions in the main query - const isResourceBrowserEnabled = (await callbacks?.isResourceBrowserEnabled?.()) ?? false; + const canSuggestResourceBrowser = (await callbacks?.canSuggestResourceBrowser?.()) ?? false; const context = { ...references, ...additionalCommandContext, activeProduct: callbacks?.getActiveProduct?.(), isCursorInSubquery: astContext.isCursorInSubquery, - isFieldsBrowserEnabled: isResourceBrowserEnabled && !isInsideSubquery, + isFieldsBrowserEnabled: canSuggestResourceBrowser && !isInsideSubquery, unmappedFieldsStrategy, }; @@ -291,7 +291,7 @@ async function getSuggestionsWithinCommandExpression( hasMinimumLicenseRequired, getKqlSuggestions: callbacks?.getKqlSuggestions, canCreateLookupIndex: callbacks?.canCreateLookupIndex, - isResourceBrowserEnabled: callbacks?.isResourceBrowserEnabled, + canSuggestResourceBrowser: callbacks?.canSuggestResourceBrowser, isServerless: callbacks?.isServerless, }, context, diff --git a/src/platform/packages/shared/kbn-esql-resource-browser/src/browser_popover_wrapper.tsx b/src/platform/packages/shared/kbn-esql-resource-browser/src/browser_popover_wrapper.tsx index 56ae043c97f8d..25703542adc5b 100644 --- a/src/platform/packages/shared/kbn-esql-resource-browser/src/browser_popover_wrapper.tsx +++ b/src/platform/packages/shared/kbn-esql-resource-browser/src/browser_popover_wrapper.tsx @@ -106,15 +106,15 @@ export function BrowserPopoverWrapper({ searchInputRef.current = node; }; - // Focus the search input when popover opens + // Focus the search input as soon as the popover opens so that focus + // transfers away from the editor immediately (avoids visible flicker). useEffect(() => { - if (isOpen && !isLoading) { - // Use setTimeout to ensure the DOM is ready - setTimeout(() => { + if (isOpen) { + requestAnimationFrame(() => { searchInputRef.current?.focus(); - }, 0); + }); } - }, [isOpen, isLoading]); + }, [isOpen]); const filterButton = ( Promise; isServerless?: boolean; /** Enables the "Browse indices" suggestion and command integration. */ - isResourceBrowserEnabled?: () => Promise; + canSuggestResourceBrowser?: () => Promise; getKqlSuggestions?: ( kqlQuery: string, cursorPositionInKql: number diff --git a/src/platform/packages/shared/kbn-esql-utils/constants.ts b/src/platform/packages/shared/kbn-esql-utils/constants.ts index 2977162b1da9b..dc7e9f11145ff 100644 --- a/src/platform/packages/shared/kbn-esql-utils/constants.ts +++ b/src/platform/packages/shared/kbn-esql-utils/constants.ts @@ -8,4 +8,3 @@ */ export const ENABLE_ESQL = 'enableESQL'; -export const FEEDBACK_LINK = 'https://ela.st/esql-feedback'; diff --git a/src/platform/packages/shared/kbn-esql-utils/index.ts b/src/platform/packages/shared/kbn-esql-utils/index.ts index def1e76f2c7f2..95ee620f89563 100644 --- a/src/platform/packages/shared/kbn-esql-utils/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/index.ts @@ -70,4 +70,4 @@ export { type ESQLStatsQueryMeta, } from './src'; -export { ENABLE_ESQL, FEEDBACK_LINK } from './constants'; +export { ENABLE_ESQL } from './constants'; diff --git a/src/platform/packages/shared/kbn-eval-kql/README.md b/src/platform/packages/shared/kbn-eval-kql/README.md new file mode 100644 index 0000000000000..04f6703098bd0 --- /dev/null +++ b/src/platform/packages/shared/kbn-eval-kql/README.md @@ -0,0 +1,3 @@ +# @kbn/eval-kql + +Empty package generated by @kbn/generate diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/index.ts b/src/platform/packages/shared/kbn-eval-kql/index.ts similarity index 91% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/index.ts rename to src/platform/packages/shared/kbn-eval-kql/index.ts index e8cfb565ee0fb..b53152ddb3e62 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/index.ts +++ b/src/platform/packages/shared/kbn-eval-kql/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './action_factory_picker'; +export { evaluateKql } from './src/eval_kql'; diff --git a/src/platform/packages/shared/kbn-flyout-ui/jest.config.js b/src/platform/packages/shared/kbn-eval-kql/jest.config.js similarity index 89% rename from src/platform/packages/shared/kbn-flyout-ui/jest.config.js rename to src/platform/packages/shared/kbn-eval-kql/jest.config.js index e8747570988a8..0020f18d9c80b 100644 --- a/src/platform/packages/shared/kbn-flyout-ui/jest.config.js +++ b/src/platform/packages/shared/kbn-eval-kql/jest.config.js @@ -10,5 +10,5 @@ module.exports = { preset: '@kbn/test/jest_node', rootDir: '../../../../..', - roots: ['/src/platform/packages/shared/kbn-flyout-ui'], + roots: ['/src/platform/packages/shared/kbn-eval-kql'], }; diff --git a/src/platform/packages/shared/kbn-eval-kql/kibana.jsonc b/src/platform/packages/shared/kbn-eval-kql/kibana.jsonc new file mode 100644 index 0000000000000..f12baa27a2323 --- /dev/null +++ b/src/platform/packages/shared/kbn-eval-kql/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/eval-kql", + "owner": "@elastic/workflows-eng", + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/kbn-flyout-ui/moon.yml b/src/platform/packages/shared/kbn-eval-kql/moon.yml similarity index 68% rename from src/platform/packages/shared/kbn-flyout-ui/moon.yml rename to src/platform/packages/shared/kbn-eval-kql/moon.yml index 78f0844b30be0..09e474d782d21 100644 --- a/src/platform/packages/shared/kbn-flyout-ui/moon.yml +++ b/src/platform/packages/shared/kbn-eval-kql/moon.yml @@ -1,23 +1,26 @@ # This file is generated by the @kbn/moon package. Any manual edits will be erased! # To extend this, write your extensions/overrides to 'moon.extend.yml' -# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/flyout-ui' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/eval-kql' $schema: https://moonrepo.dev/schemas/project.json -id: '@kbn/flyout-ui' +id: '@kbn/eval-kql' type: unknown owners: - defaultOwner: '@elastic/security-threat-hunting-investigations' + defaultOwner: '@elastic/workflows-eng' toolchain: default: node language: typescript project: - name: '@kbn/flyout-ui' - description: Moon project for @kbn/flyout-ui + name: '@kbn/eval-kql' + description: Moon project for @kbn/eval-kql channel: '' - owner: '@elastic/security-threat-hunting-investigations' + owner: '@elastic/workflows-eng' metadata: - sourceRoot: src/platform/packages/shared/kbn-flyout-ui -dependsOn: [] + sourceRoot: src/platform/packages/shared/kbn-eval-kql +dependsOn: + - '@kbn/datemath' + - '@kbn/es-query' + - '@kbn/workflows' tags: - shared-common - package diff --git a/src/platform/packages/shared/kbn-flyout-ui/package.json b/src/platform/packages/shared/kbn-eval-kql/package.json similarity index 82% rename from src/platform/packages/shared/kbn-flyout-ui/package.json rename to src/platform/packages/shared/kbn-eval-kql/package.json index 316e20eba1985..0c1723d866b05 100644 --- a/src/platform/packages/shared/kbn-flyout-ui/package.json +++ b/src/platform/packages/shared/kbn-eval-kql/package.json @@ -1,5 +1,5 @@ { - "name": "@kbn/flyout-ui", + "name": "@kbn/eval-kql", "private": true, "version": "1.0.0", "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/utils/eval_kql/eval_kql.test.ts b/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.test.ts similarity index 100% rename from src/platform/plugins/shared/workflows_execution_engine/server/utils/eval_kql/eval_kql.test.ts rename to src/platform/packages/shared/kbn-eval-kql/src/eval_kql.test.ts diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/utils/eval_kql/eval_kql.ts b/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.ts similarity index 97% rename from src/platform/plugins/shared/workflows_execution_engine/server/utils/eval_kql/eval_kql.ts rename to src/platform/packages/shared/kbn-eval-kql/src/eval_kql.ts index 5b4c91c421b9c..1f7111584d7b6 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/utils/eval_kql/eval_kql.ts +++ b/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.ts @@ -7,9 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -// TODO: Remove eslint exceptions comments and fix the issues -/* eslint-disable @typescript-eslint/no-explicit-any */ - import dateMath from '@kbn/datemath'; import type { KueryNode } from '@kbn/es-query'; import { fromKueryExpression } from '@kbn/es-query'; diff --git a/src/platform/packages/shared/kbn-security-solution-flyout/tsconfig.json b/src/platform/packages/shared/kbn-eval-kql/tsconfig.json similarity index 67% rename from src/platform/packages/shared/kbn-security-solution-flyout/tsconfig.json rename to src/platform/packages/shared/kbn-eval-kql/tsconfig.json index d240814c45361..5ff8669170ad5 100644 --- a/src/platform/packages/shared/kbn-security-solution-flyout/tsconfig.json +++ b/src/platform/packages/shared/kbn-eval-kql/tsconfig.json @@ -5,10 +5,13 @@ }, "include": [ "**/*.ts", - "**/*.tsx", ], "exclude": [ "target/**/*" ], - "kbn_references": [] + "kbn_references": [ + "@kbn/datemath", + "@kbn/es-query", + "@kbn/workflows", + ] } diff --git a/src/platform/packages/shared/kbn-flyout-ui/README.md b/src/platform/packages/shared/kbn-flyout-ui/README.md deleted file mode 100644 index 124e11fffe18a..0000000000000 --- a/src/platform/packages/shared/kbn-flyout-ui/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# @kbn/flyout-ui - -## Purpose - -This package offers a set a UI components, hooks and util functions intended to be used in flyouts. These components -currently exist in the `shared` folder under the `flyout` folder in the `security_solution` plugin ( -see https://github.com/elastic/kibana/tree/main/x-pack/solutions/security/plugins/security_solution/public/flyout/shared). - -These components will be used (at least at first ) in Security document flyouts (like alert, event, host, user, rule and -network) and tools flyouts (like analyzer, session view, graph, insights...) in the Security Solution and Discover -plugins. - -## List of components, hooks and utils - -> Components, hooks and util functions will be added to this package as they needed when moving the content of the -> Security flyouts. - -## Thoughts when contributing to this package - -As these components, hooks and util functions are meant to be used in all flyouts, please make sure that they are: - -- well documented -- extremely well unit tested - -Also, for UI components, Storybook files should be added. diff --git a/src/platform/packages/shared/kbn-flyout-ui/index.ts b/src/platform/packages/shared/kbn-flyout-ui/index.ts deleted file mode 100644 index ad21076a9ca93..0000000000000 --- a/src/platform/packages/shared/kbn-flyout-ui/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ diff --git a/src/platform/packages/shared/kbn-flyout-ui/kibana.jsonc b/src/platform/packages/shared/kbn-flyout-ui/kibana.jsonc deleted file mode 100644 index 2f929072fbd0a..0000000000000 --- a/src/platform/packages/shared/kbn-flyout-ui/kibana.jsonc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "shared-common", - "id": "@kbn/flyout-ui", - "owner": "@elastic/security-threat-hunting-investigations", - "group": "platform", - "visibility": "shared" -} diff --git a/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts b/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts index 7eb81e2e47d0a..19173fba15c15 100644 --- a/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts +++ b/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts @@ -44,9 +44,9 @@ import type { PaletteOutput } from '@kbn/coloring'; import type { ESQLControlVariable } from '@kbn/esql-types'; import type { Adapters } from '@kbn/inspector-plugin/common'; import type { InspectorOptions } from '@kbn/inspector-plugin/public'; -import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; import type { DefaultInspectorAdapters, RenderMode } from '@kbn/expressions-plugin/common'; import type { Ast } from '@kbn/interpreter'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { IndexPatternMap, IndexPatternRef, @@ -193,6 +193,10 @@ export interface LensPublicCallbacks extends LensApiProps { * Let the consumer overwrite embeddable user messages */ onBeforeBadgesRender?: (userMessages: UserMessage[]) => UserMessage[]; + /** + * Optional user messages from the consumer. + */ + userMessages?: UserMessage[]; onAlertRule?: (data: unknown) => void; } @@ -255,8 +259,8 @@ export type LensSerializedSharedState = Simplify< LensUnifiedSearchContext & LensPanelProps & SerializedTitles & - Omit & - Partial & { isNewPanel?: boolean } + SerializedDrilldowns & + Omit & { isNewPanel?: boolean } >; export type LensByValueSerializedState = Simplify; diff --git a/src/platform/packages/shared/kbn-lens-common/moon.yml b/src/platform/packages/shared/kbn-lens-common/moon.yml index fa528e92c11ad..517a2a603e61a 100644 --- a/src/platform/packages/shared/kbn-lens-common/moon.yml +++ b/src/platform/packages/shared/kbn-lens-common/moon.yml @@ -23,7 +23,6 @@ dependsOn: - '@kbn/expression-xy-plugin' - '@kbn/expression-gauge-plugin' - '@kbn/expression-partition-vis-plugin' - - '@kbn/embeddable-enhanced-plugin' - '@kbn/expression-legacy-metric-vis-plugin' - '@kbn/expressions-plugin' - '@kbn/tinymath' diff --git a/src/platform/packages/shared/kbn-lens-common/tsconfig.json b/src/platform/packages/shared/kbn-lens-common/tsconfig.json index 43226c549d7ef..bfccffab6481d 100644 --- a/src/platform/packages/shared/kbn-lens-common/tsconfig.json +++ b/src/platform/packages/shared/kbn-lens-common/tsconfig.json @@ -12,7 +12,6 @@ "@kbn/expression-xy-plugin", "@kbn/expression-gauge-plugin", "@kbn/expression-partition-vis-plugin", - "@kbn/embeddable-enhanced-plugin", "@kbn/expression-legacy-metric-vis-plugin", "@kbn/expressions-plugin", "@kbn/tinymath", diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.test.ts index 4eb954e5943e8..d9db50baa37bf 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.test.ts @@ -298,3 +298,195 @@ test('it generates xy chart with multiple reference lines', async () => { ], }); }); + +describe('breakdown handling', () => { + it('should not include splitAccessors when breakdown is undefined', async () => { + const result = await buildXY( + { + chartType: 'xy', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp', + }, + layers: [ + { + type: 'series', + seriesType: 'line', + xAxis: '@timestamp', + yAxis: [ + { + label: 'test', + value: 'count', + }, + ], + }, + ], + }, + { + dataViewsAPI: mockDataViewsService() as any, + } + ); + + const xyState = result.state.visualization as XYState; + const dataLayer = xyState.layers[0] as any; + + expect(dataLayer.splitAccessors).toBeUndefined(); + }); + + it('should create single splitAccessor when breakdown is a string', async () => { + const result = await buildXY( + { + chartType: 'xy', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp, service.name', + }, + layers: [ + { + type: 'series', + seriesType: 'line', + xAxis: '@timestamp', + breakdown: 'service.name', + yAxis: [ + { + label: 'test', + value: 'count', + }, + ], + }, + ], + }, + { + dataViewsAPI: mockDataViewsService() as any, + } + ); + + const xyState = result.state.visualization as XYState; + const dataLayer = xyState.layers[0] as any; + + expect(dataLayer.splitAccessors).toEqual(['metric_formula_accessor0_breakdown_0']); + }); + + it('should create single splitAccessor when breakdown is an array with one item', async () => { + const result = await buildXY( + { + chartType: 'xy', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp, host.name', + }, + layers: [ + { + type: 'series', + seriesType: 'line', + xAxis: '@timestamp', + breakdown: ['host.name'], + yAxis: [ + { + label: 'test', + value: 'count', + }, + ], + }, + ], + }, + { + dataViewsAPI: mockDataViewsService() as any, + } + ); + + const xyState = result.state.visualization as XYState; + const dataLayer = xyState.layers[0] as any; + + expect(dataLayer.splitAccessors).toEqual(['metric_formula_accessor0_breakdown_0']); + }); + + it('should create multiple splitAccessors when breakdown is an array with multiple items', async () => { + const result = await buildXY( + { + chartType: 'xy', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp, host.name, service.name, container.id', + }, + layers: [ + { + type: 'series', + seriesType: 'line', + xAxis: '@timestamp', + breakdown: ['host.name', 'service.name', 'container.id'], + yAxis: [ + { + label: 'test', + value: 'count', + }, + ], + }, + ], + }, + { + dataViewsAPI: mockDataViewsService() as any, + } + ); + + const xyState = result.state.visualization as XYState; + const dataLayer = xyState.layers[0] as any; + + expect(dataLayer.splitAccessors).toEqual([ + 'metric_formula_accessor0_breakdown_0', + 'metric_formula_accessor0_breakdown_1', + 'metric_formula_accessor0_breakdown_2', + ]); + }); + + it('should create correct splitAccessors for multiple layers with different breakdowns', async () => { + const result = await buildXY( + { + chartType: 'xy', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp', + }, + layers: [ + { + type: 'series', + seriesType: 'line', + xAxis: '@timestamp', + breakdown: 'service.name', + yAxis: [ + { + label: 'test', + value: 'count', + }, + ], + }, + { + type: 'series', + seriesType: 'bar', + xAxis: '@timestamp', + breakdown: ['host.name', 'container.id'], + yAxis: [ + { + label: 'test2', + value: 'count', + }, + ], + }, + ], + }, + { + dataViewsAPI: mockDataViewsService() as any, + } + ); + + const xyState = result.state.visualization as XYState; + const layer0 = xyState.layers[0] as any; + const layer1 = xyState.layers[1] as any; + + expect(layer0.splitAccessors).toEqual(['metric_formula_accessor0_breakdown_0']); + expect(layer1.splitAccessors).toEqual([ + 'metric_formula_accessor1_breakdown_0', + 'metric_formula_accessor1_breakdown_1', + ]); + }); +}); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.ts index 49e954479ba32..30ff9a31d7589 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/charts/xy.ts @@ -32,6 +32,12 @@ import type { const ACCESSOR = 'metric_formula_accessor'; +function normalizeBreakdown( + breakdown: LensBreakdownConfig | LensBreakdownConfig[] | undefined +): LensBreakdownConfig[] { + return breakdown ? (Array.isArray(breakdown) ? breakdown : [breakdown]) : []; +} + function buildVisualizationState(config: LensXYConfig): XYState { return { axisTitlesVisibilitySettings: { @@ -122,15 +128,17 @@ function buildVisualizationState(config: LensXYConfig): XYState { ...(yAxis.lineThickness ? { lineWidth: yAxis.lineThickness } : {}), })), } satisfies XYReferenceLineLayerConfig; - case 'series': + case 'series': { + const layerBreakdown = normalizeBreakdown(layer.breakdown); return { layerId: `layer_${i}`, layerType: 'data', xAccessor: `x_${ACCESSOR}${i}`, - ...(layer.breakdown + ...(layerBreakdown.length > 0 ? { - // TODO fix this to allow multi-terms in esql - splitAccessors: [`${ACCESSOR}${i}_breakdown`], + splitAccessors: layerBreakdown.map( + (_, breakdownIndex) => `${ACCESSOR}${i}_breakdown_${breakdownIndex}` + ), } : {}), accessors: layer.yAxis.map((_, index) => `${ACCESSOR}${i}_${index}`), @@ -140,6 +148,7 @@ function buildVisualizationState(config: LensXYConfig): XYState { color: yAxis.seriesColor, })), } as XYDataLayerConfig; + } } }), }; @@ -153,14 +162,17 @@ function hasFormatParams(yAxis: LensSeriesLayer['yAxis'][number]) { } function getValueColumns(layer: LensSeriesLayer, i: number) { - if (layer.breakdown && typeof layer.breakdown !== 'string') { + const layerBreakdown = normalizeBreakdown(layer.breakdown); + + // For ES|QL queries, breakdown must be field names (strings) + if (layerBreakdown.some((bd) => typeof bd !== 'string')) { throw new Error('`breakdown` must be a field name when not using index source'); } return [ - ...(layer.breakdown - ? [getValueColumn(`${ACCESSOR}${i}_breakdown`, layer.breakdown as string)] - : []), + ...layerBreakdown.map((bd, breakdownIndex) => + getValueColumn(`${ACCESSOR}${i}_breakdown_${breakdownIndex}`, bd as string) + ), ...getXValueColumn(layer.xAxis, i), ...layer.yAxis.map((yAxis, index) => { const params = hasFormatParams(yAxis) @@ -238,13 +250,16 @@ function buildFormulaLayer( addLayerColumn(resultLayer, columnName, breakdownColumn, true); } - if (layer.breakdown) { - const columnName = `${ACCESSOR}${i}_breakdown`; - const breakdownColumn = getBreakdownColumn({ - options: layer.breakdown, - dataView, + const layerBreakdown = normalizeBreakdown(layer.breakdown); + if (layerBreakdown.length > 0) { + layerBreakdown.forEach((breakdown, breakdownIndex) => { + const columnName = `${ACCESSOR}${i}_breakdown_${breakdownIndex}`; + const breakdownColumn = getBreakdownColumn({ + options: breakdown, + dataView, + }); + addLayerColumn(resultLayer, columnName, breakdownColumn, true); }); - addLayerColumn(resultLayer, columnName, breakdownColumn, true); } return resultLayer; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.test.ts index e08b3458c76f1..7e89de70dbc3b 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.test.ts @@ -294,6 +294,24 @@ describe('Datatable Schema', () => { expect(() => datatableStateSchema.validate(input)).toThrow(); }); + it('throws on empty metrics for non-esql', () => { + const input: DatatableWithoutDefaultsConfig = { + ...baseDatatableConfig, + metrics: [], + rows: [ + { + operation: 'date_histogram', + field: '@timestamp', + suggested_interval: '1d', + use_original_time_range: true, + include_empty_rows: true, + }, + ], + }; + + expect(() => datatableStateSchema.validate(input)).toThrow(); + }); + it('throws on empty rows', () => { const input: DatatableWithoutDefaultsConfig = { ...baseDatatableConfig, @@ -452,6 +470,58 @@ describe('Datatable Schema', () => { expect(() => datatableStateSchema.validate(input)).toThrow(); }); + it('throws when esql datatable has no metrics and no rows', () => { + const input: Omit = { + type: 'datatable', + dataset: { + type: 'esql', + query: 'FROM my-index | LIMIT 100', + }, + }; + + expect(() => datatableStateSchema.validate(input)).toThrow( + 'Datatable must have at least one column' + ); + }); + + it('throws on empty metrics array for esql', () => { + const input: DatatableWithoutDefaultsConfig = { + type: 'datatable', + dataset: { + type: 'esql', + query: 'FROM my-index | LIMIT 100', + }, + metrics: [], + rows: [ + { + operation: 'value', + column: 'location', + }, + ], + }; + + expect(() => datatableStateSchema.validate(input)).toThrow(); + }); + + it('throws on empty rows array for esql', () => { + const input: DatatableWithoutDefaultsConfig = { + type: 'datatable', + dataset: { + type: 'esql', + query: 'FROM my-index | LIMIT 100', + }, + metrics: [ + { + operation: 'value', + column: 'bytes', + }, + ], + rows: [], + }; + + expect(() => datatableStateSchema.validate(input)).toThrow(); + }); + it('throws when using invalid sorting index', () => { const input: DatatableWithoutDefaultsConfig = { ...baseDatatableConfig, @@ -771,5 +841,44 @@ describe('Datatable Schema', () => { const validated = datatableStateSchema.validate(input); expect(validated).toEqual({ ...defaultValues, ...input }); }); + + it('allows no metrics when using esql', () => { + const input: Omit = { + type: 'datatable', + title: 'Datatable', + description: 'ESQL table without metrics', + dataset: { + type: 'esql', + query: 'FROM my-index | LIMIT 100', + }, + rows: [ + { + operation: 'value', + column: 'location', + alignment: 'right', + apply_color_to: 'value', + visible: true, + click_filter: true, + collapse_by: 'avg', + color: { + mode: 'categorical', + palette: 'palette_name', + mapping: [ + { + values: ['value1', 'value2', 'value3'], + color: { + type: 'colorCode', + value: '#000000', + }, + }, + ], + }, + }, + ], + }; + + const validated = datatableStateSchema.validate(input); + expect(validated).toEqual({ ...defaultValues, ...input }); + }); }); }); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.ts index 18c08832eb08d..08186903ce683 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/datatable.ts @@ -298,7 +298,7 @@ const datatableStateMetricsOptionsSchema = { }; interface SortByValidationInput { - metrics: Array<{}>; + metrics?: Array<{}>; rows?: Array<{}>; split_metrics_by?: Array<{}>; sort_by?: { @@ -320,9 +320,11 @@ function validateSortBy({ const { column_type, index, values } = sort_by; + const numberOfMetrics = metrics?.length ?? 0; + if (column_type === 'metric') { - if (index == null || index >= metrics.length) { - return `The 'sort_by.index' (${index}) is out of bounds. The 'metrics' array has ${metrics.length} item(s).`; + if (index == null || index >= numberOfMetrics) { + return `The 'sort_by.index' (${index}) is out of bounds. The 'metrics' array has ${numberOfMetrics} item(s).`; } } @@ -341,8 +343,8 @@ function validateSortBy({ return `Cannot sort by 'pivoted_metric' when no split_metrics_by columns are defined.`; } - if (index == null || index >= metrics.length) { - return `The 'sort_by.index' (${index}) is out of bounds. The 'metrics' array has ${metrics.length} item(s).`; + if (index == null || index >= numberOfMetrics) { + return `The 'sort_by.index' (${index}) is out of bounds. The 'metrics' array has ${numberOfMetrics} item(s).`; } if (values == null || values.length !== split_metrics_by.length) { @@ -413,15 +415,17 @@ export const datatableStateSchemaESQL = schema.object( /** * Metric columns configuration, must define operation. */ - metrics: schema.arrayOf( - esqlColumnOperationWithLabelAndFormatSchema.extends(datatableStateMetricsOptionsSchema, { - meta: { id: 'datatableESQLMetric' }, - }), - { - minSize: 1, - maxSize: 1000, - meta: { description: 'Array of metrics to display as columns in the datatable' }, - } + metrics: schema.maybe( + schema.arrayOf( + esqlColumnOperationWithLabelAndFormatSchema.extends(datatableStateMetricsOptionsSchema, { + meta: { id: 'datatableESQLMetric' }, + }), + { + minSize: 1, + maxSize: 1000, + meta: { description: 'Array of metrics to display as columns in the datatable' }, + } + ) ), /** * Row configuration, optional operations. @@ -445,7 +449,18 @@ export const datatableStateSchemaESQL = schema.object( ), }, { - validate: validateSortBy, + validate: (arg) => { + const sortByError = validateSortBy(arg); + if (sortByError) { + return sortByError; + } + + const { metrics, rows } = arg; + + if (!metrics && !rows) { + return 'Datatable must have at least one column'; + } + }, meta: { id: 'datatableESQL', description: 'Datatable state configuration for ES|QL queries', diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts index 03c8bf286124b..06817bd018298 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts @@ -126,29 +126,98 @@ describe('Metric Schema', () => { expect(validated).toEqual({ ...defaultValues, ...input }); }); - it('should throw for invalid color by value configuration', () => { - const input = { - ...baseMetricConfig, - metrics: [ - { - type: 'primary', - operation: 'average', - field: 'temperature', - color: { - type: 'dynamic', - range: 'percentage', - steps: [ - { type: 'from', from: 0, color: '#blue' }, - { type: 'to', to: 100, color: '#red' }, - ], + describe('coloring configuration', () => { + it('should throw for invalid color by value configuration', () => { + const input = { + ...baseMetricConfig, + metrics: [ + { + type: 'primary', + operation: 'average', + field: 'temperature', + color: { + type: 'dynamic', + range: 'percentage', + min: 0, + max: 100, + steps: [ + { type: 'from', from: 0, color: '#blue' }, + { type: 'to', to: 100, color: '#red' }, + ], + }, + fit: false, + alignments: { labels: 'left', value: 'left' }, }, - fit: false, - alignments: { labels: 'left', value: 'left' }, + ], + }; + + expect(() => metricStateSchema.validate(input)).toThrow( + 'When using percentage-based dynamic coloring, a breakdown dimension or max must be defined.' + ); + }); + + it('accepts percentage-based dynamic coloring with breakdown_by', () => { + const input = { + ...baseMetricConfig, + metrics: [ + { + type: 'primary', + operation: 'average', + field: 'temperature', + color: { + type: 'dynamic', + range: 'percentage', + min: 0, + max: 100, + steps: [ + { type: 'from', from: 0, color: '#blue' }, + { type: 'to', to: 100, color: '#red' }, + ], + }, + fit: false, + alignments: { labels: 'left', value: 'left' }, + }, + ], + breakdown_by: { + operation: 'terms', + fields: ['category'], + columns: 3, }, - ], - }; + }; - expect(() => metricStateSchema.validate(input)).toThrow(); + expect(() => metricStateSchema.validate(input)).not.toThrow(); + }); + + it('accepts percentage-based dynamic coloring with bar background_chart', () => { + const input = { + ...baseMetricConfig, + metrics: [ + { + type: 'primary', + operation: 'average', + field: 'temperature', + color: { + type: 'dynamic', + range: 'percentage', + min: 0, + max: 100, + steps: [ + { type: 'from', from: 0, color: '#blue' }, + { type: 'to', to: 100, color: '#red' }, + ], + }, + fit: false, + alignments: { labels: 'left', value: 'left' }, + background_chart: { + type: 'bar', + max_value: { operation: 'static_value', value: 100 }, + }, + }, + ], + }; + + expect(() => metricStateSchema.validate(input)).not.toThrow(); + }); }); }); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts index 46d409e543385..9080d00a40bf0 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts @@ -18,7 +18,12 @@ import { esqlColumnSchema, esqlColumnOperationWithLabelAndFormatSchema, } from '../metric_ops'; -import { colorByValueAbsolute, staticColorSchema, applyColorToSchema } from '../color'; +import { + colorByValueAbsolute, + staticColorSchema, + applyColorToSchema, + colorByValueSchema, +} from '../color'; import { datasetSchema, datasetEsqlTableSchema } from '../dataset'; import { collapseBySchema, @@ -168,7 +173,7 @@ const metricStatePrimaryMetricOptionsSchema = { /** * Color configuration */ - color: schema.maybe(schema.oneOf([colorByValueAbsolute, staticColorSchema])), + color: schema.maybe(schema.oneOf([colorByValueSchema, staticColorSchema])), /** * Where to apply the color (background or value) */ @@ -327,6 +332,15 @@ export const esqlMetricState = schema.object({ export const metricStateSchema = schema.oneOf([metricStateSchemaNoESQL, esqlMetricState], { meta: { id: 'metricChartSchema' }, + validate: ({ metrics, breakdown_by }) => { + const primaryMetric = metrics.find((metric) => isPrimaryMetric(metric)); + + if (primaryMetric?.color?.type === 'dynamic' && primaryMetric.color.range === 'percentage') { + if (!breakdown_by && !(primaryMetric.background_chart?.type === 'bar')) { + return 'When using percentage-based dynamic coloring, a breakdown dimension or max must be defined.'; + } + } + }, }); export type MetricState = TypeOf; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_api/columns.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_api/columns.ts index fbb5e3cfc6ce9..e3fff6ba443dc 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_api/columns.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_api/columns.ts @@ -21,7 +21,7 @@ import { isMetricColumnESQL, isMetricColumnNoESQL } from '../helpers'; import { stripUndefined } from '../../utils'; type APIMetricRowCommonProps = Partial< - Pick + Pick[number], 'visible' | 'alignment' | 'width'> >; function buildCommonMetricRowProps(column: ColumnState): APIMetricRowCommonProps { @@ -39,7 +39,7 @@ function buildCommonMetricRowProps(column: ColumnState): APIMetricRowCommonProps */ function buildColorProps( column: ColumnState -): Partial> { +): Partial[number], 'apply_color_to' | 'color'>> { const { colorMode, palette, colorMapping } = column; if (!colorMode || colorMode === 'none') return {}; @@ -64,7 +64,9 @@ function buildColorProps( } type APIMetricProps = APIMetricRowCommonProps & - Partial>; + Partial< + Pick[number], 'apply_color_to' | 'color' | 'summary'> + >; function buildMetricsAPI(column: ColumnState): APIMetricProps { const { summaryRow, summaryLabel } = column; @@ -158,7 +160,7 @@ export function convertDatatableColumnsToAPI( ): DatatableColumnsNoESQLAndMapping | DatatableColumnsESQLAndMapping { const { columns } = visualization; if (columns.length === 0) { - throw new Error('Datatable must have at least one metric column'); + throw new Error('Datatable must have at least one column'); } // Used for the sorting columnId mapping during transformation to API format @@ -256,12 +258,12 @@ export function convertDatatableColumnsToAPI( } } - if (metrics.length === 0) { - throw new Error('Datatable must have at least one metric column'); + if (metrics.length === 0 && rows.length === 0) { + throw new Error('Datatable must have at least one column'); } return { - metrics, + ...(metrics.length > 0 ? { metrics } : {}), ...(rows.length > 0 ? { rows } : {}), ...(splitMetricsBy.length > 0 ? { split_metrics_by: splitMetricsBy } : {}), columnIdMapping, diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/columns.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/columns.ts index 46996f4d1c821..17bb51936370a 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/columns.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/columns.ts @@ -17,7 +17,9 @@ import { } from '../constants'; function buildColorProps( - config: DatatableState['metrics'][number] | NonNullable[number] + config: + | NonNullable[number] + | NonNullable[number] ): Partial> { if (!config.apply_color_to) return {}; const colorMode = config.apply_color_to === 'value' ? 'text' : 'cell'; @@ -34,7 +36,9 @@ function buildColorProps( } function buildCommonMetricRowState( - config: DatatableState['metrics'][number] | NonNullable[number] + config: + | NonNullable[number] + | NonNullable[number] ): Pick< ColumnState, 'hidden' | 'alignment' | 'colorMode' | 'isTransposed' | 'palette' | 'colorMapping' | 'width' @@ -49,6 +53,8 @@ function buildCommonMetricRowState( } export function buildMetricsState(metrics: DatatableState['metrics']): ColumnState[] { + if (!metrics) return []; + return metrics.map((metric, index) => { const columnId = getAccessorName(METRIC_ACCESSOR_PREFIX, index); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/index.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/index.ts index cef10feba8598..c1058bba39aa1 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/index.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/datatable/to_state/index.ts @@ -80,7 +80,7 @@ export function getValueColumns(config: DatatableStateESQL) { ...(config.split_metrics_by ?? []).map((splitBy, index) => getValueColumn(getAccessorName(SPLIT_METRIC_BY_ACCESSOR_PREFIX, index), splitBy.column) ), - ...config.metrics.map((metric, index) => + ...(config.metrics ?? []).map((metric, index) => getValueColumn(getAccessorName(METRIC_ACCESSOR_PREFIX, index), metric.column, 'number', true) ), ]; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts index 107b1600075a2..1eba043966f96 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts @@ -374,10 +374,7 @@ function enrichConfigurationWithVisualizationProperties( } if (visualization.palette) { - const colorByValue = fromColorByValueLensStateToAPI(visualization.palette); - if (colorByValue?.range === 'absolute') { - primaryMetric.color = colorByValue; - } + primaryMetric.color = fromColorByValueLensStateToAPI(visualization.palette); } if (visualization.applyColorTo) { diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts index af7c91f9f00f8..b870f5b1c5334 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts @@ -291,7 +291,7 @@ export type LensAnnotationLayer = Identity< export type LensSeriesLayer = Identity< LensBaseXYLayer & { type: 'series'; - breakdown?: LensBreakdownConfig; + breakdown?: LensBreakdownConfig | LensBreakdownConfig[]; xAxis?: LensBreakdownConfig; seriesType: 'line' | 'bar' | 'area'; } diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts new file mode 100644 index 0000000000000..75397c6883b60 --- /dev/null +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.test.ts @@ -0,0 +1,414 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { MutableRefObject } from 'react'; +import type { ESQLCallbacks } from '@kbn/esql-types'; +import type { suggest as suggestFn } from '@kbn/esql-language'; +import type { wrapAsMonacoSuggestions as wrapFn } from '../esql/lib/converters/suggestions'; +import type { + checkForTripleQuotesAndEsqlQuery as checkFn, + unescapeInvalidChars as unescapeFn, +} from './utils'; +import type { setupConsoleErrorsProvider as setupErrorsProviderFn } from './console_errors_provider'; +import type { ConsoleParsedRequestsProvider as ParsedProviderCtor } from './console_parsed_requests_provider'; + +import { monaco } from '../../monaco_imports'; +import { ESQL_AUTOCOMPLETE_TRIGGER_CHARS } from '../esql'; + +const mockWorkerSetup = jest.fn(); + +const mockSuggest = jest.fn, Parameters>(); +const mockWrapAsMonacoSuggestions = jest.fn, Parameters>(); +const mockCheckForTripleQuotesAndEsqlQuery = jest.fn< + ReturnType, + Parameters +>(); +const mockUnescapeInvalidChars = jest.fn< + ReturnType, + Parameters +>(); +const mockSetupConsoleErrorsProvider = jest.fn< + ReturnType, + Parameters +>(); +const mockConsoleParsedRequestsProvider = jest.fn< + InstanceType, + ConstructorParameters +>(); + +jest.mock('@kbn/esql-language', () => ({ + suggest: (...args: Parameters) => mockSuggest(...args), +})); + +jest.mock('../esql/lib/converters/suggestions', () => ({ + wrapAsMonacoSuggestions: (...args: Parameters) => + mockWrapAsMonacoSuggestions(...args), +})); + +jest.mock('./utils', () => ({ + checkForTripleQuotesAndEsqlQuery: ( + ...args: Parameters + ) => mockCheckForTripleQuotesAndEsqlQuery(...args), + unescapeInvalidChars: (...args: Parameters) => + mockUnescapeInvalidChars(...args), +})); + +jest.mock('./console_errors_provider', () => ({ + setupConsoleErrorsProvider: (...args: Parameters) => + mockSetupConsoleErrorsProvider(...args), +})); + +jest.mock('./console_parsed_requests_provider', () => { + function ConsoleParsedRequestsProvider( + ...args: ConstructorParameters + ) { + mockConsoleParsedRequestsProvider(...args); + } + return { ConsoleParsedRequestsProvider }; +}); + +jest.mock('./console_worker_proxy', () => { + function ConsoleWorkerProxyService(this: { setup: () => void }) { + this.setup = () => mockWorkerSetup(); + } + return { ConsoleWorkerProxyService }; +}); + +import { ConsoleLang, CONSOLE_TRIGGER_CHARS, getParsedRequestsProvider } from './language'; + +type ProvideCompletionItems = NonNullable< + monaco.languages.CompletionItemProvider['provideCompletionItems'] +>; + +const createToken = (): { token: monaco.CancellationToken; dispose: () => void } => { + const source = new monaco.CancellationTokenSource(); + return { token: source.token, dispose: () => source.dispose() }; +}; + +const createActionsProvider = (): { + actionsProvider: MutableRefObject<{ provideCompletionItems: ProvideCompletionItems } | null>; + provideCompletionItems: jest.MockedFunction; +} => { + const completionList: monaco.languages.CompletionList = { suggestions: [] }; + const provideCompletionItems = jest.fn< + ReturnType, + Parameters + >(() => completionList); + + const actionsProvider: MutableRefObject<{ + provideCompletionItems: ProvideCompletionItems; + } | null> = { + current: { provideCompletionItems }, + }; + + return { actionsProvider, provideCompletionItems }; +}; + +const createProvider = ( + esqlCallbacks: Pick | undefined, + actionsProvider: MutableRefObject<{ provideCompletionItems: ProvideCompletionItems } | null> +): monaco.languages.CompletionItemProvider => { + if (!ConsoleLang.getSuggestionProvider) { + throw new Error('expected ConsoleLang.getSuggestionProvider to be defined'); + } + return ConsoleLang.getSuggestionProvider(esqlCallbacks, actionsProvider); +}; + +const createModel = (lines: string[]): monaco.editor.ITextModel => { + return monaco.editor.createModel(lines.join('\n'), 'plaintext'); +}; + +describe('console language', () => { + const baseContext: monaco.languages.CompletionContext = { + triggerKind: monaco.languages.CompletionTriggerKind.Invoke, + }; + + const createEsqlCallbacks = (): Pick => ({ + getSources: async () => [], + getPolicies: async () => [], + }); + + const createdModels: monaco.editor.ITextModel[] = []; + afterEach(() => { + jest.restoreAllMocks(); + while (createdModels.length) { + createdModels.pop()?.dispose(); + } + }); + + beforeEach(() => { + // Global mocks stay, but each test starts from a clean slate: + // - clears call history + // - resets per-test stubbed implementations + jest.resetAllMocks(); + }); + + it('exposes triggerCharacters including Console + ES|QL triggers', () => { + const { actionsProvider } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + expect(provider.triggerCharacters).toEqual( + expect.arrayContaining([...CONSOLE_TRIGGER_CHARS, ...ESQL_AUTOCOMPLETE_TRIGGER_CHARS]) + ); + }); + + it('delegates to actions provider when no request line is found', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['{ "not": "a request line" }']); + createdModels.push(model); + + const getValueSpy = jest.spyOn(model, 'getValue'); + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(1, 1), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(getValueSpy).not.toHaveBeenCalled(); + expect(getValueInRangeSpy).not.toHaveBeenCalled(); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + expect(mockSuggest).not.toHaveBeenCalled(); + expect(mockWrapAsMonacoSuggestions).not.toHaveBeenCalled(); + + dispose(); + }); + + it('delegates to actions provider for non-_query request lines (e.g. GET _search)', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['GET _search', '{ "query": { "match_all": {} } }']); + createdModels.push(model); + + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 5), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(getValueInRangeSpy).not.toHaveBeenCalled(); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + + dispose(); + }); + + it('does not treat GET _query as ES|QL request (POST-only) and delegates to actions provider', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['GET _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + + dispose(); + }); + + it('limits request-line lookback to 2000 lines and delegates when request line is beyond lookback', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const lines = ['POST _query', ...Array.from({ length: 2000 }, () => '')]; + const model = createModel(lines); + createdModels.push(model); + + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(2001, 1), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(getValueInRangeSpy).not.toHaveBeenCalled(); + expect(mockCheckForTripleQuotesAndEsqlQuery).not.toHaveBeenCalled(); + + dispose(); + }); + + it('runs _query detection via getValueInRange and delegates when not inside ES|QL', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + const getValueInRangeSpy = jest.spyOn(model, 'getValueInRange'); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + expect(getValueInRangeSpy).toHaveBeenCalledTimes(1); + expect(mockCheckForTripleQuotesAndEsqlQuery).toHaveBeenCalledTimes(1); + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(mockSuggest).not.toHaveBeenCalled(); + expect(mockWrapAsMonacoSuggestions).not.toHaveBeenCalled(); + dispose(); + }); + + it('delegates when inside ES|QL but esqlCallbacks are undefined', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: 0, + }); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + expect(provideCompletionItems).toHaveBeenCalledTimes(1); + expect(mockSuggest).not.toHaveBeenCalled(); + expect(mockWrapAsMonacoSuggestions).not.toHaveBeenCalled(); + dispose(); + }); + + it('returns empty suggestions when no actions provider is available and request line is not found', async () => { + const actionsProvider: MutableRefObject<{ + provideCompletionItems: ProvideCompletionItems; + } | null> = { + current: null, + }; + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + const model = createModel(['{ "no": "request line" }']); + createdModels.push(model); + + const result = await provider.provideCompletionItems!( + model, + new monaco.Position(1, 1), + baseContext, + token + ); + + expect(result).toEqual({ suggestions: [] }); + dispose(); + }); + + it('returns empty suggestions when _query detection runs but actions provider is missing', async () => { + const actionsProvider: MutableRefObject<{ + provideCompletionItems: ProvideCompletionItems; + } | null> = { + current: null, + }; + const provider = createProvider(undefined, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + const result = await provider.provideCompletionItems!( + model, + new monaco.Position(2, 7), + baseContext, + token + ); + + expect(result).toEqual({ suggestions: [] }); + dispose(); + }); + + it('runs ES|QL suggestions and wraps them when inside ES|QL (single quoted)', async () => { + const { actionsProvider, provideCompletionItems } = createActionsProvider(); + const esqlCallbacks = createEsqlCallbacks(); + const provider = createProvider(esqlCallbacks, actionsProvider); + const { token, dispose } = createToken(); + + const wrapped: monaco.languages.CompletionList = { suggestions: [] }; + mockWrapAsMonacoSuggestions.mockReturnValue(wrapped); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: 0, + }); + + mockUnescapeInvalidChars.mockReturnValue('UNESCAPED_QUERY'); + mockSuggest.mockResolvedValue([]); + + const model = createModel(['POST _query', '{ "query": "FROM logs" }']); + createdModels.push(model); + + const result = await provider.provideCompletionItems!( + model, + new monaco.Position(2, 7), + baseContext, + token + ); + + expect(mockSuggest).toHaveBeenCalledWith( + 'UNESCAPED_QUERY', + 'UNESCAPED_QUERY'.length, + esqlCallbacks + ); + expect(mockWrapAsMonacoSuggestions).toHaveBeenCalledTimes(1); + expect(provideCompletionItems).not.toHaveBeenCalled(); + expect(result).toBe(wrapped); + dispose(); + }); + + it('passes allowSnippets=false when inside triple quotes (ES|QL)', async () => { + const { actionsProvider } = createActionsProvider(); + const esqlCallbacks = createEsqlCallbacks(); + const provider = createProvider(esqlCallbacks, actionsProvider); + const { token, dispose } = createToken(); + + mockCheckForTripleQuotesAndEsqlQuery.mockReturnValue({ + insideTripleQuotes: true, + insideEsqlQuery: true, + esqlQueryIndex: 0, + }); + + mockUnescapeInvalidChars.mockReturnValue('UNESCAPED_QUERY'); + mockSuggest.mockResolvedValue([]); + mockWrapAsMonacoSuggestions.mockReturnValue({ suggestions: [] }); + + const model = createModel(['POST _query', '{ "query": """FROM logs""" }']); + createdModels.push(model); + + await provider.provideCompletionItems!(model, new monaco.Position(2, 7), baseContext, token); + + // The 4th arg is `!insideTripleQuotes` -> false when inside triple quotes. + expect(mockWrapAsMonacoSuggestions.mock.calls[0][3]).toBe(false); + dispose(); + }); + + it('wires onLanguage + parsed request provider', () => { + ConsoleLang.onLanguage?.(); + expect(mockWorkerSetup).toHaveBeenCalledTimes(1); + expect(mockSetupConsoleErrorsProvider).toHaveBeenCalledTimes(1); + + const model = createModel(['GET _search']); + createdModels.push(model); + getParsedRequestsProvider(model); + + expect(mockConsoleParsedRequestsProvider).toHaveBeenCalledWith(expect.anything(), model); + }); +}); diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts index 453e18ccfb938..601ad40af5cb9 100644 --- a/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/language.ts @@ -32,6 +32,52 @@ import { foldingRangeProvider } from './folding_range_provider'; export const CONSOLE_TRIGGER_CHARS = ['/', '.', '_', ',', '?', '=', '&', '"']; +const requestMethodRe = /^\s*(GET|POST|PUT|DELETE|HEAD|PATCH)\b/i; +const esqlRequestLineRe = /^\s*post\s+\/?_query(?:\/async)?(?:\s|\?|$)/i; +/** + * Safeguards for request-line lookup. We scan backwards from the cursor until we find the nearest + * request method line (GET/POST/...), but we cap the amount of work to avoid a potentially large + * number of `getLineContent()` calls on very long documents. + * + * If these limits are hit, ES|QL context detection is skipped and we fall back to the + * actions provider (preserving completion behavior, just without ES|QL suggestions). + */ +const MAX_REQUEST_LINE_LOOKBACK_LINES = 2000; +const MAX_REQUEST_LINE_LOOKBACK_CHARS = 100_000; + +const findEsqlRequestLineNumber = ( + model: monaco.editor.ITextModel, + positionLineNumber: number +): number | undefined => { + for ( + let lineNumber = positionLineNumber, scannedLines = 0, scannedChars = 0; + lineNumber >= 1 && + scannedLines < MAX_REQUEST_LINE_LOOKBACK_LINES && + scannedChars < MAX_REQUEST_LINE_LOOKBACK_CHARS; + lineNumber--, scannedLines++ + ) { + const line = model.getLineContent(lineNumber); + scannedChars += line.length + 1; + if (requestMethodRe.test(line)) { + // Only treat this as an ES|QL request if the request line matches POST _query(/async)?... + return esqlRequestLineRe.test(line) ? lineNumber : undefined; + } + } +}; + +const getRequestTextBeforeCursor = ( + model: monaco.editor.ITextModel, + requestLineNumber: number, + position: monaco.Position +): string => { + return model.getValueInRange({ + startLineNumber: requestLineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); +}; + /** * @description This language definition is used for the console input panel */ @@ -46,8 +92,10 @@ export const ConsoleLang: LangModuleType = { }, languageThemeResolver: buildConsoleTheme, getSuggestionProvider: ( - esqlCallbacks: Pick, - actionsProvider: MutableRefObject + esqlCallbacks: Pick | undefined, + actionsProvider: MutableRefObject<{ + provideCompletionItems: monaco.languages.CompletionItemProvider['provideCompletionItems']; + } | null> ): monaco.languages.CompletionItemProvider => { return { // force suggestions when these characters are used @@ -55,15 +103,36 @@ export const ConsoleLang: LangModuleType = { provideCompletionItems: async ( model: monaco.editor.ITextModel, position: monaco.Position, - context: monaco.languages.CompletionContext + context: monaco.languages.CompletionContext, + token: monaco.CancellationToken ) => { - const fullText = model.getValue(); - const cursorOffset = model.getOffsetAt(position); - const textBeforeCursor = fullText.slice(0, cursorOffset); + // NOTE: Materializing the full editor content (e.g. via `model.getValue()`) can be very + // expensive for large inputs (like pasted JSON with huge string fields). We only do ES|QL + // context detection when the cursor is within a POST /_query request. + const delegateToActionsProvider = () => { + const actions = actionsProvider.current; + return ( + actions?.provideCompletionItems(model, position, context, token) ?? { + suggestions: [], + } + ); + }; + + const esqlRequestLineNumber = findEsqlRequestLineNumber(model, position.lineNumber); + if (!esqlRequestLineNumber) { + return delegateToActionsProvider(); + } + + const requestTextBeforeCursor = getRequestTextBeforeCursor( + model, + esqlRequestLineNumber, + position + ); const { insideTripleQuotes, insideEsqlQuery, esqlQueryIndex } = - checkForTripleQuotesAndEsqlQuery(textBeforeCursor); + checkForTripleQuotesAndEsqlQuery(requestTextBeforeCursor); + if (esqlCallbacks && insideEsqlQuery) { - const queryText = textBeforeCursor.slice(esqlQueryIndex, cursorOffset); + const queryText = requestTextBeforeCursor.slice(esqlQueryIndex); const unescapedQuery = unescapeInvalidChars(queryText); const esqlSuggestions = await suggest( unescapedQuery, @@ -77,12 +146,8 @@ export const ConsoleLang: LangModuleType = { !insideTripleQuotes, true ); - } else if (actionsProvider.current) { - return actionsProvider.current?.provideCompletionItems(model, position, context); } - return { - suggestions: [], - }; + return delegateToActionsProvider(); }, }; }, diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts index 3dde1b534c8a7..9c6ce044400a9 100644 --- a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.test.ts @@ -73,6 +73,19 @@ describe('autocomplete_utils', () => { esqlQueryIndex: -1, }); }); + + it('does not treat longer words as request methods (e.g. GETS, POSTER)', () => { + const methods = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'] as const; + for (const method of methods) { + const requestMethod = `${method}A`; + const request = `${requestMethod} _query\n{\n "query": "SELECT * FROM logs `; + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); + } + }); }); it('sets insideEsqlQuery for single quoted query after POST _query', () => { @@ -114,6 +127,16 @@ describe('autocomplete_utils', () => { }); }); + it('detects query with /_query/async endpoint', () => { + const request = `POST /_query/async\n{\n "query": "FROM logs | STATS `; + const result = checkForTripleQuotesAndEsqlQuery(request); + expect(result).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"FROM logs ') + 1, + }); + }); + it('detects triple quoted query after POST _query?foo=bar with extra spaces', () => { const request = `POST _query?foo=bar\n{\n "query": """FROM metrics `; const result = checkForTripleQuotesAndEsqlQuery(request); @@ -124,6 +147,36 @@ describe('autocomplete_utils', () => { }); }); + it('detects query when request line is indented', () => { + const request = ` \tPOST _query\n{\n "query": "FROM logs | STATS `; + const result = checkForTripleQuotesAndEsqlQuery(request); + expect(result).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"FROM logs ') + 1, + }); + }); + + it('detects query value with whitespace around the colon', () => { + const request = `POST _query\n{\n "query" :\t "FROM logs | STATS `; + const result = checkForTripleQuotesAndEsqlQuery(request); + expect(result).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: true, + esqlQueryIndex: request.indexOf('"FROM logs ') + 1, + }); + }); + + it('does not treat near-miss keys as the "query" value', () => { + const request = `POST _query\n{\n "queryx": "FROM logs | STATS `; + const result = checkForTripleQuotesAndEsqlQuery(request); + expect(result).toEqual({ + insideTripleQuotes: false, + insideEsqlQuery: false, + esqlQueryIndex: -1, + }); + }); + it('does not set ESQL flags for subsequent non-_query request in same buffer', () => { const request = `POST _query\n{\n "query": "FROM a | STATS "\n}\nGET other_index/_search\n{\n "query": "match_all" }`; const result = checkForTripleQuotesAndEsqlQuery(request); diff --git a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts index 79980ff785ec7..bbbd2e32592cf 100644 --- a/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts +++ b/src/platform/packages/shared/kbn-monaco/src/languages/console/utils/autocomplete_utils.ts @@ -9,10 +9,161 @@ /** * This function takes a Console text up to the current position and determines whether - * the current position is inside triple quotes, triple-quote or single-quote query, - * and the start index of the current query. + * the current position is: + * - inside a `""" ... """` triple-quoted string + * - inside the JSON string value for the `"query"` key (either `"..."` or `"""..."""`) + * - and whether the surrounding request section is a POST /_query(/async) request. + * + * When inside an ES|QL query value, it returns the start index of the query text (the first + * character after the opening quote(s)). * @param text The text up to the current position */ +const TRIPLE_QUOTES = '"""'; +const QUERY_KEY = '"query"'; +const ESQL_QUERY_REQUEST_LINE_RE = /^post\s+\/?_query(?:\/async)?(?:\s|\?|$)/i; +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'] as const; + +const ASCII = { + A_UPPER: 65, + Z_UPPER: 90, + A_LOWER: 97, + Z_LOWER: 122, +} as const; + +const isWhitespace = (ch: string | undefined) => + ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; + +/** + * Walks backwards from `fromIndex` until a non-whitespace character is found. + * Returns that index, or -1 if the scan runs past the beginning. + */ +const skipWhitespaceBackward = (text: string, fromIndex: number): number => { + for (let index = fromIndex; index >= 0; index--) { + if (!isWhitespace(text[index])) { + return index; + } + } + return -1; +}; + +const isAsciiLetter = (ch: string | undefined): boolean => { + if (!ch) return false; + const code = ch.charCodeAt(0); + return ( + (code >= ASCII.A_UPPER && code <= ASCII.Z_UPPER) || + (code >= ASCII.A_LOWER && code <= ASCII.Z_LOWER) + ); +}; + +/** + * Returns true when `index` is positioned at the start of a line. + * In this file we treat `\n` as the line separator (Console input is normalized to `\n`). + */ +const isStartOfLine = (text: string, index: number): boolean => { + if (index === 0) { + return true; + } + const previousChar = text[index - 1]; + return previousChar === '\n'; +}; + +/** + * Checks whether `text[quoteIndex]` (the opening quote character of either `"` or `"""`) is the + * start of the JSON value for the `"query"` key, i.e. the preceding text ends with: + * `"query"\s*:\s*`. + * + * This is intentionally implemented without regexes and without creating large substrings. + */ +const isQueryValueStartAtQuote = (text: string, quoteIndex: number): boolean => { + // We expect the preceding text to end with: `"query"\s*:\s*` + const colonIndex = skipWhitespaceBackward(text, quoteIndex - 1); + if (colonIndex < 0 || text[colonIndex] !== ':') { + return false; + } + + const keyEndIndex = skipWhitespaceBackward(text, colonIndex - 1); + const keyStartIndex = keyEndIndex - (QUERY_KEY.length - 1); + if (keyStartIndex < 0) { + return false; + } + return text.startsWith(QUERY_KEY, keyStartIndex); +}; + +/** + * Case-insensitive word match at `startIndex` for ASCII methods. + * Ensures we don't accidentally match longer identifiers (e.g. `GETS`). + */ +const matchesWordAt = (text: string, startIndex: number, word: string): boolean => { + for (let offset = 0; offset < word.length; offset++) { + const ch = text[startIndex + offset]; + if (!ch || ch.toUpperCase() !== word[offset]) { + return false; + } + } + // Ensure we don't match a larger identifier (e.g. GETS). + return !isAsciiLetter(text[startIndex + word.length]); +}; + +/** + * Returns true when `text[startIndex...]` starts with an HTTP method token (GET/POST/...) + * and is not part of a longer word. + */ +const isRequestMethodAt = (text: string, startIndex: number): boolean => { + if (!isAsciiLetter(text[startIndex])) return false; + for (const method of HTTP_METHODS) { + if (matchesWordAt(text, startIndex, method)) { + return true; + } + } + return false; +}; + +/** + * Returns true if the given request line corresponds to an ES|QL request (`POST /_query` or + * `POST /_query/async`), allowing querystring suffixes. + */ +const isEsqlQueryRequestLine = (line: string): boolean => ESQL_QUERY_REQUEST_LINE_RE.test(line); + +/** + * Returns the index where query text begins if `quoteIndex` starts the `"query"` value. + * Otherwise returns -1. + */ +const getQueryValueStartIndex = (text: string, quoteIndex: number, quoteLen: 1 | 3): number => { + return isQueryValueStartAtQuote(text, quoteIndex) ? quoteIndex + quoteLen : -1; +}; + +/** + * Attempts to interpret the line starting at `lineStartIndex` as a Console request line + * (HTTP method + path). When a request line is found, returns: + * - `isEsqlQueryRequest`: whether this request line is a POST /_query(/async) request + * - `nextIndex`: where the main scan loop should continue (the beginning of the next line) + */ +const scanRequestLineFrom = ( + text: string, + lineStartIndex: number +): { nextIndex: number; isEsqlQueryRequest: boolean } | undefined => { + let scanIndex = lineStartIndex; + // Skip leading spaces/tabs on the request line. + while (scanIndex < text.length && (text[scanIndex] === ' ' || text[scanIndex] === '\t')) { + scanIndex++; + } + + if (scanIndex >= text.length || !isRequestMethodAt(text, scanIndex)) { + return; + } + + const newlineIndex = text.indexOf('\n', scanIndex); + const lineEnd = newlineIndex === -1 ? text.length : newlineIndex; + + // The request line is typically short; substring allocation here is bounded. + const line = text.slice(scanIndex, lineEnd); + const isEsqlQueryRequest = isEsqlQueryRequestLine(line); + + // Move the index past the current request line. + const nextIndex = newlineIndex === -1 ? text.length : newlineIndex + 1; + return { nextIndex, isEsqlQueryRequest }; +}; + export const checkForTripleQuotesAndEsqlQuery = ( text: string ): { @@ -20,66 +171,67 @@ export const checkForTripleQuotesAndEsqlQuery = ( insideEsqlQuery: boolean; esqlQueryIndex: number; } => { - let insideSingleQuotes = false; - let insideTripleQuotes = false; - - let insideSingleQuotesQuery = false; - let insideTripleQuotesQuery = false; - - let insideEsqlQueryRequest = false; - - let currentQueryStartIndex = -1; - let i = 0; - - while (i < text.length) { - const textBefore = text.slice(0, i); - const textFromIndex = text.slice(i); - if (text.startsWith('"""', i)) { - insideTripleQuotes = !insideTripleQuotes; - if (insideTripleQuotes) { - insideTripleQuotesQuery = /.*"query"\s*:\s*$/.test(textBefore); - if (insideTripleQuotesQuery) { - currentQueryStartIndex = i + 3; - } - } else { - insideTripleQuotesQuery = false; - currentQueryStartIndex = -1; + // Quote tracking for the JSON body: + // - inDoubleQuoteString: between unescaped `" ... "` + // - inTripleQuoteString: between `""" ... """` (only toggled when not already in double quotes) + let inDoubleQuoteString = false; + let inTripleQuoteString = false; + + // Tracks whether the *current* string (double or triple) is the value for `"query"`. + let inQueryValueString = false; + + // Tracks whether the current request section is a POST /_query(/async) request. + let inEsqlQueryRequest = false; + + // Start index of the query text (first character after opening quote(s)) when inQueryValueString=true. + let esqlQueryStartIndex = -1; + + for (let index = 0; index < text.length; ) { + // Detect request boundaries (only meaningful outside quoted regions). + if (!inDoubleQuoteString && !inTripleQuoteString && isStartOfLine(text, index)) { + const requestLineScan = scanRequestLineFrom(text, index); + if (requestLineScan) { + inEsqlQueryRequest = requestLineScan.isEsqlQueryRequest; + index = requestLineScan.nextIndex; + continue; } - i += 3; // Skip the triple quotes - } else if (text.at(i) === '"' && text.at(i - 1) !== '\\') { - insideSingleQuotes = !insideSingleQuotes; - if (insideSingleQuotes) { - insideSingleQuotesQuery = /.*"query"\s*:\s*$/.test(textBefore); - if (insideSingleQuotesQuery) { - currentQueryStartIndex = i + 1; - } + } + + // Triple quotes (only when we're not already inside a standard JSON string). + if (!inDoubleQuoteString && text.startsWith(TRIPLE_QUOTES, index)) { + inTripleQuoteString = !inTripleQuoteString; + if (inTripleQuoteString) { + esqlQueryStartIndex = getQueryValueStartIndex(text, index, 3); + inQueryValueString = esqlQueryStartIndex !== -1; } else { - insideSingleQuotesQuery = false; - currentQueryStartIndex = -1; + inQueryValueString = false; + esqlQueryStartIndex = -1; } - i++; - } else if (/^(GET|POST|PUT|DELETE|HEAD|PATCH)/i.test(textFromIndex)) { - // If this is the start of a new request, check if it is a _query API request - insideEsqlQueryRequest = /^(P|p)(O|o)(S|s)(T|t)\s+\/?_query(\/async)?(\n|\s|\?)/.test( - textFromIndex - ); - // Move the index past the current line that contains request method and endpoint. - const newlineIndex = text.indexOf('\n', i); - if (newlineIndex === -1) { - // No newline after the request line; advance to end to avoid infinite loop. - i = text.length; + index += 3; + continue; + } + + // Standard JSON string quotes (unescaped only, and only when not in triple quotes). + if (!inTripleQuoteString && text[index] === '"' && text[index - 1] !== '\\') { + inDoubleQuoteString = !inDoubleQuoteString; + if (inDoubleQuoteString) { + esqlQueryStartIndex = getQueryValueStartIndex(text, index, 1); + inQueryValueString = esqlQueryStartIndex !== -1; } else { - i = newlineIndex + 1; // Position at start of next line + inQueryValueString = false; + esqlQueryStartIndex = -1; } - } else { - i++; + index++; + continue; } + + index++; } return { - insideTripleQuotes, - insideEsqlQuery: insideEsqlQueryRequest && (insideSingleQuotesQuery || insideTripleQuotesQuery), - esqlQueryIndex: insideEsqlQueryRequest ? currentQueryStartIndex : -1, + insideTripleQuotes: inTripleQuoteString, + insideEsqlQuery: inEsqlQueryRequest && inQueryValueString, + esqlQueryIndex: inEsqlQueryRequest ? esqlQueryStartIndex : -1, }; }; diff --git a/src/platform/packages/shared/kbn-scout/src/cli/config_discovery.test.ts b/src/platform/packages/shared/kbn-scout/src/cli/config_discovery.test.ts index dd0ce56f7a1af..ea2a6d6cad7b7 100644 --- a/src/platform/packages/shared/kbn-scout/src/cli/config_discovery.test.ts +++ b/src/platform/packages/shared/kbn-scout/src/cli/config_discovery.test.ts @@ -158,7 +158,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginA/config1.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'abc123', tests: [ { @@ -189,7 +188,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginA/parallel.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'def456', tests: [ { @@ -218,7 +216,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginB/config3.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'ghi789', tests: [ { @@ -247,7 +244,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'packageA/config4.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'jkl012', tests: [ { @@ -391,7 +387,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginLocalServerless/config.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'local789', tests: [ { @@ -457,7 +452,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'x-pack/platform/plugins/private/pluginCustom/test/scout_custom/config.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'custom123', tests: [ { @@ -477,7 +471,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'x-pack/platform/plugins/private/pluginCustom/config.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'normal456', tests: [ { @@ -532,7 +525,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: excludedConfigPath, exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'exclude123', tests: [ { @@ -552,7 +544,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'x-pack/solutions/security/plugins/cloud_security_posture/test/scout/ui/config.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'include456', tests: [ { @@ -640,7 +631,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginNoMatch/config.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'mno345', tests: [ { @@ -723,7 +713,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginNoTests/config.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'pqr678', tests: [ { @@ -776,7 +765,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginMixedTests/config.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'stu901', tests: [ { @@ -877,7 +865,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginTestModes/config1.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'vwx234', tests: [ { @@ -901,7 +888,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginTestModes/config2.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'yza567', tests: [ { @@ -951,7 +937,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginSearch/config1.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'bcd234', tests: [ { @@ -971,7 +956,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginSearch/config2.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'cde345', tests: [ { @@ -1000,7 +984,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginPlatform/config1.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'def456', tests: [ { @@ -1029,7 +1012,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginOblt/config1.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'efg567', tests: [ { @@ -1242,7 +1224,6 @@ describe('runDiscoverPlaywrightConfigs', () => { manifest: { path: 'pluginMultiMode/config1.playwright.config.ts', exists: true, - lastModified: '2024-01-01T00:00:00Z', sha1: 'fgh678', tests: [ { diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/login_page.ts b/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/login_page.ts index f414f3cb7b545..d7d03a68b8ca6 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/login_page.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/login_page.ts @@ -11,10 +11,22 @@ import type { ScoutPage } from '../fixtures/scope/test'; import type { KibanaUrl } from '../../common/services/kibana_url'; export class LoginPage { - constructor(private readonly page: ScoutPage, private readonly kbnUrl: KibanaUrl) {} + public readonly loginBtn; + public readonly roleSelectionInput; + + constructor(private readonly page: ScoutPage, private readonly kbnUrl: KibanaUrl) { + this.loginBtn = this.page.testSubj.locator('loginButton'); + this.roleSelectionInput = this.page.getByRole('combobox'); + } async goto() { await this.page.goto(this.kbnUrl.get('/login')); await this.page.testSubj.locator('loginSubmit').waitFor({ state: 'visible' }); } + + async loginWithRole(role: string) { + await this.loginBtn.waitFor({ state: 'visible' }); + await this.roleSelectionInput.fill(role); + await this.loginBtn.click(); + } } diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/uiam_local/serverless/security_complete.serverless.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/uiam_local/serverless/security_complete.serverless.config.ts index 34eabe78c5adc..819e04d7fca7d 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/uiam_local/serverless/security_complete.serverless.config.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/uiam_local/serverless/security_complete.serverless.config.ts @@ -10,6 +10,7 @@ import { MOCK_IDP_UIAM_SERVICE_URL, MOCK_IDP_UIAM_SHARED_SECRET } from '@kbn/mock-idp-utils'; import { resolve } from 'path'; import { REPO_ROOT } from '@kbn/repo-info'; +import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { servers as defaultConfig } from '../../default/serverless/security_complete.serverless.config'; import type { ScoutServerConfig } from '../../../../../types'; @@ -34,6 +35,9 @@ export const servers: ScoutServerConfig = { `--xpack.security.uiam.enabled=true`, `--xpack.security.uiam.url=${MOCK_IDP_UIAM_SERVICE_URL}`, `--xpack.security.uiam.sharedSecret=${MOCK_IDP_UIAM_SHARED_SECRET}`, + `--xpack.security.uiam.ssl.certificate=${KBN_CERT_PATH}`, + `--xpack.security.uiam.ssl.key=${KBN_KEY_PATH}`, + '--xpack.security.uiam.ssl.verificationMode=none', ], }, }; diff --git a/src/platform/packages/shared/kbn-scout/src/servers/run_kibana_server.ts b/src/platform/packages/shared/kbn-scout/src/servers/run_kibana_server.ts index dfcf25515e9df..7c2c96afe29ee 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/run_kibana_server.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/run_kibana_server.ts @@ -28,6 +28,15 @@ export async function runKibanaServer(options: { const installDir = runOptions.alwaysUseSource ? undefined : options.installDir; const devMode = !installDir; const useTaskRunner = options.config.get('kbnTestServer.useDedicatedTaskRunner'); + const env = { + ...process.env, + ...options.config.get('kbnTestServer.env'), + }; + if (env.NO_COLOR !== undefined) { + delete env.FORCE_COLOR; + } else if (env.FORCE_COLOR === undefined) { + env.FORCE_COLOR = '1'; + } const procRunnerOpts = { cwd: installDir || REPO_ROOT, @@ -36,11 +45,7 @@ export async function runKibanaServer(options: { ? Path.resolve(installDir, 'bin/kibana.bat') : Path.resolve(installDir, 'bin/kibana') : process.execPath, - env: { - FORCE_COLOR: 1, - ...process.env, - ...options.config.get('kbnTestServer.env'), - }, + env, wait: runOptions.wait, onEarlyExit: options.onEarlyExit, }; diff --git a/src/platform/packages/shared/kbn-scout/test/scout/.meta/api/standard.json b/src/platform/packages/shared/kbn-scout/test/scout/.meta/api/parallel.json similarity index 82% rename from src/platform/packages/shared/kbn-scout/test/scout/.meta/api/standard.json rename to src/platform/packages/shared/kbn-scout/test/scout/.meta/api/parallel.json index f06a4dd104b47..a00a1ab5dd638 100644 --- a/src/platform/packages/shared/kbn-scout/test/scout/.meta/api/standard.json +++ b/src/platform/packages/shared/kbn-scout/test/scout/.meta/api/parallel.json @@ -1,9 +1,8 @@ { - "lastModified": "2026-02-03T16:06:40.631Z", - "sha1": "2c249d0d69b356fc5842679e42ff18439aea04ad", + "sha1": "2e96536c3282ad2dce4514b6f2c2a9eaf2c1571a", "tests": [ { - "id": "f44f18cc703276d-178a4921f7b18d0", + "id": "fa4e4cd5fb0f29c-178a4921f7b18d0", "title": "Alerting Rules helpers should fetch alert with 'alerting.rules.get'", "expectedStatus": "passed", "tags": [ @@ -13,13 +12,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/alerting_rules.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/alerting_rules.spec.ts", "line": 49, "column": 12 } }, { - "id": "f44f18cc703276d-33ca8fa92624a9c", + "id": "fa4e4cd5fb0f29c-33ca8fa92624a9c", "title": "Alerting Rules helpers should update alert with 'alerting.rules.update'", "expectedStatus": "passed", "tags": [ @@ -29,13 +28,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/alerting_rules.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/alerting_rules.spec.ts", "line": 57, "column": 12 } }, { - "id": "f44f18cc703276d-73b7d633c4103c0", + "id": "fa4e4cd5fb0f29c-73b7d633c4103c0", "title": "Alerting Rules helpers should enable/disable rule", "expectedStatus": "passed", "tags": [ @@ -45,13 +44,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/alerting_rules.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/alerting_rules.spec.ts", "line": 65, "column": 12 } }, { - "id": "f44f18cc703276d-b40ee20740b3d69", + "id": "fa4e4cd5fb0f29c-b40ee20740b3d69", "title": "Alerting Rules helpers should find rule with 'alerting.rules.find'", "expectedStatus": "passed", "tags": [ @@ -61,13 +60,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/alerting_rules.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/alerting_rules.spec.ts", "line": 81, "column": 12 } }, { - "id": "f44f18cc703276d-96731296a9891c1", + "id": "fa4e4cd5fb0f29c-96731296a9891c1", "title": "Alerting Rules helpers should mute/unmute rule", "expectedStatus": "passed", "tags": [ @@ -77,13 +76,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/alerting_rules.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/alerting_rules.spec.ts", "line": 94, "column": 12 } }, { - "id": "f44f18cc703276d-ff05561b9156a5c", + "id": "fa4e4cd5fb0f29c-ff05561b9156a5c", "title": "Alerting Rules helpers should mute/unmute alert for rule", "expectedStatus": "passed", "tags": [ @@ -93,13 +92,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/alerting_rules.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/alerting_rules.spec.ts", "line": 112, "column": 12 } }, { - "id": "f44f18cc703276d-5221ee78cdc80b3", + "id": "fa4e4cd5fb0f29c-5221ee78cdc80b3", "title": "Alerting Rules helpers should snooze/unsnooze rule", "expectedStatus": "passed", "tags": [ @@ -109,13 +108,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/alerting_rules.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/alerting_rules.spec.ts", "line": 132, "column": 12 } }, { - "id": "fce093fbca08710-6e904464520b5be", + "id": "c3260b48058c482-6e904464520b5be", "title": "Cases Helpers should fetch case with 'cases.get'", "expectedStatus": "passed", "tags": [ @@ -125,13 +124,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 42, "column": 12 } }, { - "id": "fce093fbca08710-90abc09aa216d42", + "id": "c3260b48058c482-90abc09aa216d42", "title": "Cases Helpers should update case with a new severity with 'cases.update'", "expectedStatus": "passed", "tags": [ @@ -141,13 +140,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 47, "column": 12 } }, { - "id": "fce093fbca08710-5408585221b6fe5", + "id": "c3260b48058c482-5408585221b6fe5", "title": "Cases Helpers should add a new connector to a case", "expectedStatus": "passed", "tags": [ @@ -157,13 +156,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 67, "column": 12 } }, { - "id": "fce093fbca08710-ce6612540863f1f", + "id": "c3260b48058c482-ce6612540863f1f", "title": "Cases Helpers should delete multiple cases", "expectedStatus": "passed", "tags": [ @@ -173,13 +172,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 85, "column": 12 } }, { - "id": "fce093fbca08710-867e650994dc709", + "id": "c3260b48058c482-867e650994dc709", "title": "Cases Helpers should delete cases by tags", "expectedStatus": "passed", "tags": [ @@ -189,13 +188,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 105, "column": 12 } }, { - "id": "fce093fbca08710-f9fa16a32a85b71", + "id": "c3260b48058c482-f9fa16a32a85b71", "title": "Cases Helpers should post and find a comment", "expectedStatus": "passed", "tags": [ @@ -205,13 +204,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 132, "column": 12 } }, { - "id": "fce093fbca08710-e2cb6fcca5c3e88", + "id": "c3260b48058c482-e2cb6fcca5c3e88", "title": "Cases Helpers should post an alert", "expectedStatus": "passed", "tags": [ @@ -221,13 +220,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 153, "column": 12 } }, { - "id": "fce093fbca08710-c6649d9e4143889", + "id": "c3260b48058c482-c6649d9e4143889", "title": "Cases Helpers should search for a case by category", "expectedStatus": "passed", "tags": [ @@ -237,13 +236,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/cases.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/cases.spec.ts", "line": 166, "column": 12 } }, { - "id": "ac287aee91a2e9c-f534a82c1ec5149", + "id": "2d413bd2b8d0d7e-f534a82c1ec5149", "title": "Data Views API Service should get all data views with getAll()", "expectedStatus": "passed", "tags": [ @@ -253,13 +252,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 47, "column": 12 } }, { - "id": "ac287aee91a2e9c-be676318898c619", + "id": "2d413bd2b8d0d7e-be676318898c619", "title": "Data Views API Service should get a single data view by ID with get()", "expectedStatus": "passed", "tags": [ @@ -269,13 +268,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 60, "column": 12 } }, { - "id": "ac287aee91a2e9c-3f46260460f1aec", + "id": "2d413bd2b8d0d7e-3f46260460f1aec", "title": "Data Views API Service should handle get() for non-existent data view", "expectedStatus": "passed", "tags": [ @@ -285,13 +284,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 69, "column": 12 } }, { - "id": "ac287aee91a2e9c-47d7bba7f59f2c1", + "id": "2d413bd2b8d0d7e-47d7bba7f59f2c1", "title": "Data Views API Service should delete a data view with delete()", "expectedStatus": "passed", "tags": [ @@ -301,13 +300,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 78, "column": 12 } }, { - "id": "ac287aee91a2e9c-dc3283d3803eb09", + "id": "2d413bd2b8d0d7e-dc3283d3803eb09", "title": "Data Views API Service should handle delete() for non-existent data view", "expectedStatus": "passed", "tags": [ @@ -317,13 +316,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 103, "column": 12 } }, { - "id": "ac287aee91a2e9c-926ee551afecbf6", + "id": "2d413bd2b8d0d7e-926ee551afecbf6", "title": "Data Views API Service should find data views with a predicate using find()", "expectedStatus": "passed", "tags": [ @@ -333,13 +332,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 112, "column": 12 } }, { - "id": "ac287aee91a2e9c-7ba205abc502386", + "id": "2d413bd2b8d0d7e-7ba205abc502386", "title": "Data Views API Service should return empty array when find() predicate matches nothing", "expectedStatus": "passed", "tags": [ @@ -349,13 +348,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 168, "column": 12 } }, { - "id": "ac287aee91a2e9c-c76c896477bb9a2", + "id": "2d413bd2b8d0d7e-c76c896477bb9a2", "title": "Data Views API Service should delete a data view by title with deleteByTitle()", "expectedStatus": "passed", "tags": [ @@ -365,13 +364,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 180, "column": 12 } }, { - "id": "ac287aee91a2e9c-cbc50b9173dd0ee", + "id": "2d413bd2b8d0d7e-cbc50b9173dd0ee", "title": "Data Views API Service should return 200 when deleteByTitle() is called with non-existent title", "expectedStatus": "passed", "tags": [ @@ -381,13 +380,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 209, "column": 12 } }, { - "id": "ac287aee91a2e9c-3b690cd4f7735a5", + "id": "2d413bd2b8d0d7e-3b690cd4f7735a5", "title": "Data Views API Service should delete only the first matching data view when multiple have same title", "expectedStatus": "passed", "tags": [ @@ -397,13 +396,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 221, "column": 12 } }, { - "id": "ac287aee91a2e9c-e5bcf554ead858d", + "id": "2d413bd2b8d0d7e-e5bcf554ead858d", "title": "Data Views API Service should handle multiple data views operations", "expectedStatus": "passed", "tags": [ @@ -413,13 +412,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 272, "column": 12 } }, { - "id": "ac287aee91a2e9c-a53a3ef77df8551", + "id": "2d413bd2b8d0d7e-a53a3ef77df8551", "title": "Data Views API Service should not throw error when deleting already deleted data view", "expectedStatus": "passed", "tags": [ @@ -429,13 +428,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 330, "column": 12 } }, { - "id": "ac287aee91a2e9c-658dfffd4fad26c", + "id": "2d413bd2b8d0d7e-658dfffd4fad26c", "title": "Data Views API Service should handle deleteByTitle() when called twice", "expectedStatus": "passed", "tags": [ @@ -445,13 +444,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/data_views.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/data_views.spec.ts", "line": 358, "column": 12 } }, { - "id": "1ac39428f850160-0db434f3b0a7c11", + "id": "f3df016665ae5d0-0db434f3b0a7c11", "title": "Fleet Integration Management should install a custom integration", "expectedStatus": "passed", "tags": [ @@ -461,13 +460,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 28, "column": 12 } }, { - "id": "1ac39428f850160-4fbf626746dcffc", + "id": "f3df016665ae5d0-4fbf626746dcffc", "title": "Fleet Integration Management should delete an integration and return status code", "expectedStatus": "passed", "tags": [ @@ -477,13 +476,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 34, "column": 12 } }, { - "id": "1ac39428f850160-3f9dd1df7ce971d", + "id": "f3df016665ae5d0-3f9dd1df7ce971d", "title": "Fleet Integration Management should handle delete of non-existent integration", "expectedStatus": "passed", "tags": [ @@ -493,13 +492,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 44, "column": 12 } }, { - "id": "1ac39428f850160-a1fbb9043d34cc7", + "id": "f3df016665ae5d0-a1fbb9043d34cc7", "title": "Fleet Agent Policies Management should get agent policies with query parameters", "expectedStatus": "passed", "tags": [ @@ -509,13 +508,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 74, "column": 12 } }, { - "id": "1ac39428f850160-2993bae6be1562a", + "id": "f3df016665ae5d0-2993bae6be1562a", "title": "Fleet Agent Policies Management should create an agent policy with additional parameters", "expectedStatus": "passed", "tags": [ @@ -525,13 +524,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 85, "column": 12 } }, { - "id": "1ac39428f850160-edd65cbcfec5424", + "id": "f3df016665ae5d0-edd65cbcfec5424", "title": "Fleet Agent Policies Management should update an agent policy", "expectedStatus": "passed", "tags": [ @@ -541,13 +540,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 105, "column": 12 } }, { - "id": "1ac39428f850160-b897c3ef548d7b5", + "id": "f3df016665ae5d0-b897c3ef548d7b5", "title": "Fleet Agent Policies Management should bulk get agent policies", "expectedStatus": "passed", "tags": [ @@ -557,13 +556,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 130, "column": 12 } }, { - "id": "1ac39428f850160-2633914e42ffea8", + "id": "f3df016665ae5d0-2633914e42ffea8", "title": "Fleet Agent Policies Management should delete an agent policy", "expectedStatus": "passed", "tags": [ @@ -573,13 +572,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 156, "column": 12 } }, { - "id": "1ac39428f850160-1e4a39abe840169", + "id": "f3df016665ae5d0-1e4a39abe840169", "title": "Fleet Agent Policies Management should delete an agent policy with force flag", "expectedStatus": "passed", "tags": [ @@ -589,13 +588,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 170, "column": 12 } }, { - "id": "1ac39428f850160-041513758093b5d", + "id": "f3df016665ae5d0-041513758093b5d", "title": "Fleet Outputs Management should get all outputs", "expectedStatus": "passed", "tags": [ @@ -605,13 +604,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 200, "column": 12 } }, { - "id": "1ac39428f850160-1ceeea1f4f18b0a", + "id": "f3df016665ae5d0-1ceeea1f4f18b0a", "title": "Fleet Outputs Management should get a specific output by ID", "expectedStatus": "passed", "tags": [ @@ -621,13 +620,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 208, "column": 12 } }, { - "id": "1ac39428f850160-57aec8b030a14c4", + "id": "f3df016665ae5d0-57aec8b030a14c4", "title": "Fleet Outputs Management should create an output with additional parameters", "expectedStatus": "passed", "tags": [ @@ -637,13 +636,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 222, "column": 12 } }, { - "id": "1ac39428f850160-9b6ecdc71c30d07", + "id": "f3df016665ae5d0-9b6ecdc71c30d07", "title": "Fleet Outputs Management should delete an output", "expectedStatus": "passed", "tags": [ @@ -653,13 +652,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 243, "column": 12 } }, { - "id": "1ac39428f850160-2045e266f5c3994", + "id": "f3df016665ae5d0-2045e266f5c3994", "title": "Fleet Server Hosts Management should get fleet server hosts", "expectedStatus": "passed", "tags": [ @@ -669,13 +668,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 278, "column": 12 } }, { - "id": "1ac39428f850160-0dd9cd554941f9e", + "id": "f3df016665ae5d0-0dd9cd554941f9e", "title": "Fleet Server Hosts Management should create a fleet server host with parameters", "expectedStatus": "passed", "tags": [ @@ -685,13 +684,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 285, "column": 12 } }, { - "id": "1ac39428f850160-6a7a56d10bb14d9", + "id": "f3df016665ae5d0-6a7a56d10bb14d9", "title": "Fleet Server Hosts Management should delete a fleet server host", "expectedStatus": "passed", "tags": [ @@ -701,13 +700,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 302, "column": 12 } }, { - "id": "1ac39428f850160-e7f6e669552d63a", + "id": "f3df016665ae5d0-e7f6e669552d63a", "title": "Fleet Agent Management should setup fleet agents", "expectedStatus": "passed", "tags": [ @@ -717,13 +716,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 324, "column": 12 } }, { - "id": "1ac39428f850160-015134015804081", + "id": "f3df016665ae5d0-015134015804081", "title": "Fleet Agent Management should get agents with query parameters", "expectedStatus": "passed", "tags": [ @@ -733,13 +732,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 330, "column": 12 } }, { - "id": "1ac39428f850160-688642bba3fe0dc", + "id": "f3df016665ae5d0-688642bba3fe0dc", "title": "Fleet Agent Management should handle delete of non-existent agent", "expectedStatus": "passed", "tags": [ @@ -749,13 +748,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 340, "column": 12 } }, { - "id": "1ac39428f850160-a28714e7d3e9305", + "id": "f3df016665ae5d0-a28714e7d3e9305", "title": "Fleet API Error Handling should handle bulk get with non-existent policy IDs", "expectedStatus": "passed", "tags": [ @@ -765,13 +764,13 @@ "@cloud-stateful-classic" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/api_services/fleet.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/api_services/fleet.spec.ts", "line": 355, "column": 12 } }, { - "id": "fe047b1920cdb73-7e93077b7f1a2c8", + "id": "4022e536dd0fdb7-7e93077b7f1a2c8", "title": "SAML Auth fixture should create a session for 'admin' role", "expectedStatus": "passed", "tags": [ @@ -791,13 +790,13 @@ "@cloud-serverless-security_complete" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/auth/saml_login.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/auth/saml_login.spec.ts", "line": 14, "column": 10 } }, { - "id": "fe047b1920cdb73-c842fa63ffb17a2", + "id": "4022e536dd0fdb7-c842fa63ffb17a2", "title": "SAML Auth fixture should create API Key for 'admin' role", "expectedStatus": "passed", "tags": [ @@ -817,7 +816,7 @@ "@cloud-serverless-security_complete" ], "location": { - "file": "src/platform/packages/shared/kbn-scout/test/scout/api/tests/auth/saml_login.spec.ts", + "file": "src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/auth/saml_login.spec.ts", "line": 19, "column": 10 } diff --git a/src/platform/packages/shared/kbn-scout/test/scout/.meta/ui/standard.json b/src/platform/packages/shared/kbn-scout/test/scout/.meta/ui/standard.json index 5f1e482887003..05044f0cefc66 100644 --- a/src/platform/packages/shared/kbn-scout/test/scout/.meta/ui/standard.json +++ b/src/platform/packages/shared/kbn-scout/test/scout/.meta/ui/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-03T16:06:43.645Z", "sha1": "4dd4954c9438502d5b3a7547a1b1579aa45dd894", "tests": [ { diff --git a/src/platform/packages/shared/kbn-security-solution-common/README.md b/src/platform/packages/shared/kbn-security-solution-common/README.md deleted file mode 100644 index 46d128c132cf1..0000000000000 --- a/src/platform/packages/shared/kbn-security-solution-common/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# @kbn/security-solution-common - -## Purpose - -This package hosts all the security solution code that we need to use in the Security Solution plugin as well as the -Discover plugin. While the `kbn-security-solution-flyout` package hosts all the flyout specific code, this -`kbn-security-solution-common` package contains the code that will be used both by that package and by other pages in -Security Solution. That way, we avoid code duplication and make sure that both plugins use the same code. - -This package is intended to grow pretty quickly, as we're moving components in the `kbn-security-solution-flyout` -package. We might consider splitting it into multiple smaller packages if the need arises. - -## Thoughts when contributing to this package - -As this code is meant to be used in the Security Solution and Discover plugins, please make sure that they are: - -- well documented -- well unit tested diff --git a/src/platform/packages/shared/kbn-security-solution-common/index.ts b/src/platform/packages/shared/kbn-security-solution-common/index.ts deleted file mode 100644 index ad21076a9ca93..0000000000000 --- a/src/platform/packages/shared/kbn-security-solution-common/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ diff --git a/src/platform/packages/shared/kbn-security-solution-common/kibana.jsonc b/src/platform/packages/shared/kbn-security-solution-common/kibana.jsonc deleted file mode 100644 index 93e4adb068e98..0000000000000 --- a/src/platform/packages/shared/kbn-security-solution-common/kibana.jsonc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "shared-common", - "id": "@kbn/security-solution-common", - "owner": "@elastic/security-threat-hunting-investigations", - "group": "platform", - "visibility": "shared" -} diff --git a/src/platform/packages/shared/kbn-security-solution-flyout/README.md b/src/platform/packages/shared/kbn-security-solution-flyout/README.md deleted file mode 100644 index 58df1abcebf08..0000000000000 --- a/src/platform/packages/shared/kbn-security-solution-flyout/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# @kbn/security-solution-flyout - -## Purpose - -This package contains the code that is used to render Security document flyouts (alert, event, host, user, network, -network) and Security tools flyouts (analyzer, session view, graph, insights...) in the Security Solution and Discover -plugins. - -Most the of code already exists in the `flyout` folder in the `security_solution` plugin ( -see https://github.com/elastic/kibana/tree/main/x-pack/solutions/security/plugins/security_solution/public/flyout) and -will slowly be moved here. - -## Folder Structure - -The structure of the `flyout` folder is intended to work as follows: - -- multiple top level folders referring to the _type_ of flyout (for example document details, user, host, rule, - cases...) and would contain all the panels for that flyout _type_. Each of these top level folders can be organized - the way you want, but we recommend following a similar structure to the one we have for the `document_details` flyout - type, where the `right`, `left` and `preview` folders correspond to the panels displayed in the right, left and - preview flyout sections respectively. The `shared` folder contains any shared components/hooks/services/helpers that - are used within the other folders. - -``` -documents -└─── alert -└─── host -└─── user -└─── newtwork -tools -└─── analyzer -└─── session_view -└─── graph -└─── prevalence -└─── correlations -└─── threat_intelligence -└─── prevalence -└─── entities -``` - -## Thoughts when contributing to this package - -As this code is meant to be used in the Security Solution and Discover plugins, please make sure that they are: - -- well documented -- well unit tested diff --git a/src/platform/packages/shared/kbn-security-solution-flyout/index.ts b/src/platform/packages/shared/kbn-security-solution-flyout/index.ts deleted file mode 100644 index ad21076a9ca93..0000000000000 --- a/src/platform/packages/shared/kbn-security-solution-flyout/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ diff --git a/src/platform/packages/shared/kbn-security-solution-flyout/kibana.jsonc b/src/platform/packages/shared/kbn-security-solution-flyout/kibana.jsonc deleted file mode 100644 index 0bbe8c4323953..0000000000000 --- a/src/platform/packages/shared/kbn-security-solution-flyout/kibana.jsonc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "shared-browser", - "id": "@kbn/security-solution-flyout", - "owner": "@elastic/security-threat-hunting-investigations", - "group": "platform", - "visibility": "shared" -} diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/index.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/index.ts index a4a3a973bb345..a8d720852edae 100644 --- a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/index.ts +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/index.ts @@ -10,8 +10,6 @@ export * from './tools/get_hosts/hosts'; export * from './tools/get_services/services'; export * from './tools/get_log_groups/log_groups'; -export * from './tools/get_correlated_logs/correlated_logs'; -export * from './tools/get_downstream_dependencies/dependencies'; export * from './tools/get_alerts/alerts'; export * from './tools/get_alerts/apm_errors'; export * from './tools/run_log_rate_analysis/log_rate_analysis_spike'; @@ -22,4 +20,7 @@ export * from './tools/get_index_info/field_discovery'; export * from './tools/get_log_change_points/log_change_points'; export * from './tools/get_metric_change_points/metric_change_points'; export * from './tools/get_trace_change_points/trace_change_points'; +export * from './tools/get_service_topology/topology'; +export * from './tools/get_service_topology/topology_trace_isolation'; +export * from './tools/get_service_topology/topology_cycle'; export * from './tools/get_runtime_metrics/runtime_metrics'; diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_correlated_logs/correlated_logs.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_correlated_logs/correlated_logs.ts deleted file mode 100644 index bb9f6d214c323..0000000000000 --- a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_correlated_logs/correlated_logs.ts +++ /dev/null @@ -1,332 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -/** - * SCENARIO: Correlated Logs for get_correlated_logs tool - * - * Generates log sequences linked by correlation identifiers. - * Each sequence contains logs with a shared correlation ID and at least one anchor log (error/warning). - * - * CLI Usage: - * ``` - * node scripts/synthtrace src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_correlated_logs/correlated_logs.ts \ - * --from "now-1h" --to "now" --clean --workers=1 - * ``` - * - * API Test Usage: - * ```typescript - * await indexCorrelatedLogs({ - * logsEsClient, - * logs: [ - * { 'log.level': 'info', message: 'Request started', 'service.name': 'my-service', 'trace.id': 'abc123' }, - * { 'log.level': 'error', message: 'Request failed', 'service.name': 'my-service', 'trace.id': 'abc123' }, - * ], - * }); - * ``` - */ - -import type { LogDocument, Timerange } from '@kbn/synthtrace-client'; -import { log } from '@kbn/synthtrace-client'; -import type { Scenario } from '../../../../cli/scenario'; -import { withClient, type ScenarioReturnType } from '../../../../lib/utils/with_client'; -import { IndexTemplateName } from '../../../../lib/logs/custom_logsdb_index_templates'; -import type { LogsSynthtraceEsClient } from '../../../../lib/logs/logs_synthtrace_es_client'; - -/** - * A log event with any fields. Minimal required field is `message`. - * All other fields (severity, correlation IDs, service info) are optional. - */ -export interface CorrelatedLogEvent { - message: string; - '@timestamp'?: number; - [key: string]: unknown; -} - -/** - * A minimal log entry for use with createLogSequence. - * Only requires message; all other fields are optional. - */ -export interface LogEntry { - message: string; - [key: string]: unknown; -} - -/** - * Generates correlated log data. - * - * @param range - Time range for log generation - * @param logsEsClient - Synthtrace ES client - * @param logs - Optional array of log events. If not provided, generates default realistic sequences. - */ -export function generateCorrelatedLogsData({ - range, - logsEsClient, - logs, -}: { - range: Timerange; - logsEsClient: LogsSynthtraceEsClient; - logs?: CorrelatedLogEvent[]; -}): ScenarioReturnType { - const data = range - .interval('5m') - .rate(1) - .generator((timestamp) => { - // Use custom logs if provided (API tests), otherwise generate default sequences (CLI) - const events = logs ?? generateDefaultSequences(timestamp); - - return events.map((event, index) => { - const { message, '@timestamp': eventTimestamp, ...fields } = event; - - return log - .create() - .message(message) - .defaults(fields) - .timestamp(eventTimestamp ?? timestamp + index * 10000); - }); - }); - - return withClient(logsEsClient, data); -} - -/** - * Generates realistic log sequences for CLI usage. - * Creates multiple independent sequences with different: - * - Services (payment, order, auth, shipping) - * - Correlation types (trace.id, request.id, session.id) - * - Severity patterns (some with errors, some with warnings) - */ -function generateDefaultSequences(baseTimestamp: number): CorrelatedLogEvent[] { - return [ - // Sequence 1: Payment failure (trace.id correlation) - ...createLogSequence({ - service: 'payment-service', - correlation: { 'trace.id': `trace-payment-${baseTimestamp}` }, - logs: [ - { 'log.level': 'info', message: 'Payment request received', '@timestamp': baseTimestamp }, - { - 'log.level': 'info', - message: 'Validating card details', - '@timestamp': baseTimestamp + 100, - }, - { - 'log.level': 'info', - message: 'Contacting payment gateway', - '@timestamp': baseTimestamp + 200, - }, - { - 'log.level': 'error', - message: 'Payment gateway timeout after 30s', - '@timestamp': baseTimestamp + 30200, - }, - { - 'log.level': 'warn', - message: 'Initiating retry (attempt 2/3)', - '@timestamp': baseTimestamp + 30300, - }, - { - 'log.level': 'info', - message: 'Payment succeeded on retry', - '@timestamp': baseTimestamp + 32000, - }, - ], - }), - - // Sequence 2: Order processing (request.id correlation) - ...createLogSequence({ - service: 'order-service', - correlation: { 'request.id': `req-order-${baseTimestamp}` }, - logs: [ - { 'log.level': 'info', message: 'Order created', '@timestamp': baseTimestamp + 5000 }, - { - 'log.level': 'info', - message: 'Inventory check passed', - '@timestamp': baseTimestamp + 5100, - }, - { 'log.level': 'info', message: 'Order confirmed', '@timestamp': baseTimestamp + 5200 }, - ], - }), - - // Sequence 3: Auth failure (session.id correlation) - ...createLogSequence({ - service: 'auth-service', - correlation: { 'session.id': `session-auth-${baseTimestamp}` }, - logs: [ - { - 'log.level': 'info', - message: 'Login attempt started', - '@timestamp': baseTimestamp + 10000, - }, - { - 'log.level': 'warn', - message: 'Invalid password (attempt 1/3)', - '@timestamp': baseTimestamp + 10100, - }, - { - 'log.level': 'warn', - message: 'Invalid password (attempt 2/3)', - '@timestamp': baseTimestamp + 12000, - }, - { - 'log.level': 'error', - message: 'Account locked after 3 failed attempts', - '@timestamp': baseTimestamp + 14000, - }, - ], - }), - - // Sequence 4: Shipping error (transaction.id correlation) - ...createLogSequence({ - service: 'shipping-service', - correlation: { 'transaction.id': `txn-ship-${baseTimestamp}` }, - logs: [ - { - 'log.level': 'info', - message: 'Shipping label generation started', - '@timestamp': baseTimestamp + 20000, - }, - { - 'log.level': 'info', - message: 'Address validation passed', - '@timestamp': baseTimestamp + 20100, - }, - { - 'log.level': 'error', - message: 'Carrier API returned 503 Service Unavailable', - '@timestamp': baseTimestamp + 20200, - }, - { - 'log.level': 'info', - message: 'Fallback to secondary carrier', - '@timestamp': baseTimestamp + 20300, - }, - { - 'log.level': 'info', - message: 'Shipping label generated successfully', - '@timestamp': baseTimestamp + 20500, - }, - ], - }), - - // Sequence 5: Notification service (correlation.id) - ...createLogSequence({ - service: 'notification-service', - correlation: { 'correlation.id': `corr-notify-${baseTimestamp}` }, - logs: [ - { - 'log.level': 'info', - message: 'Email notification queued', - '@timestamp': baseTimestamp + 25000, - }, - { - 'log.level': 'info', - message: 'Email sent successfully', - '@timestamp': baseTimestamp + 25500, - }, - ], - }), - - // Sequence 6: Database error (span.id correlation) - ...createLogSequence({ - service: 'inventory-service', - correlation: { 'span.id': `span-db-${baseTimestamp}` }, - logs: [ - { - 'log.level': 'info', - message: 'Database query started', - '@timestamp': baseTimestamp + 30000, - }, - { - 'log.level': 'warn', - message: 'Query taking longer than expected (>5s)', - '@timestamp': baseTimestamp + 35000, - }, - { - 'log.level': 'error', - message: 'Database connection pool exhausted', - '@timestamp': baseTimestamp + 40000, - }, - { - 'log.level': 'info', - message: 'Connection recovered after pool expansion', - '@timestamp': baseTimestamp + 42000, - }, - ], - }), - ]; -} - -/** - * Creates a sequence of correlated log events with shared fields. - * Reduces boilerplate in API tests by applying common fields to all logs. - * - * @example - * // Basic usage with ECS log.level - * createLogSequence({ - * service: 'payment-service', - * correlation: { 'trace.id': 'trace-123' }, - * logs: [ - * { 'log.level': 'info', message: 'Request started' }, - * { 'log.level': 'error', message: 'Request failed' }, - * ], - * }); - * - * @example - * // With alternative severity format (syslog) - * createLogSequence({ - * service: 'syslog-service', - * correlation: { 'trace.id': 'trace-456' }, - * logs: [ - * { message: 'Request started', 'syslog.severity': 6 }, // info - * { message: 'Request failed', 'syslog.severity': 3 }, // error - * ], - * }); - * - * @example - * // With custom correlation field - * createLogSequence({ - * service: 'order-service', - * correlation: { order_id: 'ORD-789' }, - * logs: [ - * { 'log.level': 'info', message: 'Order created' }, - * { 'log.level': 'error', message: 'Order failed' }, - * ], - * }); - */ -export function createLogSequence({ - service, - correlation, - logs, - defaults = {}, -}: { - /** Service name (maps to service.name) */ - service: string; - /** Correlation field(s) shared by all logs, e.g. { 'trace.id': 'abc' } or { order_id: '123' } */ - correlation: Record; - /** Log entries - each must have `message`, other fields are optional */ - logs: LogEntry[]; - /** Additional fields to apply to all logs */ - defaults?: Record; -}): CorrelatedLogEvent[] { - return logs.map((entry) => ({ - 'service.name': service, - ...correlation, - ...defaults, - ...entry, - })); -} - -const scenario: Scenario = async () => ({ - bootstrap: async ({ logsEsClient }) => { - await logsEsClient.createIndexTemplate(IndexTemplateName.LogsDb); - }, - generate: ({ range, clients: { logsEsClient } }) => - generateCorrelatedLogsData({ range, logsEsClient }), -}); - -export default scenario; diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_downstream_dependencies/dependencies.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_downstream_dependencies/dependencies.ts deleted file mode 100644 index 2b00cd3bb7b53..0000000000000 --- a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_downstream_dependencies/dependencies.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -/** - * SCENARIO: Generated Dependencies - * - * Story: Generates downstream dependencies for `checkout-service`. - * - * Dependencies: - * - `payment-gateway` (HTTP) - * - `postgres` (DB) - * - `kafka` (Messaging) - * - * Validate via: - * - * ``` - * POST kbn:///api/agent_builder/tools/_execute - * { - * "tool_id": "observability.get_downstream_dependencies", - * "tool_params": { - * "start": "now-1h", - * "end": "now", - * "serviceName": "checkout-service" - * } - * } - * ``` - */ - -import type { ApmFields, Timerange } from '@kbn/synthtrace-client'; -import { apm } from '@kbn/synthtrace-client'; -import { createCliScenario } from '../../../../lib/utils/create_scenario'; -import { withClient, type ScenarioReturnType } from '../../../../lib/utils/with_client'; -import type { ApmSynthtraceEsClient } from '../../../../lib/apm/client/apm_synthtrace_es_client'; - -interface DependencyConfig { - spanName: string; - spanType: string; - spanSubtype: string; - destination: string; - duration: number; -} - -export function generateDependenciesData({ - range, - apmEsClient, - serviceName, - environment, - agentName, - transactionName, - dependencies, -}: { - range: Timerange; - apmEsClient: ApmSynthtraceEsClient; - serviceName: string; - environment: string; - agentName: string; - transactionName: string; - dependencies: DependencyConfig[]; -}): ScenarioReturnType { - const service = apm.service(serviceName, environment, agentName).instance(`${serviceName}-01`); - - const data = range - .interval('1m') - .rate(1) - .generator((timestamp) => { - let offset = 10; - const spans = dependencies.map((dep) => { - const span = service - .span(dep.spanName, dep.spanType, dep.spanSubtype) - .destination(dep.destination) - .timestamp(timestamp + offset) - .duration(dep.duration) - .success(); - offset += dep.duration + 10; - return span; - }); - - return [ - service - .transaction(transactionName, 'request') - .timestamp(timestamp) - .duration(200) - .success() - .children(...spans), - ]; - }); - - return withClient(apmEsClient, data); -} - -export default createCliScenario(({ range, clients: { apmEsClient } }) => { - const dependencies = [ - { - spanName: 'POST https://payment.gateway/charge', - spanType: 'external', - spanSubtype: 'http', - destination: 'payment-gateway', - duration: 100, - }, - { - spanName: 'SELECT FROM orders', - spanType: 'db', - spanSubtype: 'postgresql', - destination: 'postgres', - duration: 20, - }, - { - spanName: 'orders', - spanType: 'messaging', - spanSubtype: 'kafka', - destination: 'kafka', - duration: 10, - }, - ]; - - return generateDependenciesData({ - range, - apmEsClient, - serviceName: 'checkout-service', - environment: 'production', - agentName: 'nodejs', - transactionName: 'POST /api/checkout', - dependencies, - }); -}); diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_hosts/hosts.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_hosts/hosts.ts index db9317dbc82bc..48242c3a04af8 100644 --- a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_hosts/hosts.ts +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_hosts/hosts.ts @@ -24,7 +24,7 @@ * at 30-second intervals). For faster execution, use shorter time ranges (15-30 minutes): * * ``` - * node scripts/synthtrace --from "now-15m" --to "now" --clean --workers=1 + * node scripts/synthtrace --from "now-15m" --to "now" --clean * ``` * * Validate via: diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_index_info/field_discovery.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_index_info/field_discovery.ts index c1fe5d695f41f..92c9adcf9f940 100644 --- a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_index_info/field_discovery.ts +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_index_info/field_discovery.ts @@ -40,7 +40,7 @@ * ``` * node scripts/synthtrace \ * src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_index_info/field_discovery.ts \ - * --from "now-15m" --to "now" --clean --workers=1 + * --from "now-15m" --to "now" --clean * ``` */ diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology.ts new file mode 100644 index 0000000000000..0505bb2119b61 --- /dev/null +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology.ts @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * SCENARIO: Service Topology + * + * Generates a multi-hop service topology with instrumented services and + * uninstrumented dependencies (databases, caches, message queues). + * + * IMPORTANT: The span.destination.service.resource values intentionally differ from + * the service.name values (e.g., "checkout-proxy:5050" instead of "checkout-service"). + * This prevents tests from passing if someone reintroduces heuristic matching on + * span.destination.service.resource. The implementation must rely on resolved + * target['service.name'] (from the parent.id → span.id join between instrumented services). + * + * Topology: + * + * frontend (nodejs) + * → checkout-service (java) [destination: "checkout-proxy:5050"] + * → postgres (db) + * → redis (cache) + * → kafka (messaging) + * → recommendation-service (python) [destination: "recommendation-lb:8080"] + * → postgres (db) + * + * Validate via: + * + * ``` + * POST kbn:///api/agent_builder/tools/_execute + * { + * "tool_id": "observability.get_service_topology", + * "tool_params": { + * "start": "now-1h", + * "end": "now", + * "serviceName": "frontend" + * } + * } + * ``` + */ + +import type { ApmFields, Timerange } from '@kbn/synthtrace-client'; +import { apm } from '@kbn/synthtrace-client'; +import { createCliScenario } from '../../../../lib/utils/create_scenario'; +import { withClient, type ScenarioReturnType } from '../../../../lib/utils/with_client'; +import type { ApmSynthtraceEsClient } from '../../../../lib/apm/client/apm_synthtrace_es_client'; + +// NOTE: These resource names intentionally differ from service.name to prevent +// heuristic matching from accidentally passing tests. See scenario comment above. +export const FRONTEND_SERVICE = { + serviceName: 'frontend', +} as const; + +export const CHECKOUT_SERVICE = { + serviceName: 'checkout-service', + resource: 'checkout-proxy:5050', +} as const; + +const EXTERNAL_HTTP_SPAN = { + spanType: 'external', + spanSubtype: 'http', +} as const; + +export const RECOMMENDATION_SERVICE = { + serviceName: 'recommendation-service', + resource: 'recommendation-lb:8080', +} as const; + +export const POSTGRES_DEPENDENCY = { + resource: 'postgres', + spanType: 'db', + spanSubtype: 'postgresql', +} as const; + +export const REDIS_DEPENDENCY = { + resource: 'redis', + spanType: 'db', + spanSubtype: 'redis', +} as const; + +export const KAFKA_DEPENDENCY = { + resource: 'kafka', + spanType: 'messaging', + spanSubtype: 'kafka', +} as const; + +export function generateTopologyData({ + range, + apmEsClient, +}: { + range: Timerange; + apmEsClient: ApmSynthtraceEsClient; +}): ScenarioReturnType { + const frontend = apm + .service(FRONTEND_SERVICE.serviceName, 'production', 'nodejs') + .instance('frontend-01'); + const checkoutService = apm + .service(CHECKOUT_SERVICE.serviceName, 'production', 'java') + .instance('checkout-01'); + const recommendationService = apm + .service(RECOMMENDATION_SERVICE.serviceName, 'production', 'python') + .instance('recommendation-01'); + + const data = range + .interval('1m') + .rate(1) + .generator((timestamp) => { + // frontend → checkout-service → dependencies + const frontendToCheckout = frontend + .transaction('GET /checkout', 'request') + .timestamp(timestamp) + .duration(300) + .success() + .children( + frontend + .span('POST /api/checkout', EXTERNAL_HTTP_SPAN.spanType, EXTERNAL_HTTP_SPAN.spanSubtype) + .destination(CHECKOUT_SERVICE.resource) + .timestamp(timestamp + 10) + .duration(180) + .success() + .children( + checkoutService + .transaction('POST /api/checkout', 'request') + .timestamp(timestamp + 15) + .duration(150) + .success() + .children( + checkoutService + .span( + 'SELECT FROM orders', + POSTGRES_DEPENDENCY.spanType, + POSTGRES_DEPENDENCY.spanSubtype + ) + .destination(POSTGRES_DEPENDENCY.resource) + .timestamp(timestamp + 20) + .duration(30) + .success(), + checkoutService + .span('GET cart:*', REDIS_DEPENDENCY.spanType, REDIS_DEPENDENCY.spanSubtype) + .destination(REDIS_DEPENDENCY.resource) + .timestamp(timestamp + 60) + .duration(5) + .success(), + checkoutService + .span('order.created', KAFKA_DEPENDENCY.spanType, KAFKA_DEPENDENCY.spanSubtype) + .destination(KAFKA_DEPENDENCY.resource) + .timestamp(timestamp + 80) + .duration(10) + .success() + ) + ) + ); + + // frontend → recommendation-service → postgres (separate trace for sibling exclusion testing) + const frontendToRecommendation = frontend + .transaction('GET /recommendations', 'request') + .timestamp(timestamp + 400) + .duration(150) + .success() + .children( + frontend + .span( + 'GET /api/recommendations', + EXTERNAL_HTTP_SPAN.spanType, + EXTERNAL_HTTP_SPAN.spanSubtype + ) + .destination(RECOMMENDATION_SERVICE.resource) + .timestamp(timestamp + 410) + .duration(90) + .success() + .children( + recommendationService + .transaction('GET /api/recommendations', 'request') + .timestamp(timestamp + 415) + .duration(80) + .success() + .children( + recommendationService + .span( + 'SELECT FROM products', + POSTGRES_DEPENDENCY.spanType, + POSTGRES_DEPENDENCY.spanSubtype + ) + .destination(POSTGRES_DEPENDENCY.resource) + .timestamp(timestamp + 420) + .duration(40) + .success() + ) + ) + ); + + return [frontendToCheckout, frontendToRecommendation]; + }); + + return withClient(apmEsClient, data); +} + +export default createCliScenario(({ range, clients: { apmEsClient } }) => { + return generateTopologyData({ range, apmEsClient }); +}); diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology_cycle.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology_cycle.ts new file mode 100644 index 0000000000000..3cf092ff0b125 --- /dev/null +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology_cycle.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * SCENARIO: Service Topology — Cycle Detection + * + * Tests that BFS traversal terminates correctly when the service graph + * contains a cycle (A→B→A callback pattern). + * + * Topology: + * cycle-service-a → cycle-service-b → cycle-service-a (callback) + * + * This verifies the visitedServices guard prevents infinite traversal. + * + * Validate via: + * + * ``` + * POST kbn:///api/agent_builder/tools/_execute + * { + * "tool_id": "observability.get_service_topology", + * "tool_params": { + * "start": "now-1h", + * "end": "now", + * "serviceName": "cycle-service-a" + * } + * } + * ``` + */ + +import type { ApmFields, Timerange } from '@kbn/synthtrace-client'; +import { apm } from '@kbn/synthtrace-client'; +import { createCliScenario } from '../../../../lib/utils/create_scenario'; +import { withClient, type ScenarioReturnType } from '../../../../lib/utils/with_client'; +import type { ApmSynthtraceEsClient } from '../../../../lib/apm/client/apm_synthtrace_es_client'; + +export const CYCLE_SERVICE_A = { + serviceName: 'cycle-service-a', +} as const; + +export const CYCLE_SERVICE_B = { + serviceName: 'cycle-service-b', + resource: 'cycle-b-lb:8080', +} as const; + +export function generateCycleTopologyData({ + range, + apmEsClient, +}: { + range: Timerange; + apmEsClient: ApmSynthtraceEsClient; +}): ScenarioReturnType { + const serviceA = apm + .service(CYCLE_SERVICE_A.serviceName, 'production', 'nodejs') + .instance('a-01'); + const serviceB = apm.service(CYCLE_SERVICE_B.serviceName, 'production', 'java').instance('b-01'); + + const data = range + .interval('1m') + .rate(1) + .generator((timestamp) => { + // Trace: A → B → A (callback pattern creating a cycle in the service graph) + const trace = serviceA + .transaction('GET /start', 'request') + .timestamp(timestamp) + .duration(500) + .success() + .children( + serviceA + .span('POST /api/b', 'external', 'http') + .destination(CYCLE_SERVICE_B.resource) + .timestamp(timestamp + 10) + .duration(400) + .success() + .children( + serviceB + .transaction('POST /api/b', 'request') + .timestamp(timestamp + 15) + .duration(350) + .success() + .children( + serviceB + .span('GET /api/a/callback', 'external', 'http') + .destination('cycle-a-lb:8080') + .timestamp(timestamp + 20) + .duration(200) + .success() + .children( + serviceA + .transaction('GET /api/a/callback', 'request') + .timestamp(timestamp + 25) + .duration(100) + .success() + ) + ) + ) + ); + + return [trace]; + }); + + return withClient(apmEsClient, data); +} + +export default createCliScenario(({ range, clients: { apmEsClient } }) => { + return generateCycleTopologyData({ range, apmEsClient }); +}); diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology_trace_isolation.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology_trace_isolation.ts new file mode 100644 index 0000000000000..e803e24ca8a64 --- /dev/null +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology_trace_isolation.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * SCENARIO: Service Topology — Trace Isolation + * + * Tests that topology queries respect trace boundaries when an instrumented + * intermediate service is shared across separate traces. + * + * Topology: + * Trace 1: api-gateway → payment-service → kafka-consumer → postgres + * Trace 2: batch-worker → kafka-consumer → redis + * + * kafka-consumer appears in both traces but with different downstream deps. + * Querying api-gateway downstream should show kafka-consumer → postgres + * but NOT kafka-consumer → redis (which belongs to batch-worker's trace). + * + * Validate via: + * + * ``` + * POST kbn:///api/agent_builder/tools/_execute + * { + * "tool_id": "observability.get_service_topology", + * "tool_params": { + * "start": "now-1h", + * "end": "now", + * "serviceName": "api-gateway" + * } + * } + * ``` + */ + +import type { ApmFields, Timerange } from '@kbn/synthtrace-client'; +import { apm } from '@kbn/synthtrace-client'; +import { createCliScenario } from '../../../../lib/utils/create_scenario'; +import { withClient, type ScenarioReturnType } from '../../../../lib/utils/with_client'; +import type { ApmSynthtraceEsClient } from '../../../../lib/apm/client/apm_synthtrace_es_client'; + +export const API_GATEWAY_SERVICE = { + serviceName: 'api-gateway', +} as const; + +export const PAYMENT_SERVICE = { + serviceName: 'payment-service', + resource: 'payment-lb:3000', +} as const; + +export const KAFKA_CONSUMER_SERVICE = { + serviceName: 'kafka-consumer', + resource: 'kafka-broker:9092', +} as const; + +export const BATCH_WORKER_SERVICE = { + serviceName: 'batch-worker', +} as const; + +export const POSTGRES_DB = { + resource: 'postgres:5432', + spanType: 'db', + spanSubtype: 'postgresql', +} as const; + +export const REDIS_DB = { + resource: 'redis:6379', + spanType: 'db', + spanSubtype: 'redis', +} as const; + +export function generateTraceIsolationData({ + range, + apmEsClient, +}: { + range: Timerange; + apmEsClient: ApmSynthtraceEsClient; +}): ScenarioReturnType { + const apiGateway = apm + .service(API_GATEWAY_SERVICE.serviceName, 'production', 'nodejs') + .instance('api-gw-01'); + const paymentService = apm + .service(PAYMENT_SERVICE.serviceName, 'production', 'java') + .instance('payment-01'); + const kafkaConsumer = apm + .service(KAFKA_CONSUMER_SERVICE.serviceName, 'production', 'go') + .instance('kafka-consumer-01'); + const batchWorker = apm + .service(BATCH_WORKER_SERVICE.serviceName, 'production', 'python') + .instance('batch-worker-01'); + + const data = range + .interval('1m') + .rate(1) + .generator((timestamp) => { + // Trace 1: api-gateway → payment-service → kafka-consumer → postgres + const trace1 = apiGateway + .transaction('POST /checkout', 'request') + .timestamp(timestamp) + .duration(500) + .success() + .children( + apiGateway + .span('POST /api/payment', 'external', 'http') + .destination(PAYMENT_SERVICE.resource) + .timestamp(timestamp + 10) + .duration(400) + .success() + .children( + paymentService + .transaction('POST /api/payment', 'request') + .timestamp(timestamp + 15) + .duration(350) + .success() + .children( + paymentService + .span('publish order.created', 'messaging', 'kafka') + .destination(KAFKA_CONSUMER_SERVICE.resource) + .timestamp(timestamp + 20) + .duration(200) + .success() + .children( + kafkaConsumer + .transaction('consume order.created', 'messaging') + .timestamp(timestamp + 25) + .duration(150) + .success() + .children( + kafkaConsumer + .span( + 'INSERT INTO orders', + POSTGRES_DB.spanType, + POSTGRES_DB.spanSubtype + ) + .destination(POSTGRES_DB.resource) + .timestamp(timestamp + 30) + .duration(50) + .success() + ) + ) + ) + ) + ); + + // Trace 2 (separate): batch-worker → kafka-consumer → redis + const trace2 = batchWorker + .transaction('process cleanup', 'scheduled') + .timestamp(timestamp + 600) + .duration(300) + .success() + .children( + batchWorker + .span('publish cleanup.batch', 'messaging', 'kafka') + .destination(KAFKA_CONSUMER_SERVICE.resource) + .timestamp(timestamp + 610) + .duration(200) + .success() + .children( + kafkaConsumer + .transaction('consume cleanup.batch', 'messaging') + .timestamp(timestamp + 615) + .duration(150) + .success() + .children( + kafkaConsumer + .span('DEL cache:*', REDIS_DB.spanType, REDIS_DB.spanSubtype) + .destination(REDIS_DB.resource) + .timestamp(timestamp + 620) + .duration(30) + .success() + ) + ) + ); + + return [trace1, trace2]; + }); + + return withClient(apmEsClient, data); +} + +export default createCliScenario(({ range, clients: { apmEsClient } }) => { + return generateTraceIsolationData({ range, apmEsClient }); +}); diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_traces/traces.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_traces/traces.ts index 42e5b2b699f84..ed5841a58b078 100644 --- a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_traces/traces.ts +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_traces/traces.ts @@ -63,15 +63,11 @@ */ import type { ApmFields, LogDocument, Timerange } from '@kbn/synthtrace-client'; -import { apm } from '@kbn/synthtrace-client'; +import { apm, log } from '@kbn/synthtrace-client'; import { createCliScenario } from '../../../../lib/utils/create_scenario'; import { withClient, type ScenarioReturnType } from '../../../../lib/utils/with_client'; import type { ApmSynthtraceEsClient } from '../../../../lib/apm/client/apm_synthtrace_es_client'; import type { LogsSynthtraceEsClient } from '../../../../lib/logs/logs_synthtrace_es_client'; -import { - createLogSequence, - generateCorrelatedLogsData, -} from '../get_correlated_logs/correlated_logs'; interface SpanConfig { spanName: string; @@ -95,6 +91,12 @@ export interface GetTracesScenarioConfig { children?: SpanConfig[]; } +export interface LogEntry { + message: string; + '@timestamp'?: number; + [key: string]: unknown; +} + export const DEFAULT_TRACE_CONFIGS: GetTracesScenarioConfig[] = [ { traceId: 'trace-get-traces-001', @@ -134,6 +136,71 @@ export const DEFAULT_TRACE_CONFIGS: GetTracesScenarioConfig[] = [ }, ]; +export const DEFAULT_LOGS: LogEntry[] = [ + { 'log.level': 'info', message: 'Checkout request received' }, + { 'log.level': 'debug', message: 'Calling downstream cart service' }, + { 'log.level': 'error', message: 'Database query failed: timeout' }, + { 'log.level': 'warn', message: 'Retrying operation' }, + { 'log.level': 'info', message: 'Checkout completed' }, +]; + +/** + * Generates log data. + * + * @param range - Time range for log generation + * @param logsEsClient - Synthtrace ES client + * @param logs - Optional array of log entries. If not provided, generates default realistic sequences. + */ +export function generateLogsData({ + range, + logsEsClient, + logs, +}: { + range: Timerange; + logsEsClient: LogsSynthtraceEsClient; + logs: LogEntry[]; +}): ScenarioReturnType { + const data = range + .interval('5m') + .rate(1) + .generator((timestamp) => { + return logs.map((event, index) => { + const { message, '@timestamp': eventTimestamp, ...fields } = event; + + return log + .create() + .message(message) + .defaults(fields) + .timestamp(eventTimestamp ?? timestamp + index * 10000); + }); + }); + + return withClient(logsEsClient, data); +} + +export function createLogSequence({ + service, + correlation, + logs, + defaults = {}, +}: { + /** Service name (maps to service.name) */ + service: string; + /** Correlation field(s) shared by all logs, e.g. { 'trace.id': 'abc' } or { order_id: '123' } */ + correlation: Record; + /** Log entries - each must have `message`, other fields are optional */ + logs: LogEntry[]; + /** Additional fields to apply to all logs */ + defaults?: Record; +}): LogEntry[] { + return logs.map((entry) => ({ + 'service.name': service, + ...correlation, + ...defaults, + ...entry, + })); +} + export function generateGetTracesApmDataset({ range, apmEsClient, @@ -213,7 +280,7 @@ export function generateGetTracesLogsData({ // `traceId` lookup path and the anchor-from-logs path (which prefers `trace.id`). // For additional correlation identifiers (request.id, session.id, etc.) we rely on the // default sequences from `logs.ts` (see default export below). - const correlatedLogs = createLogSequence({ + const logs = createLogSequence({ service: serviceName, correlation: { 'trace.id': traceId, @@ -224,14 +291,10 @@ export function generateGetTracesLogsData({ defaults: { 'service.environment': environment, }, - logs: [ - { 'log.level': 'info', message: 'Checkout request received' }, - { 'log.level': 'debug', message: 'Calling downstream cart service' }, - { 'log.level': 'error', message: 'Database query failed: timeout' }, - ], + logs: DEFAULT_LOGS, }); - return generateCorrelatedLogsData({ range, logsEsClient, logs: correlatedLogs }); + return generateLogsData({ range, logsEsClient, logs }); } export default createCliScenario( @@ -242,26 +305,14 @@ export default createCliScenario( traces: DEFAULT_TRACE_CONFIGS, }); - // Index two log sets: - // 1) Default realistic sequences (uses generateDefaultSequences via generateCorrelatedLogsData) - // 2) A deterministic trace.id-correlated sequence matching DEFAULT_CONFIG.traceId - const defaultLogsData = generateCorrelatedLogsData({ range, logsEsClient }); - - const correlatedLogsData = [ - // Deterministic anchor log (has stable _id for logId lookups) - generateGetTracesLogsData({ - range, - logsEsClient, - config: DEFAULT_TRACE_CONFIGS[0], - }), - // Additional trace.id-correlated sequences for direct lookups and anchor expansion + const logsData = DEFAULT_TRACE_CONFIGS.map((config) => generateGetTracesLogsData({ range, logsEsClient, - config: DEFAULT_TRACE_CONFIGS[1], - }), - ]; + config, + }) + ); - return [apmData, defaultLogsData, ...correlatedLogsData]; + return [apmData, ...logsData]; } ); diff --git a/src/platform/packages/shared/kbn-telemetry/src/init_autoinstrumentations.ts b/src/platform/packages/shared/kbn-telemetry/src/init_autoinstrumentations.ts new file mode 100644 index 0000000000000..1603afaffd19a --- /dev/null +++ b/src/platform/packages/shared/kbn-telemetry/src/init_autoinstrumentations.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { HapiInstrumentation } from '@opentelemetry/instrumentation-hapi'; + +export function maybeInitAutoInstrumentations() { + /** + * Auto-instrumentation is intentionally opt-in. + * + * It can increase trace volume significantly, and Kibana generally relies on explicit + * instrumentation for tracing. For evals, enabling this provides request-scoped context + * propagation (so W3C baggage like `kibana.evals.run_id` can be extracted and attached). + */ + if (process.env.KBN_OTEL_AUTO_INSTRUMENTATIONS === 'true') { + // Register OpenTelemetry auto-instrumentations once per process. + // NOTE: these instrumentations must not be enabled alongside Elastic APM. + const INSTRUMENTATIONS_REGISTERED = Symbol.for('kbn.tracing.instrumentations_registered'); + if (!(globalThis as any)[INSTRUMENTATIONS_REGISTERED]) { + (globalThis as any)[INSTRUMENTATIONS_REGISTERED] = true; + registerInstrumentations({ + instrumentations: [ + // Kibana runs on Hapi. This instrumentation gives us higher-level request spans + // and ensures context propagation for request-scoped correlation (like eval run ids). + new HapiInstrumentation(), + // Create incoming HTTP server spans and extract trace context + baggage. + new HttpInstrumentation({ + // Only create outgoing spans when there is an active parent span. + // This keeps noise down and ensures spans remain connected to request traces. + requireParentforOutgoingSpans: true, + }), + // undici is used by Elasticsearch client; require a parent so we don't create a new trace per request. + new UndiciInstrumentation({ + requireParentforSpans: true, + }), + ], + }); + } + } +} diff --git a/src/platform/packages/shared/kbn-telemetry/src/init_telemetry.ts b/src/platform/packages/shared/kbn-telemetry/src/init_telemetry.ts index 8fc50c7a3168b..56d9b2e8340eb 100644 --- a/src/platform/packages/shared/kbn-telemetry/src/init_telemetry.ts +++ b/src/platform/packages/shared/kbn-telemetry/src/init_telemetry.ts @@ -16,6 +16,7 @@ import { ATTR_SERVICE_INSTANCE_ID, ATTR_DEPLOYMENT_ENVIRONMENT_NAME, } from '@opentelemetry/semantic-conventions/incubating'; +import { maybeInitAutoInstrumentations } from './init_autoinstrumentations'; /** * @@ -39,26 +40,39 @@ export const initTelemetry = ( const telemetryConfig = apmConfigLoader.getTelemetryConfig(); const monitoringCollectionConfig = apmConfigLoader.getMonitoringCollectionConfig(); - // attributes.resource.* - const resource = resources.resourceFromAttributes({ - [ATTR_SERVICE_NAME]: apmConfig.serviceName, - [ATTR_SERVICE_VERSION]: apmConfig.serviceVersion, - [ATTR_SERVICE_INSTANCE_ID]: apmConfig.serviceNodeName, - // Reverse-mapping APM Server transformations: - // https://github.com/elastic/apm-data/blob/2f9cdbf722e5be5bf77d99fbcaab7a70a7e83fff/input/otlp/metadata.go#L69-L74 - [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: apmConfig.environment, + // resource.attributes.* + const resource = resources + .detectResources({ + detectors: [ + resources.envDetector, + resources.hostDetector, + resources.osDetector, + resources.processDetector, + ], + }) + .merge( + resources.resourceFromAttributes({ + // TODO: Since we are deprecating `elastic.apm.*` settings, we should provide a way to set these attributes in the `telemetry.*` config. + [ATTR_SERVICE_NAME]: apmConfig.serviceName, + [ATTR_SERVICE_VERSION]: apmConfig.serviceVersion, + [ATTR_SERVICE_INSTANCE_ID]: apmConfig.serviceNodeName, + // Reverse-mapping APM Server transformations: + // https://github.com/elastic/apm-data/blob/2f9cdbf722e5be5bf77d99fbcaab7a70a7e83fff/input/otlp/metadata.go#L69-L74 + [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: apmConfig.environment, - // From https://opentelemetry.io/docs/specs/semconv/resource/process/ - 'process.pid': process.pid, - 'process.runtime.name': 'nodejs', - 'process.runtime.version': process.version, + // From https://opentelemetry.io/docs/specs/semconv/resource/process/ + 'process.pid': process.pid, + 'process.runtime.name': 'nodejs', + 'process.runtime.version': process.version, - ...(apmConfig.globalLabels as Record), - }); + ...(apmConfig.globalLabels as Record), + }) + ); if (telemetryConfig.enabled) { if (telemetryConfig.tracing.enabled) { initTracing({ resource, tracingConfig: telemetryConfig.tracing }); + maybeInitAutoInstrumentations(); } if (telemetryConfig.metrics.enabled || monitoringCollectionConfig.enabled) { diff --git a/src/platform/packages/shared/kbn-test/src/functional_tests/lib/run_kibana_server.ts b/src/platform/packages/shared/kbn-test/src/functional_tests/lib/run_kibana_server.ts index f3eb080fb4518..de5e0a34a348b 100644 --- a/src/platform/packages/shared/kbn-test/src/functional_tests/lib/run_kibana_server.ts +++ b/src/platform/packages/shared/kbn-test/src/functional_tests/lib/run_kibana_server.ts @@ -33,6 +33,15 @@ export async function runKibanaServer(options: { const installDir = runOptions.alwaysUseSource ? undefined : options.installDir; const devMode = !installDir; const useTaskRunner = options.config.get('kbnTestServer.useDedicatedTaskRunner'); + const env = { + ...process.env, + ...options.config.get('kbnTestServer.env'), + }; + if (env.NO_COLOR !== undefined) { + delete env.FORCE_COLOR; + } else if (env.FORCE_COLOR === undefined) { + env.FORCE_COLOR = '1'; + } const procRunnerOpts = { cwd: installDir || REPO_ROOT, @@ -41,11 +50,7 @@ export async function runKibanaServer(options: { ? Path.resolve(installDir, 'bin/kibana.bat') : Path.resolve(installDir, 'bin/kibana') : process.execPath, - env: { - FORCE_COLOR: 1, - ...process.env, - ...options.config.get('kbnTestServer.env'), - }, + env, wait: runOptions.wait, onEarlyExit: options.onEarlyExit, }; diff --git a/src/platform/packages/shared/kbn-tracing-config/src/schema.ts b/src/platform/packages/shared/kbn-tracing-config/src/schema.ts index 677f89a60d5d5..622a4652e5b8c 100644 --- a/src/platform/packages/shared/kbn-tracing-config/src/schema.ts +++ b/src/platform/packages/shared/kbn-tracing-config/src/schema.ts @@ -32,6 +32,9 @@ const tracingExportConfigSchema: Type = schema.oneOf([ schema.object({ http: otlpExportConfigSchema, }), + schema.object({ + proto: otlpExportConfigSchema, + }), ]); /** diff --git a/src/platform/packages/shared/kbn-tracing-config/src/types.ts b/src/platform/packages/shared/kbn-tracing-config/src/types.ts index 8ce8a889d9bbe..7f5a62059e959 100644 --- a/src/platform/packages/shared/kbn-tracing-config/src/types.ts +++ b/src/platform/packages/shared/kbn-tracing-config/src/types.ts @@ -34,7 +34,8 @@ export interface OTLPExportConfig { export type TracingExporterConfig = | InferenceTracingExportConfig | { grpc: OTLPExportConfig } - | { http: OTLPExportConfig }; + | { http: OTLPExportConfig } + | { proto: OTLPExportConfig }; /** * Configuration for OpenTelemetry tracing diff --git a/src/platform/packages/shared/kbn-tracing/src/init_tracing.ts b/src/platform/packages/shared/kbn-tracing/src/init_tracing.ts index 018eb1a912b6a..3dd6a6767156d 100644 --- a/src/platform/packages/shared/kbn-tracing/src/init_tracing.ts +++ b/src/platform/packages/shared/kbn-tracing/src/init_tracing.ts @@ -19,10 +19,6 @@ import { context, propagation, trace } from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { castArray } from 'lodash'; import { cleanupBeforeExit } from '@kbn/cleanup-before-exit'; -import { registerInstrumentations } from '@opentelemetry/instrumentation'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; -import { HapiInstrumentation } from '@opentelemetry/instrumentation-hapi'; import { EvalSpanProcessor } from './eval_span_processor'; import { OTLPSpanProcessor } from './otlp_span_processor'; import { LateBindingSpanProcessor } from '..'; @@ -39,39 +35,6 @@ export function initTracing({ resource: resources.Resource; tracingConfig: TracingConfig; }) { - /** - * Auto-instrumentation is intentionally opt-in. - * - * It can increase trace volume significantly, and Kibana generally relies on explicit - * instrumentation for tracing. For evals, enabling this provides request-scoped context - * propagation (so W3C baggage like `kibana.evals.run_id` can be extracted and attached). - */ - if (process.env.KBN_OTEL_AUTO_INSTRUMENTATIONS === 'true') { - // Register OpenTelemetry auto-instrumentations once per process. - // NOTE: these instrumentations must not be enabled alongside Elastic APM. - const INSTRUMENTATIONS_REGISTERED = Symbol.for('kbn.tracing.instrumentations_registered'); - if (!(globalThis as any)[INSTRUMENTATIONS_REGISTERED]) { - (globalThis as any)[INSTRUMENTATIONS_REGISTERED] = true; - registerInstrumentations({ - instrumentations: [ - // Kibana runs on Hapi. This instrumentation gives us higher-level request spans - // and ensures context propagation for request-scoped correlation (like eval run ids). - new HapiInstrumentation(), - // Create incoming HTTP server spans and extract trace context + baggage. - new HttpInstrumentation({ - // Only create outgoing spans when there is an active parent span. - // This keeps noise down and ensures spans remain connected to request traces. - requireParentforOutgoingSpans: true, - }), - // undici is used by Elasticsearch client; require a parent so we don't create a new trace per request. - new UndiciInstrumentation({ - requireParentforSpans: true, - }), - ], - }); - } - } - const contextManager = new AsyncLocalStorageContextManager(); context.setGlobalContextManager(contextManager); contextManager.enable(); @@ -116,6 +79,10 @@ export function initTracing({ LateBindingSpanProcessor.get().register(new OTLPSpanProcessor(variant.value, 'grpc')); break; + case 'proto': + LateBindingSpanProcessor.get().register(new OTLPSpanProcessor(variant.value, 'proto')); + break; + case 'http': LateBindingSpanProcessor.get().register(new OTLPSpanProcessor(variant.value, 'http')); break; diff --git a/src/platform/packages/shared/kbn-tracing/src/otlp_span_processor.ts b/src/platform/packages/shared/kbn-tracing/src/otlp_span_processor.ts index c40685bae9c4f..5a676fd34786d 100644 --- a/src/platform/packages/shared/kbn-tracing/src/otlp_span_processor.ts +++ b/src/platform/packages/shared/kbn-tracing/src/otlp_span_processor.ts @@ -10,23 +10,42 @@ import type { OTLPExportConfig } from '@kbn/tracing-config'; import { OTLPTraceExporter as OTLPTraceExporterHTTP } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter as OTLPTraceExporterGRPC } from '@opentelemetry/exporter-trace-otlp-grpc'; +import { OTLPTraceExporter as OTLPTraceExporterPROTO } from '@opentelemetry/exporter-trace-otlp-proto'; import { tracing } from '@elastic/opentelemetry-node/sdk'; import { diag } from '@opentelemetry/api'; +import { Metadata } from '@grpc/grpc-js'; export class OTLPSpanProcessor extends tracing.BatchSpanProcessor { - constructor(config: OTLPExportConfig, protocol: 'grpc' | 'http') { + constructor(config: OTLPExportConfig, protocol: 'grpc' | 'http' | 'proto') { diag.info(`Initializing OTLP exporter with protocol: ${protocol}, url: ${config.url}`); - const exporter = - protocol === 'grpc' - ? new OTLPTraceExporterGRPC({ - url: config.url, - headers: config.headers, - }) - : new OTLPTraceExporterHTTP({ - url: config.url, - headers: config.headers, - }); + let exporter: OTLPTraceExporterHTTP | OTLPTraceExporterGRPC | OTLPTraceExporterPROTO; + + switch (protocol) { + case 'grpc': + const metadata = new Metadata(); + Object.entries(config.headers || {}).forEach(([key, value]) => { + metadata.add(key, value); + }); + + exporter = new OTLPTraceExporterGRPC({ + url: config.url, + metadata, + }); + break; + case 'http': + exporter = new OTLPTraceExporterHTTP({ + url: config.url, + headers: config.headers, + }); + break; + case 'proto': + exporter = new OTLPTraceExporterPROTO({ + url: config.url, + headers: config.headers, + }); + break; + } super(exporter, { scheduledDelayMillis: config.scheduled_delay, diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/constants.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/constants.ts index a8125c161ef4c..946251cfa8912 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/constants.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/constants.ts @@ -19,7 +19,7 @@ export const METRICS_GRID_RESTRICT_BODY_CLASS = `${METRICS_GRID_CLASS}--restrict export const METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ = 'metricsExperienceBreakdownSelector'; // Selection limits -export const MAX_DIMENSIONS_SELECTIONS = 1; +export const MAX_DIMENSIONS_SELECTIONS = 5; export const PAGE_SIZE = 20; // Debounce time for dimensions selector diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts index caeec9e088df3..f195050bb99a7 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts @@ -89,6 +89,15 @@ describe('getAggregationTemplate', () => { }); expect(result).toBe('PERCENTILE(??metricName, 95)'); }); + + it('should return PERCENTILE with to_tdigest casting for legacy histogram', () => { + const result = getAggregationTemplate({ + type: ES_FIELD_TYPES.HISTOGRAM, + instrument: 'histogram', + placeholderName: 'metricName', + }); + expect(result).toBe('PERCENTILE(TO_TDIGEST(??metricName), 95)'); + }); }); describe('createTimeBucketAggregation', () => { diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts index 1f8159e5fec14..95a2dad41edfe 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts @@ -16,6 +16,7 @@ import { } from '@kbn/esql-language'; import { replaceParameters } from '@kbn/esql-composer'; import type { MetricField } from '../../../types'; +import { isLegacyHistogram } from '../legacy_histogram'; type Params = Record; interface AggegationTemplateParams { @@ -80,7 +81,7 @@ export function replaceFunctionParams(functionString: string, params: Params): s * @param instrument - The metric instrument type (e.g., 'counter', 'histogram', 'gauge'). * @param placeholderName - The name of the placeholder to use in the template. * @param customFunction - Optional custom aggregation function to use. - * @returns The ES|QL aggregation function template string. + * @returns The ES|QL aggregation function template string. Legacy histograms (type + instrument both histogram) use PERCENTILE(TO_TDIGEST(...), 95). */ export function getAggregationTemplate({ type, @@ -92,6 +93,10 @@ export function getAggregationTemplate({ return `${customFunction}(??${placeholderName})`; } + if (isLegacyHistogram({ type, instrument })) { + return `PERCENTILE(TO_TDIGEST(??${placeholderName}), 95)`; + } + if (type === 'exponential_histogram' || type === 'tdigest') { return `PERCENTILE(??${placeholderName}, 95)`; } @@ -106,8 +111,8 @@ export function getAggregationTemplate({ /** * Creates the metric aggregation part of an ES|QL query. * It returns: - * - For `histogram` instrument: - * - `PERCENTILE(..., 95)` if type is `exponential_histogram or tdigest` + * - For legacy histogram (field type + instrument both histogram): `PERCENTILE(TO_TDIGEST(...), 95)` + * - For `histogram` instrument: `PERCENTILE(..., 95)` if type is `exponential_histogram` or `tdigest` * - `SUM(RATE(...))` for counter instruments * - `AVG(...)` for other metric types * diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts index 5f6798866ac38..5ce548eba6802 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts @@ -43,6 +43,13 @@ const mockExponentialHistogramMetric: MetricField = { instrument: 'histogram', }; +const mockLegacyHistogramMetric: MetricField = { + ...mockMetric, + name: 'histogram.legacy', + type: ES_FIELD_TYPES.HISTOGRAM, + instrument: 'histogram', +}; + describe('createESQLQuery', () => { it('should generate a basic AVG query for a metric field', () => { const query = createESQLQuery({ metric: mockMetric }); @@ -90,6 +97,31 @@ TS metrics-* ); }); + it('should generate a PERCENTILE query for legacy histogram', () => { + const query = createESQLQuery({ + metric: mockLegacyHistogramMetric, + }); + expect(query).toBe( + ` +TS metrics-* + | STATS PERCENTILE(TO_TDIGEST(histogram.legacy), 95) BY BUCKET(@timestamp, 100, ?_tstart, ?_tend) +`.trim() + ); + }); + + it('should generate a PERCENTILE query for legacy histogram with multiple dimensions', () => { + const query = createESQLQuery({ + metric: mockLegacyHistogramMetric, + splitAccessors: ['service.name', 'host.name'], + }); + expect(query).toBe( + ` +TS metrics-* + | STATS PERCENTILE(TO_TDIGEST(histogram.legacy), 95) BY BUCKET(@timestamp, 100, ?_tstart, ?_tend), \`service.name\`, \`host.name\` +`.trim() + ); + }); + it('should generate exponential histogram query with single dimension', () => { const query = createESQLQuery({ metric: mockExponentialHistogramMetric, diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts index 58b41a85d16e7..49a8b27dd1bab 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts @@ -13,3 +13,4 @@ export * from './metric_unit/get_lens_metric_format'; export * from './metric_unit/get_unit_label'; export * from './metric_unit/normalize_unit'; export * from './fields'; +export * from './user_messages'; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.test.ts new file mode 100644 index 0000000000000..e644139a4c5e4 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ES_FIELD_TYPES } from '@kbn/field-types'; +import { isLegacyHistogram } from './legacy_histogram'; + +describe('isLegacyHistogram', () => { + it('returns true when type and instrument are both histogram', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: 'histogram' })).toBe( + true + ); + }); + + it('returns false when type is histogram but instrument is not', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: 'gauge' })).toBe(false); + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: 'counter' })).toBe( + false + ); + }); + + it('returns false when type is histogram and instrument is undefined', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: undefined })).toBe( + false + ); + }); + + it('returns false when type is not histogram', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.LONG, instrument: 'histogram' })).toBe(false); + expect( + isLegacyHistogram({ type: ES_FIELD_TYPES.EXPONENTIAL_HISTOGRAM, instrument: 'histogram' }) + ).toBe(false); + }); +}); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/drilldown_hello_bar.stories.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts similarity index 51% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/drilldown_hello_bar.stories.tsx rename to src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts index 58fafbf45d8dc..fae80c4a53b5e 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/drilldown_hello_bar.stories.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts @@ -7,26 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import * as React from 'react'; -import { DrilldownHelloBar } from '.'; +import type { ES_FIELD_TYPES } from '@kbn/field-types'; +import type { MappingTimeSeriesMetricType } from '@elastic/elasticsearch/lib/api/types'; -const Demo = () => { - const [show, setShow] = React.useState(true); - return show ? ( - { - setShow(false); - }} - /> - ) : null; -}; - -export default { - title: 'components/DrilldownHelloBar', -}; - -export const Default = { - render: () => , - name: 'default', -}; +/** + * A legacy histogram is a metric where both the ES field type and the + * metric instrument are histogram. + */ +export const isLegacyHistogram = (field: { + type: ES_FIELD_TYPES; + instrument?: MappingTimeSeriesMetricType; +}): boolean => field.type === 'histogram' && field.instrument === 'histogram'; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts new file mode 100644 index 0000000000000..34a2fdf760828 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; +import type { UserMessage } from '@kbn/lens-plugin/public'; + +export const LEGACY_HISTOGRAM_USER_MESSAGES: UserMessage[] = [ + { + uniqueId: 'metrics-experience-histogram-warning', + severity: 'warning', + shortMessage: i18n.translate('metricsExperience.userMessage.histogram.short', { + defaultMessage: 'Histogram warning', + }), + longMessage: i18n.translate('metricsExperience.userMessage.histogram.long', { + defaultMessage: + 'Calculated assuming T-Digest encoding. If the histogram was encoded differently, the data is approximate', + }), + fixableInEditor: false, + displayLocations: [{ id: 'embeddableBadge' }], + }, +]; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.test.ts index 0135e7c6cdbbf..4e880833fdd8b 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.test.ts @@ -55,12 +55,12 @@ describe('useChartLayers', () => { const [layer] = result.current; expect(layer.seriesType).toBe('line'); - expect(layer.breakdown).toBe('service.name'); // Single dimension as string + expect(layer.breakdown).toEqual(['service.name']); // Single dimension as array expect(layer.yAxis[0].value).toBe('AVG(system.cpu.total.norm.pct)'); expect(layer.yAxis[0].seriesColor).toBe('#FFF'); }); - it('should return a line chart configuration with first dimension when multiple dimensions are provided', () => { + it('should return a line chart configuration with array when multiple dimensions are provided', () => { const { result } = renderHook(() => useChartLayers({ metric: mockMetric, @@ -74,8 +74,8 @@ describe('useChartLayers', () => { const [layer] = result.current; expect(layer.seriesType).toBe('line'); - // Lens uses first dimension only after revert - expect(layer.breakdown).toBe('service.name'); + // Lens natively supports multiple dimensions - pass all dimensions as array + expect(layer.breakdown).toEqual(['service.name', 'host.name']); expect(layer.yAxis[0].value).toBe('AVG(system.cpu.total.norm.pct)'); expect(layer.yAxis[0].seriesColor).toBe('#FFF'); }); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.ts index 61eebed4af720..c6b092776d909 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers.ts @@ -65,7 +65,7 @@ export const useChartLayers = ({ ...(metric.unit ? getLensMetricFormat(metric.unit) : {}), }, ], - breakdown: hasDimensions ? dimensions[0].name : undefined, + breakdown: hasDimensions ? dimensions.map((dim) => dim.name) : undefined, }, ]; }, [ diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.test.ts index 138414ac92516..3daa2a61f187a 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.test.ts @@ -121,10 +121,10 @@ describe('useChartLayers', () => { expect(layer.yAxis[0].label).toBe('value'); expect(layer.yAxis[0].seriesColor).toBe('blue'); expect(layer.seriesType).toBe('area'); - expect(layer.breakdown).toBe('service.name'); // Single dimension uses actual dimension name + expect(layer.breakdown).toEqual(['service.name']); // Single dimension as array }); - it('maps columns correctly to yAxis and uses first dimension for multiple dimensions', async () => { + it('maps columns correctly to yAxis and uses array for multiple dimensions', async () => { getESQLQueryColumnsMock.mockResolvedValue([ { name: '@timestamp', meta: { type: 'date' }, id: '@timestamp' }, { name: 'value', meta: { type: 'number' }, id: 'value' }, @@ -161,8 +161,8 @@ describe('useChartLayers', () => { expect(layer.yAxis[0].label).toBe('value'); expect(layer.yAxis[0].seriesColor).toBe('blue'); expect(layer.seriesType).toBe('area'); - // Lens natively supports multiple dimensions - pass first dimension as breakdown - expect(layer.breakdown).toBe('service.name'); + // Lens natively supports multiple dimensions - pass all dimensions as array + expect(layer.breakdown).toEqual(['service.name', 'host.name']); }); it('uses first date column as xAxis', async () => { diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.ts index ff3d6a4452ce7..cd5dfbdf53331 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_chart_layers_from_esql.ts @@ -90,7 +90,7 @@ export const useChartLayersFromEsql = ({ seriesType, xAxis, yAxis, - breakdown: hasDimensions ? queryInfo.dimensions[0] : undefined, + breakdown: hasDimensions ? queryInfo.dimensions : undefined, }, ]; }, [columns, queryInfo.dimensions, seriesType, color, unit]); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts index c2d4d54e79255..0e26a810bfb1c 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts @@ -46,6 +46,7 @@ export type LensProps = Pick< | 'executionContext' | 'onLoad' | 'lastReloadRequestTime' + | 'userMessages' >; export const useLensProps = ({ @@ -58,6 +59,7 @@ export const useLensProps = ({ chartLayers, yBounds, error, + userMessages, }: { title: string; query: string; @@ -66,13 +68,14 @@ export const useLensProps = ({ chartLayers: LensSeriesLayer[]; yBounds?: LensYBoundsConfig; error?: Error; + userMessages?: EmbeddableComponentProps['userMessages']; } & Pick) => { const { euiTheme } = useEuiTheme(); const chartConfigUpdates$ = useRef>(new BehaviorSubject(undefined)); useEffect(() => { chartConfigUpdates$.current.next(void 0); - }, [query, title, chartLayers, yBounds, error]); + }, [query, title, chartLayers, yBounds, error, userMessages]); // creates a stable function that builds the Lens attributes const buildAttributesFn = useLatest(async () => { @@ -100,6 +103,7 @@ export const useLensProps = ({ esqlVariables: fetchParams.esqlVariables, attributes, lastReloadRequestTime: fetchParams.lastReloadRequestTime, + userMessages, }); }, [ @@ -107,6 +111,7 @@ export const useLensProps = ({ fetchParams.relativeTimeRange, fetchParams.lastReloadRequestTime, fetchParams.esqlVariables, + userMessages, ] ); @@ -204,12 +209,14 @@ const getLensProps = ({ attributes, lastReloadRequestTime, esqlVariables, + userMessages, }: { searchSessionId?: string; attributes: LensAttributes; esqlVariables: ESQLControlVariable[] | undefined; timeRange: TimeRange; lastReloadRequestTime?: number; + userMessages?: EmbeddableComponentProps['userMessages']; }): LensProps => ({ id: 'metricsExperienceLensComponent', viewMode: 'view', @@ -222,4 +229,5 @@ const getLensProps = ({ description: 'metrics experience chart data', }, lastReloadRequestTime, + userMessages, }); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx index afaed63abdded..bf0c522ab53cf 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx @@ -13,6 +13,7 @@ import type { LensSeriesLayer } from '@kbn/lens-embeddable-utils/config_builder' import { useBoolean } from '@kbn/react-hooks'; import React, { useRef } from 'react'; import type { LensYBoundsConfig } from '@kbn/lens-embeddable-utils/config_builder/types'; +import type { EmbeddableComponentProps } from '@kbn/lens-plugin/public'; import { useLensProps } from './hooks/use_lens_props'; import type { LensWrapperProps } from './lens_wrapper'; import { LensWrapper } from './lens_wrapper'; @@ -34,6 +35,7 @@ export type ChartProps = Pick & yBounds?: LensYBoundsConfig; isLoading?: boolean; error?: Error; + userMessages?: EmbeddableComponentProps['userMessages']; }; const LensWrapperMemo = React.memo(LensWrapper); @@ -56,6 +58,7 @@ export const Chart = ({ extraDisabledActions, isLoading = false, error, + userMessages, }: ChartProps) => { const chartRef = useRef(null); const { euiTheme } = useEuiTheme(); @@ -73,6 +76,7 @@ export const Chart = ({ chartLayers, yBounds, error, + userMessages, }); return ( diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts index 4e561b28ad720..db361c2ff2977 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts @@ -164,7 +164,7 @@ describe('useMetricFields', () => { expect(cpuField?.dimensions).toEqual([{ name: 'host.name', type: ES_FIELD_TYPES.KEYWORD }]); }); - it('filters out legacy histogram metric types', () => { + it('returns legacy histogram metric types', () => { const sampleRows = new Map([ ['cpu.usage', { 'cpu.usage': 0.75 }], ['http.request.duration', { 'http.request.duration': 150 }], @@ -189,10 +189,10 @@ describe('useMetricFields', () => { const { result } = renderHook(() => useMetricFields()); - expect(result.current.allMetricFields).toHaveLength(2); + expect(result.current.allMetricFields).toHaveLength(3); expect( result.current.allMetricFields.find((f) => f.name === 'http.request.duration') - ).toBeUndefined(); + ).toBeDefined(); expect(result.current.allMetricFields.find((f) => f.name === 'cpu.usage')).toBeDefined(); expect(result.current.allMetricFields.find((f) => f.name === 'memory.used')).toBeDefined(); }); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts index f577d2b33a534..63fcc57fe5fd4 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts @@ -49,11 +49,6 @@ export const useMetricFields = (): UseMetricFieldsReturn => { const fields: MetricField[] = []; for (const metricField of metricFields) { - // Filter out legacy histogram metric types - if (metricField.type === 'histogram') { - continue; - } - const row = getSampleRow(metricField.name); if (row) { const enriched = enrichMetricField(metricField, dimensions, row); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx index 6103889dc370d..57f0da600f246 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { @@ -19,9 +19,10 @@ import { useEuiTheme, type EuiFlexGridProps, } from '@elastic/eui'; - import type { MetricField, UnifiedMetricsGridProps } from '../../../types'; import { PAGE_SIZE } from '../../../common/constants'; +import { isLegacyHistogram } from '../../../common/utils/legacy_histogram'; +import { LEGACY_HISTOGRAM_USER_MESSAGES } from '../../../common/utils/user_messages'; import { MetricsGrid } from './metrics_grid'; import { Pagination } from '../../pagination'; import { usePagination } from './hooks'; @@ -71,6 +72,12 @@ export const MetricsExperienceGridContent = ({ [filteredFieldsCount] ); + const getUserMessages = useCallback( + (metric: MetricField) => + isLegacyHistogram(metric) ? LEGACY_HISTOGRAM_USER_MESSAGES : undefined, + [] + ); + return ( diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx index 7c82b683e5a01..b9f389fbf40e4 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx @@ -127,6 +127,40 @@ describe('MetricsGrid', () => { expect(Chart).toHaveBeenCalledWith(expect.objectContaining({ size: 's' }), expect.anything()); }); + it('passes getUserMessages(metric) result to each chart when getUserMessages is provided', () => { + const messagesForCpu = [ + { + uniqueId: 'cpu-message', + severity: 'warning' as const, + shortMessage: 'CPU', + longMessage: 'CPU message', + fixableInEditor: false, + displayLocations: [{ id: 'embeddableBadge' as const }], + }, + ]; + + const getUserMessages = jest.fn((metric: (typeof fields)[0]) => + metric.name === 'system.cpu.utilization' ? messagesForCpu : undefined + ); + + renderMetricsGrid({ getUserMessages }); + + expect(getUserMessages).toHaveBeenCalledTimes(fields.length); + expect(getUserMessages).toHaveBeenNthCalledWith(1, fields[0]); + expect(getUserMessages).toHaveBeenNthCalledWith(2, fields[1]); + + expect(Chart).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ userMessages: messagesForCpu }), + expect.anything() + ); + expect(Chart).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ userMessages: undefined }), + expect.anything() + ); + }); + it('handles multiple dimensions correctly in ESQL query and chart layers', () => { const multipleDimensions = [ { name: 'host.name', type: ES_FIELD_TYPES.KEYWORD }, @@ -147,7 +181,7 @@ describe('MetricsGrid', () => { expect.objectContaining({ chartLayers: expect.arrayContaining([ expect.objectContaining({ - breakdown: 'host.name', + breakdown: ['host.name', 'service.name', 'container.id'], }), ]), }), diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx index 084df28aeedd1..09e82eb9ac53e 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx @@ -12,6 +12,7 @@ import type { EuiFlexGridProps } from '@elastic/eui'; import { EuiFlexGrid, EuiFlexItem, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; +import type { EmbeddableComponentProps } from '@kbn/lens-plugin/public'; import type { MetricField, Dimension, UnifiedMetricsGridProps } from '../../../types'; import type { ChartSize } from '../../chart'; import { Chart } from '../../chart'; @@ -33,6 +34,7 @@ export type MetricsGridProps = Pick< discoverFetch$: UnifiedMetricsGridProps['fetch$']; fields: MetricField[]; whereStatements?: string[]; + getUserMessages?: (metric: MetricField) => EmbeddableComponentProps['userMessages']; }; const getItemKey = (metric: MetricField, index: number) => { @@ -50,6 +52,7 @@ export const MetricsGrid = ({ fetchParams, discoverFetch$, searchTerm, + getUserMessages, }: MetricsGridProps) => { const gridRef = useRef(null); const { euiTheme } = useEuiTheme(); @@ -149,6 +152,7 @@ export const MetricsGrid = ({ onViewDetails={handleViewDetails} searchTerm={searchTerm} whereStatements={whereStatements} + userMessages={getUserMessages ? getUserMessages(metric) : undefined} /> ); @@ -184,6 +188,7 @@ interface ChartItemProps onFocusCell: (rowIndex: number, colIndex: number) => void; onViewDetails: (index: number, esqlQuery: string, metric: MetricField) => void; whereStatements?: string[]; + userMessages?: EmbeddableComponentProps['userMessages']; } const ChartItem = React.memo( @@ -206,6 +211,7 @@ const ChartItem = React.memo( whereStatements, onFocusCell, onViewDetails, + userMessages, }: ChartItemProps) => { const { euiTheme } = useEuiTheme(); const colorPalette = useMemo( @@ -254,6 +260,7 @@ const ChartItem = React.memo( chartLayers={chartLayers} titleHighlight={searchTerm} extraDisabledActions={[ACTION_OPEN_IN_DISCOVER]} + userMessages={userMessages} /> ); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx index 8065880c1e5dd..c9430e7d31641 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx @@ -26,6 +26,7 @@ jest.mock('@kbn/shared-ux-toolbar-selector', () => { options, onChange, buttonLabel, + buttonTooltipContent, popoverContentBelowSearch, 'data-test-subj': dataTestSubj, singleSelection, @@ -33,12 +34,21 @@ jest.mock('@kbn/shared-ux-toolbar-selector', () => { options: any[]; onChange?: (option: any) => void; buttonLabel: React.ReactNode; + buttonTooltipContent?: React.ReactNode; popoverContentBelowSearch?: React.ReactNode; 'data-test-subj'?: string; singleSelection?: boolean; }) => (
-
{buttonLabel}
+
+ {buttonLabel} + {buttonTooltipContent && ( +
{buttonTooltipContent}
+ )} +
{popoverContentBelowSearch} {options.map((option) => ( @@ -83,7 +93,7 @@ jest.mock('../../common/constants', () => { const actual = jest.requireActual('../../common/constants'); return { ...actual, - MAX_DIMENSIONS_SELECTIONS: 10, // Override for tests to allow multiple selections + MAX_DIMENSIONS_SELECTIONS: 5, // Override for tests to allow multiple selections }; }); @@ -173,8 +183,17 @@ describe('DimensionsSelector', () => { expect(button).toHaveTextContent('Dimensions'); expect(button).toHaveTextContent(String(MAX_DIMENSIONS_SELECTIONS)); - const tooltipAnchor = button.querySelector('.euiToolTipAnchor'); - expect(tooltipAnchor).toBeInTheDocument(); + expect(button).toHaveAttribute('data-tooltip-content', 'true'); + + const tooltip = screen.getByTestId( + `${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}ButtonTooltip` + ); + expect(tooltip).toBeInTheDocument(); + + const tooltipText = tooltip.textContent || ''; + expect(tooltipText).toContain('Maximum'); + expect(tooltipText).toContain(String(MAX_DIMENSIONS_SELECTIONS)); + expect(tooltipText).toContain('dimensions selected'); }); }); @@ -228,31 +247,6 @@ describe('DimensionsSelector', () => { }); }); - describe('Option sorting', () => { - it('sorts options correctly using helper functions', () => { - renderWithIntl( - - ); - const options = screen.getAllByTestId( - new RegExp(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-`) - ); - - const firstUnselectedIndex = options.findIndex( - (opt) => opt.getAttribute('data-checked') !== 'on' - ); - const lastSelectedIndex = options.findLastIndex( - (opt) => opt.getAttribute('data-checked') === 'on' - ); - - if (firstUnselectedIndex >= 0 && lastSelectedIndex >= 0) { - expect(lastSelectedIndex).toBeLessThan(firstUnselectedIndex); - } - }); - }); - describe('Single selection mode', () => { it('calls onChange immediately when option is selected', () => { const onChange = jest.fn(); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx index 028dc20afe1ed..5b5fb81e166de 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx @@ -17,6 +17,8 @@ import { EuiText, EuiToolTip, EuiButtonEmpty, + EuiSpacer, + useEuiTheme, } from '@elastic/eui'; import { ToolbarSelector, type SelectableEntry } from '@kbn/shared-ux-toolbar-selector'; import { comboBoxFieldOptionMatcher } from '@kbn/field-utils'; @@ -28,7 +30,7 @@ import { METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ, DEBOUNCE_TIME, } from '../../common/constants'; -import { getOptionDisabledState, sortDimensionOptions } from './dimensions_selector_helpers'; +import { getOptionDisabledState } from './dimensions_selector_helpers'; interface DimensionsSelectorProps { fields: Array<{ dimensions: Dimension[] }>; @@ -49,6 +51,7 @@ export const DimensionsSelector = ({ singleSelection = false, isLoading = false, }: DimensionsSelectorProps) => { + const { euiTheme } = useEuiTheme(); const [localSelectedDimensions, setLocalSelectedDimensions] = useState(selectedDimensions); @@ -96,28 +99,62 @@ export const DimensionsSelector = ({ const mappedOptions = dimensions.map((dimension) => { const isSelected = selectedNamesSet.has(dimension.name); const isIntersecting = intersectingDimensions.has(dimension.name); + const isDisabled = getOptionDisabledState({ + singleSelection, + isSelected, + isIntersecting, + isAtMaxLimit, + }); - return { + const tooltipContent = + isAtMaxLimit && isDisabled ? ( + + ) : undefined; + + const option: SelectableEntry = { value: dimension.name, label: dimension.name, checked: isSelected ? 'on' : undefined, - disabled: getOptionDisabledState({ - singleSelection, - isSelected, - isIntersecting, - isAtMaxLimit, - }), + disabled: isDisabled, key: dimension.name, }; + + if (tooltipContent) { + option.append = ( + +
+ + ); + } + + return option; }); - return sortDimensionOptions(mappedOptions, localSelectedDimensions); + return mappedOptions; }, [ dimensions, selectedNamesSet, localSelectedDimensions, intersectingDimensions, singleSelection, + euiTheme.levels.menu, ]); const onChangeRef = useRef(onChange); @@ -176,7 +213,6 @@ export const DimensionsSelector = ({ const buttonLabel = useMemo(() => { const count = localSelectedDimensions.length; - const isAtMaxDimensions = count >= MAX_DIMENSIONS_SELECTIONS; return ( @@ -193,7 +229,7 @@ export const DimensionsSelector = ({ values={{ maxDimensions: MAX_DIMENSIONS_SELECTIONS }} /> ) : ( - + - {isAtMaxDimensions ? ( - - } - > - {count} - - ) : ( - {count} - )} + {count} )} @@ -229,39 +251,62 @@ export const DimensionsSelector = ({ ); }, [localSelectedDimensions, isLoading]); - const popoverContentBelowSearch = useMemo(() => { + // Create tooltip content for when at max dimensions + const buttonTooltipContent = useMemo(() => { const count = localSelectedDimensions.length; - if (count === 0) { - return undefined; + const isAtMaxDimensions = count >= MAX_DIMENSIONS_SELECTIONS; + + if (isAtMaxDimensions) { + return ( + + ); } + + return undefined; + }, [localSelectedDimensions]); + + const popoverContentBelowSearch = useMemo(() => { + const count = localSelectedDimensions.length; return ( - - - - - - - - - - - - + <> + + + + + + + + {count > 0 && ( + + + + + + )} + + + ); - }, [localSelectedDimensions.length, handleClearAll]); + }, [localSelectedDimensions.length, handleClearAll, euiTheme.size.l]); return ( { describe('getOptionDisabledState', () => { @@ -98,176 +95,4 @@ describe('dimensions_selector_helpers', () => { ).toBe(false); }); }); - - describe('sortDimensionOptions', () => { - const createOption = (value: string, checked?: 'on', disabled?: boolean): SelectableEntry => ({ - value, - label: value, - checked, - disabled: disabled ?? false, - key: value, - }); - - const createDimension = (name: string): Dimension => ({ - name, - type: ES_FIELD_TYPES.KEYWORD, - }); - - it('sorts selected options first, then available, then disabled', () => { - const options: SelectableEntry[] = [ - createOption('disabled1', undefined, true), - createOption('available1'), - createOption('selected1', 'on'), - createOption('disabled2', undefined, true), - createOption('available2'), - createOption('selected2', 'on'), - ]; - - const result = sortDimensionOptions(options, []); - - expect(result[0].value).toBe('selected1'); - expect(result[1].value).toBe('selected2'); - expect(result[2].value).toBe('available1'); - expect(result[3].value).toBe('available2'); - expect(result[4].value).toBe('disabled1'); - expect(result[5].value).toBe('disabled2'); - }); - - it('sorts selected options by selection order', () => { - const options: SelectableEntry[] = [ - createOption('service.name', 'on'), - createOption('host.name', 'on'), - createOption('container.id', 'on'), - ]; - - const selectedDimensions: Dimension[] = [ - createDimension('host.name'), - createDimension('container.id'), - createDimension('service.name'), - ]; - - const result = sortDimensionOptions(options, selectedDimensions); - - expect(result[0].value).toBe('host.name'); - expect(result[1].value).toBe('container.id'); - expect(result[2].value).toBe('service.name'); - }); - - it('sorts available options alphabetically', () => { - const options: SelectableEntry[] = [ - createOption('zebra'), - createOption('alpha'), - createOption('beta'), - ]; - - const result = sortDimensionOptions(options, []); - - expect(result[0].value).toBe('alpha'); - expect(result[1].value).toBe('beta'); - expect(result[2].value).toBe('zebra'); - }); - - it('sorts disabled options alphabetically', () => { - const options: SelectableEntry[] = [ - createOption('zebra', undefined, true), - createOption('alpha', undefined, true), - createOption('beta', undefined, true), - ]; - - const result = sortDimensionOptions(options, []); - - expect(result[0].value).toBe('alpha'); - expect(result[1].value).toBe('beta'); - expect(result[2].value).toBe('zebra'); - }); - - it('handles case-insensitive alphabetical sorting', () => { - const options: SelectableEntry[] = [ - createOption('Zebra'), - createOption('alpha'), - createOption('Beta'), - ]; - - const result = sortDimensionOptions(options, []); - - expect(result[0].value).toBe('alpha'); - expect(result[1].value).toBe('Beta'); - expect(result[2].value).toBe('Zebra'); - }); - - it('handles selected option not in selectedDimensions list', () => { - const options: SelectableEntry[] = [ - createOption('selected1', 'on'), - createOption('selected2', 'on'), - ]; - - const selectedDimensions: Dimension[] = [createDimension('selected1')]; - - const result = sortDimensionOptions(options, selectedDimensions); - - expect(result[0].value).toBe('selected1'); - expect(result[1].value).toBe('selected2'); - }); - - it('handles empty options array', () => { - const result = sortDimensionOptions([], []); - - expect(result).toEqual([]); - }); - - it('handles empty selectedDimensions array', () => { - const options: SelectableEntry[] = [createOption('option1', 'on'), createOption('option2')]; - - const result = sortDimensionOptions(options, []); - - expect(result).toHaveLength(2); - expect(result[0].value).toBe('option1'); - expect(result[1].value).toBe('option2'); - }); - - it('maintains stable sort for options with same priority', () => { - const options: SelectableEntry[] = [createOption('b'), createOption('a'), createOption('c')]; - - const result1 = sortDimensionOptions(options, []); - const result2 = sortDimensionOptions(options, []); - - expect(result1).toEqual(result2); - expect(result1[0].value).toBe('a'); - expect(result1[1].value).toBe('b'); - expect(result1[2].value).toBe('c'); - }); - - it('handles complex scenario with all option types', () => { - const options: SelectableEntry[] = [ - createOption('zebra', undefined, true), - createOption('host.name', 'on'), - createOption('alpha'), - createOption('service.name', 'on'), - createOption('beta'), - createOption('container.id', 'on'), - createOption('gamma', undefined, true), - ]; - - const selectedDimensions: Dimension[] = [ - createDimension('service.name'), - createDimension('container.id'), - createDimension('host.name'), - ]; - - const result = sortDimensionOptions(options, selectedDimensions); - - // Selected first (in selection order) - expect(result[0].value).toBe('service.name'); - expect(result[1].value).toBe('container.id'); - expect(result[2].value).toBe('host.name'); - - // Available next (alphabetically) - expect(result[3].value).toBe('alpha'); - expect(result[4].value).toBe('beta'); - - // Disabled last (alphabetically) - expect(result[5].value).toBe('gamma'); - expect(result[6].value).toBe('zebra'); - }); - }); }); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts index e987d62e9c429..ba0720eabe884 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts @@ -7,10 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { sortBy } from 'lodash'; -import type { SelectableEntry } from '@kbn/shared-ux-toolbar-selector'; -import type { Dimension } from '../../types'; - interface OptionDisabledStateParams { singleSelection: boolean; isSelected: boolean; @@ -18,12 +14,6 @@ interface OptionDisabledStateParams { isAtMaxLimit: boolean; } -const SORT_PRIORITY = { - SELECTED: 0, - AVAILABLE: 1, - DISABLED: 2, -} as const; - /** * Determines if a dimension option should be disabled. * - In single-selection mode: never disabled @@ -40,28 +30,3 @@ export const getOptionDisabledState = ({ if (isSelected) return false; return !isIntersecting || isAtMaxLimit; }; - -/** - * Sorts dimension options: Selected first (in selection order), - * then available (alphabetically), then disabled (alphabetically). - */ -export const sortDimensionOptions = ( - options: SelectableEntry[], - selectedDimensions: Dimension[] -): SelectableEntry[] => { - const selectionOrderMap = new Map(selectedDimensions.map((dim, index) => [dim.name, index])); - - return sortBy(options, [ - (option) => { - if (option.checked === 'on') return SORT_PRIORITY.SELECTED; - if (option.disabled) return SORT_PRIORITY.DISABLED; - return SORT_PRIORITY.AVAILABLE; - }, - (option) => { - if (option.checked === 'on') { - return selectionOrderMap.get(option.value) ?? Infinity; - } - return option.label.toLowerCase(); - }, - ]); -}; diff --git a/src/platform/packages/shared/serverless/settings/search_project/index.ts b/src/platform/packages/shared/serverless/settings/search_project/index.ts index 756f1255e1e9a..0abc9da3238a1 100644 --- a/src/platform/packages/shared/serverless/settings/search_project/index.ts +++ b/src/platform/packages/shared/serverless/settings/search_project/index.ts @@ -9,6 +9,7 @@ import { COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX_ID, + DATA_SOURCES_ENABLED_SETTING_ID, OBSERVABILITY_AI_ASSISTANT_SEARCH_CONNECTOR_INDEX_PATTERN, OBSERVABILITY_AI_ASSISTANT_SIMULATED_FUNCTION_CALLING, AI_ANONYMIZATION_SETTINGS, @@ -34,4 +35,5 @@ export const SEARCH_PROJECT_SETTINGS = [ GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, // This setting is temporary, will be removed on 9.4.0 release. WORKFLOWS_UI_SETTING_ID, + DATA_SOURCES_ENABLED_SETTING_ID, ]; diff --git a/src/platform/packages/shared/shared-ux/ai-components/README.mdx b/src/platform/packages/shared/shared-ux/ai-components/README.mdx new file mode 100644 index 0000000000000..f7838d1d4684e --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/README.mdx @@ -0,0 +1,49 @@ +--- +id: sharedUX/Components/AiComponents +slug: /shared-ux/components/ai-components +title: AI Components +description: Shared AI-related visual components for Kibana. +tags: ['shared-ux', 'component'] +date: 2026-02-11 +--- + +## AI components + +Shared AI-related visual components for Kibana. + +For now, this package contains the `AiButton` components (see `ai_button/`). + +`AiButton` is the recommended public API. It is a wrapper around the internal `AiButtonBase` primitive and renders the correct underlying EUI button type based on the props you pass (e.g. `variant`, `iconOnly`). + +We also expose thin wrapper components for convenience: `AiButtonDefault`, `AiButtonEmpty`, and `AiButtonIcon`. + +## Usage + +```tsx +import { + AiButton, + AiButtonDefault, + AiButtonEmpty, + AiButtonIcon, +} from '@kbn/shared-ux-ai-components'; + + undefined}>AI Button; + + undefined}> + AI Button +; + + undefined}> + AI Assistant +; + + undefined}>AI Button; + + undefined}> + AI Button +; + + undefined} />; + + undefined}>AI Button; +``` diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_assistant_logo.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_assistant_logo.tsx new file mode 100644 index 0000000000000..8dbc9b46dc119 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_assistant_logo.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import React from 'react'; + +// Kibana-only: hardcoded locally so this icon isn't shared/owned by EUI. +interface AiAssistantLogoProps extends React.SVGProps { + title?: string; + titleId?: string; +} + +export const AiAssistantLogo = ({ title, titleId, ...svgProps }: AiAssistantLogoProps) => ( + + {title ? {title} : null} + + + + + +); diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button.tsx new file mode 100644 index 0000000000000..5fa97140afdfd --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; + +import { AiButtonBase } from './ai_button_base'; +import type { AiButtonProps } from './types'; + +export type { AiButtonProps } from './types'; + +/** + * Renders the AI button with variant-based styling and icon behavior. + * @param props - AI button configuration. + */ +export const AiButton = (props: AiButtonProps) => { + return ; +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.stories.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.stories.tsx new file mode 100644 index 0000000000000..aeaf6e7ac0102 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.stories.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { EuiButtonSize, EuiButtonEmptySizes } from '@elastic/eui'; +import { AiButton } from './ai_button'; +import { AiButtonDefault } from './ai_button_default'; +import { AiButtonEmpty } from './ai_button_empty'; +import { AiButtonIcon } from './ai_button_icon'; +import type { AiButtonVariant } from './types'; + +type AiIconType = 'aiAssistantLogo' | 'sparkles' | 'productAgent'; + +interface CommonStoryArgs { + label: string; + isDisabled: boolean; + icon: AiIconType; +} + +interface StoryArgs extends CommonStoryArgs { + variant: AiButtonVariant; + size: EuiButtonEmptySizes; + withIcon: boolean; + iconOnly: boolean; +} + +interface ButtonComponentStoryArgs extends CommonStoryArgs { + size: EuiButtonEmptySizes; + iconSize?: EuiButtonSize; + withIcon: boolean; +} + +interface IconComponentStoryArgs extends CommonStoryArgs { + size: EuiButtonEmptySizes; + variant: AiButtonVariant; +} + +export default { + title: 'AI components/AiButton', + description: + 'A wrapper around EuiButton/EuiButtonEmpty/EuiButtonIcon that applies an “AI” gradient background and text.', + argTypes: { + label: { control: 'text' }, + variant: { control: 'select', options: ['base', 'accent', 'empty', 'outlined'] }, + size: { control: 'select', options: ['xs', 's', 'm'] }, + isDisabled: { control: 'boolean' }, + withIcon: { control: 'boolean' }, + iconOnly: { control: 'boolean' }, + icon: { control: 'select', options: ['aiAssistantLogo', 'sparkles', 'productAgent'] }, + }, +} as Meta; + +export const Default: StoryObj = { + render: (args) => { + const { label, variant, size, isDisabled, withIcon, iconOnly, icon } = args; + + if (iconOnly) { + return ( + + ); + } + + if (variant === 'empty' || variant === 'outlined') { + return ( + + {label} + + ); + } + + const buttonSize: EuiButtonSize = size === 'm' ? 'm' : 's'; + + if (withIcon) { + return ( + + {label} + + ); + } + + return ( + + {label} + + ); + }, + args: { + label: 'AI Assistant', + variant: 'base', + size: 's', + isDisabled: false, + withIcon: false, + iconOnly: false, + icon: 'aiAssistantLogo', + }, +}; + +export const Base: StoryObj = { + render: ({ label, size, iconSize, isDisabled, withIcon, icon }) => { + const buttonSize: EuiButtonSize = size === 'm' ? 'm' : 's'; + return ( + + {label} + + ); + }, + args: { + label: 'AI Assistant', + size: 's', + isDisabled: false, + withIcon: false, + icon: 'aiAssistantLogo', + }, +}; + +export const Accent: StoryObj = { + render: ({ label, size, iconSize, isDisabled, withIcon, icon }) => { + const buttonSize: EuiButtonSize = size === 'm' ? 'm' : 's'; + return ( + + {label} + + ); + }, + args: { + label: 'AI Assistant', + size: 's', + isDisabled: false, + withIcon: true, + icon: 'aiAssistantLogo', + }, +}; + +export const Empty: StoryObj = { + render: ({ label, size, iconSize, isDisabled, withIcon, icon }) => ( + + {label} + + ), + args: { + label: 'AI Assistant', + size: 's', + isDisabled: false, + withIcon: true, + icon: 'aiAssistantLogo', + }, +}; + +export const Icon: StoryObj = { + render: ({ label, size, isDisabled, variant, icon }) => ( + + ), + args: { + label: 'AI Assistant', + size: 's', + isDisabled: false, + variant: 'base', + icon: 'aiAssistantLogo', + }, +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.test.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.test.tsx new file mode 100644 index 0000000000000..a1a9035babf15 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { css } from '@emotion/react'; + +import { AiButtonBase } from './ai_button_base'; + +const mockUseAiButtonGradientStyles = jest.fn(); +const mockUseSvgAiGradient = jest.fn(); +jest.mock('./use_ai_gradient_styles', () => ({ + useAiButtonGradientStyles: (opts: unknown) => mockUseAiButtonGradientStyles(opts), + useSvgAiGradient: (opts: unknown) => mockUseSvgAiGradient(opts), +})); + +jest.mock('./svg_ai_gradient_defs', () => ({ + SvgAiGradientDefs: () =>
, +})); + +const defaultSvgGradient = { + gradientId: 'test-gradient', + iconGradientCss: undefined, + stops: { startColor: '#000', endColor: '#fff', startOffsetPercent: 0, endOffsetPercent: 100 }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockUseAiButtonGradientStyles.mockReturnValue({ buttonCss: undefined, labelCss: undefined }); + mockUseSvgAiGradient.mockReturnValue(defaultSvgGradient); +}); + +describe('', () => { + it('renders', () => { + render(AI Assistant); + + expect(screen.getByText('AI Assistant')).toBeInTheDocument(); + expect(mockUseAiButtonGradientStyles).toHaveBeenCalledWith( + expect.objectContaining({ isFilled: false, variant: 'base' }) + ); + expect(mockUseSvgAiGradient).toHaveBeenCalledWith( + expect.objectContaining({ isFilled: false, variant: 'base' }) + ); + }); + + it('accent variant renders EuiButton with fill', () => { + render(AI Assistant); + + expect(mockUseAiButtonGradientStyles).toHaveBeenCalledWith( + expect.objectContaining({ isFilled: true, variant: 'accent' }) + ); + expect(mockUseSvgAiGradient).toHaveBeenCalledWith( + expect.objectContaining({ isFilled: true, variant: 'accent' }) + ); + }); + + it('empty variant uses EuiButtonEmpty', () => { + const { container } = render(AI Assistant); + + expect(container.querySelector('.euiButtonEmpty')).toBeTruthy(); + expect(mockUseAiButtonGradientStyles).toHaveBeenCalledWith( + expect.objectContaining({ isFilled: false, variant: 'empty' }) + ); + expect(mockUseSvgAiGradient).toHaveBeenCalledWith( + expect.objectContaining({ isFilled: false, variant: 'empty' }) + ); + }); + + it('iconOnly variant renders EuiButtonIcon', () => { + const { container } = render( + undefined} + /> + ); + + expect(container.querySelector('button.euiButtonIcon')).toBeInTheDocument(); + }); + + it.each([{ iconGradientCss: css``, iconGradientCssState: 'set' }])( + 'renders gradient defs only when iconGradientCss is $iconGradientCssState', + ({ iconGradientCss }) => { + mockUseSvgAiGradient.mockReturnValue({ + ...defaultSvgGradient, + iconGradientCss, + }); + + render(Gradient check); + + expect(screen.getByTestId('svg-ai-gradient-defs')).toBeInTheDocument(); + } + ); + + it.each([{ iconGradientCss: undefined, iconGradientCssState: 'unset' }])( + "doesn't render gradient defs when iconGradientCss is $iconGradientCssState", + ({ iconGradientCss }) => { + mockUseSvgAiGradient.mockReturnValue({ + ...defaultSvgGradient, + iconGradientCss, + }); + + render(Gradient check); + + expect(screen.queryByTestId('svg-ai-gradient-defs')).not.toBeInTheDocument(); + } + ); +}); diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.tsx new file mode 100644 index 0000000000000..ba57cdea798c5 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_base.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import type { IconType } from '@elastic/eui'; +import { EuiButton, EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; + +import { useAiButtonGradientStyles, useSvgAiGradient } from './use_ai_gradient_styles'; +import { SvgAiGradientDefs } from './svg_ai_gradient_defs'; +import { AiAssistantLogo } from './ai_assistant_logo'; +import type { AiButtonIconType, AiButtonProps, AiButtonVariant } from './types'; + +const resolvedIconType = (iconType: AiButtonIconType): IconType => + iconType === 'aiAssistantLogo' ? AiAssistantLogo : iconType; + +const getSyncedIconSize = (size?: string): 's' | 'm' => (size === 'm' ? 'm' : 's'); + +export const AiButtonBase = (props: AiButtonProps) => { + const variant: AiButtonVariant = props.variant ?? 'base'; + const isFilled = variant === 'accent'; + + const { buttonCss, labelCss } = useAiButtonGradientStyles({ + isFilled, + variant, + }); + const { gradientId, iconGradientCss, stops } = useSvgAiGradient({ + isFilled, + variant, + }); + + // Render local SVG so icon paths can reference url(#gradientId). + // Defs are rendered before each button/icon to guarantee the id exists in the same DOM tree. + const svgGradientDefs = iconGradientCss ? ( + + ) : null; + + if (props.iconOnly === true) { + const { + iconType, + css: userCss, + display: _display, + iconOnly: _iconOnly, + variant: _variant, + ...rest + } = props; + + return ( + <> + {svgGradientDefs} + + + ); + } + + if (props.variant === 'empty' || props.variant === 'outlined') { + const { + variant: _variant, + iconOnly: _iconOnly, + children, + css: userCss, + iconType, + ...rest + } = props; + + return ( + <> + {svgGradientDefs} + + {children} + + + ); + } + + type EuiButtonBranchProps = Extract; + const { + variant: _variant, + iconOnly: _iconOnly, + children, + css: userCss, + iconType, + ...rest + } = props as EuiButtonBranchProps; + + return ( + <> + {svgGradientDefs} + + {children} + + + ); +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_default.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_default.tsx new file mode 100644 index 0000000000000..dd1ce99016002 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_default.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; + +import { AiButtonBase } from './ai_button_base'; +import type { AiButtonProps } from './types'; + +/** Props for the default text AI button variants (`base` and `accent`). */ +export type AiButtonDefaultProps = Extract< + AiButtonProps, + { iconOnly?: false; variant?: 'accent' | 'base' } +>; + +/** + * Renders the default text AI button variants (`base` and `accent`). + * @param props - Props accepted by the default AI button variant. + */ +export const AiButtonDefault = (props: AiButtonDefaultProps) => { + return ; +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_empty.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_empty.tsx new file mode 100644 index 0000000000000..bd1792747ef39 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_empty.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import type { DistributiveOmit } from '@elastic/eui'; + +import { AiButtonBase } from './ai_button_base'; +import type { AiButtonProps } from './types'; + +/** Props for the `AiButtonEmpty` component. */ +export type AiButtonEmptyProps = DistributiveOmit< + Extract, + 'variant' +>; + +/** + * Renders the empty AI button variant. + * @param props - Props accepted by the empty AI button variant. + */ +export const AiButtonEmpty = (props: AiButtonEmptyProps) => { + return ; +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_icon.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_icon.tsx new file mode 100644 index 0000000000000..6fca7b6a92aad --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/ai_button_icon.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import type { DistributiveOmit } from '@elastic/eui'; + +import { AiButtonBase } from './ai_button_base'; +import type { AiButtonProps } from './types'; + +/** Props for the icon-only AI button component. */ +export type AiButtonIconProps = DistributiveOmit< + Extract, + 'iconOnly' +>; + +/** + * Renders the icon-only AI button. + * @param props - Props accepted by the icon-only variant. + */ +export const AiButtonIcon = (props: AiButtonIconProps) => { + return ; +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/svg_ai_gradient_defs.tsx b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/svg_ai_gradient_defs.tsx new file mode 100644 index 0000000000000..da30bc6bfcd4b --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/svg_ai_gradient_defs.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; + +export interface SvgAiGradientDefsProps { + readonly gradientId: string; + readonly startColor: string; + readonly endColor: string; + readonly startOffsetPercent?: number; + readonly endOffsetPercent?: number; +} + +export const SvgAiGradientDefs = ({ + gradientId, + startColor, + endColor, + startOffsetPercent = 0, + endOffsetPercent = 100, +}: SvgAiGradientDefsProps) => { + // SVG icons need gradient defs to fill vector paths with multiple colors. + // CSS/background gradients style boxes, but defs color the actual icon shape. + return ( + + ); +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/types.ts b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/types.ts new file mode 100644 index 0000000000000..d5a10c7ceb4a1 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type React from 'react'; +import type { DistributiveOmit } from '@elastic/eui'; +import type { EuiButton, EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; + +/** Supported visual variants for AI button components. */ +export type AiButtonVariant = 'accent' | 'base' | 'empty' | 'outlined'; + +/** Allowed icon types for AI button components. */ +export type AiButtonIconType = 'sparkles' | 'productAgent' | 'aiAssistantLogo'; + +/** Props for the `AiButton` component. */ +export type AiButtonProps = + | (DistributiveOmit, 'fill' | 'iconType' | 'disabled'> & { + /** Selects text button vs icon-only button rendering. */ + iconOnly?: false; + fill?: never; + variant?: 'base' | 'accent'; + iconType?: AiButtonIconType; + }) + | (DistributiveOmit, 'iconType' | 'disabled'> & { + iconOnly?: false; + variant: 'empty' | 'outlined'; + iconType?: AiButtonIconType; + }) + | (DistributiveOmit< + React.ComponentProps, + 'display' | 'iconType' | 'disabled' + > & { + iconOnly: true; + display?: never; + variant?: AiButtonVariant; + iconType: AiButtonIconType; + 'aria-label': string; + }); diff --git a/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/use_ai_gradient_styles.ts b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/use_ai_gradient_styles.ts new file mode 100644 index 0000000000000..3f4951999ea85 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/ai_button/src/use_ai_gradient_styles.ts @@ -0,0 +1,326 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { css, type SerializedStyles } from '@emotion/react'; +import { useGeneratedHtmlId } from '@elastic/eui'; +import { useMemo } from 'react'; +import { useKibanaIsDarkMode } from '@kbn/react-kibana-context-theme'; +import type { AiButtonVariant } from './types'; + +// Keep constants local to this file so Storybook can be used to iterate independently. +// Hard-coded values are used to avoid relying on EUI tokens that are not yet available. +const gradientStartPercent = 2.98; +const gradientEndPercent = 66.24; + +const diagonalButtonGradientAngle = 150; +const verticalButtonGradientAngle = 90; +const buttonGradientStartPercent = 3.97; +const buttonGradientEndPercent = 65.6; + +const buttonTextGradientAngle = 170; + +const gradients = { + buttonBackground: { + diagonalAngle: diagonalButtonGradientAngle, + verticalAngle: verticalButtonGradientAngle, + startPercent: buttonGradientStartPercent, + endPercent: buttonGradientEndPercent, + lightMode: { startColor: '#D9E8FF', endColor: '#ECE2FE' }, + darkMode: { startColor: '#123A79', endColor: '#3B1D66' }, + }, + foreground: { + angle: buttonTextGradientAngle, + startPercent: gradientStartPercent, + endPercent: gradientEndPercent, + lightMode: { startColor: '#1750BA', endColor: '#6B3C9F' }, + darkMode: { startColor: '#D9E8FF', endColor: '#ECE2FE' }, + }, +} as const; + +// TEMP: design iteration shades for dark-mode base background. +// These will be replaced with tokens once EUI exposes AI button gradient tokens. +const darkModeBaseBackgroundColors = { + startColor: '#61A2FF', + endColor: '#C5A5FA', +} as const; + +// TEMP: design iteration shades for dark-mode filled background. +const darkModeFilledBackgroundColors = { + startColor: '#0D2F5E', + endColor: '#3E2C63', +} as const; + +const darkModeBaseForegroundColor = '#07101F'; + +// TEMP: design iteration shades for light-mode filled background. +const lightModeFilledBackgroundColors = { + startColor: '#0B64DD', + endColor: '#8144CC', +} as const; + +const lightModeFilledForegroundColor = '#FFFFFF'; + +const makeLinearGradient = ({ + angle, + startColor, + startPercent, + endColor, + endPercent, +}: { + angle: number; + startColor: string; + startPercent: number; + endColor: string; + endPercent: number; +}) => `linear-gradient(${angle}deg, ${startColor} ${startPercent}%, ${endColor} ${endPercent}%)`; + +export interface AiButtonGradientOptions { + readonly isFilled?: boolean; + /** + * When provided, variant-specific gradient behavior can be applied. + * This is optional to keep backwards compatibility with existing `fill` callers. + */ + readonly variant?: AiButtonVariant; +} + +export interface AiButtonGradientStyles { + readonly buttonCss: SerializedStyles; + readonly labelCss: SerializedStyles; +} + +export interface AiGradientStopsDefinition { + readonly startColor: string; + readonly endColor: string; + readonly startOffsetPercent: number; + readonly endOffsetPercent: number; +} + +interface AiGradientColors { + readonly startColor: string; + readonly endColor: string; +} + +const makeButtonBackgroundGradient = ({ + colors, + angle, +}: { + colors: AiGradientColors; + angle: number; +}) => + makeLinearGradient({ + angle, + startColor: colors.startColor, + startPercent: gradients.buttonBackground.startPercent, + endColor: colors.endColor, + endPercent: gradients.buttonBackground.endPercent, + }); + +const makeForegroundGradient = (colors: AiGradientColors) => + makeLinearGradient({ + angle: gradients.foreground.angle, + startColor: colors.startColor, + startPercent: gradients.foreground.startPercent, + endColor: colors.endColor, + endPercent: gradients.foreground.endPercent, + }); + +const makeForegroundStops = (colors: AiGradientColors): AiGradientStopsDefinition => ({ + startColor: colors.startColor, + endColor: colors.endColor, + startOffsetPercent: gradients.foreground.startPercent, + endOffsetPercent: gradients.foreground.endPercent, +}); + +const getForegroundColors = ({ + isDarkMode, + variant, +}: { + isDarkMode: boolean; + variant?: AiButtonVariant; +}): AiGradientColors => { + if (isDarkMode && (variant === 'accent' || variant === 'empty' || variant === 'outlined')) { + return darkModeBaseBackgroundColors; + } + + return isDarkMode ? gradients.foreground.darkMode : gradients.foreground.lightMode; +}; + +const gradientTextCss = (cssGradient: string) => css` + display: inline-block; + background: ${cssGradient} !important; + background-clip: text !important; + -webkit-background-clip: text !important; + color: transparent !important; + -webkit-text-fill-color: transparent !important; +`; + +const solidTextCss = (color: string) => css` + background: none !important; + background-clip: initial !important; + -webkit-background-clip: initial !important; + color: ${color} !important; + -webkit-text-fill-color: currentColor !important; +`; + +const outlinedBorderRingCss = (borderGradient: string) => css` + position: relative; + border: none; + isolation: isolate; + + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: ${borderGradient}; + pointer-events: none; + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + } +`; + +export const useAiButtonGradientStyles = ({ + isFilled, + variant, +}: AiButtonGradientOptions = {}): AiButtonGradientStyles => { + const isDarkMode = useKibanaIsDarkMode(); + + return useMemo(() => { + const resolvedVariant = (variant ?? (isFilled ? 'accent' : 'base')) as AiButtonVariant; + + const accentGradientColors = isDarkMode + ? darkModeFilledBackgroundColors + : lightModeFilledBackgroundColors; + const accentBackgroundAngle = isDarkMode + ? gradients.buttonBackground.verticalAngle + : gradients.buttonBackground.diagonalAngle; + const baseBackgroundAngle = isDarkMode + ? gradients.buttonBackground.diagonalAngle + : gradients.buttonBackground.verticalAngle; + + let outlinedBorderGradientCss: string | undefined; + let buttonBackground: string; + if (resolvedVariant === 'empty') { + buttonBackground = 'transparent'; + } else if (resolvedVariant === 'outlined') { + outlinedBorderGradientCss = makeButtonBackgroundGradient({ + colors: accentGradientColors, + angle: accentBackgroundAngle, + }); + buttonBackground = 'transparent'; + } else if (resolvedVariant === 'accent') { + buttonBackground = makeButtonBackgroundGradient({ + colors: accentGradientColors, + angle: accentBackgroundAngle, + }); + } else { + // base + buttonBackground = makeButtonBackgroundGradient({ + colors: isDarkMode ? darkModeBaseBackgroundColors : gradients.buttonBackground.lightMode, + angle: baseBackgroundAngle, + }); + } + let buttonForegroundColor: string | undefined; + if (!isDarkMode && resolvedVariant === 'accent') { + buttonForegroundColor = lightModeFilledForegroundColor; + } else if (isDarkMode && resolvedVariant === 'base') { + buttonForegroundColor = darkModeBaseForegroundColor; + } + + const buttonCss = css` + background: ${buttonBackground} !important; + border-radius: 4px; + ${buttonForegroundColor ? `color: ${buttonForegroundColor} !important;` : ''} + ${outlinedBorderGradientCss ? outlinedBorderRingCss(outlinedBorderGradientCss) : ''} + + &:hover:not(:disabled) { + background: ${buttonBackground} !important; + } + &:focus:not(:disabled) { + background: ${buttonBackground} !important; + } + &:disabled { + opacity: 0.5; + } + `; + + let labelCss: SerializedStyles; + if (isDarkMode && resolvedVariant === 'base') { + labelCss = solidTextCss(darkModeBaseForegroundColor); + } else if (!isDarkMode && resolvedVariant === 'accent') { + labelCss = css` + color: ${lightModeFilledForegroundColor}; + `; + } else { + labelCss = gradientTextCss( + makeForegroundGradient(getForegroundColors({ isDarkMode, variant: resolvedVariant })) + ); + } + + return { + buttonCss, + labelCss, + }; + }, [isFilled, isDarkMode, variant]); +}; + +export interface SvgAiGradient { + /** + * Emotion CSS that applies the gradient to EUI icons (`.euiIcon`) via `fill/stroke`. + */ + readonly iconGradientCss?: SerializedStyles; + /** + * The generated gradient id used by `SvgAiGradientDefs`. + */ + readonly gradientId: string; + /** + * The gradient stops used by the defs component. + */ + readonly stops: AiGradientStopsDefinition; +} +export const useSvgAiGradient = ({ + isFilled, + variant, +}: AiButtonGradientOptions = {}): SvgAiGradient => { + const isDarkMode = useKibanaIsDarkMode(); + + const gradientId = useGeneratedHtmlId({ prefix: 'kbnAiButtonIconGradient' }); + const gradientUrl = `url(#${gradientId})`; + + const iconGradientCss = useMemo(() => { + // Backwards compatible default: filled buttons don't use gradient icons unless a variant is provided. + if (variant == null && isFilled) return undefined; + // Dark mode base should be a solid foreground color. + if (variant === 'base' && isDarkMode) return undefined; + // Keep light mode filled icons as solid (existing behavior); apply gradient in dark mode. + if (variant === 'accent' && isFilled && !isDarkMode) return undefined; + return css` + & .euiIcon { + fill: ${gradientUrl} !important; + } + & .euiIcon [fill]:not([fill='none']) { + fill: ${gradientUrl} !important; + } + & .euiIcon [stroke]:not([stroke='none']) { + stroke: ${gradientUrl} !important; + } + `; + }, [gradientUrl, isDarkMode, isFilled, variant]); + + const foregroundColors = getForegroundColors({ isDarkMode, variant }); + + return { + iconGradientCss, + gradientId, + stops: makeForegroundStops(foregroundColors), + }; +}; diff --git a/src/platform/packages/shared/shared-ux/ai-components/index.ts b/src/platform/packages/shared/shared-ux/ai-components/index.ts new file mode 100644 index 0000000000000..5d854f3dda1b8 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/index.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { AiButton, type AiButtonProps } from './ai_button/src/ai_button'; +export { AiButtonDefault, type AiButtonDefaultProps } from './ai_button/src/ai_button_default'; +export { AiButtonEmpty, type AiButtonEmptyProps } from './ai_button/src/ai_button_empty'; +export { AiButtonIcon, type AiButtonIconProps } from './ai_button/src/ai_button_icon'; + +export type { AiButtonIconType, AiButtonVariant } from './ai_button/src/types'; diff --git a/src/platform/packages/shared/kbn-security-solution-flyout/jest.config.js b/src/platform/packages/shared/shared-ux/ai-components/jest.config.js similarity index 80% rename from src/platform/packages/shared/kbn-security-solution-flyout/jest.config.js rename to src/platform/packages/shared/shared-ux/ai-components/jest.config.js index 06176b0c97e03..0155096b87b35 100644 --- a/src/platform/packages/shared/kbn-security-solution-flyout/jest.config.js +++ b/src/platform/packages/shared/shared-ux/ai-components/jest.config.js @@ -9,6 +9,6 @@ module.exports = { preset: '@kbn/test', - rootDir: '../../../../..', - roots: ['/src/platform/packages/shared/kbn-security-solution-flyout'], + rootDir: '../../../../../..', + roots: ['/src/platform/packages/shared/shared-ux/ai-components/ai_button/src'], }; diff --git a/src/platform/packages/shared/shared-ux/ai-components/kibana.jsonc b/src/platform/packages/shared/shared-ux/ai-components/kibana.jsonc new file mode 100644 index 0000000000000..c3693e125824f --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-ai-components", + "owner": ["@elastic/appex-sharedux"], + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/kbn-security-solution-common/moon.yml b/src/platform/packages/shared/shared-ux/ai-components/moon.yml similarity index 64% rename from src/platform/packages/shared/kbn-security-solution-common/moon.yml rename to src/platform/packages/shared/shared-ux/ai-components/moon.yml index d22f9ef3e181f..2eb8abafc91e8 100644 --- a/src/platform/packages/shared/kbn-security-solution-common/moon.yml +++ b/src/platform/packages/shared/shared-ux/ai-components/moon.yml @@ -1,23 +1,24 @@ # This file is generated by the @kbn/moon package. Any manual edits will be erased! # To extend this, write your extensions/overrides to 'moon.extend.yml' -# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/security-solution-common' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/shared-ux-ai-components' $schema: https://moonrepo.dev/schemas/project.json -id: '@kbn/security-solution-common' +id: '@kbn/shared-ux-ai-components' type: unknown owners: - defaultOwner: '@elastic/security-threat-hunting-investigations' + defaultOwner: '@elastic/appex-sharedux' toolchain: default: node language: typescript project: - name: '@kbn/security-solution-common' - description: Moon project for @kbn/security-solution-common + name: '@kbn/shared-ux-ai-components' + description: Moon project for @kbn/shared-ux-ai-components channel: '' - owner: '@elastic/security-threat-hunting-investigations' + owner: '@elastic/appex-sharedux' metadata: - sourceRoot: src/platform/packages/shared/kbn-security-solution-common -dependsOn: [] + sourceRoot: src/platform/packages/shared/shared-ux/ai-components +dependsOn: + - '@kbn/react-kibana-context-theme' tags: - shared-common - package @@ -28,6 +29,7 @@ tags: fileGroups: src: - '**/*.ts' + - '**/*.tsx' - '!target/**/*' tasks: jest: diff --git a/src/platform/packages/shared/kbn-security-solution-common/package.json b/src/platform/packages/shared/shared-ux/ai-components/package.json similarity index 74% rename from src/platform/packages/shared/kbn-security-solution-common/package.json rename to src/platform/packages/shared/shared-ux/ai-components/package.json index ea8d5d76c714e..764f003cae39a 100644 --- a/src/platform/packages/shared/kbn-security-solution-common/package.json +++ b/src/platform/packages/shared/shared-ux/ai-components/package.json @@ -1,7 +1,7 @@ { - "name": "@kbn/security-solution-common", + "name": "@kbn/shared-ux-ai-components", "private": true, "version": "1.0.0", "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", "sideEffects": false -} \ No newline at end of file +} diff --git a/src/platform/packages/shared/shared-ux/ai-components/tsconfig.json b/src/platform/packages/shared/shared-ux/ai-components/tsconfig.json new file mode 100644 index 0000000000000..b7237ddd252f5 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/ai-components/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "react", + "@emotion/react/types/css-prop", + "@kbn/ambient-ui-types", + "@testing-library/jest-dom" + ] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/react-kibana-context-theme" + ] +} diff --git a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx index 6b8861670fef3..00fc8117022b2 100644 --- a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx +++ b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.test.tsx @@ -9,8 +9,12 @@ import React from 'react'; import type { ComponentProps } from 'react'; -import { CODE_EDITOR_DEFAULT_THEME_ID, CODE_EDITOR_TRANSPARENT_THEME_ID } from '@kbn/monaco'; -import { render, screen } from '@testing-library/react'; +import { + CODE_EDITOR_DEFAULT_THEME_ID, + CODE_EDITOR_TRANSPARENT_THEME_ID, + monaco, +} from '@kbn/monaco'; +import { render, screen, waitFor } from '@testing-library/react'; import { MonacoEditor, OVERFLOW_WIDGETS_TEST_ID } from './editor'; import * as supportedLanguages from './languages/supported'; @@ -21,7 +25,87 @@ const defaultProps: Partial> = { editorWillUnmount: jest.fn(), }; +const createEvent = ( + changes: monaco.editor.IModelContentChange[] +): monaco.editor.IModelContentChangedEvent => ({ + changes, + eol: '\n', + versionId: 1, + isUndoing: false, + isRedoing: false, + isFlush: false, + isEolChange: false, +}); + +const createRange = (): monaco.IRange => ({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, +}); + +const createDisposable = (): monaco.IDisposable => ({ dispose: jest.fn() }); + +const setupMonacoEditorHarness = (params: { + onDidChangeModelContent?: (cb: (e: monaco.editor.IModelContentChangedEvent) => void) => void; + onPushUndoStop: jest.Mock; + onCreateModel?: (model: monaco.editor.ITextModel) => void; +}) => { + const disposable = createDisposable(); + + const createSpy = jest.spyOn(monaco.editor, 'create').mockImplementation((container, options) => { + if (!options?.model) { + throw new Error('expected create() to be called with a model'); + } + + const model = options.model; + params.onCreateModel?.(model); + + const editor = { + onDidChangeModelContent: (cb: (e: monaco.editor.IModelContentChangedEvent) => void) => { + params.onDidChangeModelContent?.(cb); + return disposable; + }, + getModel: () => model, + pushUndoStop: params.onPushUndoStop, + updateOptions: jest.fn(), + layout: jest.fn(), + dispose: jest.fn(), + getDomNode: () => null, + } as unknown as monaco.editor.IStandaloneCodeEditor; + + return editor; + }); + + const markersSpy = jest + .spyOn(monaco.editor, 'onDidChangeMarkers') + .mockImplementation(() => disposable); + const getModelMarkersSpy = jest.spyOn(monaco.editor, 'getModelMarkers').mockReturnValue([]); + + const cleanup = () => { + createSpy.mockRestore(); + markersSpy.mockRestore(); + getModelMarkersSpy.mockRestore(); + }; + + return { cleanup }; +}; + describe('react monaco editor', () => { + let cleanupMonaco: (() => void) | undefined; + + beforeEach(() => { + const { cleanup } = setupMonacoEditorHarness({ + onPushUndoStop: jest.fn(), + }); + cleanupMonaco = cleanup; + }); + + afterEach(() => { + cleanupMonaco?.(); + cleanupMonaco = undefined; + }); + beforeAll(() => { jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( (contextId, options) => @@ -50,17 +134,159 @@ describe('react monaco editor', () => { render(); - expect(defineThemeSpy).toHaveBeenCalled(); - expect(defineThemeSpy).toHaveBeenCalledWith(CODE_EDITOR_DEFAULT_THEME_ID, expect.any(Object)); - expect(defineThemeSpy).toHaveBeenCalledWith( - CODE_EDITOR_TRANSPARENT_THEME_ID, - expect.any(Object) - ); + return waitFor(() => { + expect(defineThemeSpy).toHaveBeenCalled(); + expect(defineThemeSpy).toHaveBeenCalledWith(CODE_EDITOR_DEFAULT_THEME_ID, expect.any(Object)); + expect(defineThemeSpy).toHaveBeenCalledWith( + CODE_EDITOR_TRANSPARENT_THEME_ID, + expect.any(Object) + ); + }); }); - it('renders the overflow widgets into a portal', () => { + it('renders the overflow widgets into a portal', async () => { render(); - expect(screen.getByTestId(OVERFLOW_WIDGETS_TEST_ID)).toBeDefined(); + expect(await screen.findByTestId(OVERFLOW_WIDGETS_TEST_ID)).toBeDefined(); + }); + + it('uses defaultValue when value is undefined (uncontrolled mode)', async () => { + const originalCreateModel = monaco.editor.createModel.bind(monaco.editor); + let firstArg: unknown; + const createModelSpy = jest + .spyOn(monaco.editor, 'createModel') + .mockImplementation((...args) => { + firstArg = args[0]; + return originalCreateModel(...args); + }); + + render(); + + await screen.findByTestId(OVERFLOW_WIDGETS_TEST_ID); + expect(firstArg).toBe('fallback'); + + createModelSpy.mockRestore(); + }); +}); + +describe('react monaco editor onChange performance', () => { + let lastOnDidChangeModelContentCb: + | ((e: monaco.editor.IModelContentChangedEvent) => void) + | undefined; + + beforeEach(() => { + lastOnDidChangeModelContentCb = undefined; + jest.clearAllMocks(); + }); + + it('computes the next value from event.changes (including multiple changes)', async () => { + const editorPushUndoStop = jest.fn(); + + let createdModel: monaco.editor.ITextModel | undefined; + const { cleanup } = setupMonacoEditorHarness({ + onDidChangeModelContent: (cb) => { + lastOnDidChangeModelContentCb = cb; + }, + onPushUndoStop: editorPushUndoStop, + onCreateModel: (model) => { + createdModel = model; + }, + }); + + const onChange = jest.fn(); + + render( + + ); + + await screen.findByTestId(OVERFLOW_WIDGETS_TEST_ID); + expect(typeof lastOnDidChangeModelContentCb).toBe('function'); + + // Two changes, deliberately provided out of order (ascending offsets) to ensure + // the implementation sorts descending to avoid offset shifting. + const range = createRange(); + const event = createEvent([ + { range, rangeOffset: 2, rangeLength: 2, text: 'XXXX' }, + { range, rangeOffset: 7, rangeLength: 1, text: 'Y' }, + ]); + lastOnDidChangeModelContentCb!(event); + + expect(onChange).toHaveBeenCalledWith('abXXXXefgYij', event); + expect(createdModel).toBeDefined(); + + cleanup(); + }); + + it('does not pushEditOperations for controlled rerenders when value matches last known value', async () => { + const originalCreateModel = monaco.editor.createModel.bind(monaco.editor); + let pushEditOperationsSpy: jest.SpyInstance | undefined; + const createModelSpy = jest + .spyOn(monaco.editor, 'createModel') + .mockImplementation((...args) => { + const model = originalCreateModel(...args); + pushEditOperationsSpy = jest.spyOn(model, 'pushEditOperations'); + return model; + }); + + const editorPushUndoStop = jest.fn(); + const { cleanup } = setupMonacoEditorHarness({ + onDidChangeModelContent: (cb) => { + lastOnDidChangeModelContentCb = cb; + }, + onPushUndoStop: editorPushUndoStop, + }); + + const onChange = jest.fn(); + const { rerender } = render( + + ); + + await screen.findByTestId(OVERFLOW_WIDGETS_TEST_ID); + expect(typeof lastOnDidChangeModelContentCb).toBe('function'); + + const range = createRange(); + const event = createEvent([{ range, rangeOffset: 2, rangeLength: 2, text: 'XXXX' }]); + lastOnDidChangeModelContentCb!(event); + + expect(onChange).toHaveBeenCalledWith('abXXXXefghij', event); + + rerender(); + + expect(pushEditOperationsSpy).toBeDefined(); + expect(pushEditOperationsSpy!).not.toHaveBeenCalled(); + expect(editorPushUndoStop).not.toHaveBeenCalled(); + + createModelSpy.mockRestore(); + cleanup(); + }); + + it('pushes a full replace when controlled value changes externally', async () => { + const originalCreateModel = monaco.editor.createModel.bind(monaco.editor); + let pushEditOperationsSpy: jest.SpyInstance | undefined; + const createModelSpy = jest + .spyOn(monaco.editor, 'createModel') + .mockImplementation((...args) => { + const model = originalCreateModel(...args); + pushEditOperationsSpy = jest.spyOn(model, 'pushEditOperations'); + return model; + }); + + const editorPushUndoStop = jest.fn(); + const { cleanup } = setupMonacoEditorHarness({ + onPushUndoStop: editorPushUndoStop, + }); + + const onChange = jest.fn(); + const { rerender } = render(); + + await screen.findByTestId(OVERFLOW_WIDGETS_TEST_ID); + rerender(); + + expect(pushEditOperationsSpy).toBeDefined(); + expect(pushEditOperationsSpy!).toHaveBeenCalledTimes(1); + expect(editorPushUndoStop).toHaveBeenCalledTimes(2); + + createModelSpy.mockRestore(); + cleanup(); }); }); diff --git a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx index ee9441a4f02ac..e8e31c589bbe1 100644 --- a/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx +++ b/src/platform/packages/shared/shared-ux/code_editor/impl/react_monaco_editor/editor.tsx @@ -128,6 +128,23 @@ export interface MonacoEditorProps { overflowWidgetsContainerZIndexOverride?: number; } +const applyModelContentChanges = ( + prevValue: string, + changes: monacoEditor.editor.IModelContentChange[] +): string => { + // Monaco reports offsets and lengths relative to the *previous* value. When multiple changes are + // present (e.g. multi-cursor edits), apply them from the end of the string towards the start so + // earlier edits don't shift the offsets of later ones. + const sortedChanges = [...changes].sort((a, b) => b.rangeOffset - a.rangeOffset); + + return sortedChanges.reduce((acc, change) => { + const start = change.rangeOffset; + // `rangeLength` is the number of chars to replace from the previous value. + const end = change.rangeOffset + change.rangeLength; + return acc.slice(0, start) + change.text + acc.slice(end); + }, prevValue); +}; + // initialize supported languages initializeSupportedLanguages(); @@ -172,6 +189,18 @@ export function MonacoEditor({ const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + /** + * For large models, `editor.getValue()` can be very expensive because it materializes the + * full buffer. Keep a shadow copy of the latest value so we can apply incremental edits + * using `IModelContentChangedEvent` without forcing a full `getValue()` on every keystroke. + */ + const lastKnownValueRef = useRef(value ?? defaultValue); + useEffect(() => { + if (typeof value === 'string') { + lastKnownValueRef.current = value; + } + }, [value]); + const style = useMemo( () => ({ width: fixedWidth, @@ -190,7 +219,15 @@ export function MonacoEditor({ _subscription.current = editor.current!.onDidChangeModelContent((event) => { if (!__preventTriggerChangeEvent.current) { - onChangeRef.current?.(editor.current!.getValue(), event); + const onChangeHandler = onChangeRef.current; + if (!onChangeHandler) { + return; + } + + // Apply incremental changes to the shadow value (avoid `editor.getValue()` in hot path). + const nextValue = applyModelContentChanges(lastKnownValueRef.current, event.changes); + lastKnownValueRef.current = nextValue; + onChangeHandler(nextValue, event); } }); }; @@ -215,7 +252,8 @@ export function MonacoEditor({ }, [euiTheme]); const initMonaco = () => { - const finalValue = value !== null ? value : defaultValue; + // Treat `null`/`undefined` as uncontrolled, per the prop contract. + const finalValue = value ?? defaultValue; if (containerElement.current && overflowWidgetsDomNode.current) { // add the monaco class name to the overflow widgets dom node so that styles, @@ -291,7 +329,9 @@ export function MonacoEditor({ // useLayoutEffect instead of useEffect to mitigate https://github.com/facebook/react/issues/31023 in React@18 Legacy Mode useLayoutEffect(() => { if (editor.current) { - if (value === editor.current.getValue()) { + // In controlled mode, `value` changes on every keystroke. Avoid calling `editor.getValue()` + // (which materializes the full model) by comparing against our shadow copy first. + if (typeof value !== 'string' || value === lastKnownValueRef.current) { return; } @@ -312,6 +352,9 @@ export function MonacoEditor({ ); editor.current.pushUndoStop(); __preventTriggerChangeEvent.current = false; + + // Keep shadow state in sync for programmatic updates where we suppress onDidChangeModelContent. + lastKnownValueRef.current = value; } }, [value]); diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/index.ts b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/index.ts index f8f33329fc812..0fb9327522103 100644 --- a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/index.ts +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/index.ts @@ -12,6 +12,7 @@ export type { GroupNode, LeafNode, DataCascadeProps, + DataCascadeImplRef, DataCascadeRowProps, DataCascadeRowCellProps, CascadeRowCellNestedVirtualizationAnchorProps, diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_impl.tsx b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_impl.tsx index 650821f68148e..bac36657efe4d 100644 --- a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_impl.tsx +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_impl.tsx @@ -26,6 +26,7 @@ import { calculateActiveStickyIndex, type VirtualizedCascadeListProps, } from '../../lib/core/virtualizer'; +import { useExposePublicApi } from '../../lib/core/api'; import { useRegisterCascadeAccessibilityHelpers, useTreeGridContainerARIAAttributes, @@ -63,6 +64,9 @@ export function DataCascadeImpl({ enableRowSelection = false, enableStickyGroupHeader = true, allowMultipleRowToggle = false, + initialScrollOffset, + initialRect, + cascadeRef, }: DataCascadeImplProps) { const rowElement = Children.only(children); @@ -132,6 +136,11 @@ export function DataCascadeImpl({ rowCell: cascadeRowCell, }); + const { collectVirtualizerStateChanges } = useExposePublicApi(cascadeRef, { + rows, + enableStickyGroupHeader, + }); + // persist the virtualizer instance to ref, so that invocations of getVirtualizer will always return the latest instance virtualizerInstance.current = useCascadeVirtualizer({ rows, @@ -139,6 +148,9 @@ export function DataCascadeImpl({ getScrollElement, enableStickyGroupHeader, estimatedRowHeight: size === 's' ? 32 : size === 'm' ? 40 : 48, + onStateChange: collectVirtualizerStateChanges, + initialOffset: initialScrollOffset, + initialRect, }); const { diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_row_cell/cascade_row_cell.tsx b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_row_cell/cascade_row_cell.tsx index f648701356945..1955124e71581 100644 --- a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_row_cell/cascade_row_cell.tsx +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/data_cascade_row_cell/cascade_row_cell.tsx @@ -118,11 +118,6 @@ export function CascadeRowCellPrimitive // Keep a reference to the virtualizer for cleanup and scroll-to operations const rootVirtualizer = useMemo(() => getVirtualizer(), [getVirtualizer]); - const virtualRow = useMemo( - () => rootVirtualizer.getVirtualItems().find((v) => v.index === row.index), - [rootVirtualizer, row] - ); - const getScrollElement = useCallback(() => rootVirtualizer.scrollElement, [rootVirtualizer]); /** @@ -133,26 +128,6 @@ export function CascadeRowCellPrimitive return rootVirtualizer.preventRowSizeChangePropagation(row.index); }, [rootVirtualizer, row.index]); - useEffect( - () => () => { - // ensure that for a row that's been scrolled, - // if said row is technically still in view because it's cell is being rendered, - // when we are unmounting because the expand action from the cell's row was clicked, - // we want to ensure said row is the top most item in our list - if ( - virtualRow?.index && - !rootVirtualizer.isScrolling && - getScrollOffset() > getScrollMargin() - ) { - rootVirtualizer.scrollToVirtualizedIndex(virtualRow.index, { - align: 'start', - behavior: 'auto', - }); - } - }, - [rootVirtualizer, virtualRow?.index, getScrollOffset, getScrollMargin] - ); - const memoizedChild = useMemo(() => { return React.createElement(children, { data: leafData, diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/types.ts b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/types.ts index 107197957c6a2..19ba61e1b3077 100644 --- a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/types.ts +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/data_cascade_impl/types.ts @@ -13,6 +13,7 @@ import type { Table, CellContext, Row } from '@tanstack/react-table'; import type { VirtualItem } from '@tanstack/react-virtual'; import type { GroupNode, LeafNode } from '../../store_provider'; import type { CascadeVirtualizerProps, useCascadeVirtualizer } from '../../lib/core/virtualizer'; +import type { DataCascadeImplRef } from '../../lib/core/api'; import type { SelectionDropdownProps } from './data_cascade_header/group_selection_combobox/selection_dropdown'; /** @@ -256,7 +257,16 @@ interface DataCascadeImplBaseProps * Whether to allow multiple group rows to be expanded at the same time, default is false. */ allowMultipleRowToggle?: boolean; + /** + * Initial vertical scroll position in pixels. When set, the list and scroll container start at this offset. + */ + initialScrollOffset?: number; + /** + * Initial scroll rectangle dimensions. When set, the list and scroll container start at this size. + */ + initialRect?: { width: number; height: number }; children: React.ReactElement>; + cascadeRef: React.ForwardedRef>; } export type DataCascadeImplProps< diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/index.tsx b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/index.tsx index 26a86b7519a2c..e7f6a93d6032e 100644 --- a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/index.tsx +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/components/index.tsx @@ -7,26 +7,104 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { type ComponentProps } from 'react'; +import React, { forwardRef, useMemo, useRef, type ComponentProps, type ForwardedRef } from 'react'; import { DataCascadeImpl, type DataCascadeImplProps } from './data_cascade_impl'; +import type { DataCascadeImplRef } from '../lib/core/api'; import { DataCascadeProvider, type GroupNode, type LeafNode } from '../store_provider'; -export type { GroupNode, LeafNode, DataCascadeImplProps as DataCascadeProps }; +export type { GroupNode, LeafNode, DataCascadeImplProps as DataCascadeProps, DataCascadeImplRef }; +export type { DataCascadeUISnapshot } from '../lib/core/api'; export { DataCascadeRow, DataCascadeRowCell } from './data_cascade_impl'; + export type { DataCascadeRowProps, DataCascadeRowCellProps, CascadeRowCellNestedVirtualizationAnchorProps, } from './data_cascade_impl'; -export function DataCascade({ - cascadeGroups, - initialGroupColumn, - ...props -}: DataCascadeImplProps & ComponentProps) { - return ( - - {...props} /> - +type DataCascadeProviderProps = ComponentProps; + +export type DataCascadeComponent = ( + props: Omit, 'cascadeRef'> & + DataCascadeProviderProps & { ref?: React.Ref> } +) => React.ReactElement; + +/** + * Public data cascade component. Forwards the ref to DataCascadeImpl so consumers + * receive the imperative handle on the ref. + */ +export const DataCascade = forwardRef(function DataCascadeWithProvider< + G extends GroupNode, + L extends LeafNode +>( + { + cascadeGroups, + initialGroupColumn, + customTableHeader, + tableTitleSlot, + initialTableState, + initialScrollOffset, + initialRect, + onCascadeGroupingChange, + size, + enableStickyGroupHeader, + allowMultipleRowToggle, + children, + enableRowSelection, + data, + overscan, + }: Omit, 'cascadeRef'> & DataCascadeProviderProps, + ref: ForwardedRef> +) { + // create a stable reference for the component initializer props + const initialTableStateRef = useRef(initialTableState); + const initialScrollOffsetRef = useRef(initialScrollOffset); + const initialRectRef = useRef(initialRect); + + const cascadeImplProps = useMemo>(() => { + const props = { + onCascadeGroupingChange, + size, + enableStickyGroupHeader, + allowMultipleRowToggle, + enableRowSelection, + data, + overscan, + children, + cascadeRef: ref, + }; + + return customTableHeader + ? { ...props, customTableHeader } + : { ...props, tableTitleSlot: tableTitleSlot! }; + }, [ + allowMultipleRowToggle, + children, + customTableHeader, + data, + enableRowSelection, + overscan, + onCascadeGroupingChange, + size, + enableStickyGroupHeader, + tableTitleSlot, + ref, + ]); + + return useMemo( + () => ( + + cascadeGroups={cascadeGroups} + initialGroupColumn={initialGroupColumn} + initialTableState={initialTableStateRef.current} + > + + {...cascadeImplProps} + initialScrollOffset={initialScrollOffsetRef.current} + initialRect={initialRectRef.current} + /> + + ), + [cascadeGroups, cascadeImplProps, initialGroupColumn] ); -} +}) as DataCascadeComponent; diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/api/index.test.tsx b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/api/index.test.tsx new file mode 100644 index 0000000000000..c7b9ef3bb4558 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/api/index.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useSyncExternalStore } from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useExposePublicApi, type DataCascadeImplRef } from '.'; +import { DataCascadeProvider, type GroupNode, type LeafNode } from '../../../store_provider'; +import type { UseVirtualizerReturnType } from '../virtualizer'; + +describe('useExposePublicApi', () => { + it('should return the correct value', () => { + const mockRefObject: React.RefObject> = { + current: null, + }; + + const { result } = renderHook( + () => useExposePublicApi(mockRefObject, { rows: [], enableStickyGroupHeader: false }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(result.current.collectVirtualizerStateChanges).toBeDefined(); + // ref should be populated with public API method after mount + expect(mockRefObject.current?.getUISnapshotStore).toBeDefined(); + }); + + describe('getUISnapshotStore', () => { + it('should return a store snapshot with the correct initial state', () => { + const mockRefObject: React.RefObject> = { + current: null, + }; + + const { result } = renderHook( + () => useExposePublicApi(mockRefObject, { rows: [], enableStickyGroupHeader: false }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(result.current.collectVirtualizerStateChanges).toBeDefined(); + expect(mockRefObject.current?.getUISnapshotStore).toBeDefined(); + + const snapshotStore = mockRefObject.current!.getUISnapshotStore(); + + expect(snapshotStore!.getSnapshot()).toEqual({ + scrollOffset: 0, + range: null, + isScrolling: false, + activeStickyIndex: null, + totalRowCount: 0, + totalSize: 0, + expanded: {}, + rowSelection: {}, + scrollRect: { width: 0, height: 0 }, + }); + }); + + it('changes on the virtualizer instance should notify subscribers, and reflect changes in the store snapshot', async () => { + const mockRefObject: React.RefObject> = { + current: null, + }; + + const { result } = renderHook( + () => useExposePublicApi(mockRefObject, { rows: [], enableStickyGroupHeader: false }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(result.current.collectVirtualizerStateChanges).toBeDefined(); + expect(mockRefObject.current?.getUISnapshotStore).toBeDefined(); + + const uiSnapshotStore = mockRefObject.current!.getUISnapshotStore(); + + const subscriptionSpy = jest.fn(); + + const unsubscribe = uiSnapshotStore!.subscribe(subscriptionSpy); + + const virtualizerInstance = { + range: { startIndex: 0, endIndex: 10 }, + scrollOffset: 100, + isScrolling: false, + // it's fine to cast to unknown + // because we only need a minimal implementation of the virtualizer instance for the test + } as unknown as UseVirtualizerReturnType; + + // simulate changes in the virtualizer instance + act(() => { + result.current.collectVirtualizerStateChanges(virtualizerInstance); + }); + + await waitFor(() => { + // wait for the debounce to complete + expect(subscriptionSpy).toHaveBeenCalled(); + }); + + const updatedUISnapshot = uiSnapshotStore!.getSnapshot(); + + expect(updatedUISnapshot).toHaveProperty('scrollOffset', virtualizerInstance.scrollOffset); + expect(updatedUISnapshot).toHaveProperty('range', virtualizerInstance.range); + expect(updatedUISnapshot).toHaveProperty('isScrolling', virtualizerInstance.isScrolling); + + // these properties did not change because we did not provide updates for them + expect(updatedUISnapshot).toHaveProperty('activeStickyIndex', null); + expect(updatedUISnapshot).toHaveProperty('totalRowCount', 0); + expect(updatedUISnapshot).toHaveProperty('totalSize', 0); + expect(updatedUISnapshot).toHaveProperty('expanded', {}); + expect(updatedUISnapshot).toHaveProperty('rowSelection', {}); + expect(updatedUISnapshot).toHaveProperty('scrollRect', { width: 0, height: 0 }); + + unsubscribe(); + }); + + it('should be compatible with the useSyncExternalStore hook', async () => { + const mockRefObject: React.RefObject> = { + current: null, + }; + + const { result } = renderHook( + () => useExposePublicApi(mockRefObject, { rows: [], enableStickyGroupHeader: false }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(result.current.collectVirtualizerStateChanges).toBeDefined(); + expect(mockRefObject.current?.getUISnapshotStore).toBeDefined(); + + const snapshotStore = mockRefObject.current!.getUISnapshotStore(); + + const { result: externalSyncResult } = renderHook(() => + useSyncExternalStore( + snapshotStore!.subscribe, + snapshotStore!.getSnapshot, + snapshotStore!.getServerSnapshot + ) + ); + + expect(externalSyncResult.current).toEqual({ + scrollOffset: 0, + range: null, + isScrolling: false, + activeStickyIndex: null, + totalRowCount: 0, + totalSize: 0, + expanded: {}, + rowSelection: {}, + scrollRect: { width: 0, height: 0 }, + }); + + act(() => { + result.current.collectVirtualizerStateChanges({ + range: { startIndex: 0, endIndex: 10 }, + scrollOffset: 100, + isScrolling: false, + scrollRect: { width: 0, height: 0 }, + getTotalSize: () => 0, + } as unknown as UseVirtualizerReturnType); + }); + + await waitFor(() => { + expect(externalSyncResult.current).toEqual({ + scrollOffset: 100, + range: { startIndex: 0, endIndex: 10 }, + isScrolling: false, + activeStickyIndex: null, + totalRowCount: 0, + totalSize: 0, + expanded: {}, + rowSelection: {}, + scrollRect: { width: 0, height: 0 }, + }); + }); + }); + }); +}); diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/api/index.ts b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/api/index.ts new file mode 100644 index 0000000000000..5b77dcf970900 --- /dev/null +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/api/index.ts @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Row } from '@tanstack/react-table'; +import { debounce } from 'lodash'; +import { useCallback, useLayoutEffect, useImperativeHandle, useMemo, useRef } from 'react'; +import { + useDataCascadeState, + type GroupNode, + type LeafNode, + type IStoreState, +} from '../../../store_provider'; +import { calculateActiveStickyIndex, type UseVirtualizerReturnType } from '../virtualizer'; + +/** + * Snapshot of data cascade ui state for use with useSyncExternalStore. + * Includes virtualizer-derived state and specific table state. + */ +export interface DataCascadeUISnapshot< + G extends GroupNode = GroupNode, + L extends LeafNode = LeafNode +> extends Pick['table'], 'expanded' | 'rowSelection'> { + scrollRect: { width: number; height: number }; + scrollOffset: number; + range: { startIndex: number; endIndex: number } | null; + isScrolling: boolean; + activeStickyIndex: number | null; + totalRowCount: number; + totalSize: number; +} + +/** + * useSyncExternalStore-compatible store for data cascade ui state. + */ +export interface DataCascadeUISnapshotStore { + subscribe(onStoreChange: () => void): () => void; + getSnapshot(): DataCascadeUISnapshot; + getServerSnapshot(): DataCascadeUISnapshot; +} + +/** + * Options for useExposePublicApi. Supplies table/UI state needed to build the snapshot. + */ +export interface UseExposePublicApiOptions { + rows: Row[]; + enableStickyGroupHeader: boolean; +} + +/** + * Return value of useExposePublicApi. + */ +export interface UseExposePublicApiReturnValue { + /** Updates the store snapshot from virtualizer instance changes */ + collectVirtualizerStateChanges: (instance: UseVirtualizerReturnType | undefined) => void; +} + +const createDefaultUISnapshot = (): DataCascadeUISnapshot< + G, + L +> => ({ + scrollOffset: 0, + scrollRect: { width: 0, height: 0 }, + range: null, + isScrolling: false, + activeStickyIndex: null, + totalRowCount: 0, + totalSize: 0, + expanded: {}, + rowSelection: {}, +}); + +/** + * Definition of the public API ref for the data cascade component. + */ +export interface DataCascadeImplRef { + /** + * Returns helpers to access a minimal readonly state of the data cascade component. + * This can be used as-is or put together leveraging useSyncExternalStore to create a more reactive + * component state. + * + * @example + * ```ts + * export function useDataCascadeSnapshot(ref: React.RefObject): DataCascadeSnapshot { + * return useSyncExternalStore( + * (onStoreChange) => ref.current?.getUISnapshotStore()?.subscribe(onStoreChange) ?? (() => {}), + * () => ref.current?.getUISnapshotStore()?.getSnapshot() ?? DEFAULT_SNAPSHOT, + * () => ref.current?.getUISnapshotStore()?.getServerSnapshot() ?? DEFAULT_SNAPSHOT + * ); + * } + * ``` + */ + getUISnapshotStore: () => DataCascadeUISnapshotStore | null; +} + +/** + * Hook that owns the public API ref: aggregates state + virtualizer into a snapshot store, + * updates the store when inputs change, and exposes getStateStore via useImperativeHandle. + * Call from the cascade impl with the ref and options; the ref will be populated after mount. + */ +export function useExposePublicApi( + ref: React.Ref>, + options: UseExposePublicApiOptions +): UseExposePublicApiReturnValue { + // Use a stable handle object and only update its method. + const handleRef = useRef>({ + getUISnapshotStore: () => null, + }); + + const { rows } = options; + const state = useDataCascadeState(); + + const expanded = useMemo['expanded']>( + () => + typeof state.table.expanded === 'object' && state.table.expanded !== null + ? state.table.expanded + : {}, + [state.table.expanded] + ); + + const rowSelection = useMemo['rowSelection']>( + () => state.table.rowSelection ?? {}, + [state.table.rowSelection] + ); + + const optionsRef = useRef(options); + const latestStateRef = useRef({ expanded, rowSelection }); + optionsRef.current = options; + latestStateRef.current = { expanded, rowSelection }; + + const storeRef = useRef<{ + listeners: Set<() => void>; + snapshot: DataCascadeUISnapshot; + }>({ + listeners: new Set(), + snapshot: createDefaultUISnapshot(), + }); + + const subscribe = useCallback((onUISnapshotChange: () => void) => { + storeRef.current.listeners.add(onUISnapshotChange); + return () => { + storeRef.current.listeners.delete(onUISnapshotChange); + }; + }, []); + + const getSnapshot = useCallback((): DataCascadeUISnapshot => storeRef.current.snapshot, []); + const getServerSnapshot = useCallback( + (): DataCascadeUISnapshot => storeRef.current.snapshot, + [] + ); + + /** Notifies all subscribed listeners that the store snapshot may have changed. */ + const notifyListeners = useMemo( + () => + debounce(() => { + const opts = optionsRef.current; + const { expanded: exp, rowSelection: sel } = latestStateRef.current; + + storeRef.current.snapshot = { + ...storeRef.current.snapshot, + totalRowCount: opts.rows.length, + expanded: exp, + rowSelection: sel, + }; + + storeRef.current.listeners.forEach((listener) => listener()); + }, 100), + [] + ); + + /** scans updates from virtualizer instance and updates the store snapshot. */ + const collectVirtualizerStateChanges = useCallback( + (instance: UseVirtualizerReturnType | undefined) => { + const opts = optionsRef.current; + + // if the virtualizer instance is not null, update the store snapshot + if (instance != null) { + const range = + instance.range != null + ? { startIndex: instance.range.startIndex, endIndex: instance.range.endIndex } + : null; + const activeStickyIndex = calculateActiveStickyIndex( + opts.rows, + range?.startIndex ?? 0, + opts.enableStickyGroupHeader + ); + + storeRef.current.snapshot = { + ...storeRef.current.snapshot, + scrollOffset: instance.scrollOffset ?? 0, + range, + isScrolling: instance.isScrolling ?? false, + activeStickyIndex, + scrollRect: instance.scrollRect ?? { width: 0, height: 0 }, + totalSize: instance.getTotalSize ? instance.getTotalSize() : 0, + }; + + notifyListeners(); + } + }, + [notifyListeners] + ); + + useLayoutEffect(() => { + notifyListeners(); + }, [notifyListeners, expanded, rowSelection, rows.length]); + + const getStateStore = useCallback( + (): DataCascadeUISnapshotStore => ({ + subscribe, + getSnapshot, + getServerSnapshot, + }), + [subscribe, getSnapshot, getServerSnapshot] + ); + + handleRef.current = { + getUISnapshotStore: getStateStore, + }; + + // Populate the forwarded ref with the stable handle after mount + useImperativeHandle(ref, () => handleRef.current, []); + + return { collectVirtualizerStateChanges }; +} diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/virtualizer/index.tsx b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/virtualizer/index.tsx index bee037ff6a86a..c1342159c2cc7 100644 --- a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/virtualizer/index.tsx +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/lib/core/virtualizer/index.tsx @@ -13,10 +13,13 @@ import { useVirtualizer, defaultRangeExtractor, type VirtualItem } from '@tansta import type { GroupNode } from '../../../store_provider'; type UseVirtualizerOptions = Parameters[0]; -type UseVirtualizerReturnType = ReturnType; +export type UseVirtualizerReturnType = ReturnType; export interface CascadeVirtualizerProps - extends Pick { + extends Pick< + UseVirtualizerOptions, + 'getScrollElement' | 'overscan' | 'initialOffset' | 'initialRect' + > { rows: Row[]; /** * setting a value of true causes the active group root row @@ -24,6 +27,11 @@ export interface CascadeVirtualizerProps */ enableStickyGroupHeader: boolean; estimatedRowHeight?: number; + /** + * Called whenever the virtualizer updates (scroll, range, size, etc.). + * Used to conduit values into external state (e.g. public API store). + */ + onStateChange?: (instance: UseVirtualizerReturnType) => void; } export interface UseVirtualizedRowScrollStateStoreOptions { @@ -168,6 +176,9 @@ export const useCascadeVirtualizer = ({ estimatedRowHeight = 0, rows, getScrollElement, + onStateChange, + initialOffset, + initialRect, }: CascadeVirtualizerProps): CascadeVirtualizerReturnValue => { const virtualizedRowsSizeCacheRef = useRef>(new Map()); @@ -197,14 +208,27 @@ export const useCascadeVirtualizer = ({ getScrollElement, overscan, rangeExtractor, + initialOffset, + initialRect, onChange: (rowVirtualizerInstance) => { // @ts-expect-error -- the itemsSizeCache property does exist, // but it not included in the type definition because it is marked as a private property, // see {@link https://github.com/TanStack/virtual/blob/v3.13.2/packages/virtual-core/src/index.ts#L360} virtualizedRowsSizeCacheRef.current = rowVirtualizerInstance.itemSizeCache; + // propagate virtualizer state changes + onStateChange?.(rowVirtualizerInstance); }, }), - [estimatedRowHeight, getScrollElement, overscan, rangeExtractor, rows.length] + [ + estimatedRowHeight, + getScrollElement, + initialOffset, + initialRect, + overscan, + rangeExtractor, + rows.length, + onStateChange, + ] ); const virtualizerImpl = useVirtualizer(virtualizerOptions); diff --git a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/store_provider/index.tsx b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/store_provider/index.tsx index 006810d0d958c..daf62316647f6 100644 --- a/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/store_provider/index.tsx +++ b/src/platform/packages/shared/shared-ux/document_data_cascade/impl/src/store_provider/index.tsx @@ -20,9 +20,14 @@ import { } from './reducers'; export type { GroupNode, LeafNode, IStoreState } from './reducers'; -interface IDataCascadeProviderProps { +export interface IDataCascadeProviderProps { initialGroupColumn?: string[]; cascadeGroups: string[]; + /** + * Properties to set the initial table state on mount. + * Only expanded and rowSelection properties are supported for now. + */ + initialTableState?: Pick, 'expanded' | 'rowSelection'>; } interface IStoreContext { @@ -69,6 +74,7 @@ export function useCascadeLeafNode(cach export function DataCascadeProvider({ cascadeGroups, initialGroupColumn, + initialTableState, children, }: PropsWithChildren) { const StoreContext = createStoreContext(); @@ -81,7 +87,7 @@ export function DataCascadeProvider({ const { state, actions } = useCreateStore({ initialState: { - table: {} as TableState, + table: (initialTableState ?? {}) as TableState, groupNodes: [] as G[], leafNodes: new Map(), // TODO: consider externalizing this so the consumer might provide their own external cache groupByColumns: cascadeGroups, diff --git a/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.test.tsx b/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.test.tsx index 89d04ef274892..45ed11e7cb945 100644 --- a/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.test.tsx +++ b/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.test.tsx @@ -197,4 +197,49 @@ describe('ToolbarSelector', () => { expect(screen.getByText('Maximum selection limit reached')).toBeInTheDocument(); expect(screen.getAllByLabelText('My Popover Title')).toHaveLength(3); }); + + it('uses buttonTooltipContent when provided instead of buttonLabel for tooltip', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByTestId('toolbarSelectorTooltipTestButton'); + expect(screen.getByText('Button Label')).toBeInTheDocument(); + + await user.hover(button); + + await waitFor(() => { + expect(screen.getByText('Custom Tooltip Content')).toBeInTheDocument(); + }); + }); + + it('falls back to buttonLabel for tooltip when buttonTooltipContent is not provided', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByTestId('toolbarSelectorDefaultTooltipTestButton'); + expect(screen.getByText('Default Label')).toBeInTheDocument(); + + await user.hover(button); + + await waitFor(() => { + expect(screen.getByText('Default Label')).toBeInTheDocument(); + }); + }); }); diff --git a/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.tsx b/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.tsx index 95d669bf10417..79189594ff13c 100644 --- a/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.tsx +++ b/src/platform/packages/shared/shared-ux/toolbar_selector/src/toolbar_selector.tsx @@ -34,6 +34,7 @@ export interface BaseToolbarProps { 'data-test-subj': string; 'data-selected-value'?: string | string[]; buttonLabel: ReactElement | string; + buttonTooltipContent?: ReactElement | string; popoverContentBelowSearch?: ReactElement; popoverTitle?: string; options: SelectableEntry[]; @@ -61,6 +62,7 @@ export const ToolbarSelector = ({ 'data-test-subj': dataTestSubj, 'data-selected-value': dataSelectedValue, buttonLabel, + buttonTooltipContent, popoverContentBelowSearch, popoverTitle, options, @@ -204,7 +206,13 @@ export const ToolbarSelector = ({ panelPaddingSize="none" button={ diff --git a/src/platform/plugins/private/advanced_settings/test/scout/.meta/ui/standard.json b/src/platform/plugins/private/advanced_settings/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..923b138841691 --- /dev/null +++ b/src/platform/plugins/private/advanced_settings/test/scout/.meta/ui/standard.json @@ -0,0 +1,148 @@ +{ + "sha1": "7d87791f6be5b70ca3b0454934bb745a6680933e", + "tests": [ + { + "id": "a3e4b1438a281ec-a15d1ea44076beb", + "title": "security feature controls global advanced_settings all privileges - shows management navlink", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 19, + "column": 7 + } + }, + { + "id": "a3e4b1438a281ec-70c55d39cde2292", + "title": "security feature controls global advanced_settings all privileges - allows settings to be changed", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 33, + "column": 7 + } + }, + { + "id": "a3e4b1438a281ec-b1c5ef3921e581b", + "title": "security feature controls global advanced_settings all privileges - doesn't show read-only badge", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 49, + "column": 7 + } + }, + { + "id": "a3e4b1438a281ec-c585365935086e4", + "title": "security feature controls global advanced_settings read-only privileges - shows Management navlink", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 63, + "column": 7 + } + }, + { + "id": "a3e4b1438a281ec-24e9d48b87bab9b", + "title": "security feature controls global advanced_settings read-only privileges - does not allow settings to be changed", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 77, + "column": 7 + } + }, + { + "id": "a3e4b1438a281ec-bbea8154da45959", + "title": "security feature controls global advanced_settings read-only privileges - shows read-only badge", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 92, + "column": 7 + } + }, + { + "id": "a3e4b1438a281ec-6a3472d15e66cfe", + "title": "security feature controls no advanced_settings privileges - does not show Management navlink", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 110, + "column": 7 + } + }, + { + "id": "a3e4b1438a281ec-2e2c27193dc1ee1", + "title": "security feature controls no advanced_settings privileges - does not allow navigation to advanced settings; shows \"not found\" error", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_security.spec.ts", + "line": 122, + "column": 7 + } + }, + { + "id": "1fc0f0abf7efc2c-731a1321bb41fe8", + "title": "spaces feature controls space with no features disabled - shows Management navlink", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_spaces.spec.ts", + "line": 34, + "column": 7 + } + }, + { + "id": "1fc0f0abf7efc2c-1e9fd06bfa8c668", + "title": "spaces feature controls space with no features disabled - allows settings to be changed", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_spaces.spec.ts", + "line": 53, + "column": 7 + } + }, + { + "id": "1fc0f0abf7efc2c-7343cf3be887afc", + "title": "spaces feature controls space with Advanced Settings disabled - redirects to management home", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_spaces.spec.ts", + "line": 67, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_spaces.spec.ts b/src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_spaces.spec.ts index ba4b81fdfe122..a5b4076d5eced 100644 --- a/src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_spaces.spec.ts +++ b/src/platform/plugins/private/advanced_settings/test/scout/ui/tests/advanced_settings_spaces.spec.ts @@ -39,6 +39,13 @@ test.describe('spaces feature controls', { tag: '@local-stateful-classic' }, () }) => { await browserAuth.loginAsAdmin(); await page.goto(kbnUrl.app('home', { space: 'custom_space' })); + await page.evaluate(() => { + localStorage.setItem('home:welcome:show', 'false'); + }); + await page.reload(); + await page.testSubj.locator('homeApp').waitFor({ + state: 'visible', + }); const navLinks = await pageObjects.collapsibleNav.getNavLinks(); expect(navLinks).toContain('Stack Management'); }); diff --git a/src/platform/plugins/private/image_embeddable/kibana.jsonc b/src/platform/plugins/private/image_embeddable/kibana.jsonc index 9754b9df252c9..33bd335019dae 100644 --- a/src/platform/plugins/private/image_embeddable/kibana.jsonc +++ b/src/platform/plugins/private/image_embeddable/kibana.jsonc @@ -20,7 +20,6 @@ "optionalPlugins": [ "security", "screenshotMode", - "embeddableEnhanced" ], "requiredBundles": [], "extraPublicDirs": ["common"] diff --git a/src/platform/plugins/private/image_embeddable/moon.yml b/src/platform/plugins/private/image_embeddable/moon.yml index dca0d993bb755..b55a528d49faa 100644 --- a/src/platform/plugins/private/image_embeddable/moon.yml +++ b/src/platform/plugins/private/image_embeddable/moon.yml @@ -35,7 +35,6 @@ dependsOn: - '@kbn/screenshot-mode-plugin' - '@kbn/presentation-publishing' - '@kbn/presentation-publishing-schemas' - - '@kbn/embeddable-enhanced-plugin' - '@kbn/presentation-util' - '@kbn/config-schema' - '@kbn/content-management-utils' diff --git a/src/platform/plugins/private/image_embeddable/public/components/image_embeddable.tsx b/src/platform/plugins/private/image_embeddable/public/components/image_embeddable.tsx index 1db0ad8fe7762..632c9e7ed221e 100644 --- a/src/platform/plugins/private/image_embeddable/public/components/image_embeddable.tsx +++ b/src/platform/plugins/private/image_embeddable/public/components/image_embeddable.tsx @@ -31,9 +31,9 @@ interface ImageEmbeddableProps { } export const ImageEmbeddable = ({ api, filesClient }: ImageEmbeddableProps) => { - const [imageConfig, dynamicActionsState] = useBatchedPublishingSubjects( + const [imageConfig, drilldowns] = useBatchedPublishingSubjects( api.imageConfig$, - api.dynamicActionsState$ ?? new BehaviorSubject(undefined) + api.drilldowns$ ?? new BehaviorSubject(undefined) ); const [hasTriggerActions, setHasTriggerActions] = useState(false); @@ -47,8 +47,8 @@ export const ImageEmbeddable = ({ api, filesClient }: ImageEmbeddableProps) => { useEffect(() => { // set `hasTriggerActions` depending on whether or not the image has at least one drilldown - setHasTriggerActions((dynamicActionsState?.dynamicActions.events ?? []).length > 0); - }, [dynamicActionsState]); + setHasTriggerActions(drilldowns?.length > 0); + }, [drilldowns]); return ( { +export const getImageEmbeddableFactory = () => { const imageEmbeddableFactory: EmbeddableFactory = { type: IMAGE_EMBEDDABLE_TYPE, - buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => { + buildEmbeddable: async ({ + initializeDrilldownsManager, + initialState, + finalizeApi, + uuid, + parentApi, + }) => { const titleManager = initializeTitleManager(initialState); - const dynamicActionsManager = await embeddableEnhanced?.initializeEmbeddableDynamicActions( - uuid, - () => titleManager.api.title$.getValue(), - initialState - ); - // if it is provided, start the dynamic actions manager - const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); const filesClient = filesService.filesClientFactory.asUnscoped(); const imageConfig$ = new BehaviorSubject(initialState.imageConfig); @@ -52,7 +47,7 @@ export const getImageEmbeddableFactory = ({ function serializeState() { return { ...titleManager.getLatestState(), - ...(dynamicActionsManager?.getLatestState() ?? {}), + ...drilldownsManager.getLatestState(), imageConfig: imageConfig$.getValue(), }; } @@ -64,25 +59,25 @@ export const getImageEmbeddableFactory = ({ anyStateChange$: merge( titleManager.anyStateChange$, imageConfig$.pipe(map(() => undefined)), - ...(dynamicActionsManager ? [dynamicActionsManager.anyStateChange$] : []) + drilldownsManager.anyStateChange$ ), getComparators: () => { return { - ...(dynamicActionsManager?.comparators ?? { enhancements: 'skip', drilldowns: 'skip' }), + ...drilldownsManager.comparators, ...titleComparators, imageConfig: 'deepEquality', }; }, onReset: (lastSaved) => { titleManager.reinitializeState(lastSaved); - dynamicActionsManager?.reinitializeState(lastSaved ?? {}); + drilldownsManager.reinitializeState(lastSaved ?? {}); if (lastSaved) imageConfig$.next(lastSaved.imageConfig); }, }); const embeddable = finalizeApi({ ...titleManager.api, - ...(dynamicActionsManager?.api ?? {}), + ...drilldownsManager.api, ...unsavedChangesApi, dataLoading$, supportedTriggers: () => IMAGE_EMBEDDABLE_SUPPORTED_TRIGGERS, @@ -127,8 +122,7 @@ export const getImageEmbeddableFactory = ({ useEffect(() => { return () => { - // if it was started, stop the dynamic actions manager on unmount - maybeStopDynamicActions?.stopDynamicActions(); + drilldownsManager.cleanup(); }; }, []); diff --git a/src/platform/plugins/private/image_embeddable/public/plugin.ts b/src/platform/plugins/private/image_embeddable/public/plugin.ts index 81c636b25ee73..1d6c024bdeea9 100644 --- a/src/platform/plugins/private/image_embeddable/public/plugin.ts +++ b/src/platform/plugins/private/image_embeddable/public/plugin.ts @@ -14,7 +14,6 @@ import type { ScreenshotModePluginSetup, ScreenshotModePluginStart, } from '@kbn/screenshot-mode-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { @@ -38,7 +37,6 @@ export interface ImageEmbeddableStartDependencies { uiActions: UiActionsStart; embeddable: EmbeddableStart; screenshotMode?: ScreenshotModePluginStart; - embeddableEnhanced?: EmbeddableEnhancedPluginStart; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -63,12 +61,11 @@ export class ImageEmbeddablePlugin plugins: ImageEmbeddableSetupDependencies ): SetupContract { plugins.embeddable.registerReactEmbeddableFactory(IMAGE_EMBEDDABLE_TYPE, async () => { - const [_, { getImageEmbeddableFactory }, [__, { embeddableEnhanced }]] = await Promise.all([ + const [_, { getImageEmbeddableFactory }] = await Promise.all([ untilPluginStartServicesReady(), import('./image_embeddable/get_image_embeddable_factory'), - core.getStartServices(), ]); - return getImageEmbeddableFactory({ embeddableEnhanced }); + return getImageEmbeddableFactory(); }); return {}; } diff --git a/src/platform/plugins/private/image_embeddable/public/types.ts b/src/platform/plugins/private/image_embeddable/public/types.ts index ff829f2d04496..9375c56b4df5a 100644 --- a/src/platform/plugins/private/image_embeddable/public/types.ts +++ b/src/platform/plugins/private/image_embeddable/public/types.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { HasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public'; +import type { HasDrilldowns } from '@kbn/embeddable-plugin/public'; import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import type { HasEditCapabilities, HasSupportedTriggers } from '@kbn/presentation-publishing'; import type { ImageEmbeddableState } from '../server'; @@ -15,6 +15,6 @@ import type { ImageEmbeddableState } from '../server'; export type ImageEmbeddableApi = DefaultEmbeddableApi & HasEditCapabilities & HasSupportedTriggers & - HasDynamicActions; + HasDrilldowns; export type { ImageConfig } from '../server'; diff --git a/src/platform/plugins/private/image_embeddable/tsconfig.json b/src/platform/plugins/private/image_embeddable/tsconfig.json index 271f5e5d61c8f..f07703bfb3cb4 100644 --- a/src/platform/plugins/private/image_embeddable/tsconfig.json +++ b/src/platform/plugins/private/image_embeddable/tsconfig.json @@ -22,7 +22,6 @@ "@kbn/screenshot-mode-plugin", "@kbn/presentation-publishing", "@kbn/presentation-publishing-schemas", - "@kbn/embeddable-enhanced-plugin", "@kbn/presentation-util", "@kbn/config-schema", "@kbn/content-management-utils" diff --git a/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx b/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx index a0b77f249b35e..276bb71e95301 100644 --- a/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx +++ b/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx @@ -120,6 +120,7 @@ async function buildLinksEmbeddable(state: LinksEmbeddableState) { const parentApi = getMockLinksParentApi(state); const uuid = '1234'; return await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: state, finalizeApi: (api) => { return { diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/moon.yml b/src/platform/plugins/shared/ai_assistant_management/selection/moon.yml index a6ea969d81ec1..cfffffaca7441 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/moon.yml +++ b/src/platform/plugins/shared/ai_assistant_management/selection/moon.yml @@ -37,7 +37,6 @@ dependsOn: - '@kbn/licensing-plugin' - '@kbn/shared-ux-utility' - '@kbn/spaces-plugin' - - '@kbn/ai-assistant-icon' - '@kbn/ai-assistant-common' - '@kbn/ai-agent-confirmation-modal' - '@kbn/management-settings-ids' diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.test.tsx b/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.test.tsx index 87bf76ccf518d..24481e33718ee 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.test.tsx +++ b/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.test.tsx @@ -19,9 +19,6 @@ import { AI_CHAT_EXPERIENCE_TYPE, } from '@kbn/management-settings-ids'; -jest.mock('@kbn/ai-assistant-icon', () => ({ - RobotIcon: ({ size }: { size: string }) =>
, -})); jest.mock('../../icons/assistant_icon/assistant_icon', () => ({ AssistantIcon: 'assistant-icon', })); diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.tsx b/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.tsx index e2056a302fd4c..1080f7e8cf4b6 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.tsx +++ b/src/platform/plugins/shared/ai_assistant_management/selection/public/components/navigation_control/index.tsx @@ -30,7 +30,6 @@ import { import { i18n } from '@kbn/i18n'; import type { CoreStart } from '@kbn/core/public'; import { FormattedMessage } from '@kbn/i18n-react'; -import { RobotIcon } from '@kbn/ai-assistant-icon'; import { AIChatExperience } from '@kbn/ai-assistant-common'; import { AIAgentConfirmationModal } from '@kbn/ai-agent-confirmation-modal'; import { @@ -267,7 +266,7 @@ export const AIAssistantHeaderButton: React.FC = ( } )} titleSize="xs" - icon={} + icon={} data-test-subj="aiAssistantAgentCard" isDisabled={!hasAgentBuilder} /> diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json b/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json index 6158691306154..7e7a0b9eab677 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json +++ b/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json @@ -24,7 +24,6 @@ "@kbn/licensing-plugin", "@kbn/shared-ux-utility", "@kbn/spaces-plugin", - "@kbn/ai-assistant-icon", "@kbn/ai-assistant-common", "@kbn/ai-agent-confirmation-modal", "@kbn/management-settings-ids" diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts index 1ad4f5faf7eda..c1b7ca829e300 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts @@ -14,6 +14,7 @@ import type { Datatable } from '@kbn/expressions-plugin/common'; import type { FieldFormat } from '@kbn/field-formats-plugin/common'; import type { LayersFieldFormats } from './layers'; import type { DatatablesWithFormatInfo } from './data_layers'; +import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common'; describe('color_assignment', () => { const tables: Record = { @@ -321,5 +322,72 @@ describe('color_assignment', () => { // if the split column is missing, assume it is the first splitted series. One series in front - 0/y1 expect(assignments.palette1.getRank(layers[0].layerId, 'test2')).toEqual(1); }); + + it('should handle multiple split accessors by joining them with separator', () => { + const multiSplitTable: Datatable = { + type: 'datatable', + columns: [ + { id: 'split1', name: '', meta: { type: 'string' } }, + { id: 'split2', name: '', meta: { type: 'string' } }, + { id: 'y1', name: '', meta: { type: 'number' } }, + ], + rows: [ + { split1: 'A', split2: 'X' }, + { split1: 'A', split2: 'Y' }, + { split1: 'B', split2: 'X' }, + ], + }; + + const multiSplitFieldFormats = { + first: { + splitSeriesAccessors: { + split1: { + format: { id: 'string' }, + formatter: { + convert: (x) => x, + } as FieldFormat, + }, + split2: { + format: { id: 'string' }, + formatter: { + convert: (x) => x, + } as FieldFormat, + }, + }, + }, + } as unknown as LayersFieldFormats; + + const multiSplitFormattedDatatables: DatatablesWithFormatInfo = { + first: { + table: multiSplitTable, + formattedColumns: {}, + invertedRawValueMap: new Map(multiSplitTable.columns.map((c) => [c.id, new Map()])), + }, + }; + + const multiSplitLayer: DataLayerConfig = { + ...layers[0], + splitAccessors: ['split1', 'split2'], + accessors: ['y1'], + table: multiSplitTable, + }; + + const assignments = getColorAssignments( + [multiSplitLayer], + titles, + multiSplitFieldFormats, + multiSplitFormattedDatatables + ); + + expect(assignments.palette1.totalSeriesCount).toEqual(3); + + const expectedSeriesName1 = `A${MULTI_FIELD_KEY_SEPARATOR}X`; + const expectedSeriesName2 = `A${MULTI_FIELD_KEY_SEPARATOR}Y`; + const expectedSeriesName3 = `B${MULTI_FIELD_KEY_SEPARATOR}X`; + + expect(assignments.palette1.getRank(multiSplitLayer.layerId, expectedSeriesName1)).toEqual(0); + expect(assignments.palette1.getRank(multiSplitLayer.layerId, expectedSeriesName2)).toEqual(1); + expect(assignments.palette1.getRank(multiSplitLayer.layerId, expectedSeriesName3)).toEqual(2); + }); }); }); diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.ts b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.ts index fd1176dee4692..ef52a8a6fffaf 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.ts @@ -12,6 +12,7 @@ import type { DatatableRow } from '@kbn/expressions-plugin/common'; import { euiLightVars } from '@kbn/ui-theme'; import { getAccessorByDimension } from '@kbn/chart-expressions-common'; import type { ExpressionValueVisDimension } from '@kbn/chart-expressions-common'; +import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common'; import { isDataLayer } from './visualization'; import type { CommonXYDataLayerConfig, CommonXYLayerConfig } from '../../common'; import type { @@ -40,14 +41,14 @@ function getSplitName( fieldFormats: LayerFieldFormats ) { return splitAccessors.reduce((splitName, accessor) => { - if (!formattedDatatable.table.columns.length) return; + if (!formattedDatatable.table.columns.length) return splitName; const splitAccessor = getAccessorByDimension(accessor, formattedDatatable.table.columns); const splitFormatterObj = fieldFormats.splitSeriesAccessors[splitAccessor]; const name = formattedDatatable.formattedColumns[splitAccessor] ? row[splitAccessor] : splitFormatterObj.formatter.convert(row[splitAccessor]); if (splitName) { - return `${splitName} - ${name}`; + return `${splitName}${MULTI_FIELD_KEY_SEPARATOR}${name}`; } else { return name; } diff --git a/src/platform/plugins/shared/console/test/scout/.meta/api/standard.json b/src/platform/plugins/shared/console/test/scout/.meta/api/standard.json index e677b36dbc51a..f774f6ac63dc0 100644 --- a/src/platform/plugins/shared/console/test/scout/.meta/api/standard.json +++ b/src/platform/plugins/shared/console/test/scout/.meta/api/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T13:22:10.224Z", "sha1": "c110388a77ec11d900c2552f5d393cea336dfea8", "tests": [ { diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx index ebfb6919b20ae..123b49a8e7b5a 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx @@ -77,6 +77,7 @@ describe('Options List Control Api', () => { dataviewDelayPromise = new Promise((res) => (resolveDataView = res)); (async () => { await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -99,6 +100,7 @@ describe('Options List Control Api', () => { dataviewDelayPromise = new Promise((res) => (resolveDataView = res)); (async () => { await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -130,6 +132,7 @@ describe('Options List Control Api', () => { test('should not set appliedFilters$ when selectedOptions is not provided', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -143,6 +146,7 @@ describe('Options List Control Api', () => { test('should set appliedFilters$ when selectedOptions is provided', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -184,6 +188,7 @@ describe('Options List Control Api', () => { test('should set appliedFilters$ when exists is selected', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -211,6 +216,7 @@ describe('Options List Control Api', () => { test('should set appliedFilters$ when exclude is selected', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -255,6 +261,7 @@ describe('Options List Control Api', () => { test('renders a "(blank)" option', async () => { const { Component } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -284,6 +291,7 @@ describe('Options List Control Api', () => { test('clicking another option unselects "Exists"', async () => { const { Component } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -313,6 +321,7 @@ describe('Options List Control Api', () => { test('clicking "Exists" unselects all other selections', async () => { const { Component } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -347,6 +356,7 @@ describe('Options List Control Api', () => { test('deselects when showOnlySelected is true', async () => { const { Component, api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -390,6 +400,7 @@ describe('Options List Control Api', () => { test('replace selection when singleSelect is true', async () => { const { Component, api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx index 7f83f628daf43..23c9c479c42df 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx @@ -97,6 +97,7 @@ describe('RangeSliderControlApi', () => { describe('appliedFilters$', () => { test('should not set appliedFilters$ when value is not provided', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -110,6 +111,7 @@ describe('RangeSliderControlApi', () => { test('should set appliedFilters$ when value is provided', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -148,6 +150,7 @@ describe('RangeSliderControlApi', () => { test('should set blocking error when data view is not found', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'notGonnaFindMeDataView', field_name: 'myFieldName', @@ -170,6 +173,7 @@ describe('RangeSliderControlApi', () => { 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 } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -189,6 +193,7 @@ describe('RangeSliderControlApi', () => { describe('min max', () => { test('bounds inputs should display min and max placeholders when there is no selected range', async () => { const { Component } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -210,6 +215,7 @@ describe('RangeSliderControlApi', () => { describe('step state', () => { test('default value provided when state.step is undefined', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', @@ -224,6 +230,7 @@ describe('RangeSliderControlApi', () => { test('retains value from initial state', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { data_view_id: 'myDataViewId', field_name: 'myFieldName', diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx index 571f42f8ae741..ad9cea27900de 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx @@ -53,6 +53,7 @@ describe('ESQLControlApi', () => { control_type: 'STATIC_VALUES', } as OptionsListESQLControlState; const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState, finalizeApi, uuid, @@ -78,6 +79,7 @@ describe('ESQLControlApi', () => { control_type: 'STATIC_VALUES', } as OptionsListESQLControlState; const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState, finalizeApi, uuid, @@ -106,6 +108,7 @@ describe('ESQLControlApi', () => { control_type: EsqlControlType.VALUES_FROM_QUERY, } as OptionsListESQLControlState; await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState, finalizeApi, uuid, @@ -127,6 +130,7 @@ describe('ESQLControlApi', () => { control_type: EsqlControlType.VALUES_FROM_QUERY, } as OptionsListESQLControlState; await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState, finalizeApi, uuid, @@ -165,6 +169,7 @@ describe('ESQLControlApi', () => { control_type: 'STATIC_VALUES', } as OptionsListESQLControlState; const { Component, api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState, finalizeApi, uuid, diff --git a/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx index 4cb50f7506cd6..e7e4917c3057a 100644 --- a/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx @@ -57,6 +57,7 @@ describe('TimeSliderControlApi', () => { test('Should set timeslice to undefined when state does not provide percentage of timeRange', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: {}, finalizeApi, uuid, @@ -67,6 +68,7 @@ describe('TimeSliderControlApi', () => { test('Should set timeslice to values within time range when state provides percentage of timeRange', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { start_percentage_of_time_range: 0.25, end_percentage_of_time_range: 0.5, @@ -87,6 +89,7 @@ describe('TimeSliderControlApi', () => { test('Should update timeslice when time range changes', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { start_percentage_of_time_range: 0.25, end_percentage_of_time_range: 0.5, @@ -115,6 +118,7 @@ describe('TimeSliderControlApi', () => { test('Clicking previous button should advance timeslice backward', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { start_percentage_of_time_range: 0.25, end_percentage_of_time_range: 0.5, @@ -141,6 +145,7 @@ describe('TimeSliderControlApi', () => { test('Clicking previous button should wrap when time range start is reached', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { start_percentage_of_time_range: 0.25, end_percentage_of_time_range: 0.5, @@ -168,6 +173,7 @@ describe('TimeSliderControlApi', () => { test('Clicking next button should advance timeslice forward', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { start_percentage_of_time_range: 0.25, end_percentage_of_time_range: 0.5, @@ -194,6 +200,7 @@ describe('TimeSliderControlApi', () => { test('Clicking next button should wrap when time range end is reached', async () => { const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { start_percentage_of_time_range: 0.25, end_percentage_of_time_range: 0.5, @@ -227,6 +234,7 @@ describe('TimeSliderControlApi', () => { }; dashboardApi.getLastSavedStateForChild.mockReturnValueOnce(controlState); const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: controlState, finalizeApi, uuid, diff --git a/src/platform/plugins/shared/controls/server/transforms/options_list_control_transforms.test.ts b/src/platform/plugins/shared/controls/server/transforms/options_list_control_transforms.test.ts new file mode 100644 index 0000000000000..5ce26a1f36fcf --- /dev/null +++ b/src/platform/plugins/shared/controls/server/transforms/options_list_control_transforms.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createEmbeddableSetupMock } from '@kbn/embeddable-plugin/server/mocks'; +import { registerOptionsListControlTransforms } from './options_list_control_transforms'; +import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; + +const REF_NAME = 'test-data-view'; + +const baseState = { + dataViewRefName: REF_NAME, + field_name: 'test', + title: 'Test', +}; + +const panelReferences = [{ name: REF_NAME, type: 'index-pattern', id: 'data-view-id' }]; + +const getTransformOut = () => { + const embeddable = createEmbeddableSetupMock(); + registerOptionsListControlTransforms(embeddable); + + const [, transformsSetup] = embeddable.registerTransforms.mock.calls[0]; + const { transformOut } = transformsSetup.getTransforms!({} as DrilldownTransforms); + return transformOut!; +}; + +describe('options list control transforms', () => { + describe('transformOut', () => { + const transformOut = getTransformOut(); + + it('omits null values while keeping non-null values', () => { + const result = transformOut( + { + ...baseState, + exclude: true, + sort: null, + existsSelected: null, + runPastTimeout: null, + searchTechnique: 'prefix', + selectedOptions: ['val'], + singleSelect: null, + }, + panelReferences, + undefined, + undefined + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "data_view_id": "data-view-id", + "exclude": true, + "field_name": "test", + "search_technique": "prefix", + "selected_options": Array [ + "val", + ], + "title": "Test", + } + `); + }); + }); +}); diff --git a/src/platform/plugins/shared/controls/server/transforms/options_list_control_transforms.ts b/src/platform/plugins/shared/controls/server/transforms/options_list_control_transforms.ts index c3cd24b1f82b9..02f015259a85b 100644 --- a/src/platform/plugins/shared/controls/server/transforms/options_list_control_transforms.ts +++ b/src/platform/plugins/shared/controls/server/transforms/options_list_control_transforms.ts @@ -9,12 +9,13 @@ import type { Reference } from '@kbn/content-management-utils'; import { OPTIONS_LIST_CONTROL } from '@kbn/controls-constants'; -import type { - LegacyStoredOptionsListExplicitInput, - OptionsListDSLControlState, +import { + type LegacyStoredOptionsListExplicitInput, + type OptionsListDSLControlState, } from '@kbn/controls-schemas'; import type { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; import { convertCamelCasedKeysToSnakeCase } from '@kbn/presentation-publishing'; +import { omitBy } from 'lodash'; import { transformDataControlIn, transformDataControlOut } from './data_control_transforms'; const OPTIONS_LIST_REF_NAME = 'optionsListDataView' as const; @@ -69,17 +70,23 @@ export const registerOptionsListControlTransforms = (embeddable: EmbeddableSetup } = convertCamelCasedKeysToSnakeCase( state as LegacyStoredOptionsListExplicitInput ); - return { - ...dataControlState, - exclude, - ...{ sort: sort as OptionsListDSLControlState['sort'] }, - exists_selected, - display_settings, - run_past_timeout, - search_technique: search_technique as OptionsListDSLControlState['search_technique'], - selected_options, - single_select, - }; + + // Optional legacy props may have been stored as `null` instead of `undefined`, so drop all + // null or undefined keys + return omitBy( + { + ...dataControlState, + exclude, + sort, + exists_selected, + display_settings, + run_past_timeout, + search_technique, + selected_options, + single_select, + }, + (v) => v === null || v === undefined + ) as OptionsListDSLControlState; }, }), }); diff --git a/src/platform/plugins/shared/cps/README.md b/src/platform/plugins/shared/cps/README.md index 856051083ff96..399af8ef1fcf4 100644 --- a/src/platform/plugins/shared/cps/README.md +++ b/src/platform/plugins/shared/cps/README.md @@ -1,6 +1,23 @@ # @kbn/cps -## Holds CPS related logic +## Overview + +This plugin implements the **Cross-Project Search (CPS)** logic for Kibana. CPS enables users to search data across multiple Elastic projects as if it were local, without needing to manually specify project names in queries. + +Kibana acts as a **transparent orchestrator**. It does not execute cross-project searches itself but forwards requests with the appropriate `project_routing` context to Elasticsearch. Elasticsearch then handles the execution, security enforcement, and result aggregation. + +## Client-Side (`public/`) + +- **CPSManager**: The central service for managing CPS state in the browser. + - **Project Routing**: Manages the `projectRouting$` observable (defaults to searching all projects) and allows applications to set/get the current routing. + - **Project Fetching**: Fetches and caches project data using `ProjectFetcher`. + - **UI Access Control**: Determines if the project picker should be editable, read-only, or disabled based on the current application and location (via `getProjectPickerAccess$`). + + +## Server-Side (`server/`) + +- **API Routes**: Registers endpoints like `POST /internal/cps/projects_tags` to retrieve project tags from Elasticsearch (`/_project/tags`), delegating authorization to the scoped Elasticsearch client. +- **Configuration**: Exposes the `cpsEnabled` flag via its setup contract, which is used by other parts of the system (like Core's `ElasticsearchService`) to toggle CPS behaviors. ### API Routes @@ -25,4 +42,4 @@ Retrieves project tags from Elasticsearch using the `/_project/tags` endpoint. **Features:** - Delegates authorization to the scoped Elasticsearch client - Proxies requests to the Elasticsearch `/_project/tags` API -- Returns project tag mappings as key-value pairs \ No newline at end of file +- Returns project tag mappings as key-value pairs diff --git a/src/platform/plugins/shared/cps/server/plugin.test.ts b/src/platform/plugins/shared/cps/server/plugin.test.ts index 5222158d62bf8..f7dfef97a7599 100644 --- a/src/platform/plugins/shared/cps/server/plugin.test.ts +++ b/src/platform/plugins/shared/cps/server/plugin.test.ts @@ -35,11 +35,6 @@ describe('CPSServerPlugin', () => { const setup = plugin.setup(mockCoreSetup); expect(setup.getCpsEnabled()).toBe(true); }); - - it('should call setCpsFeatureFlag with true', () => { - plugin.setup(mockCoreSetup); - expect(mockCoreSetup.elasticsearch.setCpsFeatureFlag).toHaveBeenCalledWith(true); - }); }); describe('when cpsEnabled is false', () => { @@ -53,11 +48,6 @@ describe('CPSServerPlugin', () => { const setup = plugin.setup(mockCoreSetup); expect(setup.getCpsEnabled()).toBe(false); }); - - it('should call setCpsFeatureFlag with false', () => { - plugin.setup(mockCoreSetup); - expect(mockCoreSetup.elasticsearch.setCpsFeatureFlag).toHaveBeenCalledWith(false); - }); }); it('should register routes in serverless mode', () => { diff --git a/src/platform/plugins/shared/cps/server/plugin.ts b/src/platform/plugins/shared/cps/server/plugin.ts index ed687416dbebd..9aea68155ca5b 100644 --- a/src/platform/plugins/shared/cps/server/plugin.ts +++ b/src/platform/plugins/shared/cps/server/plugin.ts @@ -27,14 +27,10 @@ export class CPSServerPlugin implements Plugin { const { initContext, config$ } = this; const { cpsEnabled } = config$; - // Register route only for serverless if (this.isServerless) { registerRoutes(core, initContext); } - // Set CPS feature flag in Elasticsearch service - core.elasticsearch.setCpsFeatureFlag(cpsEnabled); - return { getCpsEnabled: () => cpsEnabled, }; diff --git a/src/platform/plugins/shared/dashboard/common/locator/get_dashboard_locator_params.ts b/src/platform/plugins/shared/dashboard/common/locator/get_dashboard_locator_params.ts deleted file mode 100644 index bcccbf609d7f3..0000000000000 --- a/src/platform/plugins/shared/dashboard/common/locator/get_dashboard_locator_params.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { isFilterPinned, type Query } from '@kbn/es-query'; -import type { HasParentApi, PublishesUnifiedSearch } from '@kbn/presentation-publishing'; -import type { DashboardNavigationOptions } from '../../server'; -import type { DashboardLocatorParams } from '../types'; - -/** - * Extracts dashboard locator parameters from an embeddable API based on drilldown options. - * This function collects query, time range, and filters from the embeddable and its parent - * based on the provided drilldown options. - * - * @param api - The embeddable API that may publish unified search state and have a parent API. - * @param options - The drilldown options that control which parameters to include. - * @returns A partial {@link DashboardLocatorParams} object containing the extracted parameters. - */ -export const getDashboardLocatorParamsFromEmbeddable = ( - api: Partial>>, - options: DashboardNavigationOptions -): Partial => { - const params: DashboardLocatorParams = {}; - - const query = api.parentApi?.query$?.value; - if (query && options.use_filters) { - params.query = query as Query; - } - - // if useCurrentDashboardDataRange is enabled, then preserve current time range - // if undefined is passed, then destination dashboard will figure out time range itself - // for brush event this time range would be overwritten - const timeRange = api.timeRange$?.value ?? api.parentApi?.timeRange$?.value; - if (timeRange && options.use_time_range) { - params.time_range = timeRange; - } - - // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls) - // otherwise preserve only pinned - const filters = api.parentApi?.filters$?.value ?? []; - params.filters = options.use_filters ? filters : filters?.filter((f) => isFilterPinned(f)); - - return params; -}; diff --git a/src/platform/plugins/shared/dashboard/common/page_bundle_constants.ts b/src/platform/plugins/shared/dashboard/common/page_bundle_constants.ts index 4bb407030eaee..9dac1447fd8ca 100644 --- a/src/platform/plugins/shared/dashboard/common/page_bundle_constants.ts +++ b/src/platform/plugins/shared/dashboard/common/page_bundle_constants.ts @@ -7,6 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { + APPLY_FILTER_TRIGGER, + IMAGE_CLICK_TRIGGER, +} from '@kbn/ui-actions-plugin/common/trigger_ids'; + export const LANDING_PAGE_PATH = '/list'; /** The application ID for the Dashboard app. */ export const DASHBOARD_APP_ID = 'dashboards'; @@ -22,3 +27,5 @@ export const DEFAULT_DASHBOARD_NAVIGATION_OPTIONS = { // Do not change constant value - part of dashboard REST API export const DASHBOARD_DRILLDOWN_TYPE = 'dashboard_drilldown'; + +export const DASHBOARD_DRILLDOWN_SUPPORTED_TRIGGERS = [APPLY_FILTER_TRIGGER, IMAGE_CLICK_TRIGGER]; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/dashboard_drilldown.ts b/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/dashboard_drilldown.ts new file mode 100644 index 0000000000000..e8611f39be194 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/dashboard_drilldown.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; +import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import type { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; +import { isFilterPinned } from '@kbn/es-query'; +import type { DrilldownDefinition } from '@kbn/embeddable-plugin/public/drilldowns/types'; +import type { DashboardDrilldownState } from '../../server/dashboard_drilldown/types'; +import { coreServices } from '../services/kibana_services'; +import { getLocation } from './get_location'; +import { cleanEmptyKeys } from '../../common/locator/locator'; +import { + DASHBOARD_DRILLDOWN_SUPPORTED_TRIGGERS, + DEFAULT_DASHBOARD_NAVIGATION_OPTIONS, +} from '../../common/page_bundle_constants'; +import { DashboardDrilldownEditor } from './editor'; + +export const dashboardDrilldown: DrilldownDefinition< + DashboardDrilldownState, + ApplyGlobalFilterActionContext +> = { + displayName: i18n.translate('dashboard.drilldown.goToDashboard', { + defaultMessage: 'Go to Dashboard', + }), + euiIcon: 'dashboardApp', + supportedTriggers: DASHBOARD_DRILLDOWN_SUPPORTED_TRIGGERS, + action: { + execute: async ( + drilldownState: DashboardDrilldownState, + context: ApplyGlobalFilterActionContext + ) => { + if (drilldownState.open_in_new_tab) { + window.open(await getHref(drilldownState, context), '_blank'); + } else { + const { app, path, state } = await getLocation(drilldownState, context); + await coreServices.application.navigateToApp(app, { path, state }); + } + }, + getHref, + }, + setup: { + Editor: DashboardDrilldownEditor, + getInitialState: () => ({ + ...DEFAULT_DASHBOARD_NAVIGATION_OPTIONS, + }), + isStateValid: (state: Partial) => { + return Boolean(state.dashboard_id); + }, + order: 100, + }, +}; + +async function getHref( + drilldownState: DashboardDrilldownState, + context: ApplyGlobalFilterActionContext +) { + const { app, path, state } = await getLocation(drilldownState, context); + const url = await coreServices.application.getUrlForApp(app, { + path: setStateToKbnUrl( + '_a', + cleanEmptyKeys({ + query: state.query, + filters: state.filters?.filter((f) => !isFilterPinned(f)), + }), + { useHash: false, storeInHashQuery: true }, + path + ), + absolute: true, + }); + return url; +} diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/editor.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/editor.tsx new file mode 100644 index 0000000000000..28a950837453d --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/editor.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiFormRow, EuiComboBox, EuiSkeletonText } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import type { DrilldownEditorProps } from '@kbn/embeddable-plugin/public'; +import useDebounce from 'react-use/lib/useDebounce'; +import type { DashboardDrilldownState } from '../../server'; +import { findService } from '../dashboard_client'; +import { DEFAULT_DASHBOARD_NAVIGATION_OPTIONS } from '../../common/page_bundle_constants'; +import { DashboardNavigationOptionsEditor } from '../dashboard_navigation/options_editor'; + +export const DashboardDrilldownEditor = (props: DrilldownEditorProps) => { + const [options, setOptions] = useState>>([]); + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + const [initialDashboardOption, setInitialDashboardOption] = useState< + EuiComboBoxOptionOption | undefined + >(); + const [isLoadingInitialDashboard, setIsLoadingInitialDashboard] = useState(false); + const [error, setError] = useState(); + const [searchString, setSearchString] = useState(); + const [debouncedSearchString, setDebouncedSearchString] = useState(); + + useDebounce( + () => { + setDebouncedSearchString(searchString); + }, + 500, + [searchString] + ); + + useEffect(() => { + let canceled = false; + setIsLoadingOptions(true); + + findService + .search({ + search: debouncedSearchString ?? '', + per_page: 100, + }) + .then((results) => { + if (canceled) { + return; + } + + setOptions( + results.dashboards.map(({ id, data }) => ({ + value: id, + label: data.title, + })) + ); + setIsLoadingOptions(false); + }); + + return () => { + canceled = true; + }; + }, [debouncedSearchString]); + + useEffect(() => { + const initialDashboardId = props.state.dashboard_id; + if (!initialDashboardId) { + return; + } + + let canceled = false; + setIsLoadingInitialDashboard(true); + findService.findById(initialDashboardId).then((result) => { + if (canceled) { + return; + } + setIsLoadingInitialDashboard(false); + // handle case when destination dashboard no longer exists + if (result.status === 'error') { + setError( + result.notFound + ? i18n.translate('dashboard.drilldown.errorDestinationDashboardIsMissing', { + defaultMessage: + "Destination dashboard (''{dashboardId}'') no longer exists. Choose another dashboard.", + values: { + dashboardId: initialDashboardId, + }, + }) + : result.error.message + ); + props.onChange({ ...props.state, dashboard_id: undefined }); + return; + } + + setInitialDashboardOption({ + value: initialDashboardId, + label: result.attributes.title, + }); + }); + + return () => { + canceled = true; + }; + + // run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const mergedOptions = useMemo(() => { + if (!initialDashboardOption || initialDashboardOption.value !== props.state.dashboard_id) { + return options; + } + + const hasInitialDashboard = options.some(({ value }) => value === initialDashboardOption.value); + return hasInitialDashboard ? options : [initialDashboardOption, ...options]; + }, [initialDashboardOption, options, props.state]); + + const selectedOptions = useMemo(() => { + if (!props.state.dashboard_id) { + return undefined; + } + const selectedOption = mergedOptions.find(({ value }) => value === props.state.dashboard_id); + return selectedOption ? [selectedOption] : undefined; + }, [mergedOptions, props.state.dashboard_id]); + + const navigationOptions = useMemo(() => { + return { + ...DEFAULT_DASHBOARD_NAVIGATION_OPTIONS, + ...props.state, + }; + }, [props.state]); + + return ( + + + + async + selectedOptions={selectedOptions} + options={mergedOptions} + onChange={(nextSelectedOptions) => { + props.onChange({ ...props.state, dashboard_id: nextSelectedOptions?.[0]?.value }); + if (error) { + setError(undefined); + } + }} + onSearchChange={setSearchString} + isLoading={isLoadingOptions} + singleSelection={{ asPlainText: true }} + fullWidth + data-test-subj={'dashboardDrilldownSelectDashboard'} + isInvalid={!!error} + /> + + { + props.onChange({ + ...props.state, + ...changedState, + }); + }} + /> + + ); +}; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/get_location.ts b/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/get_location.ts new file mode 100644 index 0000000000000..3474c52b5af0f --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_drilldown/get_location.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { extractTimeRange, isFilterPinned, type Query } from '@kbn/es-query'; +import type { HasParentApi, PublishesUnifiedSearch } from '@kbn/presentation-publishing'; +import type { KibanaLocation } from '@kbn/share-plugin/public'; +import type { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; +import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; +import type { DashboardLocatorParams } from '../../common'; +import type { DashboardDrilldownState } from '../../server/dashboard_drilldown/types'; +import { shareService } from '../services/kibana_services'; + +export async function getLocation( + drilldownState: DashboardDrilldownState, + context: ApplyGlobalFilterActionContext +): Promise> { + const params: DashboardLocatorParams = { dashboardId: drilldownState.dashboard_id }; + + if (context.embeddable) { + const embeddableApi = context.embeddable as Partial< + PublishesUnifiedSearch & HasParentApi> + >; + const query = embeddableApi.parentApi?.query$?.value; + if (query && drilldownState.use_filters) { + params.query = query as Query; + } + const timeRange = embeddableApi.timeRange$?.value ?? embeddableApi.parentApi?.timeRange$?.value; + if (timeRange && drilldownState.use_time_range) { + params.time_range = timeRange; + } + const filters = embeddableApi.parentApi?.filters$?.value ?? []; + params.filters = drilldownState.use_filters + ? filters + : filters?.filter((f) => isFilterPinned(f)); + } + + /** Get event params */ + const { restOfFilters: filtersFromEvent, timeRange: timeRangeFromEvent } = extractTimeRange( + context.filters, + context.timeFieldName + ); + + if (filtersFromEvent) { + params.filters = [...(params.filters ?? []), ...filtersFromEvent]; + } + + if (timeRangeFromEvent) { + params.time_range = timeRangeFromEvent; + } + + const locator = shareService?.url.locators.get(DASHBOARD_APP_LOCATOR); + if (!locator) throw new Error('Dashboard locator is required for dashboard drilldown.'); + return locator.getLocation(params); +} diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/dashboard_module.ts b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/dashboard_module.ts index 36fee6c2e6a30..941380f3119e3 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/dashboard_module.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/dashboard_module.ts @@ -19,3 +19,4 @@ export { AddToLibraryAction } from '../dashboard_actions/library_add_action'; export { UnlinkFromLibraryAction } from '../dashboard_actions/library_unlink_action'; export { CopyToDashboardAction } from '../dashboard_actions/copy_to_dashboard_action'; export { AddSectionAction } from '../dashboard_actions/add_section_action'; +export { dashboardDrilldown } from '../dashboard_drilldown/dashboard_drilldown'; diff --git a/src/platform/plugins/shared/dashboard/public/index.ts b/src/platform/plugins/shared/dashboard/public/index.ts index aba597813331d..6220fdc3a553e 100644 --- a/src/platform/plugins/shared/dashboard/public/index.ts +++ b/src/platform/plugins/shared/dashboard/public/index.ts @@ -34,7 +34,6 @@ export { DEFAULT_DASHBOARD_NAVIGATION_OPTIONS, } from '../common/page_bundle_constants'; export { cleanEmptyKeys, DashboardAppLocatorDefinition } from '../common/locator/locator'; -export { getDashboardLocatorParamsFromEmbeddable } from '../common/locator/get_dashboard_locator_params'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/platform/plugins/shared/dashboard/public/plugin.tsx b/src/platform/plugins/shared/dashboard/public/plugin.tsx index 2452c320a3aa7..396c9cae59b58 100644 --- a/src/platform/plugins/shared/dashboard/public/plugin.tsx +++ b/src/platform/plugins/shared/dashboard/public/plugin.tsx @@ -68,6 +68,7 @@ import type { DashboardMountContextProps } from './dashboard_app/types'; import type { DashboardListingTab } from './dashboard_listing/types'; import { DASHBOARD_APP_ID, + DASHBOARD_DRILLDOWN_TYPE, LANDING_PAGE_PATH, SEARCH_SESSION_ID, } from '../common/page_bundle_constants'; @@ -154,7 +155,7 @@ export class DashboardPlugin public setup( core: CoreSetup, - { share, home, data, urlForwarding }: DashboardSetupDependencies + { embeddable, share, home, data, urlForwarding }: DashboardSetupDependencies ): DashboardSetup { core.analytics.registerEventType({ eventType: 'dashboard_loaded_with_data', @@ -302,6 +303,11 @@ export class DashboardPlugin }); } + embeddable.registerDrilldown(DASHBOARD_DRILLDOWN_TYPE, async () => { + const { dashboardDrilldown } = await import('./dashboard_renderer/dashboard_module'); + return dashboardDrilldown; + }); + return { registerListingPageTab: (tab: DashboardListingTab) => { this.listingViewRegistry.add(tab); diff --git a/src/platform/plugins/shared/dashboard/server/api/scope_tooling.test.ts b/src/platform/plugins/shared/dashboard/server/api/scope_tooling.test.ts index 7659bce086ea2..6e359278522c5 100644 --- a/src/platform/plugins/shared/dashboard/server/api/scope_tooling.test.ts +++ b/src/platform/plugins/shared/dashboard/server/api/scope_tooling.test.ts @@ -9,7 +9,6 @@ import { schema } from '@kbn/config-schema'; import { stripUnmappedKeys, throwOnUnmappedKeys } from './scope_tooling'; -import type { DashboardState } from './types'; const mockGetTransforms = jest.fn(); @@ -248,24 +247,6 @@ describe('stripUnmappedKeys', () => { } `); }); - - it('should drop pinned_panels', () => { - const dashboardState = { - pinned_panels: {} as unknown as DashboardState['pinned_panels'], - title: 'my dashboard', - }; - expect(stripUnmappedKeys(dashboardState)).toMatchInlineSnapshot(` - Object { - "data": Object { - "panels": Array [], - "title": "my dashboard", - }, - "warnings": Array [ - "Dropped unmapped key 'pinned_panels' from dashboard", - ], - } - `); - }); }); describe('throwOnUnmappedKeys', () => { @@ -343,12 +324,4 @@ describe('throwOnUnmappedKeys', () => { }; expect(() => throwOnUnmappedKeys(dashboardState)).toThrow(); }); - - it('should throw when dashboard contains pinned_panels', () => { - const dashboardState = { - pinned_panels: {} as unknown as DashboardState['pinned_panels'], - title: 'my dashboard', - }; - expect(() => throwOnUnmappedKeys(dashboardState)).toThrow(); - }); }); diff --git a/src/platform/plugins/shared/dashboard/server/api/scope_tooling.ts b/src/platform/plugins/shared/dashboard/server/api/scope_tooling.ts index fc0e72dd23844..80612d537983c 100644 --- a/src/platform/plugins/shared/dashboard/server/api/scope_tooling.ts +++ b/src/platform/plugins/shared/dashboard/server/api/scope_tooling.ts @@ -9,16 +9,24 @@ import { isDashboardSection } from '../../common'; import { embeddableService } from '../kibana_services'; -import type { DashboardPanel, DashboardState } from './types'; +import type { + DashboardPanel, + DashboardState, + DashboardPinnedPanel, + DashboardSection, +} from './types'; + +function isPinnedPanel( + panel: DashboardPanel | DashboardPinnedPanel | DashboardSection +): panel is DashboardPinnedPanel { + return !('grid' in panel); +} export function stripUnmappedKeys(dashboardState: DashboardState) { const warnings: string[] = []; const { pinned_panels, panels, ...rest } = dashboardState; - if (pinned_panels) { - warnings.push(`Dropped unmapped key 'pinned_panels' from dashboard`); - } - function isMappedPanelType(panel: DashboardPanel) { + function isMappedPanelType(panel: DashboardPanel | DashboardPinnedPanel) { const transforms = embeddableService?.getTransforms(panel.type); if (transforms?.throwOnUnmappedPanel) { try { @@ -58,9 +66,10 @@ export function stripUnmappedKeys(dashboardState: DashboardState) { }; } - const mappedPanels = (panels ?? []) + const mappedPanels = [...(panels ?? []), ...(pinned_panels ?? [])] .filter((panel) => isDashboardSection(panel) || isMappedPanelType(panel)) .map((panel) => { + if (isPinnedPanel(panel)) return panel; if (!isDashboardSection(panel)) return removeEnhancements(panel); const { panels: sectionPanels, ...restOfSection } = panel; return { @@ -79,11 +88,7 @@ export function stripUnmappedKeys(dashboardState: DashboardState) { } export function throwOnUnmappedKeys(dashboardState: DashboardState) { - if (dashboardState.pinned_panels) { - throw new Error('pinned_panels key is not supported by dashboard REST endpoints.'); - } - - function throwOnUnmappedPanelKeys(panel: DashboardPanel) { + function throwOnUnmappedPanelKeys(panel: DashboardPanel | DashboardPinnedPanel) { const transforms = embeddableService?.getTransforms(panel.type); const panelSchema = transforms?.schema; @@ -100,7 +105,7 @@ export function throwOnUnmappedKeys(dashboardState: DashboardState) { } } - dashboardState.panels?.forEach((panel) => { + [...(dashboardState.panels ?? []), ...(dashboardState.pinned_panels ?? [])].forEach((panel) => { if (isDashboardSection(panel)) { panel.panels.forEach(throwOnUnmappedPanelKeys); } else { diff --git a/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/register_dashboard_drilldown.ts b/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/register_dashboard_drilldown.ts index 561d7a30e6c86..86a2be38111aa 100644 --- a/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/register_dashboard_drilldown.ts +++ b/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/register_dashboard_drilldown.ts @@ -9,20 +9,19 @@ import type { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; import { - APPLY_FILTER_TRIGGER, - IMAGE_CLICK_TRIGGER, -} from '@kbn/ui-actions-plugin/common/trigger_ids'; -import { DASHBOARD_DRILLDOWN_TYPE } from '../../common/page_bundle_constants'; + DASHBOARD_DRILLDOWN_SUPPORTED_TRIGGERS, + DASHBOARD_DRILLDOWN_TYPE, +} from '../../common/page_bundle_constants'; import { transformIn, transformOut } from './transforms'; import { dashboardDrilldownSchema } from './schemas'; -import type { DashboardDrilldown, StoredDashboardDrilldown } from './types'; +import type { DashboardDrilldownState, StoredDashboardDrilldownState } from './types'; export function registerDashboardDrilldown(embeddableSetup: EmbeddableSetup) { - embeddableSetup.registerDrilldown( + embeddableSetup.registerDrilldown( DASHBOARD_DRILLDOWN_TYPE, { schema: dashboardDrilldownSchema, - supportedTriggers: [APPLY_FILTER_TRIGGER, IMAGE_CLICK_TRIGGER], + supportedTriggers: DASHBOARD_DRILLDOWN_SUPPORTED_TRIGGERS, transformIn, transformOut, } diff --git a/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/transforms.ts b/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/transforms.ts index ba4d16801ffae..003855c4a096a 100644 --- a/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/transforms.ts +++ b/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/transforms.ts @@ -8,11 +8,11 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import type { DashboardDrilldown, StoredDashboardDrilldown } from './types'; +import type { DashboardDrilldownState, StoredDashboardDrilldownState } from './types'; import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../common/constants'; -export function transformIn(state: DashboardDrilldown): { - state: StoredDashboardDrilldown; +export function transformIn(state: DashboardDrilldownState): { + state: StoredDashboardDrilldownState; references?: Reference[]; } { const { dashboard_id, ...rest } = state; @@ -32,9 +32,9 @@ export function transformIn(state: DashboardDrilldown): { } export function transformOut( - storedState: StoredDashboardDrilldown, + storedState: StoredDashboardDrilldownState, references?: Reference[] -): DashboardDrilldown { +): DashboardDrilldownState { const { dashboardRefName, ...rest } = storedState; const reference = references?.find(({ name }) => name === dashboardRefName); return { diff --git a/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/types.ts b/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/types.ts index cd95e1d498f65..2663cf5814a6a 100644 --- a/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/types.ts +++ b/src/platform/plugins/shared/dashboard/server/dashboard_drilldown/types.ts @@ -11,8 +11,8 @@ import type { DrilldownState } from '@kbn/embeddable-plugin/server'; import type { TypeOf } from '@kbn/config-schema'; import type { dashboardDrilldownSchema } from './schemas'; -export type DashboardDrilldown = DrilldownState & TypeOf; +export type DashboardDrilldownState = DrilldownState & TypeOf; -export type StoredDashboardDrilldown = Omit & { +export type StoredDashboardDrilldownState = Omit & { dashboardRefName: string; }; diff --git a/src/platform/plugins/shared/dashboard/server/index.ts b/src/platform/plugins/shared/dashboard/server/index.ts index 96c67b4d53cf7..d3c35b6a6f335 100644 --- a/src/platform/plugins/shared/dashboard/server/index.ts +++ b/src/platform/plugins/shared/dashboard/server/index.ts @@ -58,6 +58,7 @@ export type { DashboardUpdateResponseBody, GridData, } from './api'; +export type { DashboardDrilldownState } from './dashboard_drilldown/types'; export type { DashboardSavedObjectAttributes, SavedDashboardPanel } from './dashboard_saved_object'; export type { ScanDashboardsResult } from './scan_dashboards'; export { diff --git a/src/platform/plugins/shared/dashboard/test/scout/.meta/api/standard.json b/src/platform/plugins/shared/dashboard/test/scout/.meta/api/standard.json index b1643ab0441f4..cf2a9a748e052 100644 --- a/src/platform/plugins/shared/dashboard/test/scout/.meta/api/standard.json +++ b/src/platform/plugins/shared/dashboard/test/scout/.meta/api/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-09T13:45:46.419Z", - "sha1": "63686bda12a3b15365bd180d5238b3f9426e2568", + "sha1": "d6811786686af1eef33d3cb7b87bf18dfcd73a60", "tests": [ { "id": "65fe2f541b6ad11-864f48536870f42", @@ -8,11 +7,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/create_dashboard.spec.ts", - "line": 34, + "line": 35, "column": 10 } }, @@ -22,11 +33,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/create_dashboard.spec.ts", - "line": 57, + "line": 58, "column": 10 } }, @@ -36,11 +59,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/create_dashboard.spec.ts", - "line": 80, + "line": 81, "column": 10 } }, @@ -50,11 +85,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/create_dashboard.spec.ts", - "line": 102, + "line": 103, "column": 10 } }, @@ -64,11 +111,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/create_dashboard.spec.ts", - "line": 123, + "line": 124, "column": 10 } }, @@ -78,11 +137,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/create_dashboard.spec.ts", - "line": 141, + "line": 142, "column": 10 } }, @@ -92,11 +163,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/delete_dashboard.spec.ts", - "line": 33, + "line": 34, "column": 10 } }, @@ -106,11 +189,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/delete_dashboard.spec.ts", - "line": 49, + "line": 50, "column": 10 } }, @@ -276,11 +371,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/update_dashboard.spec.ts", - "line": 34, + "line": 35, "column": 10 } }, @@ -290,11 +397,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/update_dashboard.spec.ts", - "line": 53, + "line": 54, "column": 10 } }, @@ -304,11 +423,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/update_dashboard.spec.ts", - "line": 74, + "line": 75, "column": 10 } }, @@ -318,11 +449,23 @@ "expectedStatus": "passed", "tags": [ "@local-stateful-classic", - "@cloud-stateful-classic" + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" ], "location": { "file": "src/platform/plugins/shared/dashboard/test/scout/api/tests/update_dashboard.spec.ts", - "line": 92, + "line": 93, "column": 10 } } diff --git a/src/platform/plugins/shared/dashboard/test/scout/.meta/ui/parallel.json b/src/platform/plugins/shared/dashboard/test/scout/.meta/ui/parallel.json index 87dc536c6c949..b3710083afe8b 100644 --- a/src/platform/plugins/shared/dashboard/test/scout/.meta/ui/parallel.json +++ b/src/platform/plugins/shared/dashboard/test/scout/.meta/ui/parallel.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T13:22:18.539Z", "sha1": "3d17d3aa178a363f56054bbd375d59fb40d20a46", "tests": [ { diff --git a/src/platform/plugins/shared/dashboard/test/scout_oas_schema/.meta/api/standard.json b/src/platform/plugins/shared/dashboard/test/scout_oas_schema/.meta/api/standard.json index 3c924dfaf3ee5..6f60107cc145b 100644 --- a/src/platform/plugins/shared/dashboard/test/scout_oas_schema/.meta/api/standard.json +++ b/src/platform/plugins/shared/dashboard/test/scout_oas_schema/.meta/api/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T13:45:48.929Z", "sha1": "218ebe75d210c18ad105f4508e298458e1a22b9f", "tests": [ { diff --git a/src/platform/plugins/shared/dashboard_markdown/public/markdown_embeddable.test.tsx b/src/platform/plugins/shared/dashboard_markdown/public/markdown_embeddable.test.tsx index cfe1adb3b1082..0d7b85d8ae84b 100644 --- a/src/platform/plugins/shared/dashboard_markdown/public/markdown_embeddable.test.tsx +++ b/src/platform/plugins/shared/dashboard_markdown/public/markdown_embeddable.test.tsx @@ -31,6 +31,7 @@ const renderEmbeddable = async ( const factory = markdownEmbeddableFactory; const embeddable = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { content: '[click here](https://example.com)', }, diff --git a/src/platform/plugins/shared/data/public/query/filter_manager/lib/get_display_value.ts b/src/platform/plugins/shared/data/public/query/filter_manager/lib/get_display_value.ts index 90278bad5fb73..f4a1da093ef1f 100644 --- a/src/platform/plugins/shared/data/public/query/filter_manager/lib/get_display_value.ts +++ b/src/platform/plugins/shared/data/public/query/filter_manager/lib/get_display_value.ts @@ -70,5 +70,5 @@ export function getDisplayValueFromFilter(filter: Filter, indexPatterns: DataVie return getPhrasesDisplayValue(filter, valueFormatter); } else if (isRangeFilter(filter) || isScriptedRangeFilter(filter)) { return getRangeDisplayValue(filter, valueFormatter); - } else return filter.meta.value ?? ''; + } else return String(filter.meta.value ?? ''); } diff --git a/src/platform/plugins/shared/data/server/query/route_types.ts b/src/platform/plugins/shared/data/server/query/route_types.ts index 646b0ee54f305..c60e8ce8707de 100644 --- a/src/platform/plugins/shared/data/server/query/route_types.ts +++ b/src/platform/plugins/shared/data/server/query/route_types.ts @@ -89,7 +89,7 @@ type FilterMetaRestResponse = { type?: string; key?: string; params?: FilterMetaParamsRestResponse; - value?: string; + value?: string | RangeFilterParamsRestResponse | PhraseFilterValue[]; }; type FilterStateStoreRestResponse = 'appState' | 'globalState'; diff --git a/src/platform/plugins/shared/data/server/search/routes/response_schema.ts b/src/platform/plugins/shared/data/server/search/routes/response_schema.ts index 7f54ca4fff15a..b4757bf35722c 100644 --- a/src/platform/plugins/shared/data/server/search/routes/response_schema.ts +++ b/src/platform/plugins/shared/data/server/search/routes/response_schema.ts @@ -15,6 +15,12 @@ const searchSessionRequestInfoSchema = schema.object({ status: schema.maybe(schema.string()), startedAt: schema.maybe(schema.string()), completedAt: schema.maybe(schema.string()), + error: schema.maybe( + schema.object({ + code: schema.number(), + message: schema.maybe(schema.string()), + }) + ), }); const serializeableSchema = schema.mapOf(schema.string(), schema.any()); @@ -50,7 +56,7 @@ const status = schema.object({ schema.literal('cancelled'), schema.literal('expired'), ]), - errors: schema.maybe(schema.arrayOf(schema.string())), + errors: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 10000 })), }); export const searchSessionStatusSchema = status; @@ -61,12 +67,13 @@ export const searchSessionStatusesSchema = schema.object({ export const searchSessionsFindSchema = schema.object({ total: schema.number(), - saved_objects: schema.arrayOf(searchSessionSchema), + saved_objects: schema.arrayOf(searchSessionSchema, { maxSize: 10000 }), statuses: schema.recordOf(schema.string(), searchSessionStatusSchema), }); const referencesSchema = schema.arrayOf( - schema.object({ id: schema.string(), type: schema.string(), name: schema.string() }) + schema.object({ id: schema.string(), type: schema.string(), name: schema.string() }), + { maxSize: 10 } ); export const searchSessionsUpdateSchema = schema.object({ @@ -75,7 +82,8 @@ export const searchSessionsUpdateSchema = schema.object({ updated_at: schema.maybe(schema.string()), updated_by: schema.maybe(schema.string()), version: schema.maybe(schema.string()), - namespaces: schema.maybe(schema.arrayOf(schema.string())), + // The search-sessions saved object definition specifies that the namespaces are 'single', that means only one space is allowed. + namespaces: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 1 })), references: schema.maybe(referencesSchema), attributes: schema.object({ name: schema.maybe(schema.string()), diff --git a/src/platform/plugins/shared/data/server/search/routes/session.ts b/src/platform/plugins/shared/data/server/search/routes/session.ts index 069df2cc114e5..406c9032aeef1 100644 --- a/src/platform/plugins/shared/data/server/search/routes/session.ts +++ b/src/platform/plugins/shared/data/server/search/routes/session.ts @@ -196,7 +196,7 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): schema.oneOf([schema.literal('desc'), schema.literal('asc')]) ), filter: schema.maybe(schema.string()), - searchFields: schema.maybe(schema.arrayOf(schema.string())), + searchFields: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 10 })), search: schema.maybe(schema.string()), }), }, @@ -412,7 +412,10 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): validate: { request: { body: schema.object({ - sessionIds: schema.arrayOf(schema.string()), + // When a search is sent to the background the client starts polling for its status. All the searches that + // are in progress are sent to the server to get their updated status until they are in a completed status. + // For this endpoint the assumption is that the client won't have more than 100 searches in progress at the same time. + sessionIds: schema.arrayOf(schema.string(), { maxSize: 100 }), }), }, response: { diff --git a/src/platform/plugins/shared/data_views/test/scout/.meta/api/standard.json b/src/platform/plugins/shared/data_views/test/scout/.meta/api/standard.json new file mode 100644 index 0000000000000..83b0cec5a7768 --- /dev/null +++ b/src/platform/plugins/shared/data_views/test/scout/.meta/api/standard.json @@ -0,0 +1,1097 @@ +{ + "sha1": "1c1ae65620ad13f4bbe3529c79a00da12e67fa1e", + "tests": [ + { + "id": "6a1a7d28753a6bb-37b3571f127ad93", + "title": "POST api/data_views/data_view - main (data view api) can create a data view with just a title", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 42, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-7f59f3b1de5f7d7", + "title": "POST api/data_views/data_view - main (data view api) returns back the created data view object", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 63, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-4ba1ae4b834268f", + "title": "POST api/data_views/data_view - main (data view api) can specify primitive optional attributes when creating a data view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 85, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-e1218d053bb49c1", + "title": "POST api/data_views/data_view - main (data view api) can specify optional sourceFilters attribute when creating a data view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 115, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-bf9eee448b1c7c5", + "title": "POST api/data_views/data_view - main (data view api) can specify optional fieldFormats attribute when creating a data view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 144, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-7b800df61936e84", + "title": "POST api/data_views/data_view - main (data view api) can create data view with empty title", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 174, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-e1c60040870223d", + "title": "POST api/data_views/data_view - main (data view api) can specify optional fields attribute when creating a data view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 194, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-0cccba772c9ad5e", + "title": "POST api/data_views/data_view - main (data view api) can add fields created from es index", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 229, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-6d001d5905a8b59", + "title": "POST api/data_views/data_view - main (data view api) can add runtime fields", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 263, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-141dc05495cc6ee", + "title": "POST api/data_views/data_view - main (data view api) can specify optional typeMeta attribute when creating a data view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 296, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-c9e97df68b2d59d", + "title": "POST api/data_views/data_view - main (data view api) can specify optional fieldAttrs attribute with count and label when creating a data view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 320, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-f96408c10573b04", + "title": "POST api/data_views/data_view - main (data view api) when creating data view with existing name returns error by default", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 350, + "column": 12 + } + }, + { + "id": "6a1a7d28753a6bb-a4c4420be17ed27", + "title": "POST api/data_views/data_view - main (data view api) when creating data view with existing name succeeds if override flag is set", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_main.spec.ts", + "line": 387, + "column": 12 + } + }, + { + "id": "a29e3cf08ff83ef-608d1d3b285e08d", + "title": "POST /api/data_views/data_view - spaces/namespaces can specify optional namespaces array when creating a data view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_spaces.spec.ts", + "line": 59, + "column": 12 + } + }, + { + "id": "a29e3cf08ff83ef-7b0b0a6e3d06397", + "title": "POST /api/data_views/data_view - spaces/namespaces sets namespaces to the current space if namespaces array is not specified", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_spaces.spec.ts", + "line": 100, + "column": 12 + } + }, + { + "id": "6d1d8562721e403-cd95ddf762ce925", + "title": "POST api/data_views/data_view - validation (data view api) returns error when data_view object is not provided", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_validation.spec.ts", + "line": 24, + "column": 12 + } + }, + { + "id": "6d1d8562721e403-d34f990195b5355", + "title": "POST api/data_views/data_view - validation (data view api) returns error on empty data_view object", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_validation.spec.ts", + "line": 41, + "column": 12 + } + }, + { + "id": "6d1d8562721e403-7bb331c0e244f7a", + "title": "POST api/data_views/data_view - validation (data view api) returns error when \"override\" parameter is not a boolean", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_validation.spec.ts", + "line": 60, + "column": 12 + } + }, + { + "id": "6d1d8562721e403-49067917a354335", + "title": "POST api/data_views/data_view - validation (data view api) returns error when \"refresh_fields\" parameter is not a boolean", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_validation.spec.ts", + "line": 82, + "column": 12 + } + }, + { + "id": "6d1d8562721e403-a6bbc74f44e4ca8", + "title": "POST api/data_views/data_view - validation (data view api) returns an error when unknown runtime field type", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_create_validation.spec.ts", + "line": 107, + "column": 12 + } + }, + { + "id": "0061bacf01679be-641fe5738c5968a", + "title": "DELETE api/data_views/data_view/{id} - errors (data view api) returns 404 error on non-existing data_view", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_delete_errors.spec.ts", + "line": 24, + "column": 12 + } + }, + { + "id": "0061bacf01679be-08157f27f86c3ff", + "title": "DELETE api/data_views/data_view/{id} - errors (data view api) returns error when ID is too long", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/data_views/crud_delete_errors.spec.ts", + "line": 37, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-b05dd4339134bfb", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can create an index_pattern with just a title", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 42, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-2ad3240da8ae1e3", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) returns back the created index_pattern object", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 63, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-267f307a7452247", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can specify primitive optional attributes when creating an index pattern", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 85, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-3d53761fe7f7900", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can specify optional sourceFilters attribute when creating an index pattern", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 115, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-994905d33f4a52d", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can specify optional fieldFormats attribute when creating an index pattern", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 144, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-8a8d256b70dff6a", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can create index pattern with empty title", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 174, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-83f369c3fdbc6d8", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can specify optional fields attribute when creating an index pattern", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 194, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-0cfbbd3fb305c91", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can add fields created from es index", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 229, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-ded64440f1032bc", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can add runtime fields", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 263, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-7e6ed96486c5e4f", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can specify optional typeMeta attribute when creating an index pattern", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 296, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-b4982402f483626", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) can specify optional fieldAttrs attribute with count and label when creating an index pattern", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 320, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-47f943649977d6b", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) when creating index pattern with existing name returns error by default", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 350, + "column": 12 + } + }, + { + "id": "88b7be0d387b0e8-75279bbdce7f787", + "title": "POST api/index_patterns/index_pattern - main (legacy index pattern api) when creating index pattern with existing name succeeds if override flag is set", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_main.spec.ts", + "line": 387, + "column": 12 + } + }, + { + "id": "0db2d1363beaefd-19f53b44503c438", + "title": "POST api/index_patterns/index_pattern - validation (legacy index pattern api) returns error when index_pattern object is not provided", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_validation.spec.ts", + "line": 28, + "column": 12 + } + }, + { + "id": "0db2d1363beaefd-41228ee1217cd21", + "title": "POST api/index_patterns/index_pattern - validation (legacy index pattern api) returns error on empty index_pattern object", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_validation.spec.ts", + "line": 48, + "column": 12 + } + }, + { + "id": "0db2d1363beaefd-20917d7c92a8678", + "title": "POST api/index_patterns/index_pattern - validation (legacy index pattern api) returns error when \"override\" parameter is not a boolean", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_validation.spec.ts", + "line": 67, + "column": 12 + } + }, + { + "id": "0db2d1363beaefd-eb609d7c167242d", + "title": "POST api/index_patterns/index_pattern - validation (legacy index pattern api) returns error when \"refresh_fields\" parameter is not a boolean", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_validation.spec.ts", + "line": 89, + "column": 12 + } + }, + { + "id": "0db2d1363beaefd-8d42c8b431a03cb", + "title": "POST api/index_patterns/index_pattern - validation (legacy index pattern api) returns an error when unknown runtime field type", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_create_validation.spec.ts", + "line": 114, + "column": 12 + } + }, + { + "id": "78f82502a54c126-b74ae9ec996d38a", + "title": "DELETE api/index_patterns/index_pattern/{id} - errors (legacy index pattern api) returns 404 error on non-existing index_pattern", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_delete_errors.spec.ts", + "line": 24, + "column": 12 + } + }, + { + "id": "78f82502a54c126-e64a03851324463", + "title": "DELETE api/index_patterns/index_pattern/{id} - errors (legacy index pattern api) returns error when ID is too long", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/data_views/test/scout/api/tests/index_patterns/crud_delete_errors.spec.ts", + "line": 37, + "column": 12 + } + } + ] +} \ No newline at end of file diff --git a/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts b/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts index bac4daa26e39c..ff66dce2fd9b9 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts @@ -7,15 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { omit } from 'lodash'; import { SavedSearchType } from '@kbn/saved-search-plugin/common'; import type { SavedObjectReference } from '@kbn/core/server'; +import { extractReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import type { SearchEmbeddableByReferenceState, SearchEmbeddableState, StoredSearchEmbeddableState, } from './types'; -import { extract } from './search_inject_extract'; export const SAVED_SEARCH_SAVED_OBJECT_REF_NAME = 'savedObjectRef'; @@ -47,22 +48,38 @@ export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['trans } // by value - const { state: extractedState, references } = extract({ - type: SavedSearchType, - attributes: storedState.attributes, + const tabReferences: SavedObjectReference[] = []; + const tabs = storedState.attributes.tabs.map((tab) => { + try { + const searchSourceValues = parseSearchSourceJSON( + tab.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + const [searchSourceFields, searchSourceReferences] = extractReferences(searchSourceValues); + tabReferences.push(...searchSourceReferences); + return { + ...tab, + attributes: { + ...tab.attributes, + kibanaSavedObjectMeta: { + ...tab.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(searchSourceFields), + }, + }, + }; + } catch (e) { + return tab; + } }); return { state: { ...storedState, attributes: { - ...storedState.attributes, - ...extractedState.attributes, - // discover session stores references as part of attributes - references, + ...omit(storedState.attributes, 'references'), + tabs, }, }, - references: [...references, ...drilldownReferences], + references: [...tabReferences, ...drilldownReferences], }; } return transformIn; diff --git a/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts b/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts index 932dea7c1aef0..96f94d5f94dc1 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts @@ -7,18 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { flow, omit } from 'lodash'; import { extractTabs, SavedSearchType } from '@kbn/saved-search-plugin/common'; +import { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import type { SavedObjectReference } from '@kbn/core/server'; import { transformTitlesOut } from '@kbn/presentation-publishing'; -import { flow } from 'lodash'; import type { SearchEmbeddableByReferenceState, SearchEmbeddableByValueState, StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState, } from './types'; -import { inject } from './search_inject_extract'; import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './get_transform_in'; function isByValue( @@ -42,14 +42,34 @@ export function getTransformOut(transformDrilldownsOut: DrilldownTransforms['tra const state = transformsFlow(storedState); if (isByValue(state)) { - const tabsState = { - ...state, - attributes: extractTabs(state.attributes), - }; - const { attributes } = inject({ type: SavedSearchType, ...tabsState }, references ?? []); + const tabsState = { ...state, attributes: extractTabs(state.attributes) }; + const tabs = tabsState.attributes.tabs.map((tab) => { + try { + const searchSourceValues = parseSearchSourceJSON( + tab.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + const searchSourceFields = injectReferences(searchSourceValues, references ?? []); + return { + ...tab, + attributes: { + ...omit(tab.attributes, 'references'), + kibanaSavedObjectMeta: { + ...tab.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(searchSourceFields), + }, + }, + }; + } catch (e) { + return tab; + } + }); + return { ...state, - attributes, + attributes: { + ...state.attributes, + tabs, + }, } as SearchEmbeddableByValueState; } diff --git a/src/platform/plugins/shared/discover/common/embeddable/index.ts b/src/platform/plugins/shared/discover/common/embeddable/index.ts index 9e2055fcf02b9..b383982938a5c 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/index.ts @@ -7,5 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { inject, extract } from './search_inject_extract'; export { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; diff --git a/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts b/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts index ca15bb6b3afda..78140e4f0bca6 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts @@ -9,7 +9,6 @@ import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; -import { extract, inject } from './search_inject_extract'; import type { SearchEmbeddableByValueState, StoredSearchEmbeddableByValueState, @@ -18,13 +17,6 @@ import type { SearchEmbeddableState, } from './types'; -jest.mock('./search_inject_extract', () => { - return { - inject: jest.fn((state, references) => state), - extract: jest.fn((state) => ({ state, references: [] })), - }; -}); - const mockDrilldownTransforms = { transformIn: jest.fn().mockImplementation((state: SearchEmbeddableState) => ({ state, @@ -82,10 +74,6 @@ describe('searchEmbeddableTransforms', () => { state, references ); - expect(inject).toHaveBeenCalledWith( - { type: 'search', ...{ ...state, attributes: expectedAttributes } }, - references - ); expect(result).toEqual({ ...state, attributes: expectedAttributes, @@ -204,7 +192,6 @@ describe('searchEmbeddableTransforms', () => { searchSourceJSON: '{"query":{"match_all":{}}}', }, tabs: [], - references: [], }, title: 'Panel Title', }; @@ -212,10 +199,6 @@ describe('searchEmbeddableTransforms', () => { const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); - expect(extract).toHaveBeenCalledWith({ - type: 'search', - attributes: serializedState.attributes, - }); expect(result.state as StoredSearchEmbeddableByValueState).toEqual(serializedState); expect(result.references).toEqual([]); }); @@ -244,10 +227,6 @@ describe('searchEmbeddableTransforms', () => { expect(result.references).toEqual([]); expect(mockDrilldownTransforms.transformIn).toHaveBeenCalledWith(serializedState); - expect(extract).toHaveBeenCalledWith({ - type: 'search', - attributes: serializedState.attributes, - }); }); }); }); diff --git a/src/platform/plugins/shared/discover/common/embeddable/search_inject_extract.test.ts b/src/platform/plugins/shared/discover/common/embeddable/search_inject_extract.test.ts deleted file mode 100644 index aa0af9b0394f3..0000000000000 --- a/src/platform/plugins/shared/discover/common/embeddable/search_inject_extract.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/common'; -import { extract, inject } from './search_inject_extract'; - -describe('search inject extract', () => { - describe('inject', () => { - it('should not inject references if state does not have attributes', () => { - const state = { type: 'type', id: 'id' }; - const injectedReferences = [{ name: 'name', type: 'type', id: 'id' }]; - expect(inject(state, injectedReferences)).toEqual(state); - }); - - it('should inject references if state has references with the same name', () => { - const state = { - type: 'type', - id: 'id', - attributes: { - references: [{ name: 'name', type: 'type', id: '1' }], - }, - }; - const injectedReferences = [{ name: 'name', type: 'type', id: '2' }]; - expect(inject(state, injectedReferences)).toEqual({ - ...state, - attributes: { - ...state.attributes, - references: injectedReferences, - }, - }); - }); - - it('should clear references if state has no references with the same name', () => { - const state = { - type: 'type', - id: 'id', - attributes: { - references: [{ name: 'name', type: 'type', id: '1' }], - }, - }; - const injectedReferences = [{ name: 'other', type: 'type', id: '2' }]; - expect(inject(state, injectedReferences)).toEqual({ - ...state, - attributes: { - ...state.attributes, - references: [], - }, - }); - }); - }); - - describe('extract', () => { - it('should not extract references if state does not have attributes', () => { - const state = { type: 'type', id: 'id' }; - expect(extract(state)).toEqual({ state, references: [] }); - }); - - it('should extract references if state has references', () => { - const state = { - type: 'type', - id: 'id', - attributes: { - references: [{ name: 'name', type: 'type', id: '1' }], - } as SavedSearchByValueAttributes, - }; - expect(extract(state)).toEqual({ - state, - references: [{ name: 'name', type: 'type', id: '1' }], - }); - }); - }); -}); diff --git a/src/platform/plugins/shared/discover/common/embeddable/search_inject_extract.ts b/src/platform/plugins/shared/discover/common/embeddable/search_inject_extract.ts deleted file mode 100644 index b3ce3529a3585..0000000000000 --- a/src/platform/plugins/shared/discover/common/embeddable/search_inject_extract.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { WithRequiredProperty } from '@kbn/utility-types'; -import type { SavedObjectReference } from '@kbn/core-saved-objects-server'; -import type { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -import type { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/common'; - -type EmbeddableStateWithTypeAndAttributes = EmbeddableStateWithType & { - attributes?: SavedSearchByValueAttributes; -}; - -export const inject = ( - state: EmbeddableStateWithType, - injectedReferences: SavedObjectReference[] -): EmbeddableStateWithTypeAndAttributes => { - if (hasAttributes(state)) { - // Filter out references that are not in the state - // https://github.com/elastic/kibana/pull/119079 - const references = state.attributes.references - .map((stateRef) => - injectedReferences.find((injectedRef) => injectedRef.name === stateRef.name) - ) - .filter(Boolean); - - state = { - ...state, - attributes: { - ...state.attributes, - references, - }, - } as EmbeddableStateWithTypeAndAttributes; - } - - return state; -}; - -export const extract = ( - state: EmbeddableStateWithTypeAndAttributes -): { - state: EmbeddableStateWithTypeAndAttributes; - references: SavedObjectReference[]; -} => { - let references: SavedObjectReference[] = []; - - if (hasAttributes(state)) { - references = state.attributes.references; - } - - return { state, references }; -}; - -const hasAttributes = ( - state: EmbeddableStateWithType -): state is WithRequiredProperty => - 'attributes' in state; diff --git a/src/platform/plugins/shared/discover/common/embeddable/types.ts b/src/platform/plugins/shared/discover/common/embeddable/types.ts index 06788273257f3..d2c14aaa6bdfc 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/types.ts @@ -12,7 +12,7 @@ import type { SavedSearchAttributes, SavedSearchByValueAttributes, } from '@kbn/saved-search-plugin/common'; -import type { DrilldownsState } from '@kbn/embeddable-plugin/server'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { EDITABLE_SAVED_SEARCH_KEYS } from './constants'; // These are options that are not persisted in the saved object, but can be used by solutions @@ -29,7 +29,7 @@ export type EditableSavedSearchAttributes = Partial< type SearchEmbeddableBaseState = SerializedTitles & SerializedTimeRange & - DrilldownsState & + SerializedDrilldowns & EditableSavedSearchAttributes & { nonPersistedDisplayOptions?: NonPersistedDisplayOptions; }; diff --git a/src/platform/plugins/shared/discover/kibana.jsonc b/src/platform/plugins/shared/discover/kibana.jsonc index 3f0f4f26540ad..3ec6bcc794c90 100644 --- a/src/platform/plugins/shared/discover/kibana.jsonc +++ b/src/platform/plugins/shared/discover/kibana.jsonc @@ -46,7 +46,6 @@ "aiops", "fieldsMetadata", "logsDataAccess", - "embeddableEnhanced", "apmSourcesAccess", "fileUpload", "cps" diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 1ad1f28cdd481..d28a2299c3d64 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -64,7 +64,6 @@ dependsOn: - '@kbn/unified-field-list' - '@kbn/cell-actions' - '@kbn/shared-ux-utility' - - '@kbn/core-saved-objects-server' - '@kbn/discover-utils' - '@kbn/search-errors' - '@kbn/search-response-warnings' @@ -105,7 +104,6 @@ dependsOn: - '@kbn/esql-language' - '@kbn/discover-shared-plugin' - '@kbn/response-ops-rule-form' - - '@kbn/embeddable-enhanced-plugin' - '@kbn/shared-ux-page-analytics-no-data-types' - '@kbn/core-application-browser-mocks' - '@kbn/rison' diff --git a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.test.tsx index 04cc23912ca5f..3455fa44d8d84 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.test.tsx @@ -23,31 +23,46 @@ import { DiscoverGrid } from '../../../../components/discover_grid'; import { internalStateActions, selectTabRuntimeState } from '../../state_management/redux'; import { DiscoverToolkitTestProvider } from '../../../../__mocks__/test_provider'; import type { DiscoverServices } from '../../../../build_services'; +import { createEsqlDataSource } from '../../../../../common/data_sources'; const setup = async ({ services }: { services?: DiscoverServices } = {}) => { const toolkit = getDiscoverInternalStateMock({ services }); await toolkit.initializeTabs(); - const { customizationService } = await toolkit.initializeSingleTab({ - tabId: toolkit.getCurrentTab().id, - }); + await toolkit.initializeSingleTab({ tabId: toolkit.getCurrentTab().id }); - return { toolkit, customizationService }; + return { toolkit }; }; async function mountComponent({ fetchStatus, hits, toolkit, + isEsqlMode, }: { fetchStatus: FetchStatus; hits: EsHitRecord[]; toolkit?: InternalStateMockToolkit; + isEsqlMode?: boolean; }) { if (!toolkit) { ({ toolkit } = await setup()); } + if (isEsqlMode) { + toolkit.internalState.dispatch( + internalStateActions.updateAppState({ + tabId: toolkit.getCurrentTab().id, + appState: { + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }, + }) + ); + + await toolkit.waitForDataFetching({ tabId: toolkit.getCurrentTab().id }); + } + const stateContainer = selectTabRuntimeState( toolkit.runtimeStateManager, toolkit.getCurrentTab().id @@ -105,6 +120,26 @@ describe('Discover documents layout', () => { expect(findTestSubject(component, 'viewModeToggle').exists()).toBe(true); }); + test('ES|QL: render complete when partial and documents were already fetched', async () => { + const component = await mountComponent({ + fetchStatus: FetchStatus.PARTIAL, + hits: esHitsMock, + isEsqlMode: true, + }); + expect(component.find('.dscDocuments__loading').exists()).toBeFalsy(); + expect(component.find('.dscTable').exists()).toBeTruthy(); + }); + + test('ES|QL: render loading when partial and no documents', async () => { + const component = await mountComponent({ + fetchStatus: FetchStatus.PARTIAL, + hits: [], + isEsqlMode: true, + }); + expect(component.find('.dscDocuments__loading').exists()).toBeTruthy(); + expect(component.find('.dscTable').exists()).toBeFalsy(); + }); + test('should set rounded width to state on resize column', async () => { const { toolkit } = await setup(); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx index 94ff33b524d1c..cfd3a08f43194 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx @@ -170,8 +170,7 @@ function DiscoverDocumentsComponent({ // This solution switches to the loading state in this component when the URL index doesn't match the dataView.id const isDataViewLoading = useCurrentTabSelector((state) => state.isDataViewLoading) && !isEsqlMode; - const isEmptyDataResult = - isEsqlMode || !documentState.result || documentState.result.length === 0; + const isEmptyDataResult = !documentState.result || documentState.result.length === 0; const rows = useMemo(() => documentState.result || [], [documentState.result]); const { isMoreDataLoading, totalHits, onFetchMoreRecords } = useFetchMoreRecords({ diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx index b792f88bdd9ab..d2301c1c92e67 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx @@ -10,25 +10,33 @@ import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { ES_QUERY_ID } from '@kbn/rule-data-utils'; import { getAlertsAppMenuItem } from './get_alerts'; -import { discoverServiceMock } from '../../../../../__mocks__/services'; +import { createDiscoverServicesMock } from '../../../../../__mocks__/services'; import { dataViewWithTimefieldMock } from '../../../../../__mocks__/data_view_with_timefield'; import { dataViewWithNoTimefieldMock } from '../../../../../__mocks__/data_view_no_timefield'; -import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../../../../../__mocks__/discover_state.mock'; import type { AppMenuExtensionParams } from '../../../../../context_awareness'; import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; -import { internalStateActions } from '../../../state_management/redux'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { DiscoverServices } from '../../../../../build_services'; -const getAlertsMenuItem = ( +const getAlertsMenuItem = async ({ dataView = dataViewMock, isEsqlMode = false, - authorizedRuleTypeIds = [ES_QUERY_ID] -): DiscoverAppMenuItemType => { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); - stateContainer.internalState.dispatch( - stateContainer.injectCurrentTab(internalStateActions.assignNextDataView)({ - dataView, - }) - ); + authorizedRuleTypeIds = [ES_QUERY_ID], + services = createDiscoverServicesMock(), +}: { + dataView?: DataView; + isEsqlMode?: boolean; + authorizedRuleTypeIds?: string[]; + services?: DiscoverServices; +} = {}): Promise => { + const toolkit = getDiscoverInternalStateMock({ services }); + + await toolkit.initializeTabs(); + + const { stateContainer } = await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); const discoverParamsMock: AppMenuExtensionParams = { dataView, @@ -42,31 +50,39 @@ const getAlertsMenuItem = ( return getAlertsAppMenuItem({ discoverParams: discoverParamsMock, - services: discoverServiceMock, + services, stateContainer, }); }; describe('getAlertsAppMenuItem', () => { describe('Authorized Rule Types', () => { - it('should include the manage alerts button if there is any authorized rule type', () => { - const alertsMenuItem = getAlertsMenuItem(dataViewMock, false, ['anyAuthorizedRule']); + it('should include the manage alerts button if there is any authorized rule type', async () => { + const alertsMenuItem = await getAlertsMenuItem({ + dataView: dataViewMock, + isEsqlMode: false, + authorizedRuleTypeIds: ['anyAuthorizedRule'], + }); const manageAlertsItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverManageAlertsButton' ); expect(manageAlertsItem).toBeDefined(); }); - it('should include the create search threshold rule button if it is authorized', () => { - const alertsMenuItem = getAlertsMenuItem(); + it('should include the create search threshold rule button if it is authorized', async () => { + const alertsMenuItem = await getAlertsMenuItem(); const createAlertItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverCreateAlertButton' ); expect(createAlertItem).toBeDefined(); }); - it('should not include the create search threshold rule button if it is not authorized', () => { - const alertsMenuItem = getAlertsMenuItem(dataViewMock, false, []); + it('should not include the create search threshold rule button if it is not authorized', async () => { + const alertsMenuItem = await getAlertsMenuItem({ + dataView: dataViewMock, + isEsqlMode: false, + authorizedRuleTypeIds: [], + }); const createAlertItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverCreateAlertButton' ); @@ -74,24 +90,24 @@ describe('getAlertsAppMenuItem', () => { }); }); describe('Dataview mode', () => { - it('should have the create search threshold rule button disabled if the data view has no time field', () => { - const alertsMenuItem = getAlertsMenuItem(); + it('should have the create search threshold rule button disabled if the data view has no time field', async () => { + const alertsMenuItem = await getAlertsMenuItem(); const createAlertItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverCreateAlertButton' ); expect(createAlertItem?.disableButton).toBe(true); }); - it('should have the create search threshold rule button enabled if the data view has a time field', () => { - const alertsMenuItem = getAlertsMenuItem(dataViewWithTimefieldMock); + it('should have the create search threshold rule button enabled if the data view has a time field', async () => { + const alertsMenuItem = await getAlertsMenuItem({ dataView: dataViewWithTimefieldMock }); const createAlertItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverCreateAlertButton' ); expect(createAlertItem?.disableButton).toBe(false); }); - it('should include the manage rules and connectors link', () => { - const alertsMenuItem = getAlertsMenuItem(); + it('should include the manage rules and connectors link', async () => { + const alertsMenuItem = await getAlertsMenuItem(); const manageAlertsItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverManageAlertsButton' ); @@ -100,32 +116,38 @@ describe('getAlertsAppMenuItem', () => { }); describe('ES|QL mode', () => { - it('should have the create search threshold rule button enabled if the data view has no timeFieldName but at least one time field', () => { - const alertsMenuItem = getAlertsMenuItem(dataViewMock, true); + it('should have the create search threshold rule button enabled if the data view has no timeFieldName but at least one time field', async () => { + const alertsMenuItem = await getAlertsMenuItem({ dataView: dataViewMock, isEsqlMode: true }); const createAlertItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverCreateAlertButton' ); expect(createAlertItem?.disableButton).toBe(false); }); - it('should have the create search threshold rule button enabled if the data view has a time field', () => { - const alertsMenuItem = getAlertsMenuItem(dataViewWithTimefieldMock, true); + it('should have the create search threshold rule button enabled if the data view has a time field', async () => { + const alertsMenuItem = await getAlertsMenuItem({ + dataView: dataViewWithTimefieldMock, + isEsqlMode: true, + }); const createAlertItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverCreateAlertButton' ); expect(createAlertItem?.disableButton).toBe(false); }); - it('should have the create search threshold rule button disabled if the data view has no time fields at all', () => { - const alertsMenuItem = getAlertsMenuItem(dataViewWithNoTimefieldMock, true); + it('should have the create search threshold rule button disabled if the data view has no time fields at all', async () => { + const alertsMenuItem = await getAlertsMenuItem({ + dataView: dataViewWithNoTimefieldMock, + isEsqlMode: true, + }); const createAlertItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverCreateAlertButton' ); expect(createAlertItem?.disableButton).toBe(true); }); - it('should include the manage rules and connectors link', () => { - const alertsMenuItem = getAlertsMenuItem(); + it('should include the manage rules and connectors link', async () => { + const alertsMenuItem = await getAlertsMenuItem(); const manageAlertsItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverManageAlertsButton' ); @@ -134,24 +156,26 @@ describe('getAlertsAppMenuItem', () => { }); describe('Manage rules and connectors link', () => { - it('should link to the unified rules page when rules app is registered', () => { - (discoverServiceMock.application.isAppRegistered as jest.Mock).mockReturnValue(true); - (discoverServiceMock.application.getUrlForApp as jest.Mock).mockImplementation( + it('should link to the unified rules page when rules app is registered', async () => { + const services = createDiscoverServicesMock(); + (services.application.isAppRegistered as jest.Mock).mockReturnValue(true); + (services.application.getUrlForApp as jest.Mock).mockImplementation( (appId: string) => `/app/${appId}` ); - const alertsMenuItem = getAlertsMenuItem(); + const alertsMenuItem = await getAlertsMenuItem({ services }); const manageAlertsItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverManageAlertsButton' ); expect(manageAlertsItem?.href).toBe('/app/rules'); }); - it('should link to the management page when rules app is not registered', () => { - (discoverServiceMock.application.isAppRegistered as jest.Mock).mockReturnValue(false); - (discoverServiceMock.application.getUrlForApp as jest.Mock).mockImplementation( + it('should link to the management page when rules app is not registered', async () => { + const services = createDiscoverServicesMock(); + (services.application.isAppRegistered as jest.Mock).mockReturnValue(false); + (services.application.getUrlForApp as jest.Mock).mockImplementation( (appId: string) => `/app/${appId}` ); - const alertsMenuItem = getAlertsMenuItem(); + const alertsMenuItem = await getAlertsMenuItem({ services }); const manageAlertsItem = alertsMenuItem.items?.find( (item) => item.testId === 'discoverManageAlertsButton' ); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/esql_dataview_transition/esql_dataview_transition_modal.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/esql_dataview_transition/esql_dataview_transition_modal.tsx index 09d9282b5aad9..3e46a2a58c613 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/esql_dataview_transition/esql_dataview_transition_modal.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/esql_dataview_transition/esql_dataview_transition_modal.tsx @@ -9,7 +9,6 @@ import React, { useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { FEEDBACK_LINK } from '@kbn/esql-utils'; import { EuiModal, EuiModalBody, @@ -22,11 +21,9 @@ import { EuiCheckbox, EuiFlexItem, EuiFlexGroup, - EuiLink, EuiHorizontalRule, useGeneratedHtmlId, } from '@elastic/eui'; -import { useDiscoverServices } from '../../../../../hooks/use_discover_services'; export interface ESQLToDataViewTransitionModalProps { onClose: (dismissFlag?: boolean, needsSave?: boolean) => void; @@ -40,8 +37,6 @@ export default function ESQLToDataViewTransitionModal({ const onTransitionModalDismiss = useCallback((e: React.ChangeEvent) => { setDismissModalChecked(e.target.checked); }, []); - const { notifications } = useDiscoverServices(); - const isFeedbackEnabled = notifications.feedback.isEnabled(); const modalTitleId = useGeneratedHtmlId({ prefix: 'discover-esql-to-dataview-modal-title', }); @@ -68,17 +63,6 @@ export default function ESQLToDataViewTransitionModal({ 'Switching data views removes the current ES|QL query. Save this session to avoid losing work.', })} - {isFeedbackEnabled && ( - - - - {i18n.translate('discover.esqlToDataViewTransitionModal.feedbackLink', { - defaultMessage: 'Submit ES|QL feedback', - })} - - - - )} diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_esql_variables.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_esql_variables.test.tsx index 883a06729663c..15764a56fc6ff 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_esql_variables.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_esql_variables.test.tsx @@ -6,18 +6,20 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ + import React from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; import type { ControlGroupRendererApi, ControlPanelsState } from '@kbn/control-group-renderer'; import { BehaviorSubject, Observable, skip } from 'rxjs'; -import { DiscoverTestProvider } from '../../../../__mocks__/test_provider'; +import { DiscoverToolkitTestProvider } from '../../../../__mocks__/test_provider'; import { getDiscoverInternalStateMock } from '../../../../__mocks__/discover_state.mock'; import { mockControlState } from '../../../../__mocks__/esql_controls'; import { useESQLVariables } from './use_esql_variables'; import type { ESQLControlVariable, ESQLVariableType, EsqlControlType } from '@kbn/esql-types'; -import type { DiscoverStateContainer } from '../../state_management/discover_state'; import { internalStateActions } from '../../state_management/redux'; import type { OptionsListESQLControlState } from '@kbn/controls-schemas'; +import type { InternalStateMockToolkit } from '../../../../__mocks__/discover_state.mock'; +import { selectTabRuntimeState } from '../../state_management/redux'; // Mock ControlGroupRendererApi class MockControlGroupRendererApi { @@ -56,37 +58,35 @@ class MockControlGroupRendererApi { // --- Test Suite --- describe('useESQLVariables', () => { let mockControlGroupAPI: MockControlGroupRendererApi; + const setup = async () => { const toolkit = getDiscoverInternalStateMock(); - await toolkit.initializeTabs(); const { stateContainer } = await toolkit.initializeSingleTab({ tabId: toolkit.getCurrentTab().id, }); - - return { - stateContainer, - }; + return { toolkit, stateContainer }; }; const renderUseESQLVariables = async ({ - stateContainer: originalStateContainer, + toolkit, isEsqlMode = true, controlGroupApi = mockControlGroupAPI as unknown as ControlGroupRendererApi, currentEsqlVariables = [], onUpdateESQLQuery = jest.fn(), }: { - stateContainer?: DiscoverStateContainer; + toolkit?: InternalStateMockToolkit; isEsqlMode?: boolean; controlGroupApi?: ControlGroupRendererApi; currentEsqlVariables?: ESQLControlVariable[]; onUpdateESQLQuery?: (query: string) => void; }) => { - const Wrapper = ({ children }: React.PropsWithChildren) => ( - {children} - ); + toolkit ??= (await setup()).toolkit; - const stateContainer = originalStateContainer ?? (await setup()).stateContainer; + const stateContainer = selectTabRuntimeState( + toolkit.runtimeStateManager, + toolkit.internalState.getState().tabs.unsafeCurrentId + ).stateContainer$.getValue()!; const hook = renderHook( () => @@ -97,13 +97,15 @@ describe('useESQLVariables', () => { onUpdateESQLQuery, }), { - wrapper: Wrapper, + wrapper: ({ children }) => ( + {children} + ), } ); await act(() => setTimeout(() => {}, 0)); - return { hook }; + return { hook, toolkit, stateContainer }; }; beforeEach(() => { @@ -120,13 +122,10 @@ describe('useESQLVariables', () => { describe('useEffect for ControlGroupAPI input', () => { it('should not subscribe if not in ESQL mode or controlGroupAPI is missing', async () => { - const { stateContainer } = await setup(); - const dispatchSpy = jest.spyOn(stateContainer.internalState, 'dispatch'); - - const { hook } = await renderUseESQLVariables({ + const { hook, toolkit } = await renderUseESQLVariables({ isEsqlMode: false, - stateContainer, }); + const dispatchSpy = jest.spyOn(toolkit.internalState, 'dispatch'); // Try to simulate input, it should not trigger any dispatch act(() => { @@ -146,14 +145,11 @@ describe('useESQLVariables', () => { { key: 'foo', type: 'values', value: 'bar' }, ] as ESQLControlVariable[]; - const { stateContainer } = await setup(); - const dispatchSpy = jest.spyOn(stateContainer.internalState, 'dispatch'); - const fetchSpy = jest.spyOn(stateContainer.dataState, 'fetch'); - - await renderUseESQLVariables({ + const { toolkit, stateContainer } = await renderUseESQLVariables({ isEsqlMode: true, - stateContainer, }); + const dispatchSpy = jest.spyOn(toolkit.internalState, 'dispatch'); + const fetchSpy = jest.spyOn(stateContainer.dataState, 'fetch'); // Simulate initial input from controlGroupAPI act(() => { @@ -162,7 +158,7 @@ describe('useESQLVariables', () => { // Assert dispatches happened await waitFor(() => { - expect(dispatchSpy).toHaveBeenCalledTimes(3); + expect(dispatchSpy).toHaveBeenCalledTimes(4); const dispatchCalls = dispatchSpy.mock.calls; dispatchCalls.forEach((call) => { const action = call[0] as { type: string; payload?: unknown }; @@ -195,11 +191,11 @@ describe('useESQLVariables', () => { }) ); - const { stateContainer } = await setup(); + const { toolkit } = await setup(); const { hook } = await renderUseESQLVariables({ + toolkit, isEsqlMode: true, - stateContainer, }); act(() => { @@ -211,7 +207,7 @@ describe('useESQLVariables', () => { }); it('should reset control panels when tab attributes change', async () => { - const { stateContainer } = await setup(); + const { toolkit } = await setup(); // Create a mock control group API with a mock updateInput method const mockUpdateInput = jest.fn(); @@ -219,15 +215,16 @@ describe('useESQLVariables', () => { // Render the hook await renderUseESQLVariables({ - stateContainer, + toolkit, controlGroupApi: mockControlGroupAPI as unknown as ControlGroupRendererApi, }); expect(mockUpdateInput).not.toHaveBeenCalled(); act(() => { - stateContainer.internalState.dispatch( - stateContainer.injectCurrentTab(internalStateActions.updateAttributes)({ + toolkit.internalState.dispatch( + internalStateActions.updateAttributes({ + tabId: toolkit.getCurrentTab().id, attributes: { controlGroupState: mockControlState, }, @@ -296,12 +293,12 @@ describe('useESQLVariables', () => { }); it('should handle numeric type coercion for ESQL variable values', async () => { - const { stateContainer } = await setup(); - const dispatchSpy = jest.spyOn(stateContainer.internalState, 'dispatch'); + const { toolkit } = await setup(); + const dispatchSpy = jest.spyOn(toolkit.internalState, 'dispatch'); await renderUseESQLVariables({ + toolkit, isEsqlMode: true, - stateContainer, }); // Test case 1: String that can be converted to a number diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index f319a850f39d8..83b2c7aa07fb5 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -9,83 +9,72 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; -import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { BehaviorSubject } from 'rxjs'; import { useTopNavLinks } from './use_top_nav_links'; -import type { DiscoverServices } from '../../../../build_services'; -import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../../../../__mocks__/discover_state.mock'; import { createDiscoverServicesMock } from '../../../../__mocks__/services'; -import { DiscoverTestProvider } from '../../../../__mocks__/test_provider'; -import { internalStateActions } from '../../state_management/redux'; +import { DiscoverToolkitTestProvider } from '../../../../__mocks__/test_provider'; +import { ENABLE_ESQL } from '@kbn/esql-utils'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; + +jest.mock('@kbn/alerts-ui-shared', () => ({ + ...jest.requireActual('@kbn/alerts-ui-shared'), + useGetRuleTypesPermissions: jest.fn().mockReturnValue({ authorizedRuleTypes: [] }), +})); describe('useTopNavLinks', () => { - const services = { - ...createDiscoverServicesMock(), - application: { - ...createDiscoverServicesMock().application, - currentAppId$: new BehaviorSubject('discover'), - }, - capabilities: { - discover_v2: { - save: true, - storeSearchSession: true, - }, - }, - uiSettings: { - get: jest.fn(() => true), - }, - } as unknown as DiscoverServices; - - const state = getDiscoverStateMock({ isTimeBased: true }); - state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.assignNextDataView)({ - dataView: dataViewMock, - }) - ); - - // identifier to denote if share integration is available, - // we default to false especially that there a specific test scenario for when this is true - const hasShareIntegration = false; - - const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return ( - - {children} - - ); + const getServices = () => { + const services = createDiscoverServicesMock(); + const uiSettingsGetMock = services.uiSettings.get; + + services.share = sharePluginMock.createStartContract(); + services.application.currentAppId$ = new BehaviorSubject('discover'); + services.capabilities.discover_v2 = { + save: true, + storeSearchSession: true, + }; + services.uiSettings.get = (key: string) => { + return key === ENABLE_ESQL ? (true as T) : uiSettingsGetMock(key); + }; + + return services; }; - const setup = (hookAttrs: Partial[0]> = {}) => { + const setup = async (hookAttrs: Partial[0]> = {}) => { + const services = hookAttrs.services ?? getServices(); + const toolkit = getDiscoverInternalStateMock({ services }); + + await toolkit.initializeTabs(); + + const { stateContainer } = await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); + return renderHook( () => useTopNavLinks({ dataView: dataViewMock, onOpenInspector: jest.fn(), services, - state, + state: stateContainer, hasUnsavedChanges: false, isEsqlMode: false, adHocDataViews: [], - hasShareIntegration, + hasShareIntegration: false, persistedDiscoverSession: undefined, ...hookAttrs, }), { - wrapper: Wrapper, + wrapper: ({ children }) => ( + {children} + ), } ).result.current; }; - it('should return results', () => { - const appMenuConfig = setup(); + it('should return results', async () => { + const appMenuConfig = await setup(); expect(appMenuConfig.items).toBeDefined(); expect(appMenuConfig.items?.length).toBeGreaterThan(0); @@ -101,8 +90,8 @@ describe('useTopNavLinks', () => { }); describe('when ES|QL mode is true', () => { - it('should NOT include the esql secondary action item', () => { - const appMenuConfig = setup({ + it('should NOT include the esql secondary action item', async () => { + const appMenuConfig = await setup({ isEsqlMode: true, }); @@ -112,8 +101,8 @@ describe('useTopNavLinks', () => { }); describe('when ES|QL mode is false (classic mode)', () => { - it('should include the esql secondary action item', () => { - const appMenuConfig = setup({ + it('should include the esql secondary action item', async () => { + const appMenuConfig = await setup({ isEsqlMode: false, }); @@ -125,17 +114,12 @@ describe('useTopNavLinks', () => { }); describe('when share service included', () => { - beforeAll(() => { - services.share = sharePluginMock.createStartContract(); - jest.spyOn(services.share, 'availableIntegrations').mockReturnValue([]); - }); + it('should include the share menu item', async () => { + const services = getServices(); - afterAll(() => { - services.share = undefined; - }); + jest.spyOn(services.share!, 'availableIntegrations').mockReturnValue([]); - it('should include the share menu item', () => { - const appMenuConfig = setup(); + const appMenuConfig = await setup({ hasShareIntegration: true, services }); expect(appMenuConfig.items).toBeDefined(); @@ -145,7 +129,9 @@ describe('useTopNavLinks', () => { expect(shareItem?.label).toBe('Share'); }); - it('should include the export menu item', () => { + it('should include the export menu item', async () => { + const services = getServices(); + jest .spyOn(services.share!, 'availableIntegrations') .mockImplementation((_objectType, groupId) => { @@ -162,23 +148,7 @@ describe('useTopNavLinks', () => { return []; }); - const appMenuConfig = renderHook( - () => - useTopNavLinks({ - dataView: dataViewMock, - onOpenInspector: jest.fn(), - services, - state, - hasUnsavedChanges: false, - isEsqlMode: false, - adHocDataViews: [], - hasShareIntegration: true, - persistedDiscoverSession: undefined, - }), - { - wrapper: Wrapper, - } - ).result.current; + const appMenuConfig = await setup({ hasShareIntegration: true, services }); const exportItem = appMenuConfig.items?.find((item) => item.id === 'export'); expect(exportItem).toBeDefined(); @@ -193,16 +163,10 @@ describe('useTopNavLinks', () => { }); describe('when background search is enabled', () => { - beforeEach(() => { + it('should return the background search menu item', async () => { + const services = getServices(); services.data.search.isBackgroundSearchEnabled = true; - }); - - afterEach(() => { - services.data.search.isBackgroundSearchEnabled = false; - }); - - it('should return the background search menu item', () => { - const appMenuConfig = setup(); + const appMenuConfig = await setup({ services }); const backgroundSearchItem = appMenuConfig.items?.find( (item) => item.id === 'backgroundSearch' @@ -212,8 +176,8 @@ describe('useTopNavLinks', () => { }); describe('when background search is disabled', () => { - it('should NOT return the background search menu item', () => { - const appMenuConfig = setup(); + it('should NOT return the background search menu item', async () => { + const appMenuConfig = await setup(); const backgroundSearchItem = appMenuConfig.items?.find( (item) => item.id === 'backgroundSearch' @@ -223,8 +187,8 @@ describe('useTopNavLinks', () => { }); describe('save button with unsaved changes', () => { - it('should show notification indicator when there are unsaved changes', () => { - const appMenuConfig = setup({ hasUnsavedChanges: true }); + it('should show notification indicator when there are unsaved changes', async () => { + const appMenuConfig = await setup({ hasUnsavedChanges: true }); expect(appMenuConfig.primaryActionItem).toBeDefined(); expect(appMenuConfig.primaryActionItem?.id).toBe('save'); @@ -236,8 +200,8 @@ describe('useTopNavLinks', () => { ).toBe('You have unsaved changes'); }); - it('should NOT show notification indicator when there are no unsaved changes', () => { - const appMenuConfig = setup({ hasUnsavedChanges: false }); + it('should NOT show notification indicator when there are no unsaved changes', async () => { + const appMenuConfig = await setup({ hasUnsavedChanges: false }); expect(appMenuConfig.primaryActionItem).toBeDefined(); expect(appMenuConfig.primaryActionItem?.id).toBe('save'); @@ -249,8 +213,8 @@ describe('useTopNavLinks', () => { ).toBeUndefined(); }); - it('should include Save as and Reset changes options in split button menu', () => { - const appMenuConfig = setup({ hasUnsavedChanges: true }); + it('should include Save as and Reset changes options in split button menu', async () => { + const appMenuConfig = await setup({ hasUnsavedChanges: true }); expect(appMenuConfig.primaryActionItem?.splitButtonProps?.items).toBeDefined(); const itemIds = appMenuConfig.primaryActionItem?.splitButtonProps?.items?.map( @@ -260,8 +224,8 @@ describe('useTopNavLinks', () => { expect(itemIds).toContain('resetChanges'); }); - it('should have correct labels for split button menu items', () => { - const appMenuConfig = setup({ hasUnsavedChanges: true }); + it('should have correct labels for split button menu items', async () => { + const appMenuConfig = await setup({ hasUnsavedChanges: true }); const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; const saveAsItem = items?.find((item) => item.id === 'saveAs'); @@ -271,8 +235,8 @@ describe('useTopNavLinks', () => { expect(resetChangesItem?.label).toBe('Reset changes'); }); - it('should have run functions defined for split button menu items', () => { - const appMenuConfig = setup({ hasUnsavedChanges: true }); + it('should have run functions defined for split button menu items', async () => { + const appMenuConfig = await setup({ hasUnsavedChanges: true }); const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; const saveAsItem = items?.find((item) => item.id === 'saveAs'); @@ -282,8 +246,8 @@ describe('useTopNavLinks', () => { expect(resetChangesItem?.run).toBeDefined(); }); - it('should disable reset changes button when there are no unsaved changes', () => { - const appMenuConfig = setup({ hasUnsavedChanges: false }); + it('should disable reset changes button when there are no unsaved changes', async () => { + const appMenuConfig = await setup({ hasUnsavedChanges: false }); const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; const resetChangesItem = items?.find((item) => item.id === 'resetChanges'); @@ -291,8 +255,8 @@ describe('useTopNavLinks', () => { expect(resetChangesItem?.disableButton).toBe(true); }); - it('should enable reset changes button when there are unsaved changes', () => { - const appMenuConfig = setup({ hasUnsavedChanges: true }); + it('should enable reset changes button when there are unsaved changes', async () => { + const appMenuConfig = await setup({ hasUnsavedChanges: true }); const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; const resetChangesItem = items?.find((item) => item.id === 'resetChanges'); diff --git a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.test.ts b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.test.ts index 254c1f2df561d..1f963e5cc2ffc 100644 --- a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_all.test.ts @@ -14,7 +14,6 @@ import { reduce } from 'rxjs'; import type { SearchSource } from '@kbn/data-plugin/public'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; import { savedSearchMock } from '../../../__mocks__/saved_search'; -import { discoverServiceMock } from '../../../__mocks__/services'; import { fetchAll, fetchMoreDocuments } from './fetch_all'; import type { DataDocumentsMsg, @@ -27,9 +26,10 @@ import { fetchEsql } from './fetch_esql'; import { buildDataTableRecord } from '@kbn/discover-utils'; import { dataViewMock, esHitsMockWithSort } from '@kbn/discover-utils/src/__mocks__'; import { searchResponseIncompleteWarningLocalCluster } from '@kbn/search-response-warnings/src/__mocks__/search_response_warnings'; -import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../../../__mocks__/discover_state.mock'; import { internalStateActions, selectTabRuntimeState } from '../state_management/redux'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { createDiscoverServicesMock } from '../../../__mocks__/services'; jest.mock('./fetch_documents', () => ({ fetchDocuments: jest.fn().mockResolvedValue([]), @@ -60,24 +60,30 @@ describe('test fetchAll', () => { let deps: Parameters[0]; let searchSource: SearchSource; - beforeEach(() => { + beforeEach(async () => { subjects = { main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), }; searchSource = savedSearchMock.searchSource.createChild(); - const { internalState, runtimeStateManager, getCurrentTab } = getDiscoverStateMock({}); + const services = createDiscoverServicesMock(); + const toolkit = getDiscoverInternalStateMock({ services }); + await toolkit.initializeTabs(); + await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + skipWaitForDataFetching: true, + }); const { scopedProfilesManager$, scopedEbtManager$ } = selectTabRuntimeState( - runtimeStateManager, - getCurrentTab().id + toolkit.runtimeStateManager, + toolkit.getCurrentTab().id ); deps = { dataSubjects: subjects, reset: false, abortController: new AbortController(), inspectorAdapters: { requests: new RequestAdapter() }, - internalState, + internalState: toolkit.internalState, scopedProfilesManager: scopedProfilesManager$.getValue(), scopedEbtManager: scopedEbtManager$.getValue(), searchSessionId: '123', @@ -86,8 +92,8 @@ describe('test fetchAll', () => { ...savedSearchMock, searchSource, }, - services: discoverServiceMock, - getCurrentTab, + services, + getCurrentTab: toolkit.getCurrentTab, }; mockFetchDocuments.mockReset().mockResolvedValue({ records: [] }); mockfetchEsql.mockReset().mockResolvedValue({ records: [] }); diff --git a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_documents.test.ts b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_documents.test.ts index d531ba34f9d01..b5f78b5e68ec8 100644 --- a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_documents.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_documents.test.ts @@ -11,7 +11,6 @@ import { fetchDocuments } from './fetch_documents'; import { throwError as throwErrorRx, of } from 'rxjs'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; import { savedSearchMock } from '../../../__mocks__/saved_search'; -import { discoverServiceMock } from '../../../__mocks__/services'; import type { IKibanaSearchResponse } from '@kbn/search-types'; import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { CommonFetchParams } from './fetch_all'; @@ -19,31 +18,33 @@ import type { EsHitRecord } from '@kbn/discover-utils/types'; import { buildDataTableRecord } from '@kbn/discover-utils'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; -import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock'; -import { internalStateActions, selectTabRuntimeState } from '../state_management/redux'; - -const getDeps = (): CommonFetchParams => { - const { internalState, dataState, runtimeStateManager, getCurrentTab, injectCurrentTab } = - getDiscoverStateMock({}); +import { getDiscoverInternalStateMock } from '../../../__mocks__/discover_state.mock'; +import { selectTabRuntimeState } from '../state_management/redux'; +import { createDiscoverServicesMock } from '../../../__mocks__/services'; + +const getDeps = async (): Promise => { + const services = createDiscoverServicesMock(); + const toolkit = getDiscoverInternalStateMock({ services }); + await toolkit.initializeTabs(); + const { stateContainer } = await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); const { scopedProfilesManager$, scopedEbtManager$ } = selectTabRuntimeState( - runtimeStateManager, - getCurrentTab().id - ); - internalState.dispatch( - injectCurrentTab(internalStateActions.updateAppState)({ appState: { sampleSize: 100 } }) + toolkit.runtimeStateManager, + toolkit.getCurrentTab().id ); return { - dataSubjects: dataState.data$, - initialFetchStatus: dataState.getInitialFetchStatus(), + dataSubjects: stateContainer.dataState.data$, + initialFetchStatus: stateContainer.dataState.getInitialFetchStatus(), abortController: new AbortController(), inspectorAdapters: { requests: new RequestAdapter() }, searchSessionId: '123', - services: discoverServiceMock, + services, savedSearch: savedSearchMock, - internalState, + internalState: toolkit.internalState, scopedProfilesManager: scopedProfilesManager$.getValue(), scopedEbtManager: scopedEbtManager$.getValue(), - getCurrentTab, + getCurrentTab: toolkit.getCurrentTab, }; }; @@ -60,7 +61,7 @@ describe('test fetchDocuments', () => { const documents = hits.map((hit) => buildDataTableRecord(hit, dataViewMock)); savedSearchMock.searchSource.fetch$ = () => of({ rawResponse: { hits: { hits } } } as IKibanaSearchResponse>); - const deps = getDeps(); + const deps = await getDeps(); const resolveDocumentProfileSpy = jest.spyOn( deps.scopedProfilesManager, 'resolveDocumentProfile' @@ -78,14 +79,14 @@ describe('test fetchDocuments', () => { savedSearchMock.searchSource.fetch$ = () => throwErrorRx(() => new Error('Oh noes!')); try { - await fetchDocuments(savedSearchMock.searchSource, getDeps()); + await fetchDocuments(savedSearchMock.searchSource, await getDeps()); } catch (e) { expect(e).toEqual(new Error('Oh noes!')); } }); test('passes a correct session id', async () => { - const deps = getDeps(); + const deps = await getDeps(); const hits = [ { _id: '1', foo: 'bar' }, { _id: '2', foo: 'baz' }, diff --git a/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.test.tsx b/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.test.tsx index 3ac8311b935cd..90895f9361547 100644 --- a/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.test.tsx @@ -8,32 +8,36 @@ */ import { renderHook, act } from '@testing-library/react'; -import { discoverServiceMock } from '../../../__mocks__/services'; import { useInspector } from './use_inspector'; import type { Adapters } from '@kbn/inspector-plugin/common'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; import type { OverlayRef } from '@kbn/core/public'; import { AggregateRequestAdapter } from '../utils/aggregate_request_adapter'; -import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../../../__mocks__/discover_state.mock'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import { internalStateActions } from '../state_management/redux'; import React from 'react'; -import { DiscoverTestProvider } from '../../../__mocks__/test_provider'; +import { DiscoverToolkitTestProvider } from '../../../__mocks__/test_provider'; +import { createDiscoverServicesMock } from '../../../__mocks__/services'; describe('test useInspector', () => { test('inspector open function is executed, expanded doc is closed', async () => { + const services = createDiscoverServicesMock(); let adapters: Adapters | undefined; - jest.spyOn(discoverServiceMock.inspector, 'open').mockImplementation((localAdapters) => { + jest.spyOn(services.inspector, 'open').mockImplementation((localAdapters) => { adapters = localAdapters; return { close: jest.fn() } as unknown as OverlayRef; }); const requests = new RequestAdapter(); const lensRequests = new RequestAdapter(); - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); - const currentTabId = stateContainer.internalState.getState().tabs.unsafeCurrentId; - stateContainer.internalState.dispatch( + const toolkit = getDiscoverInternalStateMock({ services }); + await toolkit.initializeTabs(); + const { stateContainer } = await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); + toolkit.internalState.dispatch( internalStateActions.setExpandedDoc({ - tabId: currentTabId, + tabId: toolkit.getCurrentTab().id, expandedDoc: {} as unknown as DataTableRecord, }) ); @@ -41,12 +45,12 @@ describe('test useInspector', () => { () => { return useInspector({ stateContainer, - inspector: discoverServiceMock.inspector, + inspector: services.inspector, }); }, { wrapper: ({ children }) => ( - {children} + {children} ), } ); @@ -54,14 +58,12 @@ describe('test useInspector', () => { result.current(); }); - expect(discoverServiceMock.inspector.open).toHaveBeenCalled(); + expect(services.inspector.open).toHaveBeenCalled(); expect(adapters?.requests).toBeInstanceOf(AggregateRequestAdapter); expect(adapters?.requests?.getRequests()).toEqual([ ...requests.getRequests(), ...lensRequests.getRequests(), ]); - const state = stateContainer.internalState.getState(); - const tab = state.tabs.byId[currentTabId]; - expect(tab.expandedDoc).toBe(undefined); + expect(toolkit.getCurrentTab().expandedDoc).toBe(undefined); }); }); diff --git a/src/platform/plugins/shared/discover/public/build_services.ts b/src/platform/plugins/shared/discover/public/build_services.ts index 24a20f8797fec..f7cc8f005289c 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -63,7 +63,6 @@ import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/publ import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { CPSPluginStart } from '@kbn/cps/public'; import type { DiscoverStartPlugins } from './types'; import type { DiscoverContextAppLocator } from './application/context/services/locator'; @@ -155,7 +154,6 @@ export interface DiscoverServices { ebtManager: DiscoverEBTManager; fieldsMetadata?: FieldsMetadataPublicStart; logsDataAccess?: LogsDataAccessPluginStart; - embeddableEnhanced?: EmbeddableEnhancedPluginStart; cps?: CPSPluginStart; embeddableEditor: EmbeddableEditorService; } @@ -257,7 +255,6 @@ export const buildServices = ({ ebtManager, fieldsMetadata: plugins.fieldsMetadata, logsDataAccess: plugins.logsDataAccess, - embeddableEnhanced: plugins.embeddableEnhanced, cps: plugins.cps, embeddableEditor: new EmbeddableEditorService( core.application, diff --git a/src/platform/plugins/shared/discover/public/components/hits_counter/hits_counter.test.tsx b/src/platform/plugins/shared/discover/public/components/hits_counter/hits_counter.test.tsx index 9b08a42c43eb5..1a033cb31d229 100644 --- a/src/platform/plugins/shared/discover/public/components/hits_counter/hits_counter.test.tsx +++ b/src/platform/plugins/shared/discover/public/components/hits_counter/hits_counter.test.tsx @@ -12,7 +12,7 @@ import { renderWithI18n } from '@kbn/test-jest-helpers'; import { screen, within } from '@testing-library/react'; import { HitsCounter, HitsCounterMode } from './hits_counter'; import { BehaviorSubject } from 'rxjs'; -import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../../__mocks__/discover_state.mock'; import type { DataDocuments$, DataTotalHits$, @@ -28,9 +28,18 @@ function getDocuments$(count: number = 5) { }) as DataDocuments$; } +async function setup() { + const toolkit = getDiscoverInternalStateMock(); + await toolkit.initializeTabs(); + const { stateContainer } = await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); + return { stateContainer }; +} + describe('hits counter', function () { - it('expect to render the number of hits', function () { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + it('expect to render the number of hits', async function () { + const { stateContainer } = await setup(); stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.COMPLETE, result: 1, @@ -56,8 +65,8 @@ describe('hits counter', function () { component2.unmount(); }); - it('expect to render 1,899 hits if 1899 hits given', function () { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + it('expect to render 1,899 hits if 1899 hits given', async function () { + const { stateContainer } = await setup(); stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.COMPLETE, result: 1899, @@ -80,8 +89,8 @@ describe('hits counter', function () { component2.unmount(); }); - it('renders with custom hit counter labels', function () { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + it('renders with custom hit counter labels', async function () { + const { stateContainer } = await setup(); stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.COMPLETE, result: 1899, @@ -133,8 +142,8 @@ describe('hits counter', function () { component3.unmount(); }); - it('should render a EuiLoadingSpinner when status is partial', () => { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + it('should render a EuiLoadingSpinner when status is partial', async () => { + const { stateContainer } = await setup(); stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.PARTIAL, result: 2, @@ -150,8 +159,8 @@ describe('hits counter', function () { expect(progressElement[0]).toHaveClass('euiLoadingSpinner'); }); - it('should render discoverQueryHitsPartial when status is partial', () => { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + it('should render discoverQueryHitsPartial when status is partial', async () => { + const { stateContainer } = await setup(); stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.PARTIAL, result: 2, @@ -164,8 +173,8 @@ describe('hits counter', function () { expect(screen.queryByTestId('discoverQueryTotalHits')).toHaveTextContent('≥2 results'); }); - it('should not render if loading', () => { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + it('should not render if loading', async () => { + const { stateContainer } = await setup(); stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.LOADING, result: undefined, @@ -178,8 +187,8 @@ describe('hits counter', function () { expect(component.container).toBeEmptyDOMElement(); }); - it('should render discoverQueryHitsPartial when status is error', () => { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + it('should render discoverQueryHitsPartial when status is error', async () => { + const { stateContainer } = await setup(); stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.ERROR, result: undefined, diff --git a/src/platform/plugins/shared/discover/public/components/panels_toggle/panels_toggle.test.tsx b/src/platform/plugins/shared/discover/public/components/panels_toggle/panels_toggle.test.tsx index 665a065007010..da4758d5ca9a5 100644 --- a/src/platform/plugins/shared/discover/public/components/panels_toggle/panels_toggle.test.tsx +++ b/src/platform/plugins/shared/discover/public/components/panels_toggle/panels_toggle.test.tsx @@ -11,43 +11,49 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { BehaviorSubject } from 'rxjs'; -import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../../__mocks__/discover_state.mock'; import { PanelsToggle, type PanelsToggleProps } from './panels_toggle'; import type { SidebarToggleState } from '../../application/types'; -import { DiscoverTestProvider } from '../../__mocks__/test_provider'; +import { DiscoverToolkitTestProvider } from '../../__mocks__/test_provider'; import { internalStateActions } from '../../application/main/state_management/redux'; describe('Panels toggle component', () => { - const mountComponent = ({ + const mountComponent = async ({ sidebarToggleState$, isChartAvailable, renderedFor, hideChart, }: Omit & { hideChart: boolean }) => { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + const toolkit = getDiscoverInternalStateMock(); - stateContainer.internalState.dispatch( - stateContainer.injectCurrentTab(internalStateActions.setAppState)({ appState: { hideChart } }) + await toolkit.initializeTabs(); + await toolkit.initializeSingleTab({ tabId: toolkit.getCurrentTab().id }); + + toolkit.internalState.dispatch( + internalStateActions.setAppState({ + tabId: toolkit.getCurrentTab().id, + appState: { hideChart }, + }) ); return mountWithIntl( - + - + ); }; describe('inside histogram toolbar', function () { - it('should render correctly when sidebar is visible and histogram is visible', () => { + it('should render correctly when sidebar is visible and histogram is visible', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: false, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: false, isChartAvailable: undefined, renderedFor: 'histogram', @@ -57,12 +63,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(true); }); - it('should render correctly when sidebar is collapsed and histogram is visible', () => { + it('should render correctly when sidebar is collapsed and histogram is visible', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: true, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: false, isChartAvailable: undefined, renderedFor: 'histogram', @@ -78,12 +84,12 @@ describe('Panels toggle component', () => { }); describe('inside view mode tabs', function () { - it('should render correctly when sidebar is visible and histogram is visible', () => { + it('should render correctly when sidebar is visible and histogram is visible', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: false, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: false, isChartAvailable: true, renderedFor: 'tabs', @@ -94,12 +100,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); }); - it('should render correctly when sidebar is visible and histogram is visible but chart is not available', () => { + it('should render correctly when sidebar is visible and histogram is visible but chart is not available', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: false, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: false, isChartAvailable: false, renderedFor: 'tabs', @@ -110,12 +116,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); }); - it('should render correctly when sidebar is hidden and histogram is visible', () => { + it('should render correctly when sidebar is hidden and histogram is visible', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: true, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: false, isChartAvailable: true, renderedFor: 'tabs', @@ -126,12 +132,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); }); - it('should render correctly when sidebar is hidden and histogram is visible but chart is not available', () => { + it('should render correctly when sidebar is hidden and histogram is visible but chart is not available', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: true, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: false, isChartAvailable: false, renderedFor: 'tabs', @@ -142,12 +148,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); }); - it('should render correctly when sidebar is visible and histogram is hidden', () => { + it('should render correctly when sidebar is visible and histogram is hidden', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: false, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: true, isChartAvailable: true, renderedFor: 'tabs', @@ -157,12 +163,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(true); }); - it('should render correctly when sidebar is visible and histogram is hidden but chart is not available', () => { + it('should render correctly when sidebar is visible and histogram is hidden but chart is not available', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: false, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: true, isChartAvailable: false, renderedFor: 'tabs', @@ -173,12 +179,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false); }); - it('should render correctly when sidebar is hidden and histogram is hidden', () => { + it('should render correctly when sidebar is hidden and histogram is hidden', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: true, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: true, isChartAvailable: true, renderedFor: 'tabs', @@ -188,12 +194,12 @@ describe('Panels toggle component', () => { expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(true); }); - it('should render correctly when sidebar is hidden and histogram is hidden but chart is not available', () => { + it('should render correctly when sidebar is hidden and histogram is hidden but chart is not available', async () => { const sidebarToggleState$ = new BehaviorSubject({ isCollapsed: true, toggle: jest.fn(), }); - const component = mountComponent({ + const component = await mountComponent({ hideChart: true, isChartAvailable: false, renderedFor: 'tabs', diff --git a/src/platform/plugins/shared/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx b/src/platform/plugins/shared/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx index eb9b7e79c525b..53d27a66976b9 100644 --- a/src/platform/plugins/shared/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx +++ b/src/platform/plugins/shared/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx @@ -15,14 +15,14 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import type { DataView } from '@kbn/data-views-plugin/common'; import { DocumentViewModeToggle } from './view_mode_toggle'; import { BehaviorSubject } from 'rxjs'; -import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../../__mocks__/discover_state.mock'; import type { DataTotalHits$ } from '../../application/main/state_management/discover_data_state_container'; import { FetchStatus } from '../../application/types'; import { ES_FIELD_TYPES } from '@kbn/field-types'; -import { discoverServiceMock } from '../../__mocks__/services'; import { act } from 'react-dom/test-utils'; -import { DiscoverTestProvider } from '../../__mocks__/test_provider'; +import { DiscoverToolkitTestProvider } from '../../__mocks__/test_provider'; import type { DiscoverServices } from '../../build_services'; +import { createDiscoverServicesMock } from '../../__mocks__/services'; describe('Document view mode toggle component', () => { const mountComponent = async ({ @@ -33,7 +33,7 @@ describe('Document view mode toggle component', () => { useDataViewWithTextFields = true, } = {}) => { const services = { - ...discoverServiceMock, + ...createDiscoverServicesMock(), uiSettings: { get: () => showFieldStatistics, }, @@ -62,14 +62,21 @@ describe('Document view mode toggle component', () => { ], } as unknown as DataView; - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + const toolkit = getDiscoverInternalStateMock({ services }); + + await toolkit.initializeTabs(); + + const { stateContainer } = await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.COMPLETE, result: 10, }) as DataTotalHits$; const component = mountWithIntl( - + { setDiscoverViewMode={setDiscoverViewMode} dataView={useDataViewWithTextFields ? dataViewWithTextFields : dataViewWithoutTextFields} /> - + ); await act(async () => { diff --git a/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx b/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx index c471d70a7716e..60c0215de6d2e 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx @@ -9,18 +9,13 @@ import { renderHook } from '@testing-library/react'; import { useDefaultAdHocDataViews } from './use_default_ad_hoc_data_views'; -import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock'; -import { discoverServiceMock } from '../../__mocks__/services'; +import { getDiscoverInternalStateMock } from '../../__mocks__/discover_state.mock'; +import { createDiscoverServicesMock } from '../../__mocks__/services'; import React from 'react'; import { internalStateActions } from '../../application/main/state_management/redux'; import { buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { omit } from 'lodash'; -import { DiscoverTestProvider } from '../../__mocks__/test_provider'; - -const clearInstanceCache = jest.spyOn(discoverServiceMock.dataViews, 'clearInstanceCache'); -const createDataView = jest - .spyOn(discoverServiceMock.dataViews, 'create') - .mockImplementation((spec) => Promise.resolve(buildDataViewMock(omit(spec, 'fields')))); +import { DiscoverToolkitTestProvider } from '../../__mocks__/test_provider'; const existingAdHocDataVew = buildDataViewMock({ id: '1', title: 'test' }); const previousDataViews = [ @@ -42,25 +37,33 @@ const rootProfileState = { }), }; -const renderDefaultAdHocDataViewsHook = () => { - const stateContainer = getDiscoverStateMock({}); - stateContainer.internalState.dispatch( - internalStateActions.appendAdHocDataViews(existingAdHocDataVew) - ); - stateContainer.internalState.dispatch( +const renderDefaultAdHocDataViewsHook = async () => { + const services = createDiscoverServicesMock(); + const clearInstanceCache = jest.spyOn(services.dataViews, 'clearInstanceCache'); + const createDataView = jest + .spyOn(services.dataViews, 'create') + .mockImplementation((spec) => Promise.resolve(buildDataViewMock(omit(spec, 'fields')))); + const toolkit = getDiscoverInternalStateMock({ services }); + + await toolkit.initializeTabs(); + await toolkit.initializeSingleTab({ tabId: toolkit.getCurrentTab().id }); + + toolkit.internalState.dispatch(internalStateActions.appendAdHocDataViews(existingAdHocDataVew)); + toolkit.internalState.dispatch( internalStateActions.setDefaultProfileAdHocDataViews(previousDataViews) ); + const { result, unmount } = renderHook(useDefaultAdHocDataViews, { wrapper: ({ children }) => ( - - {children} - + {children} ), }); return { result, unmount, - stateContainer, + toolkit, + clearInstanceCache, + createDataView, }; }; @@ -70,14 +73,15 @@ describe('useDefaultAdHocDataViews', () => { }); it('should set default profile ad hoc data views', async () => { - const { result, stateContainer } = renderDefaultAdHocDataViewsHook(); + const { result, toolkit, clearInstanceCache, createDataView } = + await renderDefaultAdHocDataViewsHook(); expect(clearInstanceCache).not.toHaveBeenCalled(); expect(createDataView).not.toHaveBeenCalled(); - expect(stateContainer.runtimeStateManager.adHocDataViews$.getValue()).toEqual([ + expect(toolkit.runtimeStateManager.adHocDataViews$.getValue()).toEqual([ existingAdHocDataVew, ...previousDataViews, ]); - expect(stateContainer.internalState.getState().defaultProfileAdHocDataViewIds).toEqual( + expect(toolkit.internalState.getState().defaultProfileAdHocDataViewIds).toEqual( previousDataViews.map((dv) => dv.id) ); await result.current.initializeProfileDataViews(rootProfileState); @@ -85,29 +89,28 @@ describe('useDefaultAdHocDataViews', () => { expect(createDataView.mock.calls).toEqual( newDataViews.map((dv) => [{ ...dv.toSpec(), managed: true }, true]) ); - expect( - stateContainer.runtimeStateManager.adHocDataViews$.getValue().map((dv) => dv.id) - ).toEqual([existingAdHocDataVew.id, ...newDataViews.map((dv) => dv.id)]); - expect(stateContainer.internalState.getState().defaultProfileAdHocDataViewIds).toEqual( + expect(toolkit.runtimeStateManager.adHocDataViews$.getValue().map((dv) => dv.id)).toEqual([ + existingAdHocDataVew.id, + ...newDataViews.map((dv) => dv.id), + ]); + expect(toolkit.internalState.getState().defaultProfileAdHocDataViewIds).toEqual( newDataViews.map((dv) => dv.id) ); }); it('should clear instance cache on unmount', async () => { - const { unmount, stateContainer } = renderDefaultAdHocDataViewsHook(); + const { unmount, toolkit, clearInstanceCache } = await renderDefaultAdHocDataViewsHook(); expect(clearInstanceCache).not.toHaveBeenCalled(); - expect(stateContainer.runtimeStateManager.adHocDataViews$.getValue()).toEqual([ + expect(toolkit.runtimeStateManager.adHocDataViews$.getValue()).toEqual([ existingAdHocDataVew, ...previousDataViews, ]); - expect(stateContainer.internalState.getState().defaultProfileAdHocDataViewIds).toEqual( + expect(toolkit.internalState.getState().defaultProfileAdHocDataViewIds).toEqual( previousDataViews.map((dv) => dv.id) ); unmount(); expect(clearInstanceCache.mock.calls).toEqual(previousDataViews.map((s) => [s.id])); - expect(stateContainer.runtimeStateManager.adHocDataViews$.getValue()).toEqual([ - existingAdHocDataVew, - ]); - expect(stateContainer.internalState.getState().defaultProfileAdHocDataViewIds).toEqual([]); + expect(toolkit.runtimeStateManager.adHocDataViews$.getValue()).toEqual([existingAdHocDataVew]); + expect(toolkit.internalState.getState().defaultProfileAdHocDataViewIds).toEqual([]); }); }); diff --git a/src/platform/plugins/shared/discover/public/customizations/customization_provider.test.tsx b/src/platform/plugins/shared/discover/public/customizations/customization_provider.test.tsx index 12ddbb8408997..f5681dd5cba1b 100644 --- a/src/platform/plugins/shared/discover/public/customizations/customization_provider.test.tsx +++ b/src/platform/plugins/shared/discover/public/customizations/customization_provider.test.tsx @@ -9,7 +9,7 @@ import { renderHook, act } from '@testing-library/react'; import React from 'react'; -import { getDiscoverStateMock } from '../__mocks__/discover_state.mock'; +import { getDiscoverInternalStateMock } from '../__mocks__/discover_state.mock'; import { type ConnectedCustomizationService, getConnectedCustomizationService, @@ -32,11 +32,16 @@ describe('getConnectedCustomizationService', () => { return promise; }); const customizationCallbacks: CustomizationCallback[] = [callback]; - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + const services = createDiscoverServicesMock(); + const toolkit = getDiscoverInternalStateMock({ services }); + await toolkit.initializeTabs(); + const { stateContainer } = await toolkit.initializeSingleTab({ + tabId: toolkit.getCurrentTab().id, + }); const servicePromise = getConnectedCustomizationService({ stateContainer, customizationCallbacks, - services: createDiscoverServicesMock(), + services, }); let service: ConnectedCustomizationService | undefined; expect(callback).toHaveBeenCalledTimes(1); diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx index 64f6b344c2e05..cfd25e77251d6 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx @@ -25,6 +25,7 @@ import { discoverServiceMock } from '../__mocks__/services'; import { getSearchEmbeddableFactory } from './get_search_embeddable_factory'; import type { SearchEmbeddableApi, SearchEmbeddableRuntimeState } from './types'; import { SolutionType } from '../context_awareness'; +import { mockInitializeDrilldownsManager } from '@kbn/embeddable-plugin/public/mocks'; jest.mock('./utils/serialization_utils', () => ({})); @@ -122,6 +123,7 @@ describe('saved search embeddable', () => { const { search, resolveSearch } = createSearchFnMock(0); runtimeState = getInitialRuntimeState({ searchMock: search }); const { Component, api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: mockInitializeDrilldownsManager, initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, @@ -156,6 +158,7 @@ describe('saved search embeddable', () => { }); const { Component, api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: mockInitializeDrilldownsManager, initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, @@ -181,6 +184,7 @@ describe('saved search embeddable', () => { partialState: { viewMode: VIEW_MODE.DOCUMENT_LEVEL }, }); const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: mockInitializeDrilldownsManager, initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, @@ -219,6 +223,7 @@ describe('saved search embeddable', () => { ); runtimeState = getInitialRuntimeState(); await factory.buildEmbeddable({ + initializeDrilldownsManager: mockInitializeDrilldownsManager, initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, @@ -243,6 +248,7 @@ describe('saved search embeddable', () => { }, }; await factory.buildEmbeddable({ + initializeDrilldownsManager: mockInitializeDrilldownsManager, initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, @@ -269,6 +275,7 @@ describe('saved search embeddable', () => { .mockReturnValueOnce(scopedProfilesManager); runtimeState = getInitialRuntimeState(); const { api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: mockInitializeDrilldownsManager, initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, @@ -297,6 +304,7 @@ describe('saved search embeddable', () => { partialState: { columns: ['rootProfile', 'message', 'extension'] }, }); const { Component, api } = await factory.buildEmbeddable({ + initializeDrilldownsManager: mockInitializeDrilldownsManager, initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx index aee875c4381da..0d47b6c646660 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -61,7 +61,13 @@ export const getSearchEmbeddableFactory = ({ SearchEmbeddableApi > = { type: SEARCH_EMBEDDABLE_TYPE, - buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + buildEmbeddable: async ({ + initializeDrilldownsManager, + initialState, + finalizeApi, + parentApi, + uuid, + }) => { const runtimeState = await deserializeState({ serializedState: initialState, discoverServices, @@ -96,13 +102,7 @@ export const getSearchEmbeddableFactory = ({ /** Build API */ const titleManager = initializeTitleManager(initialState); const timeRangeManager = initializeTimeRangeManager(initialState); - const dynamicActionsManager = - await discoverServices.embeddableEnhanced?.initializeEmbeddableDynamicActions( - uuid, - () => titleManager.api.title$.getValue(), - initialState - ); - const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); const searchEmbeddable = await initializeSearchEmbeddableApi(runtimeState, { discoverServices, }); @@ -114,7 +114,7 @@ export const getSearchEmbeddableFactory = ({ savedSearch: searchEmbeddable.api.savedSearch$.getValue(), serializeTitles: titleManager.getLatestState, serializeTimeRange: timeRangeManager.getLatestState, - serializeDynamicActions: dynamicActionsManager?.getLatestState, + serializeDynamicActions: drilldownsManager.getLatestState, savedObjectId, }); @@ -123,14 +123,14 @@ export const getSearchEmbeddableFactory = ({ parentApi, serializeState: () => serialize(savedObjectId$.getValue()), anyStateChange$: merge( - ...(dynamicActionsManager ? [dynamicActionsManager.anyStateChange$] : []), + drilldownsManager.anyStateChange$, searchEmbeddable.anyStateChange$, titleManager.anyStateChange$, timeRangeManager.anyStateChange$ ), getComparators: () => { return { - ...(dynamicActionsManager?.comparators ?? { drilldowns: 'skip', enhancements: 'skip' }), + ...drilldownsManager.comparators, ...titleComparators, ...timeRangeComparators, ...searchEmbeddable.comparators, @@ -151,7 +151,7 @@ export const getSearchEmbeddableFactory = ({ }; }, onReset: async (lastSaved) => { - dynamicActionsManager?.reinitializeState(lastSaved ?? {}); + drilldownsManager.reinitializeState(lastSaved ?? {}); timeRangeManager.reinitializeState(lastSaved); titleManager.reinitializeState(lastSaved); if (lastSaved) { @@ -169,7 +169,7 @@ export const getSearchEmbeddableFactory = ({ ...titleManager.api, ...searchEmbeddable.api, ...timeRangeManager.api, - ...dynamicActionsManager?.api, + ...drilldownsManager.api, ...initializeEditApi({ uuid, parentApi, @@ -254,9 +254,9 @@ export const getSearchEmbeddableFactory = ({ useEffect(() => { return () => { + drilldownsManager.cleanup(); searchEmbeddable.cleanup(); unsubscribeFromFetch(); - maybeStopDynamicActions?.stopDynamicActions(); }; }, []); diff --git a/src/platform/plugins/shared/discover/public/embeddable/types.ts b/src/platform/plugins/shared/discover/public/embeddable/types.ts index b61c686a43d3f..9604ce7860979 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/types.ts @@ -8,7 +8,7 @@ */ import type { DataTableRecord } from '@kbn/discover-utils/types'; -import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import type { DefaultEmbeddableApi, HasDrilldowns } from '@kbn/embeddable-plugin/public'; import type { HasInspectorAdapters } from '@kbn/inspector-plugin/public'; import type { EmbeddableApiContext, @@ -30,10 +30,7 @@ import type { SavedSearch, SerializableSavedSearch } from '@kbn/saved-search-plu import type { DataTableColumnsMeta } from '@kbn/unified-data-table'; import type { BehaviorSubject } from 'rxjs'; import type { PublishesWritableDataViews } from '@kbn/presentation-publishing/interfaces/publishes_data_views'; -import type { - DynamicActionsSerializedState, - HasDynamicActions, -} from '@kbn/embeddable-enhanced-plugin/public'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { EditableSavedSearchAttributes, NonPersistedDisplayOptions, @@ -73,7 +70,7 @@ export type SearchEmbeddableSerializedAttributes = Omit< export type SearchEmbeddableRuntimeState = SearchEmbeddableSerializedAttributes & SerializedTitles & SerializedTimeRange & - Partial & { + SerializedDrilldowns & { rawSavedObjectAttributes?: EditableSavedSearchAttributes; savedObjectTitle?: string; savedObjectId?: string; @@ -95,7 +92,7 @@ export type SearchEmbeddableApi = DefaultEmbeddableApi & HasTimeRange & HasInspectorAdapters & Partial & - HasDynamicActions & + HasDrilldowns & HasSupportedTriggers; export interface PublishesSavedSearch { diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts index 7aa15737d5f25..f5ba30a5099f2 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts @@ -132,10 +132,8 @@ describe('Serialization utils', () => { serializeDynamicActions: jest.fn(), }); - const attributes = toSavedSearchAttributes( - savedSearch, - searchSource.serialize().searchSourceJSON - ); + const searchSourceJSON = JSON.stringify(searchSource.getSerializedFields()); + const attributes = toSavedSearchAttributes(savedSearch, searchSourceJSON); expect(serializedState).toEqual({ attributes: { @@ -146,7 +144,6 @@ describe('Serialization utils', () => { id: expect.any(String), }, ], - references: mockedSavedSearchAttributes.references, }, }); }); diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts index 38d7953325d5f..4aa53ac074654 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts @@ -11,7 +11,7 @@ import { omit, pick } from 'lodash'; import deepEqual from 'react-fast-compare'; import { type SerializedTimeRange, type SerializedTitles } from '@kbn/presentation-publishing'; import { toSavedSearchAttributes, type SavedSearch } from '@kbn/saved-search-plugin/common'; -import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; import type { SearchEmbeddableByReferenceState, @@ -81,11 +81,11 @@ export const serializeState = ({ savedSearch: SavedSearch; serializeTitles: () => SerializedTitles; serializeTimeRange: () => SerializedTimeRange; - serializeDynamicActions: (() => DynamicActionsSerializedState) | undefined; + serializeDynamicActions: () => SerializedDrilldowns; savedObjectId?: string; }): SearchEmbeddableState => { const searchSource = savedSearch.searchSource; - const { searchSourceJSON, references: originalReferences } = searchSource.serialize(); + const searchSourceJSON = JSON.stringify(searchSource.getSerializedFields()); const savedSearchAttributes = toSavedSearchAttributes(savedSearch, searchSourceJSON); if (savedObjectId) { @@ -110,17 +110,10 @@ export const serializeState = ({ }; } - const state = { - attributes: { - ...savedSearchAttributes, - references: originalReferences, - }, - }; - return { ...serializeTitles(), ...serializeTimeRange(), ...serializeDynamicActions?.(), - ...state, + attributes: savedSearchAttributes, }; }; diff --git a/src/platform/plugins/shared/discover/public/types.ts b/src/platform/plugins/shared/discover/public/types.ts index b91947df724fa..70e2df85798f4 100644 --- a/src/platform/plugins/shared/discover/public/types.ts +++ b/src/platform/plugins/shared/discover/public/types.ts @@ -43,7 +43,6 @@ import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/publ import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { ApmSourceAccessPluginStart } from '@kbn/apm-sources-access-plugin/public'; import type { Setup as InspectorPublicPluginSetup } from '@kbn/inspector-plugin/public/plugin'; import type { FileUploadPluginStart } from '@kbn/file-upload-plugin/public'; @@ -179,7 +178,6 @@ export interface DiscoverStartPlugins { unifiedSearch: UnifiedSearchPublicPluginStart; urlForwarding: UrlForwardingStart; usageCollection?: UsageCollectionSetup; - embeddableEnhanced?: EmbeddableEnhancedPluginStart; apmSourcesAccess?: ApmSourceAccessPluginStart; fileUpload?: FileUploadPluginStart; cps?: CPSPluginStart; diff --git a/src/platform/plugins/shared/discover/server/embeddable/search_embeddable_factory.ts b/src/platform/plugins/shared/discover/server/embeddable/search_embeddable_factory.ts index 4a93f87b39954..f4beb0df760c1 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/search_embeddable_factory.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/search_embeddable_factory.ts @@ -9,10 +9,7 @@ import type { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; -import { inject, extract } from '../../common/embeddable'; export const createSearchEmbeddableFactory = (): EmbeddableRegistryDefinition => ({ id: SEARCH_EMBEDDABLE_TYPE, - inject, - extract, }); diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 1e04653fbf535..14129a65a2ec0 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -57,7 +57,6 @@ "@kbn/unified-field-list", "@kbn/cell-actions", "@kbn/shared-ux-utility", - "@kbn/core-saved-objects-server", "@kbn/discover-utils", "@kbn/search-errors", "@kbn/search-response-warnings", @@ -98,7 +97,6 @@ "@kbn/esql-language", "@kbn/discover-shared-plugin", "@kbn/response-ops-rule-form", - "@kbn/embeddable-enhanced-plugin", "@kbn/shared-ux-page-analytics-no-data-types", "@kbn/core-application-browser-mocks", "@kbn/rison", diff --git a/src/platform/plugins/shared/embeddable/README.md b/src/platform/plugins/shared/embeddable/README.md index 7dc89e5ab8299..262660525af24 100644 --- a/src/platform/plugins/shared/embeddable/README.md +++ b/src/platform/plugins/shared/embeddable/README.md @@ -63,7 +63,7 @@ The table below lists optional publishing package interfaces. Embeddables may im | Interface | Description | Used by | | --------- | ----------- | --------- | -| HasDynamicActions | Interface for accessing dynamic actions. Dynamics actions are actions that manage their own state. Dynamic action state is stored in embeddable state but managed by the dynamic action. | OPEN_FLYOUT_ADD_DRILLDOWN, OPEN_FLYOUT_EDIT_DRILLDOWN | +| HasDrilldowns | Interface for accessing drilldowns. Drilldowns manage their own state. Drilldown state is stored in embeddable state but managed by drilldown manager. | OPEN_FLYOUT_ADD_DRILLDOWN, OPEN_FLYOUT_EDIT_DRILLDOWN | | HasEditCapabilities | Interface for editing embeddable state | ACTION_EDIT_PANEL | | HasInspectorAdapters | Interface for accessing embeddable inspector adaptors | ACTION_INSPECT_PANEL, ACTION_EXPORT_CSV | | HasLibraryTransforms | Interface for linking to and unlinking from the library | ACTION_ADD_TO_LIBRARY, ACTION_UNLINK_FROM_LIBRARY | @@ -138,8 +138,8 @@ The table below lists the UiActions registered to embeddable panel triggers. | open-in-anomaly-explorer | Open in Anomaly Explorer | CONTEXT_MENU_TRIGGER | | | open-in-single-metric-viewer | Open in Single Metric Viewer | CONTEXT_MENU_TRIGGER | | | CUSTOM_TIME_RANGE_BADGE | Displays custom time range badge | PANEL_BADGE_TRIGGER | PublishesTimeRange | -| OPEN_FLYOUT_ADD_DRILLDOWN | Create drilldown | CONTEXT_MENU_TRIGGER | HasDynamicActions, HasSupportedTriggers | -| OPEN_FLYOUT_EDIT_DRILLDOWN | Edit drilldown | CONTEXT_MENU_TRIGGER | HasDynamicActions, HasSupportedTriggers | +| OPEN_FLYOUT_ADD_DRILLDOWN | Create drilldown | CONTEXT_MENU_TRIGGER | HasDrilldowns, HasSupportedTriggers | +| OPEN_FLYOUT_EDIT_DRILLDOWN | Edit drilldown | CONTEXT_MENU_TRIGGER | HasDrilldowns, HasSupportedTriggers | | SYNCHRONIZE_MOVEMENT_ACTION | Synchronize maps, so that if you zoom and pan in one map, the movement is reflected in other maps | CONTEXT_MENU_TRIGGER | | | URL_DRILLDOWN | Go to URL | CONTEXT_MENU_TRIGGER | | diff --git a/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.test.ts b/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.test.ts index ec05aeca600fc..2776f710d5d0e 100644 --- a/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.test.ts +++ b/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.test.ts @@ -49,6 +49,31 @@ describe('transformEnhancementsOut', () => { `); }); + test('should convert dashboard drilldown event with VALUE_CLICK_TRIGGER', () => { + const state = { + enhancements: { + dynamicActions: { + events: [ + { + action: { + config: { + openInNewTab: false, + useCurrentDateRange: true, + useCurrentFilters: true, + }, + factoryId: 'DASHBOARD_TO_DASHBOARD_DRILLDOWN', + name: 'Go to Dashboard', + }, + eventId: '8aeddba7-a7ed-42e2-988e-794c8435028d', + triggers: ['VALUE_CLICK_TRIGGER'], + }, + ], + }, + }, + }; + expect(transformEnhancementsOut(state).drilldowns?.[0].trigger).toBe('FILTER_TRIGGER'); + }); + test('should convert discover drilldown event', () => { const state = { enhancements: { diff --git a/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.ts b/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.ts index 4ea2bd52456bc..a78f489ed279a 100644 --- a/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.ts +++ b/src/platform/plugins/shared/embeddable/common/bwc/enhancements/transform_enhancements_out.ts @@ -7,11 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { DrilldownsState } from '../../../server'; +import { APPLY_FILTER_TRIGGER } from '@kbn/ui-actions-plugin/common/trigger_ids'; +import type { SerializedDrilldowns } from '../../../server'; import { generateRefName } from './dynamic_actions/dashboard_drilldown_persistable_state'; import type { DynamicActionsState, SerializedEvent } from './dynamic_actions/types'; -export function transformEnhancementsOut( +export function transformEnhancementsOut( state: StoredState & { enhancements?: { dynamicActions?: DynamicActionsState } } ): StoredState { const { enhancements, ...restOfState } = state; @@ -51,11 +52,17 @@ export function transformEnhancementsOut( function convertToDashboardDrilldown(event: SerializedEvent) { const { openInNewTab, useCurrentDateRange, useCurrentFilters } = event.action.config; + const trigger = event.triggers[0] ?? 'unknown'; + return { dashboardRefName: generateRefName(event.eventId), label: event.action.name, open_in_new_tab: openInNewTab ?? false, - trigger: event.triggers[0] ?? 'unknown', + // Initially dashboard drilldown relied on VALUE_CLICK & RANGE_SELECT - versions unknown + trigger: + trigger === 'VALUE_CLICK_TRIGGER' || trigger === 'SELECT_RANGE_TRIGGER' + ? APPLY_FILTER_TRIGGER + : trigger, type: 'dashboard_drilldown', use_filters: useCurrentFilters ?? true, use_time_range: useCurrentDateRange ?? true, diff --git a/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_in.ts b/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_in.ts index f50b81289f0e7..063312b7557b8 100644 --- a/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_in.ts +++ b/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_in.ts @@ -8,7 +8,7 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import type { DrilldownsState, DrilldownState } from '../../server'; +import type { SerializedDrilldowns, DrilldownState } from '../../server'; export function getTransformDrilldownsIn( getTranformIn: (type: string) => @@ -18,7 +18,7 @@ export function getTransformDrilldownsIn( }) | undefined ) { - function transformDrilldownsIn(state: State) { + function transformDrilldownsIn(state: State) { const { drilldowns, ...restOfState } = state; if (!drilldowns) { return { diff --git a/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_out.ts b/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_out.ts index 9f59a7e081ccc..90d9c1b4e8683 100644 --- a/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_out.ts +++ b/src/platform/plugins/shared/embeddable/common/drilldowns/transform_drilldowns_out.ts @@ -8,7 +8,7 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import type { DrilldownsState, DrilldownState } from '../../server'; +import type { SerializedDrilldowns, DrilldownState } from '../../server'; import { transformEnhancementsOut } from '../bwc/enhancements/transform_enhancements_out'; export function getTransformDrilldownsOut( @@ -16,7 +16,7 @@ export function getTransformDrilldownsOut( type: string ) => ((state: DrilldownState, references?: Reference[]) => DrilldownState) | undefined ) { - function transformDrilldownsOut( + function transformDrilldownsOut( storedState: StoredState, references?: Reference[] ): StoredState { diff --git a/src/platform/plugins/shared/embeddable/kibana.jsonc b/src/platform/plugins/shared/embeddable/kibana.jsonc index 8a012aefbc30b..fb25338a76e46 100644 --- a/src/platform/plugins/shared/embeddable/kibana.jsonc +++ b/src/platform/plugins/shared/embeddable/kibana.jsonc @@ -17,7 +17,7 @@ "savedObjectsManagement", "contentManagement" ], - "optionalPlugins": ["savedObjectsTaggingOss", "usageCollection"], + "optionalPlugins": ["licensing", "savedObjectsTaggingOss", "usageCollection"], "requiredBundles": ["kibanaUtils", "presentationPanel"], "extraPublicDirs": ["common"] } diff --git a/src/platform/plugins/shared/embeddable/moon.yml b/src/platform/plugins/shared/embeddable/moon.yml index 1017a6d127663..9a18f56d48668 100644 --- a/src/platform/plugins/shared/embeddable/moon.yml +++ b/src/platform/plugins/shared/embeddable/moon.yml @@ -36,6 +36,10 @@ dependsOn: - '@kbn/analytics' - '@kbn/content-management-utils' - '@kbn/config-schema' + - '@kbn/std' + - '@kbn/licensing-types' + - '@kbn/licensing-plugin' + - '@kbn/presentation-util' tags: - plugin - prod diff --git a/src/platform/plugins/shared/embeddable/public/async_module.ts b/src/platform/plugins/shared/embeddable/public/async_module.ts index fd0c552a837c0..99ecbb2e3e1ec 100644 --- a/src/platform/plugins/shared/embeddable/public/async_module.ts +++ b/src/platform/plugins/shared/embeddable/public/async_module.ts @@ -9,3 +9,6 @@ export { getTransformDrilldownsOut } from '../common/drilldowns/transform_drilldowns_out'; export { transformDashboardDrilldown } from './bwc/dashboard_drilldown'; +export { initializeDrilldownsManager } from './drilldowns/drilldowns_manager'; +export { openCreateDrilldownFlyout } from './ui_actions/open_create_drilldown_flyout'; +export { openManageDrilldownsFlyout } from './ui_actions/open_manage_drilldowns_flyout'; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/api_has_drilldowns.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/api_has_drilldowns.ts new file mode 100644 index 0000000000000..642f86cc938ab --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/api_has_drilldowns.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { HasDrilldowns } from './types'; + +export const apiHasDrilldowns = (api: unknown): api is HasDrilldowns => { + return Boolean( + api && + typeof (api as HasDrilldowns).setDrilldowns === 'function' && + (api as HasDrilldowns).drilldowns$ + ); +}; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/create_action.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/create_action.ts new file mode 100644 index 0000000000000..78e52e6ed7011 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/create_action.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { apiHasUniqueId } from '@kbn/presentation-publishing'; +import { createElement } from 'react'; +import { getDrilldown, hasDrilldown } from './registry'; +import { isCompatibleLicense, licensing, uiActions } from '../kibana_services'; +import type { DrilldownActionState } from './types'; + +export function createAction(embeddableUuid: string, drilldownState: DrilldownActionState) { + const { actionId, label, trigger, type } = drilldownState; + + if (!hasDrilldown(type)) { + // eslint-disable-next-line no-console + console.warn( + `Unable to create action for drilldown. Drilldown type not registered for [type = ${type}].` + ); + return; + } + + uiActions.addTriggerActionAsync(trigger, actionId, async () => { + const { + action: { execute, getHref, isCompatible, MenuItem }, + euiIcon, + license, + } = (await getDrilldown(type)) ?? {}; + + return { + id: actionId, + type, + getDisplayName: () => label, + getIconType: () => euiIcon, + ...(getHref + ? { + getHref: async (context: EmbeddableApiContext) => { + return getHref(drilldownState, context); + }, + } + : {}), + execute: async (context: EmbeddableApiContext) => { + if (license && licensing) { + licensing.featureUsage.notifyUsage(license.featureName).catch(() => { + // eslint-disable-next-line no-console + console.warn(`Drilldown [type = ${type}] fail notify feature usage.`); + }); + } + + await execute(drilldownState, context); + }, + isCompatible: async (context: EmbeddableApiContext) => { + const { embeddable } = context; + if (!apiHasUniqueId(embeddable) || embeddable.uuid !== embeddableUuid) { + return false; + } + + if (!(await isCompatibleLicense(license?.minimalLicense))) return false; + + return isCompatible ? isCompatible(drilldownState, context) : true; + }, + ...(MenuItem + ? { + MenuItem: ({ context }) => createElement(MenuItem, { context, drilldownState }), + } + : {}), + }; + }); +} diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/delete_action.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/delete_action.ts new file mode 100644 index 0000000000000..7276987c9469b --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/delete_action.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { uiActions } from '../kibana_services'; +import type { DrilldownActionState } from './types'; + +export function deleteAction(drilldownState: DrilldownActionState) { + const { actionId, trigger } = drilldownState; + if (!uiActions.hasAction(actionId)) return; + + uiActions.detachAction(trigger, actionId); + uiActions.unregisterAction(actionId); +} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/README.md b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/README.md similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/README.md rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/README.md diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/button_submit/button_submit.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/button_submit/button_submit.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/button_submit/button_submit.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/button_submit/button_submit.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/button_submit/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/button_submit/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/button_submit/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/button_submit/index.ts diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory/drilldown_factory.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory/drilldown_factory.tsx new file mode 100644 index 0000000000000..24b615df218dc --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory/drilldown_factory.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiText, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +const txtDrilldownAction = i18n.translate( + 'embeddableApi.components.DrilldownForm.drilldownAction', + { + defaultMessage: 'Action', + } +); + +const txtChangeButton = i18n.translate('embeddableApi.components.DrilldownForm.changeButton', { + defaultMessage: 'Change', +}); + +interface Props { + name?: string; + icon?: string; + /** On drilldown type change click. */ + onChange?: () => void; +} + +export const DrilldownFactory: React.FC = ({ name, icon, onChange }) => { + return ( + +
+ + {!!icon && ( + + + + )} + + +

{name}

+
+
+ {!!onChange && ( + + + {txtChangeButton} + + + )} +
+
+
+ ); +}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_view/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory/index.ts similarity index 89% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_view/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory/index.ts index 58111eacfd205..c3955ed26eead 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_view/index.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './action_factory_view'; +export { DrilldownFactory } from './drilldown_factory'; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/drilldown_factory_item.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/drilldown_factory_item.tsx new file mode 100644 index 0000000000000..3795f9e05771f --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/drilldown_factory_item.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexItem, EuiIcon, EuiKeyPadMenuItem, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { txtInsufficientLicenseLevel } from './i18n'; +import type { DrilldownFactory } from '../../types'; + +interface Props { + factory: DrilldownFactory; + onSelect: (drilldownType: string) => void; +} + +export const DrilldownFactoryItem: React.FC = ({ factory, onSelect }) => { + const showTooltip = !factory.isLicenseCompatible; + const { euiTheme } = useEuiTheme(); + + let content = ( + onSelect(factory.type)} + disabled={!factory.isLicenseCompatible} + > + {factory.euiIcon && } + + ); + + if (showTooltip) { + content = {content}; + } + + return ( + + {content} + + ); +}; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/drilldown_factory_picker.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/drilldown_factory_picker.tsx new file mode 100644 index 0000000000000..0f8bc59038dfb --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/drilldown_factory_picker.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { DrilldownFactory } from '../../types'; +import { DrilldownFactoryItem } from './drilldown_factory_item'; + +interface Props { + factories: DrilldownFactory[]; + onSelect: (factory: DrilldownFactory) => void; +} + +// The below style is applied to fix Firefox rendering bug. +// See: https://github.com/elastic/kibana/pull/61219/#pullrequestreview-402903330 +const firefoxBugFix = { + willChange: 'opacity', +}; + +const sort = (f1: DrilldownFactory, f2: DrilldownFactory): number => f2.order - f1.order; + +export const DrilldownFactoryPicker: React.FC = ({ factories, onSelect }) => { + /** + * Make sure items with incompatible license are at the end. + */ + const factoriesSorted = React.useMemo(() => { + const compatible = factories.filter((f) => f.isLicenseCompatible ?? true); + const incompatible = factories.filter((f) => !(f.isLicenseCompatible ?? true)); + return [...compatible.sort(sort), ...incompatible.sort(sort)]; + }, [factories]); + + const handleSelect = React.useCallback( + (id: string) => { + if (!onSelect) return; + const actionFactory = factories.find((af) => af.type === id); + if (!actionFactory) return; + onSelect(actionFactory); + }, + [onSelect, factories] + ); + + if (factoriesSorted.length === 0) { + // This is not user facing, as it would be impossible to get into this state + // just leaving for dev purposes for troubleshooting. + return
No drilldown factories to pick from.
; + } + + return ( + + {factoriesSorted.map((factory) => ( + + + + ))} + + ); +}; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/i18n.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/i18n.ts new file mode 100644 index 0000000000000..68b5e01987f5c --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/i18n.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; + +export const txtInsufficientLicenseLevel = i18n.translate( + 'embeddableApi.components.actionWizard.insufficientLicenseLevelTooltip', + { + defaultMessage: 'Insufficient license level', + } +); diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/index.ts new file mode 100644 index 0000000000000..6b5d78fbe13e2 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_factory_picker/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { DrilldownFactoryPicker } from './drilldown_factory_picker'; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/drilldown_form.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_form/drilldown_form.tsx similarity index 92% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/drilldown_form.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_form/drilldown_form.tsx index 1f43d5e6d867c..8543019973a00 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/drilldown_form.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_form/drilldown_form.tsx @@ -15,20 +15,20 @@ import type { TriggerPickerProps } from '../trigger_picker'; import { TriggerPicker } from '../trigger_picker'; const txtNameOfDrilldown = i18n.translate( - 'uiActionsEnhanced.components.DrilldownForm.nameOfDrilldown', + 'embeddableApi.components.DrilldownForm.nameOfDrilldown', { defaultMessage: 'Name', } ); const txtUntitledDrilldown = i18n.translate( - 'uiActionsEnhanced.components.DrilldownForm.untitledDrilldown', + 'embeddableApi.components.DrilldownForm.untitledDrilldown', { defaultMessage: 'Untitled drilldown', } ); -const txtTrigger = i18n.translate('uiActionsEnhanced.components.DrilldownForm.trigger', { +const txtTrigger = i18n.translate('embeddableApi.components.DrilldownForm.trigger', { defaultMessage: 'Trigger', }); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/index.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_form/index.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/index.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_form/index.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_hello_bar/drilldown_hello_bar.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/drilldown_hello_bar.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_hello_bar/drilldown_hello_bar.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/i18n.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_hello_bar/i18n.ts similarity index 79% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/i18n.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_hello_bar/i18n.ts index 5422691ed51d3..3e6f9c9e80656 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/i18n.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_hello_bar/i18n.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const txtHelpText = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText', + 'embeddableApi.drilldowns.components.DrilldownHelloBar.helpText', { defaultMessage: 'Drilldowns enable you to define new behaviors for interacting with panels. You can add multiple actions and override the default filter.', @@ -18,14 +18,14 @@ export const txtHelpText = i18n.translate( ); export const txtViewDocsLinkLabel = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', + 'embeddableApi.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', { defaultMessage: 'View docs', } ); export const txtHideHelpButtonLabel = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', + 'embeddableApi.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', { defaultMessage: 'Hide', } diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/index.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_hello_bar/index.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_hello_bar/index.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_hello_bar/index.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.test.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_table/drilldown_table.test.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.test.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_table/drilldown_table.test.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_table/drilldown_table.tsx similarity index 96% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_table/drilldown_table.tsx index ed95f51914b4b..75938e46b4077 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_table/drilldown_table.tsx @@ -29,7 +29,7 @@ export interface DrilldownTableItem { drilldownName: string; icon?: string; error?: string; - triggers?: Trigger[]; + trigger?: Trigger; triggerIncompatible?: boolean; } @@ -92,14 +92,14 @@ export const DrilldownTable: React.FC = ({ ), }, { - field: 'triggers', + field: 'trigger', name: txtTrigger, textOnly: true, sortable: (drilldown: DrilldownTableItem) => - drilldown.triggers ? drilldown.triggers[0].title : '', + drilldown.trigger ? drilldown.trigger.title : '', render: (triggers: unknown, drilldown: DrilldownTableItem) => { - if (!drilldown.triggers) return null; - const trigger = drilldown.triggers[0]; + if (!drilldown.trigger) return null; + const trigger = drilldown.trigger; return ( - i18n.translate('uiActionsEnhanced.components.DrilldownTable.deleteDrilldownsButtonLabel', { + i18n.translate('embeddableApi.components.DrilldownTable.deleteDrilldownsButtonLabel', { defaultMessage: 'Delete ({count})', values: { count, @@ -39,28 +39,25 @@ export const txtDeleteDrilldowns = (count: number) => }); export const txtSelectDrilldown = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTable.selectThisDrilldownCheckboxLabel', + 'embeddableApi.components.DrilldownTable.selectThisDrilldownCheckboxLabel', { defaultMessage: 'Select this drilldown', } ); -export const txtName = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTable.nameColumnTitle', - { - defaultMessage: 'Name', - } -); +export const txtName = i18n.translate('embeddableApi.components.DrilldownTable.nameColumnTitle', { + defaultMessage: 'Name', +}); export const txtAction = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTable.actionColumnTitle', + 'embeddableApi.components.DrilldownTable.actionColumnTitle', { defaultMessage: 'Action', } ); export const txtTrigger = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTable.triggerColumnTitle', + 'embeddableApi.components.DrilldownTable.triggerColumnTitle', { defaultMessage: 'Trigger', } diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/index.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_table/index.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/index.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_table/index.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/drilldown_template_table.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/drilldown_template_table.tsx similarity index 99% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/drilldown_template_table.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/drilldown_template_table.tsx index dc33696e4acfa..1030e216d8f97 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/drilldown_template_table.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/drilldown_template_table.tsx @@ -24,7 +24,6 @@ import { TriggerLineItem } from '../trigger_line_item'; export interface DrilldownTemplateTableItem { id: string; name: string; - icon?: string; description?: string; actionName?: string; actionIcon?: string; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/i18n.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/i18n.ts similarity index 74% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/i18n.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/i18n.ts index d872af1b8b207..56371dd52a163 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/i18n.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/i18n.ts @@ -10,14 +10,14 @@ import { i18n } from '@kbn/i18n'; export const txtSelectableMessage = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTemplateTable.selectableMessage', + 'embeddableApi.components.DrilldownTemplateTable.selectableMessage', { defaultMessage: 'Select this template', } ); export const txtNameColumnTitle = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTemplateTable.nameColumnTitle', + 'embeddableApi.components.DrilldownTemplateTable.nameColumnTitle', { defaultMessage: 'Name', description: 'Title of the first column in drilldown template cloning table.', @@ -25,7 +25,7 @@ export const txtNameColumnTitle = i18n.translate( ); export const txtSourceColumnTitle = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTemplateTable.sourceColumnTitle', + 'embeddableApi.components.DrilldownTemplateTable.sourceColumnTitle', { defaultMessage: 'Panel', description: 'Column title which describes from where the drilldown is cloned.', @@ -33,21 +33,21 @@ export const txtSourceColumnTitle = i18n.translate( ); export const txtActionColumnTitle = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTemplateTable.actionColumnTitle', + 'embeddableApi.components.DrilldownTemplateTable.actionColumnTitle', { defaultMessage: 'Action', } ); export const txtTriggerColumnTitle = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTemplateTable.triggerColumnTitle', + 'embeddableApi.components.DrilldownTemplateTable.triggerColumnTitle', { defaultMessage: 'Trigger', } ); export const txtSingleItemCopyActionLabel = i18n.translate( - 'uiActionsEnhanced.components.DrilldownTemplateTable.singleItemCopyAction', + 'embeddableApi.components.DrilldownTemplateTable.singleItemCopyAction', { defaultMessage: 'Copy', description: '"Copy" action button label in drilldown template cloning table last column.', @@ -55,7 +55,7 @@ export const txtSingleItemCopyActionLabel = i18n.translate( ); export const txtCopyButtonLabel = (count: number) => - i18n.translate('uiActionsEnhanced.components.DrilldownTemplateTable.copyButtonLabel', { + i18n.translate('embeddableApi.components.DrilldownTemplateTable.copyButtonLabel', { defaultMessage: 'Copy ({count})', description: 'Label of drilldown template table bottom copy button.', values: { diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/index.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/index.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_template_table/index.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/drilldown_template_table/index.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/flyout_frame.test.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/flyout_frame.test.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/flyout_frame.test.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/flyout_frame.test.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/flyout_frame.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/flyout_frame.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/flyout_frame.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/flyout_frame.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/i18n.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/i18n.ts similarity index 81% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/i18n.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/i18n.ts index 396ef53bfca69..8263ca42a854b 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/i18n.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/i18n.ts @@ -10,14 +10,14 @@ import { i18n } from '@kbn/i18n'; export const txtClose = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel', + 'embeddableApi.drilldowns.components.FlyoutFrame.CloseButtonLabel', { defaultMessage: 'Close', } ); export const txtBack = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel', + 'embeddableApi.drilldowns.components.FlyoutFrame.BackButtonLabel', { defaultMessage: 'Back', } diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/index.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/index.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/index.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/flyout_frame/index.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/text_with_icon/index.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/text_with_icon/index.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/text_with_icon/index.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/text_with_icon/index.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/text_with_icon/text_with_icon.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/text_with_icon/text_with_icon.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/text_with_icon/text_with_icon.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/text_with_icon/text_with_icon.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_line_item/index.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_line_item/index.tsx similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_line_item/index.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_line_item/index.tsx diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_line_item/trigger_line_item.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_line_item/trigger_line_item.tsx similarity index 94% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_line_item/trigger_line_item.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_line_item/trigger_line_item.tsx index fbed2483c72bc..6fbd585fd2e7c 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_line_item/trigger_line_item.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_line_item/trigger_line_item.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { TextWithIcon } from '../text_with_icon'; export const txtIncompatibleTooltip = i18n.translate( - 'uiActionsEnhanced.components.TriggerLineItem.incompatibleTooltip', + 'embeddableApi.components.TriggerLineItem.incompatibleTooltip', { defaultMessage: 'This trigger type not supported by this panel', } diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/trigger_picker.tsx similarity index 84% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/trigger_picker.tsx index 94174355cc015..2d12c4029181f 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/trigger_picker.tsx @@ -14,21 +14,21 @@ import type { TriggerPickerItemDescription } from './trigger_picker_item'; import { TriggerPickerItem } from './trigger_picker_item'; const txtTriggerPickerLabel = i18n.translate( - 'uiActionsEnhanced.components.actionWizard.triggerPickerLabel', + 'embeddableApi.components.actionWizard.triggerPickerLabel', { defaultMessage: 'Show option on:', } ); const txtTriggerPickerHelpText = i18n.translate( - 'uiActionsEnhanced.components.actionWizard.triggerPickerHelpText', + 'embeddableApi.components.actionWizard.triggerPickerHelpText', { defaultMessage: "What's this?", } ); const txtTriggerPickerHelpTooltip = i18n.translate( - 'uiActionsEnhanced.components.actionWizard.triggerPickerHelpTooltip', + 'embeddableApi.components.actionWizard.triggerPickerHelpTooltip', { defaultMessage: 'Determines when the drilldown appears in context menu', } @@ -38,8 +38,8 @@ export interface TriggerPickerProps { /** List of available triggers. */ items: TriggerPickerItemDescription[]; - /** List of IDs of selected triggers. */ - selected?: string[]; + /** selected trigger. */ + selected?: string; /** Link to documentation. */ docs?: string; @@ -48,12 +48,12 @@ export interface TriggerPickerProps { disabled?: boolean; /** Called on trigger selection change. */ - onChange: (selected: string[]) => void; + onChange: (selected: string) => void; } export const TriggerPicker: React.FC = ({ items, - selected = [], + selected = '', docs, disabled, onChange, @@ -83,9 +83,9 @@ export const TriggerPicker: React.FC = ({ id={trigger.id} title={trigger.title} description={trigger.description} - checked={trigger.id === selected[0]} + checked={trigger.id === selected} disabled={disabled} - onSelect={(id) => onChange([id])} + onSelect={onChange} /> ))} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker_item.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/trigger_picker_item.tsx similarity index 95% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker_item.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/trigger_picker_item.tsx index 9e56d453504ce..244dc55165d6c 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker_item.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/components/trigger_picker/trigger_picker_item.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { EuiSpacer, EuiText, EuiCheckableCard, EuiTextColor, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -const txtUnknown = i18n.translate('uiActionsEnhanced.components.TriggerPickerItem.unknown', { +const txtUnknown = i18n.translate('embeddableApi.components.TriggerPickerItem.unknown', { defaultMessage: 'Unknown', }); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/context/context.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/context/context.tsx similarity index 62% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/context/context.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/context/context.tsx index 937d69895fbe0..bf3ba396c9989 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/context/context.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/context/context.tsx @@ -9,20 +9,20 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; -import type { DrilldownManagerStateDeps } from '../../state'; -import { DrilldownManagerState } from '../../state'; +import type { DrilldownsManagerDeps } from '../../state'; +import { DrilldownsManager } from '../../state'; -const context = React.createContext(null); +const context = React.createContext(null); -export const useDrilldownManager = () => React.useContext(context)!; +export const useDrilldownsManager = () => React.useContext(context)!; -export type DrilldownManagerProviderProps = DrilldownManagerStateDeps; +export type DrilldownsManagerProviderProps = DrilldownsManagerDeps; export const DrilldownManagerProvider: React.FC< - PropsWithChildren + PropsWithChildren > = ({ children, ...deps }) => { // eslint-disable-next-line react-hooks/exhaustive-deps - const value = React.useMemo(() => new DrilldownManagerState(deps), []); + const value = React.useMemo(() => new DrilldownsManager(deps), []); return {children}; }; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/context/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/context/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/context/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/context/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/create_drilldown_form/create_drilldown_form.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/create_drilldown_form/create_drilldown_form.tsx similarity index 76% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/create_drilldown_form/create_drilldown_form.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/create_drilldown_form/create_drilldown_form.tsx index 72852caefb6dc..e0bed9daefdbc 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/create_drilldown_form/create_drilldown_form.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/create_drilldown_form/create_drilldown_form.tsx @@ -11,14 +11,14 @@ import * as React from 'react'; import { i18n } from '@kbn/i18n'; import useMountedState from 'react-use/lib/useMountedState'; import { DrilldownManagerTitle } from '../drilldown_manager_title'; -import { useDrilldownManager } from '../context'; -import { ActionFactoryPicker } from '../action_factory_picker'; +import { useDrilldownsManager } from '../context'; +import { DrilldownFactoryPicker } from '../drilldown_factory_picker'; import { DrilldownManagerFooter } from '../drilldown_manager_footer'; import { DrilldownStateForm } from '../drilldown_state_form'; import { ButtonSubmit } from '../../components/button_submit'; const txtCreateDrilldown = i18n.translate( - 'uiActionsEnhanced.drilldowns.containers.createDrilldownForm.title', + 'embeddableApi.drilldowns.containers.createDrilldownForm.title', { defaultMessage: 'Create Drilldown', description: 'Drilldowns flyout title for new drilldown form.', @@ -26,7 +26,7 @@ const txtCreateDrilldown = i18n.translate( ); const txtCreateDrilldownButton = i18n.translate( - 'uiActionsEnhanced.drilldowns.containers.createDrilldownForm.primaryButton', + 'embeddableApi.drilldowns.containers.createDrilldownForm.primaryButton', { defaultMessage: 'Create drilldown', description: 'Primary button on new drilldown creation form.', @@ -35,9 +35,9 @@ const txtCreateDrilldownButton = i18n.translate( export const CreateDrilldownForm: React.FC = () => { const isMounted = useMountedState(); - const drilldowns = useDrilldownManager(); - const drilldownState = drilldowns.getDrilldownState()!; - const error = drilldownState.useError(); + const drilldowns = useDrilldownsManager(); + const drilldown = drilldowns.getDrilldownManager(); + const error = drilldown?.useError(); const [disabled, setDisabled] = React.useState(false); const handleCreate = () => { @@ -51,9 +51,9 @@ export const CreateDrilldownForm: React.FC = () => { return ( <> {txtCreateDrilldown} - - {!!drilldownState && } - {!!drilldownState && ( + + {!!drilldown && } + {!!drilldown && ( {txtCreateDrilldownButton} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/create_drilldown_form/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/create_drilldown_form/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/create_drilldown_form/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/create_drilldown_form/index.ts diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_picker/drilldown_factory_picker.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_picker/drilldown_factory_picker.tsx new file mode 100644 index 0000000000000..14e58ce8bb21d --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_picker/drilldown_factory_picker.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { DrilldownFactoryPicker as DrilldownFactoryPickerUi } from '../../components/drilldown_factory_picker'; +import { useDrilldownsManager } from '../context'; +import { DrilldownFactoryView } from '../drilldown_factory_view'; + +export const DrilldownFactoryPicker: React.FC = ({}) => { + const drilldowns = useDrilldownsManager(); + const factory = drilldowns.useDrilldownFactory(); + + if (!!factory) { + return ; + } + + return ( + { + drilldowns.setDrilldownFactory(next); + }} + /> + ); +}; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_picker/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_picker/index.ts new file mode 100644 index 0000000000000..6b5d78fbe13e2 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_picker/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { DrilldownFactoryPicker } from './drilldown_factory_picker'; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_view/drilldown_factory_view.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_view/drilldown_factory_view.tsx new file mode 100644 index 0000000000000..771b7ab1b6b49 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_view/drilldown_factory_view.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { DrilldownFactory as DrilldownFactoryUi } from '../../components/drilldown_factory'; +import { useDrilldownsManager } from '../context'; +import type { DrilldownFactory } from '../../types'; + +interface Props { + factory: DrilldownFactory; + constant?: boolean; +} + +export const DrilldownFactoryView: React.FC = ({ factory, constant }) => { + const drilldowns = useDrilldownsManager(); + const handleChange = React.useMemo(() => { + if (constant) return undefined; + return () => drilldowns.setDrilldownFactory(undefined); + }, [drilldowns, constant]); + + return ( + + ); +}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_view/index.ts similarity index 87% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_view/index.ts index 730fb611e8748..759de833fb7db 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/index.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_factory_view/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './create_public_drilldown_manager'; +export { DrilldownFactoryView } from './drilldown_factory_view'; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/cloning_notification.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/cloning_notification.tsx similarity index 88% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/cloning_notification.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/cloning_notification.tsx index b17084b74b39d..850db9befa890 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/cloning_notification.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/cloning_notification.tsx @@ -12,7 +12,7 @@ import * as React from 'react'; import { i18n } from '@kbn/i18n'; const txtDismiss = i18n.translate( - 'uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.dismiss', + 'embeddableApi.drilldowns.containers.drilldownList.copyingNotification.dismiss', { defaultMessage: 'Dismiss', description: 'Dismiss button in cloning notification callout.', @@ -20,7 +20,7 @@ const txtDismiss = i18n.translate( ); const txtBody = (count: number) => - i18n.translate('uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.body', { + i18n.translate('embeddableApi.drilldowns.containers.drilldownList.copyingNotification.body', { defaultMessage: '{count, number} {count, plural, one {drilldown} other {drilldowns}} copied.', description: 'Title of notification show when one or more drilldowns were copied.', values: { diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/drilldown_list.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/drilldown_list.tsx similarity index 85% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/drilldown_list.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/drilldown_list.tsx index cad30396e9b5a..d9bb02f3b8a1a 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/drilldown_list.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/drilldown_list.tsx @@ -7,16 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import * as React from 'react'; +import React from 'react'; import { DrilldownTable } from '../../components/drilldown_table'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { CloningNotification } from './cloning_notification'; const FIVE_SECONDS = 5e3; export const DrilldownList: React.FC = ({}) => { - const drilldowns = useDrilldownManager(); - const events = drilldowns.useEvents(); + const drilldowns = useDrilldownsManager(); + const tableItems = drilldowns.useTableItems(); const cloningNotificationCount = React.useMemo( () => !!drilldowns.lastCloneRecord && drilldowns.lastCloneRecord.time > Date.now() - FIVE_SECONDS @@ -37,12 +37,12 @@ export const DrilldownList: React.FC = ({}) => { <> {notification} { drilldowns.setRoute(['manage', id]); }} - onCopy={drilldowns.onCreateFromDrilldown} + onCopy={drilldowns.cloneDrilldown} /> ); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_list/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_list/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager.tsx similarity index 93% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager.tsx index 194d02c6bdc70..c7e6f83b324e5 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager.tsx @@ -8,7 +8,7 @@ */ import * as React from 'react'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { FlyoutFrame } from '../../components/flyout_frame'; import { DrilldownManagerContent } from './drilldown_manager_content'; import { RenderDrilldownManagerTitle } from '../drilldown_manager_title'; @@ -16,7 +16,7 @@ import { RenderDrilldownManagerFooter } from '../drilldown_manager_footer'; import { HelloBar } from '../hello_bar'; export const DrilldownManager: React.FC = ({}) => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const route = drilldowns.useRoute(); const handleBack = diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_content.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager_content.tsx similarity index 87% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_content.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager_content.tsx index 6d93ab4f21fa6..c2a6e0df339ab 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_content.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager_content.tsx @@ -10,15 +10,15 @@ import * as React from 'react'; import { CreateDrilldownForm } from '../create_drilldown_form'; import { Tabs } from '../tabs'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { EditDrilldownForm } from '../edit_drilldown_form'; export const DrilldownManagerContent: React.FC = ({}) => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const route = drilldowns.useRoute(); if (route[0] === 'new' && !!route[1]) return ; - if (route[0] === 'manage' && !!route[1]) return ; + if (route[0] === 'manage' && !!route[1]) return ; return ; }; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_with_provider.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager_with_provider.tsx similarity index 81% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_with_provider.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager_with_provider.tsx index de008855209ec..e57d17946c9ef 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_with_provider.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager/drilldown_manager_with_provider.tsx @@ -8,11 +8,11 @@ */ import React from 'react'; -import type { DrilldownManagerProviderProps } from '../context'; +import type { DrilldownsManagerProviderProps } from '../context'; import { DrilldownManagerProvider } from '../context'; import { DrilldownManager } from './drilldown_manager'; -export const DrilldownManagerWithProvider: React.FC = (props) => { +export const DrilldownManagerWithProvider: React.FC = (props) => { return ( diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_footer/drilldown_manager_footer.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_footer/drilldown_manager_footer.tsx similarity index 86% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_footer/drilldown_manager_footer.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_footer/drilldown_manager_footer.tsx index 303a0c766c73b..748188b3228d2 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_footer/drilldown_manager_footer.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_footer/drilldown_manager_footer.tsx @@ -9,10 +9,10 @@ import type { FC, PropsWithChildren } from 'react'; import React from 'react'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; export const DrilldownManagerFooter: FC> = ({ children }) => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); React.useEffect(() => { drilldowns.setFooter(children); return () => { @@ -23,7 +23,7 @@ export const DrilldownManagerFooter: FC> = ({ childre }; export const RenderDrilldownManagerFooter: React.FC = () => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const footer = drilldowns.useFooter(); return <>{footer}; }; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_footer/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_footer/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_footer/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_footer/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_title/drilldown_manager_title.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_title/drilldown_manager_title.tsx similarity index 86% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_title/drilldown_manager_title.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_title/drilldown_manager_title.tsx index 0ec4f47af278d..47980f3e00d59 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_title/drilldown_manager_title.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_title/drilldown_manager_title.tsx @@ -8,10 +8,10 @@ */ import * as React from 'react'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; export const DrilldownManagerTitle: React.FC<{ children?: React.ReactNode }> = ({ children }) => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); React.useEffect(() => { drilldowns.setTitle(children); return () => { @@ -22,7 +22,7 @@ export const DrilldownManagerTitle: React.FC<{ children?: React.ReactNode }> = ( }; export const RenderDrilldownManagerTitle: React.FC = () => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const title = drilldowns.useTitle(); return <>{title}; }; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_title/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_title/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager_title/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_manager_title/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_state_form/drilldown_state_form.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_state_form/drilldown_state_form.tsx similarity index 56% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_state_form/drilldown_state_form.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_state_form/drilldown_state_form.tsx index ec8088c706eb2..0050d9f4e0443 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_state_form/drilldown_state_form.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_state_form/drilldown_state_form.tsx @@ -8,45 +8,41 @@ */ import React from 'react'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { DrilldownForm } from '../../components/drilldown_form'; -import type { DrilldownState } from '../../state'; +import type { DrilldownManager } from '../../state'; import type { TriggerPickerProps } from '../../components/trigger_picker'; export interface DrilldownStateFormProps { - state: DrilldownState; + drilldown: DrilldownManager; disabled?: boolean; } -export const DrilldownStateForm: React.FC = ({ state, disabled }) => { - const drilldowns = useDrilldownManager(); - const name = state.useName(); - const triggers = state.useTriggers(); - const config = state.useConfig(); +export const DrilldownStateForm: React.FC = ({ drilldown, disabled }) => { + const drilldowns = useDrilldownsManager(); + const state = drilldown.useState(); const triggerPickerProps: TriggerPickerProps = React.useMemo( () => ({ - items: state.uiTriggers.map((id) => { - const trigger = drilldowns.deps.getTrigger(id); - return trigger; + items: drilldown.uiTriggers.map((id) => { + return drilldowns.deps.getTrigger(id); }), - selected: triggers, - onChange: state.setTriggers, + selected: state.trigger, + onChange: drilldown.setTrigger, }), - [drilldowns, triggers, state] + [drilldowns, state.trigger, drilldown] ); - const context = state.getFactoryContext(); return ( - {} : state.setConfig} - context={context} + {} : drilldown.setState} /> ); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_state_form/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_state_form/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_state_form/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/drilldown_state_form/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/edit_drilldown_form/edit_drilldown_form.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/edit_drilldown_form/edit_drilldown_form.tsx similarity index 68% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/edit_drilldown_form/edit_drilldown_form.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/edit_drilldown_form/edit_drilldown_form.tsx index 48ad94eef9ae4..17bdc596e7f21 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/edit_drilldown_form/edit_drilldown_form.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/edit_drilldown_form/edit_drilldown_form.tsx @@ -11,14 +11,14 @@ import * as React from 'react'; import { i18n } from '@kbn/i18n'; import useMountedState from 'react-use/lib/useMountedState'; import { DrilldownManagerTitle } from '../drilldown_manager_title'; -import { useDrilldownManager } from '../context'; -import { ActionFactoryView } from '../action_factory_view'; +import { useDrilldownsManager } from '../context'; +import { DrilldownFactoryView } from '../drilldown_factory_view'; import { DrilldownManagerFooter } from '../drilldown_manager_footer'; import { DrilldownStateForm } from '../drilldown_state_form'; import { ButtonSubmit } from '../../components/button_submit'; const txtEditDrilldown = i18n.translate( - 'uiActionsEnhanced.drilldowns.containers.editDrilldownForm.title', + 'embeddableApi.drilldowns.containers.editDrilldownForm.title', { defaultMessage: 'Edit Drilldown', description: 'Drilldowns flyout title for edit drilldown form.', @@ -26,7 +26,7 @@ const txtEditDrilldown = i18n.translate( ); const txtEditDrilldownButton = i18n.translate( - 'uiActionsEnhanced.drilldowns.containers.editDrilldownForm.primaryButton', + 'embeddableApi.drilldowns.containers.editDrilldownForm.primaryButton', { defaultMessage: 'Save', description: 'Primary button on new drilldown edit form.', @@ -34,23 +34,23 @@ const txtEditDrilldownButton = i18n.translate( ); export interface EditDrilldownFormProps { - eventId: string; + actionId: string; } -export const EditDrilldownForm: React.FC = ({ eventId }) => { +export const EditDrilldownForm: React.FC = ({ actionId }) => { const isMounted = useMountedState(); - const drilldowns = useDrilldownManager(); - const drilldownState = React.useMemo( - () => drilldowns.createEventDrilldownState(eventId), - [drilldowns, eventId] + const drilldowns = useDrilldownsManager(); + const drilldown = React.useMemo( + () => drilldowns.createDrilldownManager(actionId), + [drilldowns, actionId] ); const [disabled, setDisabled] = React.useState(false); - if (!drilldownState) return null; + if (!drilldown) return null; const handleSave = () => { setDisabled(true); - drilldowns.updateEvent(eventId, drilldownState).finally(() => { + drilldowns.updateDrilldown(actionId, drilldown).finally(() => { if (!isMounted()) return; setDisabled(false); }); @@ -59,13 +59,9 @@ export const EditDrilldownForm: React.FC = ({ eventId }) return ( <> {txtEditDrilldown} - - {!!drilldownState && } - {!!drilldownState && ( + + {drilldown && } + {drilldown && ( {txtEditDrilldownButton} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/edit_drilldown_form/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/edit_drilldown_form/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/edit_drilldown_form/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/edit_drilldown_form/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/create_drilldown_form.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/create_drilldown_form.tsx similarity index 54% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/create_drilldown_form.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/create_drilldown_form.tsx index fa67e97db6c35..f2596748874c2 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/create_drilldown_form.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/create_drilldown_form.tsx @@ -8,36 +8,40 @@ */ import React from 'react'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { DrilldownForm } from '../../components/drilldown_form'; -import type { DrilldownState } from '../../state'; +import type { DrilldownManager } from '../../state'; import type { TriggerPickerProps } from '../../components/trigger_picker'; export interface CreateDrilldownFormProps { - state: DrilldownState; + drilldown: DrilldownManager; } -export const CreateDrilldownForm: React.FC = ({ state }) => { - const drilldowns = useDrilldownManager(); - const name = state.useName(); - const triggers = state.useTriggers(); - const config = state.useConfig(); +export const CreateDrilldownForm: React.FC = ({ drilldown }) => { + const drilldowns = useDrilldownsManager(); + const state = drilldown.useState(); const triggerPickerProps: TriggerPickerProps = React.useMemo( () => ({ - items: state.uiTriggers.map((id) => { - const trigger = drilldowns.deps.getTrigger(id); - return trigger; + items: drilldown.uiTriggers.map((id) => { + return drilldowns.deps.getTrigger(id); }), - selected: triggers, - onChange: state.setTriggers, + selected: state.trigger, + onChange: drilldown.setTrigger, }), - [drilldowns, triggers, state] + [drilldowns, state.trigger, drilldown] ); - const context = state.getFactoryContext(); return ( - - + + ); }; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/edit_drilldown_form.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/edit_drilldown_form.tsx similarity index 53% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/edit_drilldown_form.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/edit_drilldown_form.tsx index ab2677e20c83a..486e5811d823e 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/edit_drilldown_form.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/edit_drilldown_form.tsx @@ -9,45 +9,42 @@ import React from 'react'; import { EuiButton, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { DrilldownForm } from '../../components/drilldown_form'; -import type { DrilldownState } from '../../state'; +import type { DrilldownManager } from '../../state'; import type { TriggerPickerProps } from '../../components/trigger_picker'; - -export const txtDeleteDrilldownButtonLabel = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', - { - defaultMessage: 'Delete drilldown', - } -); +import { txtDeleteDrilldownButtonLabel } from './i18n'; export interface EditDrilldownFormProps { - state: DrilldownState; + drilldown: DrilldownManager; } -export const EditDrilldownForm: React.FC = ({ state }) => { - const drilldowns = useDrilldownManager(); - const name = state.useName(); - const triggers = state.useTriggers(); - const config = state.useConfig(); +export const EditDrilldownForm: React.FC = ({ drilldown }) => { + const drilldowns = useDrilldownsManager(); + const state = drilldown.useState(); const triggerPickerProps: TriggerPickerProps = React.useMemo( () => ({ - items: state.uiTriggers.map((id) => { - const trigger = drilldowns.deps.getTrigger(id); - return trigger; + items: drilldown.uiTriggers.map((id) => { + return drilldowns.deps.getTrigger(id); }), - selected: triggers, - onChange: state.setTriggers, + selected: state.trigger, + onChange: drilldown.setTrigger, }), - [drilldowns, triggers, state] + [drilldowns, state.trigger, drilldown] ); - const context = state.getFactoryContext(); return ( <> - - + + { - const drilldowns = useDrilldownManager(); - const actionFactory = drilldowns.useActionFactory(); - - const drilldownState = drilldowns.getDrilldownState(); - let content: React.ReactNode = null; - - if (!actionFactory) content = null; - if (drilldownState) content = ; + const drilldowns = useDrilldownsManager(); + const drilldown = drilldowns.getDrilldownManager(); return ( <> - - {content} + + {drilldown && } ); }; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/i18n.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/i18n.ts similarity index 75% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/i18n.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/i18n.ts index b96feaf853eee..05dc4f1e8be1f 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/i18n.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/i18n.ts @@ -10,21 +10,21 @@ import { i18n } from '@kbn/i18n'; export const txtCreateDrilldownTitle = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', { defaultMessage: 'Create Drilldown', } ); export const txtEditDrilldownTitle = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', { defaultMessage: 'Edit Drilldown', } ); export const txtDeleteDrilldownButtonLabel = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', { defaultMessage: 'Delete drilldown', } diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/form_drilldown_wizard/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/form_drilldown_wizard/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/hello_bar/hello_bar.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/hello_bar/hello_bar.tsx similarity index 89% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/hello_bar/hello_bar.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/hello_bar/hello_bar.tsx index a4f053d86811e..0af03da1cd709 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/hello_bar/hello_bar.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/hello_bar/hello_bar.tsx @@ -8,11 +8,11 @@ */ import * as React from 'react'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { DrilldownHelloBar } from '../../components/drilldown_hello_bar'; export const HelloBar: React.FC = ({}) => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const hideWelcomeMessage = drilldowns.useWelcomeMessage(); if (hideWelcomeMessage) return null; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/hello_bar/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/hello_bar/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/hello_bar/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/hello_bar/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/tabs/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/tabs/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/tabs/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/tabs/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/tabs/tabs.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/tabs/tabs.tsx similarity index 87% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/tabs/tabs.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/tabs/tabs.tsx index 96bd369a50bcd..b9772ad967964 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/tabs/tabs.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/tabs/tabs.tsx @@ -11,20 +11,20 @@ import * as React from 'react'; import type { EuiTabbedContentProps } from '@elastic/eui'; import { EuiSpacer, EuiTabbedContent } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { FormDrilldownWizard } from '../form_drilldown_wizard'; import { DrilldownList } from '../drilldown_list'; import { TemplatePicker } from '../template_picker'; export const txtCreateNew = i18n.translate( - 'uiActionsEnhanced.drilldowns.containers.DrilldownManager.createNew', + 'embeddableApi.drilldowns.containers.DrilldownManager.createNew', { defaultMessage: 'Create new', } ); export const txtManage = i18n.translate( - 'uiActionsEnhanced.drilldowns.containers.DrilldownManager.manage', + 'embeddableApi.drilldowns.containers.DrilldownManager.manage', { defaultMessage: 'Manage', } @@ -56,7 +56,7 @@ const tabs: EuiTabbedContentProps['tabs'] = [ ]; export const Tabs: React.FC = ({}) => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const route = drilldowns.useRoute(); return ( diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/i18n.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/i18n.ts similarity index 90% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/i18n.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/i18n.ts index 6445309a2e2a5..bdd19a88d22f2 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/i18n.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/i18n.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const txtLabel = i18n.translate( - 'uiActionsEnhanced.drilldownManager.containers.TemplatePicker.label', + 'embeddableApi.drilldownManager.containers.TemplatePicker.label', { defaultMessage: 'Copy existing drilldown', description: 'Label above template picker table.', diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/index.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/index.ts diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/template_list.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/template_list.tsx similarity index 76% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/template_list.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/template_list.tsx index 9723ef4956f20..9f39b9b0d170b 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/template_list.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/template_list.tsx @@ -12,7 +12,7 @@ import * as React from 'react'; import type { DrilldownTemplateTableItem } from '../../components/drilldown_template_table'; import { DrilldownTemplateTable } from '../../components/drilldown_template_table'; import type { DrilldownTemplate } from '../../types'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { txtLabel } from './i18n'; export interface TemplateListProps { @@ -20,25 +20,25 @@ export interface TemplateListProps { } export const TemplateList: React.FC = ({ items }) => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const tableItems: DrilldownTemplateTableItem[] = React.useMemo< DrilldownTemplateTableItem[] >(() => { return items.map((item) => { - const factory = drilldowns.deps.actionFactories.find(({ id }) => id === item.factoryId); - const trigger = drilldowns.deps.getTrigger(item.triggers[0]); + const factory = drilldowns.deps.factories.find( + ({ type }) => type === item.drilldownState.type + ); + const trigger = drilldowns.deps.getTrigger(item.drilldownState.trigger); const tableItem: DrilldownTemplateTableItem = { id: item.id, - name: item.name, - icon: item.icon, + name: item.drilldownState.label, description: item.description, triggerIncompatible: !drilldowns.deps.triggers.find((t) => t === trigger.id), }; if (factory) { - const context = drilldowns.getActionFactoryContext(); - tableItem.actionName = factory.getDisplayName(context); - tableItem.actionIcon = factory.getIconType(context); + tableItem.actionName = factory.displayName; + tableItem.actionIcon = factory.euiIcon; } if (trigger) { tableItem.trigger = trigger.title; @@ -56,7 +56,7 @@ export const TemplateList: React.FC = ({ items }) => { ); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/template_picker.tsx b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/template_picker.tsx similarity index 88% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/template_picker.tsx rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/template_picker.tsx index a9211f9868586..a0b75bd42e2e2 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/template_picker/template_picker.tsx +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/containers/template_picker/template_picker.tsx @@ -8,11 +8,11 @@ */ import * as React from 'react'; -import { useDrilldownManager } from '../context'; +import { useDrilldownsManager } from '../context'; import { TemplateList } from './template_list'; export const TemplatePicker: React.FC = () => { - const drilldowns = useDrilldownManager(); + const drilldowns = useDrilldownsManager(); const { templates } = drilldowns.deps; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_compatible_factories.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_compatible_factories.ts new file mode 100644 index 0000000000000..7974d364beab3 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_compatible_factories.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { asyncForEach } from '@kbn/std'; +import type { DrilldownRegistryEntry } from '../types'; +import type { DrilldownFactory } from './types'; +import { isCompatibleLicense } from '../../kibana_services'; + +export async function getCompatibleFactories( + entries: DrilldownRegistryEntry[], + context: object, + triggers: string[] +): Promise { + const factories: DrilldownFactory[] = []; + await asyncForEach(entries, async ([type, getDrilldownDefinition]) => { + const { displayName, euiIcon, license, setup, supportedTriggers } = + await getDrilldownDefinition(); + const isCompatible = setup.isCompatible ? setup.isCompatible(context) : true; + const intersectsWithTriggers = supportedTriggers.some((supportedTrigger) => + triggers.includes(supportedTrigger) + ); + if (isCompatible && intersectsWithTriggers) { + factories.push({ + type, + isLicenseCompatible: await isCompatibleLicense(license?.minimalLicense), + displayName, + euiIcon, + supportedTriggers, + ...setup, + order: setup.order ?? 0, + }); + } + }); + return factories; +} diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_drilldown_factories.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_drilldown_factories.ts new file mode 100644 index 0000000000000..71bf941a3797e --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_drilldown_factories.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { asyncForEach } from '@kbn/std'; +import type { DrilldownRegistryEntry } from '../types'; +import type { DrilldownFactory } from './types'; +import { isCompatibleLicense } from '../../kibana_services'; + +export async function getDrilldownFactories( + entries: DrilldownRegistryEntry[], + context: object +): Promise { + const factories: DrilldownFactory[] = []; + await asyncForEach(entries, async ([type, getDrilldownDefinition]) => { + const { displayName, euiIcon, license, setup, supportedTriggers } = + await getDrilldownDefinition(); + const isCompatible = setup.isCompatible ? setup.isCompatible(context) : true; + if (isCompatible) { + factories.push({ + type, + isLicenseCompatible: await isCompatibleLicense(license?.minimalLicense), + displayName, + euiIcon, + supportedTriggers, + ...setup, + order: setup.order ?? 0, + }); + } + }); + return factories; +} diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_sibling_drilldowns.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_sibling_drilldowns.ts new file mode 100644 index 0000000000000..5c325f9bb4840 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/get_sibling_drilldowns.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { HasUniqueId, PublishesTitle } from '@kbn/presentation-publishing'; +import { + apiHasParentApi, + apiHasUniqueId, + apiIsPresentationContainer, + getTitle, +} from '@kbn/presentation-publishing'; +import { v4 } from 'uuid'; +import type { DrilldownTemplate } from './types'; +import type { HasDrilldowns } from '../types'; + +export const getSiblingDrilldowns = ( + embeddable: unknown, + compatibleFactoryTypes: string[] +): DrilldownTemplate[] => { + if (!apiHasParentApi(embeddable)) { + return []; + } + const parentApi = embeddable.parentApi; + if (!apiIsPresentationContainer(parentApi)) return []; + + const templates: DrilldownTemplate[] = []; + for (const childId of Object.keys(parentApi.children$.value)) { + const child = parentApi.children$.value[childId] as Partial< + HasUniqueId & PublishesTitle & HasDrilldowns + >; + const embeddableId = apiHasUniqueId(embeddable) ? embeddable.uuid : undefined; + if (childId === embeddableId) continue; + if (!child.drilldowns$) continue; + + for (const drilldownState of child.drilldowns$.getValue()) { + if (compatibleFactoryTypes.includes(drilldownState.type)) { + templates.push({ + id: v4(), + description: getTitle(child) ?? child.uuid ?? '', + drilldownState, + }); + } + } + } + + return templates; +}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/hooks/use_sync_observable.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/hooks/use_sync_observable.ts similarity index 100% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/hooks/use_sync_observable.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/hooks/use_sync_observable.ts diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/hooks/use_table_items.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/hooks/use_table_items.ts new file mode 100644 index 0000000000000..814165ca258dc --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/hooks/use_table_items.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useMemo } from 'react'; +import type { PublishingSubject } from '@kbn/presentation-publishing'; +import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; +import type { DrilldownActionState } from '../../types'; +import { insufficientLicenseLevel, invalidDrilldownType } from '../state/i18n'; +import type { DrilldownsManagerDeps } from '../state'; + +export const useTableItems = ( + drilldowns$: PublishingSubject, + factories: DrilldownsManagerDeps['factories'], + getTrigger: DrilldownsManagerDeps['getTrigger'], + triggers: DrilldownsManagerDeps['triggers'] +) => { + const drilldowns = useStateFromPublishingSubject(drilldowns$); + + const items = useMemo(() => { + return drilldowns.map((drilldownState) => { + const factory = factories.find(({ type }) => type === drilldownState.type); + return { + id: drilldownState.actionId, + drilldownName: drilldownState.label, + actionName: factory?.displayName ?? drilldownState.type, + icon: factory?.euiIcon, + error: !factory + ? invalidDrilldownType(drilldownState.type) // this shouldn't happen for the end user, but useful during development + : !factory.isLicenseCompatible + ? insufficientLicenseLevel + : undefined, + trigger: getTrigger(drilldownState.trigger), + triggerIncompatible: !triggers.find((t) => t === drilldownState.trigger), + }; + }); + }, [drilldowns, factories, getTrigger, triggers]); + + return items; +}; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/index.ts new file mode 100644 index 0000000000000..a819b757e8254 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/index.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { createElement } from 'react'; +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { core, uiActions } from '../../kibana_services'; +import { DrilldownManagerWithProvider } from './containers/drilldown_manager/drilldown_manager_with_provider'; +import type { DrilldownRegistryEntry, HasDrilldowns } from '../types'; +import type { DrilldownsManagerDeps } from './state'; +import { getCompatibleFactories } from './get_compatible_factories'; +import { getSiblingDrilldowns } from './get_sibling_drilldowns'; + +export async function getDrilldownManagerUi( + props: HasDrilldowns & + Pick< + DrilldownsManagerDeps, + 'initialRoute' | 'onClose' | 'setupContext' | 'triggers' | 'templates' | 'closeAfterCreate' + > & { + entries: DrilldownRegistryEntry[]; + } +) { + const { entries, ...rest } = props; + + const factories = await getCompatibleFactories(entries, props.setupContext, props.triggers); + const templates = (props.setupContext as { embeddable?: unknown }).embeddable + ? getSiblingDrilldowns( + (props.setupContext as EmbeddableApiContext).embeddable, + factories.map(({ type }) => type) + ) + : []; + + return createElement(DrilldownManagerWithProvider, { + ...rest, + factories, + templates, + getTrigger: (triggerId) => uiActions.getTrigger(triggerId), + storage: new Storage(window?.localStorage), + toastService: core.notifications.toasts, + docsLink: core.docLinks.links.dashboard.drilldowns, + triggerPickerDocsLink: core.docLinks.links.dashboard.drilldownsTriggerPicker, + }); +} diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/drilldown_manager.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/drilldown_manager.ts new file mode 100644 index 0000000000000..155b3dc70d6d4 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/drilldown_manager.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; +import { map } from 'rxjs'; +import { useSyncObservable } from '../hooks/use_sync_observable'; +import type { DrilldownState } from '../../../../server/drilldowns/types'; +import type { DrilldownFactory } from '../types'; + +export interface DrilldownManagerDeps { + /** + * Drilldown factory. + */ + factory: DrilldownFactory; + + /** + * List of all triggers provided by the place from where the + * Drilldown Manager was opened. + */ + triggers: string[]; + + /** + * Initial drilldown state. + */ + initialState?: Partial; +} + +/** + * An instance of this class represents UI states of a single drilldown which + * is currently being created or edited. + */ +export class DrilldownManager { + /** + * Drilldown definition. + */ + public readonly factory: DrilldownFactory; + + /** + * User entered drilldown state. + */ + public readonly state$: BehaviorSubject>; + + /** + * List of all triggers from which the user can pick in UI for this specific + * drilldown. This is the selection list we show to the user. It is an + * intersection of all triggers supported by current place with the triggers + * that the action factory supports. + */ + public readonly uiTriggers: string[]; + + /** + * Whether the drilldown state is in an error and should not be saved. Value + * is `undefined` when there is no error. + */ + public readonly error$: Observable; + + constructor({ + factory, + triggers, + // placeContext, + initialState, + }: DrilldownManagerDeps) { + this.factory = factory; + this.state$ = new BehaviorSubject>(initialState ?? {}); + + this.uiTriggers = this.factory.supportedTriggers.filter((t) => triggers.includes(t)); + + // Pre-select a trigger if there is only one trigger for user to choose from. + // In case there is only one possible trigger, UI will not display a trigger picker. + if (this.uiTriggers.length === 1) + this.state$.next({ + ...initialState, + trigger: this.uiTriggers[0], + }); + + this.error$ = this.state$.pipe( + map((currentState) => { + if (!currentState.label) return 'NAME_EMPTY'; + if (!currentState.trigger) return 'NO_TRIGGER_SELECTED'; + if (!this.factory.isStateValid(currentState)) return 'INVALID_CONFIG'; + return undefined; + }) + ); + } + + /** + * Set drilldown label. + */ + public readonly setLabel = (label: string): void => { + this.state$.next({ + ...this.state$.getValue(), + label, + }); + }; + + /** + * Change the selected trigger. + */ + public readonly setTrigger = (trigger: string): void => { + this.state$.next({ + ...this.state$.getValue(), + trigger, + }); + }; + + /** + * Update the current drilldown configuration. + */ + public readonly setState = (state: Partial): void => { + this.state$.next(state); + }; + + /** + * Serialize the current drilldown draft into a serializable action which + * is persisted to disk. + */ + public serialize(): DrilldownState { + return { + label: '', + trigger: '', + ...this.state$.getValue(), + type: this.factory.type, + }; + } + + public isValid(): boolean { + const state = this.state$.getValue(); + return Boolean(state.label) && Boolean(state.trigger) && this.factory.isStateValid(state); + } + + // Below are convenience React hooks for consuming observables in connected + // React components. + + public readonly useState = () => useObservable(this.state$, this.state$.getValue()); + public readonly useError = () => useSyncObservable(this.error$); +} diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/drilldowns_manager.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/drilldowns_manager.ts new file mode 100644 index 0000000000000..977d24453513c --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/drilldowns_manager.ts @@ -0,0 +1,473 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import useObservable from 'react-use/lib/useObservable'; +import { BehaviorSubject, map } from 'rxjs'; +import type { ToastsStart } from '@kbn/core/public'; +import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; +import type { Trigger } from '@kbn/ui-actions-plugin/public'; +import type { DrilldownFactory, DrilldownTemplate } from '../types'; +import { + toastDrilldownCreated, + toastDrilldownsCRUDError, + txtDefaultTitle, + toastDrilldownDeleted, + toastDrilldownsDeleted, + toastDrilldownEdited, +} from './i18n'; +import { DrilldownManager } from './drilldown_manager'; +import { useTableItems } from '../hooks/use_table_items'; +import type { DrilldownState } from '../../../../server/drilldowns/types'; +import type { HasDrilldowns } from '../../types'; + +const helloMessageStorageKey = `drilldowns:hidWelcomeMessage`; + +export type DrilldownsManagerDeps = HasDrilldowns & { + /** + * List of registered drilldowns + */ + factories: DrilldownFactory[]; + + /** + * Initial screen which Drilldown Manager should display when it first opens. + * Afterwards the state of the currently visible screen is controlled by the + * Drilldown Manager. + * + * Possible values of the route: + * + * - `/create` --- opens with "Create new" tab selected. + * - `/new` --- opens with the "Create new" tab selected showing new drilldown form. + * - `/manage` --- opens with selected "Manage" tab. + * - `/manage/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy` --- opens in edit mode where + * drilldown with ID `yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy` is being edited. + */ + initialRoute?: string; + + /** + * Callback called when drilldown flyout should be closed. + */ + onClose: () => void; + + /** + * Drilldown setup context, i.e. context from open_context_menu trigger + */ + setupContext: object; + + /** + * List of possible triggers in current context + */ + triggers: string[]; + + /** + * List of drilldown templates, which will be displayed to user for fast + * drilldown creation flow. + */ + templates?: DrilldownTemplate[]; + + /** + * Whether to close the drilldown flyout after a drilldown was created + */ + closeAfterCreate?: boolean; + + /** + * Trigger getter from UI Actions trigger registry. + */ + getTrigger: (triggerId: string) => Trigger; + + /** + * Implementation of local storage interface for persisting user preferences, + * e.g. user can dismiss the welcome message. + */ + storage: IStorageWrapper; + + /** + * Services for displaying user toast notifications. + */ + toastService: ToastsStart; + + /** + * Link to drilldowns user facing docs on corporate website. + */ + docsLink?: string; + + /** + * Link to trigger picker user facing docs on corporate website. + */ + triggerPickerDocsLink?: string; +}; + +/** + * An instance of this class holds all the state necessary for Drilldown + * Manager. It also holds all the necessary controllers to change the state. + * + * `` and other container components access this state using + * the `useDrilldownsManager()` React hook: + * + * ```ts + * const drilldowns = useDrilldownsManager(); + * ``` + */ +export class DrilldownsManager { + /** + * Title displayed at the top of flyout. + */ + private readonly title$ = new BehaviorSubject(txtDefaultTitle); + + /** + * Footer displayed at the bottom of flyout. + */ + private readonly footer$ = new BehaviorSubject(null); + + /** + * Route inside Drilldown Manager flyout that is displayed to the user. Some + * available routes are: + * + * - `['create']` + * - `['new']` + * - `['new', 'dashboard_drilldown']` + * - `['manage']` + * - `['manage', 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy']` + */ + public readonly route$: BehaviorSubject; + + /** + * Whether a drilldowns welcome message should be displayed to the user at + * the very top of the drilldowns manager flyout. + */ + public readonly hideWelcomeMessage$: BehaviorSubject; + + /** + * Drilldown manager for each drilldown type used for new drilldown creation, so when user + * switches between drilldown types the configuration of the previous + * drilldown is preserved. + */ + public readonly drilldownManagers = new Map(); + + /** + * Whether user can unlock more drilldown types if they subscribe to a higher + * license tier. + */ + public readonly canUnlockMoreDrilldowns: boolean; + + /** + * Used to show cloning success notification. + */ + public lastCloneRecord: null | { time: number; templateIds: string[] } = null; + + constructor(public readonly deps: DrilldownsManagerDeps) { + const hideWelcomeMessage = deps.storage.get(helloMessageStorageKey); + this.hideWelcomeMessage$ = new BehaviorSubject(hideWelcomeMessage ?? false); + this.canUnlockMoreDrilldowns = deps.factories.some( + ({ isLicenseCompatible }) => !isLicenseCompatible + ); + + let { initialRoute = '' } = deps; + if (!initialRoute) initialRoute = 'manage'; + else if (initialRoute[0] === '/') initialRoute = initialRoute.substr(1); + this.route$ = new BehaviorSubject(initialRoute.split('/')); + } + + /** + * Set flyout main heading text. + * @param title New title. + */ + public setTitle(title: React.ReactNode) { + this.title$.next(title); + } + + /** + * Set the new flyout footer that renders at the very bottom of the Drilldown + * Manager flyout. + * @param footer New title. + */ + public setFooter(footer: React.ReactNode) { + this.footer$.next(footer); + } + + /** + * Set the flyout main heading back to its default state. + */ + public resetTitle() { + this.setTitle(txtDefaultTitle); + } + + /** + * Change the screen of Drilldown Manager. + */ + public setRoute(route: string[]): void { + if (route[0] === 'manage') this.deps.closeAfterCreate = false; + this.route$.next(route); + } + + /** + * Callback called to hide drilldowns welcome message, and remember in local + * storage that user opted to hide this message. + */ + public readonly hideWelcomeMessage = (): void => { + this.hideWelcomeMessage$.next(true); + this.deps.storage.set(helloMessageStorageKey, true); + }; + + /** + * Select a different drilldown. + */ + public setDrilldownFactory(nextFactory: undefined | DrilldownFactory): void { + if (!nextFactory) { + const route = this.route$.getValue(); + if (route[0] === 'new' && route.length > 1) this.setRoute(['new']); + return; + } + + if (!this.drilldownManagers.has(nextFactory.type)) { + const drilldown = new DrilldownManager({ + factory: nextFactory, + triggers: this.deps.triggers, + initialState: { + ...nextFactory.getInitialState(), + label: nextFactory.displayName, + }, + // placeContext: this.deps.placeContext || {}, + }); + this.drilldownManagers.set(nextFactory.type, drilldown); + } + + this.route$.next(['new', nextFactory.type]); + } + + /** + * Close the drilldown flyout. + */ + public readonly close = (): void => { + this.deps.onClose(); + }; + + /** + * Get state object of the drilldown which is currently being created. + */ + public getDrilldownManager(): undefined | DrilldownManager { + const [, type] = this.route$.getValue(); + return this.drilldownManagers.get(type); + } + + /** + * Called when user presses "Create drilldown" button to save the + * currently edited drilldown. + */ + public async createDrilldown(): Promise { + const { drilldowns$, setDrilldowns, toastService } = this.deps; + const drilldown = this.getDrilldownManager(); + + if (!drilldown) return; + + const serializedDrilldown = drilldown.serialize(); + + try { + setDrilldowns([...drilldowns$.getValue(), serializedDrilldown]); + toastService.addSuccess({ + title: toastDrilldownCreated.title(serializedDrilldown.label), + text: toastDrilldownCreated.text, + }); + this.drilldownManagers.delete(serializedDrilldown.type); + if (this.deps.closeAfterCreate) { + this.deps.onClose(); + } else { + this.setRoute(['manage']); + } + } catch (error) { + toastService.addError(error, { + title: toastDrilldownsCRUDError, + }); + throw error; + } + } + + /** + * Deletes a list of drilldowns and shows toast notifications to the user. + * + * @param ids Drilldown IDs. + */ + public readonly onDelete = (ids: string[]) => { + const { drilldowns$, setDrilldowns, toastService } = this.deps; + try { + setDrilldowns([...drilldowns$.getValue().filter(({ actionId }) => !ids.includes(actionId))]); + this.deps.toastService.addSuccess( + ids.length === 1 + ? { + title: toastDrilldownDeleted.title, + text: toastDrilldownDeleted.text, + } + : { + title: toastDrilldownsDeleted.title(ids.length), + text: toastDrilldownsDeleted.text, + } + ); + } catch (error) { + toastService.addError(error, { + title: toastDrilldownsCRUDError, + }); + } + }; + + /** + * Clone a list of selected templates. + */ + public readonly onCloneTemplates = async (templateIds: string[]) => { + const { drilldowns$, setDrilldowns, templates } = this.deps; + if (!templates) return; + + const clonedDrilldowns = templateIds + .map((templateId) => { + const template = templates.find(({ id }) => id === templateId); + return template?.drilldownState; + }) + .filter(Boolean) as DrilldownState[]; + + if (clonedDrilldowns.length) { + setDrilldowns([...drilldowns$.getValue(), ...clonedDrilldowns]); + } + + this.lastCloneRecord = { + time: Date.now(), + templateIds, + }; + this.setRoute(['manage']); + }; + + /** + * Checks if drilldown with such a name already exists. + */ + private hasDrilldownWithName(name: string): boolean { + return this.deps.drilldowns$.getValue().some(({ label }) => label === name); + } + + /** + * Picks a unique name for the cloned drilldown. Adds "(copy)", "(copy 1)", + * "(copy 2)", etc. if drilldown with such name already exists. + */ + private pickName(name: string): string { + if (this.hasDrilldownWithName(name)) { + const matches = name.match(/(.*) (\(copy[^\)]*\))/); + if (matches) name = matches[1]; + for (let i = 0; i < 100; i++) { + const proposedName = !i ? `${name} (copy)` : `${name} (copy ${i})`; + const exists = this.hasDrilldownWithName(proposedName); + if (!exists) return proposedName; + } + } + return name; + } + + public readonly onCreateFromTemplate = async (templateId: string) => { + const { templates } = this.deps; + if (!templates) return; + const template = templates.find(({ id }) => id === templateId); + if (!template) return; + const factory = this.deps.factories.find(({ type }) => type === template.drilldownState.type); + if (!factory) return; + this.setDrilldownFactory(factory); + const drilldownManager = this.getDrilldownManager(); + if (drilldownManager) { + drilldownManager.setState({ + ...template.drilldownState, + label: this.pickName(template.drilldownState.label), + }); + } + }; + + public readonly cloneDrilldown = async (actionId: string) => { + const { drilldowns$, factories } = this.deps; + const drilldownState = drilldowns$ + .getValue() + .find((drilldown) => drilldown.actionId === actionId); + if (!drilldownState) return null; + + const factory = factories.find(({ type }) => type === drilldownState.type); + if (!factory) return null; + + this.setDrilldownFactory(factory); + const drilldownManager = this.getDrilldownManager(); + if (drilldownManager) { + drilldownManager.setState({ + ...drilldownState, + label: this.pickName(drilldownState.label), + }); + } + }; + + /** + * Returns the drilldown manager of an existing drilldown for editing purposes. + * + * @param actionId action ID of the drilldown. + */ + public createDrilldownManager(actionId: string): null | DrilldownManager { + const { drilldowns$, factories, triggers } = this.deps; + const drilldownState = drilldowns$ + .getValue() + .find((drilldown) => drilldown.actionId === actionId); + if (!drilldownState) return null; + + const factory = factories.find(({ type }) => type === drilldownState.type); + if (!factory) return null; + + const state = new DrilldownManager({ + factory, + triggers, + initialState: drilldownState, + }); + return state; + } + + /** + * Save edits to an existing drilldown. + * + * @param actionId ID of the saved dynamic action event. + * @param drilldown DrilldownManager + */ + public async updateDrilldown(actionId: string, drilldown: DrilldownManager): Promise { + const { drilldowns$, setDrilldowns, toastService } = this.deps; + try { + const drilldownState = drilldown.serialize(); + setDrilldowns([ + ...drilldowns$.getValue().filter((d) => d.actionId !== actionId), + drilldownState, + ]); + toastService.addSuccess({ + title: toastDrilldownEdited.title(drilldownState.label), + text: toastDrilldownEdited.text, + }); + this.setRoute(['manage']); + } catch (error) { + toastService.addError(error, { + title: toastDrilldownsCRUDError, + }); + throw error; + } + } + + // Below are convenience React hooks for consuming observables in connected + // React components. + + public readonly useTitle = () => useObservable(this.title$, this.title$.getValue()); + public readonly useFooter = () => useObservable(this.footer$, this.footer$.getValue()); + public readonly useRoute = () => useObservable(this.route$, this.route$.getValue()); + public readonly useWelcomeMessage = () => + useObservable(this.hideWelcomeMessage$, this.hideWelcomeMessage$.getValue()); + public readonly useDrilldownFactory = () => + useObservable( + this.route$.pipe(map(() => this.getDrilldownManager()?.factory)), + this.getDrilldownManager()?.factory + ); + public readonly useTableItems = () => + useTableItems( + this.deps.drilldowns$, + this.deps.factories, + this.deps.getTrigger, + this.deps.triggers + ); +} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/i18n.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/i18n.ts similarity index 67% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/i18n.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/i18n.ts index 9b4668811980d..635827c2ca8f8 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/i18n.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/i18n.ts @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; export const toastDrilldownCreated = { title: (drilldownName: string) => i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', { defaultMessage: 'Drilldown "{drilldownName}" created', values: { @@ -21,7 +21,7 @@ export const toastDrilldownCreated = { } ), text: i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { // TODO: remove `Save your dashboard before testing.` part // when drilldowns are used not only in dashboard @@ -34,7 +34,7 @@ export const toastDrilldownCreated = { export const toastDrilldownEdited = { title: (drilldownName: string) => i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', { defaultMessage: 'Drilldown "{drilldownName}" updated', values: { @@ -43,7 +43,7 @@ export const toastDrilldownEdited = { } ), text: i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { defaultMessage: 'Save your dashboard before testing.', } @@ -52,13 +52,13 @@ export const toastDrilldownEdited = { export const toastDrilldownDeleted = { title: i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', { defaultMessage: 'Drilldown deleted', } ), text: i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', { defaultMessage: 'Save your dashboard before testing.', } @@ -68,14 +68,14 @@ export const toastDrilldownDeleted = { export const toastDrilldownsDeleted = { title: (n: number) => i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', { defaultMessage: '{n} drilldowns deleted', values: { n }, } ), text: i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', { defaultMessage: 'Save your dashboard before testing.', } @@ -83,7 +83,7 @@ export const toastDrilldownsDeleted = { }; export const toastDrilldownsCRUDError = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', { defaultMessage: 'Error saving drilldown', description: 'Title for generic error toast when persisting drilldown updates failed', @@ -91,7 +91,7 @@ export const toastDrilldownsCRUDError = i18n.translate( ); export const insufficientLicenseLevel = i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError', + 'embeddableApi.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError', { defaultMessage: 'Insufficient license level', description: @@ -100,18 +100,15 @@ export const insufficientLicenseLevel = i18n.translate( ); export const invalidDrilldownType = (type: string) => - i18n.translate( - 'uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType', - { - defaultMessage: "Drilldown type {type} doesn't exist", - values: { - type, - }, - } - ); + i18n.translate('embeddableApi.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType', { + defaultMessage: "Drilldown type {type} doesn't exist", + values: { + type, + }, + }); export const txtDefaultTitle = i18n.translate( - 'uiActionsEnhanced.drilldowns.drilldownManager.state.defaultTitle', + 'embeddableApi.drilldowns.drilldownManager.state.defaultTitle', { defaultMessage: 'Drilldowns', description: 'Drilldowns flyout title.', diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/index.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/index.ts similarity index 91% rename from src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/index.ts rename to src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/index.ts index 263e387a86c46..95def964684ce 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/index.ts +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/state/index.ts @@ -7,5 +7,5 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export type * from './drilldown_definition'; export * from './drilldown_manager'; +export * from './drilldowns_manager'; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/types.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/types.ts new file mode 100644 index 0000000000000..18a567ccc7ecc --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldown_manager_ui/types.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DrilldownDefinition } from '../types'; +import type { DrilldownState } from '../../../server/drilldowns/types'; + +/** + * Template for a pre-configured new drilldown, this gives ability to create a + * drilldown from a template instead of user creating a drilldown from scratch. + * This is used in "drilldown cloning" functionality, where drilldowns can be + * cloned from one dashboard panel to another. + */ +export interface DrilldownTemplate { + /** + * A string that uniquely identifies this item in a list of `DrilldownTemplate[]`. + */ + id: string; + + /** + * A user facing text that provides information about the source of this template. + */ + description: string; + + /** + * Preliminary configuration of the new drilldown, to be used in the dynamicaction factory. + */ + drilldownState: DrilldownState; +} + +export type DrilldownFactory = Pick< + DrilldownDefinition, + 'displayName' | 'euiIcon' | 'supportedTriggers' +> & + DrilldownDefinition['setup'] & { + order: number; + type: string; + isLicenseCompatible: boolean; + }; diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/drilldowns_manager.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldowns_manager.ts new file mode 100644 index 0000000000000..94bfa103a9c92 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/drilldowns_manager.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject, map } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; +import deepEqual from 'fast-deep-equal'; +import type { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import type { DrilldownsManager, DrilldownActionState } from './types'; +import { createAction } from './create_action'; +import { deleteAction } from './delete_action'; +import type { SerializedDrilldowns, DrilldownState } from '../../server'; + +export function initializeDrilldownsManager( + embeddableUuid: string, + state: SerializedDrilldowns +): DrilldownsManager { + const drilldowns$ = new BehaviorSubject([]); + const api: DrilldownsManager['api'] = { + drilldowns$: drilldowns$ as PublishingSubject, + setDrilldowns: (next: DrilldownState[]) => { + deleteActions(); + const drilldowns = next.map((drilldown) => { + return { + ...drilldown, + actionId: `${drilldown.type}_${uuidv4()}`, + }; + }); + drilldowns$.next(drilldowns); + drilldowns.forEach((drilldownState) => createAction(embeddableUuid, drilldownState)); + }, + }; + api.setDrilldowns(state.drilldowns ?? []); + + function deleteActions() { + drilldowns$.value.forEach(deleteAction); + } + + return { + api: { ...api }, + cleanup: deleteActions, + comparators: { + drilldowns: (a, b) => deepEqual(a ?? [], b ?? []), + } as StateComparators, + anyStateChange$: drilldowns$.pipe(map(() => undefined)), + getLatestState: () => ({ + drilldowns: drilldowns$.value.map((drilldown) => { + const { actionId, ...rest } = drilldown; + return rest; + }), + }), + reinitializeState: (lastState: SerializedDrilldowns) => { + api.setDrilldowns(lastState.drilldowns ?? []); + }, + }; +} diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/registry.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/registry.ts new file mode 100644 index 0000000000000..493b8450b9607 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/registry.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DrilldownDefinition } from './types'; + +const registry: { + [key: string]: () => Promise>; +} = {}; + +export function registerDrilldown( + type: string, + getFn: () => Promise> +) { + if (registry[type]) { + throw new Error(`Drilldown already registered for type "${type}".`); + } + + registry[type] = getFn; +} + +export async function getDrilldown(type: string) { + return await registry[type]?.(); +} + +export function hasDrilldown(type: string) { + return Boolean(registry[type]); +} + +export function getDrilldownRegistryEntries() { + return Object.entries(registry); +} diff --git a/src/platform/plugins/shared/embeddable/public/drilldowns/types.ts b/src/platform/plugins/shared/embeddable/public/drilldowns/types.ts new file mode 100644 index 0000000000000..e3600aa6f1161 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/drilldowns/types.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { LicenseType } from '@kbn/licensing-types'; +import type { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import type { Observable } from 'rxjs'; +import type { FC } from 'react'; +import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import type { SerializedDrilldowns, DrilldownState } from '../../server'; + +export type DrilldownActionState = DrilldownState & { actionId: string }; + +export type DrilldownDefinition< + TDrilldownState extends DrilldownState = DrilldownState, + // Drilldown action execution context, i.e. context from on_filter trigger + ExecutionContext extends object = object, + // Drilldown setup context, i.e. context from open_context_menu trigger + SetupContext extends object = object +> = { + /** + * Drilldown type display name. i.e. "Go to dashboard" + * Should be i18n string + */ + displayName: string; + + /** + * Name of EUI icon to display when showing this drilldown to user. + */ + euiIcon?: string; + + license?: { + /** + * Minimal license level + * Empty means no restrictions + */ + minimalLicense: LicenseType; + + /** + * Is a user-facing string. Has to be unique. Doesn't need i18n. + * The feature's name will be displayed to Cloud end-users when they're billed based on their feature usage. + */ + featureName: string; + }; + + /** + * List of triggers supported by drilldown type + * Used to narrow trigger selection when configuring drilldown + */ + supportedTriggers: string[]; + + /** + * Used during drilldown setup (create/edit drilldown configuration). + */ + setup: { + /** + * Drilldown editor component. Rendered as child of EuiForm component. + */ + readonly Editor: FC>; + + getInitialState(): Partial>; + + /** + * Compatibility check during drilldown setup + */ + isCompatible?(context: SetupContext): boolean; + + isStateValid(state: Partial>): boolean; + + /** + * Determines the display order of the drilldowns in the flyout picker. + * Higher numbers are displayed first. + */ + order?: number; + }; + + /** + * During embeddable setup, an action is registered for the drilldown configuration. + * Used during drilldown action execution. + */ + action: { + /** + * Implements the "navigation" action of the drilldown. This happens when + * user interacts with something in the UI that executes the drilldown trigger. + * + * @param drilldownState Drilldown state. + * @param executionContext Object that represents context in which the drilldown is being executed in. + */ + execute(drilldownState: TDrilldownState, context: ExecutionContext): Promise; + + /** + * Returns a link where drilldown should navigate on middle click or Ctrl + click. + */ + getHref?( + drilldownState: TDrilldownState, + context: ExecutionContext + ): Promise; + + /** + * Compatibility check during drilldown execution + */ + isCompatible?: (drilldownState: TDrilldownState, context: ExecutionContext) => Promise; + + MenuItem?: FC<{ + drilldownState: TDrilldownState; + context: ActionExecutionContext; + }>; + }; +}; + +export type DrilldownRegistryEntry = [string, () => Promise]; + +/** + * Props provided to `Editor` component on every re-render. + */ +export interface DrilldownEditorProps< + TDrilldownState extends DrilldownState = DrilldownState, + SetupContext extends object = object +> { + /** + * Current (latest) state. + */ + state: Partial; + + /** + * Callback called when user updates the state in UI. + */ + onChange: (state: Partial) => void; + + /** + * Context information about where component is being rendered. + */ + context: SetupContext; +} + +export interface DrilldownsManager { + api: HasDrilldowns; + cleanup: () => void; + comparators: StateComparators; + anyStateChange$: Observable; + getLatestState: () => SerializedDrilldowns; + reinitializeState: (lastState: SerializedDrilldowns) => void; +} + +export type HasDrilldowns = { + setDrilldowns: (drilldowns: DrilldownState[]) => void; + drilldowns$: PublishingSubject; +}; diff --git a/src/platform/plugins/shared/embeddable/public/index.ts b/src/platform/plugins/shared/embeddable/public/index.ts index fbb65e4e0d532..8d1873e279e95 100644 --- a/src/platform/plugins/shared/embeddable/public/index.ts +++ b/src/platform/plugins/shared/embeddable/public/index.ts @@ -10,6 +10,8 @@ import type { PluginInitializerContext } from '@kbn/core/public'; import { EmbeddablePublicPlugin } from './plugin'; +export type { DrilldownDefinition, DrilldownEditorProps } from './drilldowns/types'; + export { useAddFromLibraryTypes } from './add_from_library/registry'; export { PanelNotFoundError, PanelIncompatibleError } from './react_embeddable_system'; export { EmbeddableStateTransfer } from './state_transfer'; @@ -35,6 +37,10 @@ export { type EmbeddableFactory, } from './react_embeddable_system'; +export type { DrilldownsManager, HasDrilldowns } from './drilldowns/types'; + +export type { SerializedDrilldowns } from '../server'; + export function plugin(initializerContext: PluginInitializerContext) { return new EmbeddablePublicPlugin(initializerContext); } diff --git a/src/platform/plugins/shared/embeddable/public/kibana_services.ts b/src/platform/plugins/shared/embeddable/public/kibana_services.ts index 0f966c7c32724..d62af8960e304 100644 --- a/src/platform/plugins/shared/embeddable/public/kibana_services.ts +++ b/src/platform/plugins/shared/embeddable/public/kibana_services.ts @@ -11,6 +11,7 @@ import { BehaviorSubject } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; +import type { LicenseType } from '@kbn/licensing-types'; import type { EmbeddableStart, EmbeddableStartDependencies } from './types'; export let core: CoreStart; @@ -21,6 +22,7 @@ export let usageCollection: EmbeddableStartDependencies['usageCollection']; export let savedObjectsManagement: EmbeddableStartDependencies['savedObjectsManagement']; export let savedObjectsTaggingOss: EmbeddableStartDependencies['savedObjectsTaggingOss']; export let contentManagement: EmbeddableStartDependencies['contentManagement']; +export let licensing: EmbeddableStartDependencies['licensing']; const servicesReady$ = new BehaviorSubject(false); export const untilPluginStartServicesReady = () => { @@ -48,6 +50,15 @@ export const setKibanaServices = ( savedObjectsManagement = deps.savedObjectsManagement; savedObjectsTaggingOss = deps.savedObjectsTaggingOss; contentManagement = deps.contentManagement; + licensing = deps.licensing; servicesReady$.next(true); }; + +// isCompatibleLicense used in multiple async modules +// Putting into page load to avoid having it split into a seperate async module +export async function isCompatibleLicense(minimalLicense?: LicenseType) { + if (!minimalLicense || !licensing) return true; + const license = await licensing?.getLicense(); + return license.isAvailable && license.isActive && license.hasAtLeast(minimalLicense); +} diff --git a/src/platform/plugins/shared/embeddable/public/mocks.tsx b/src/platform/plugins/shared/embeddable/public/mocks.tsx index b1216e2bb4824..6cb91d08c1fcd 100644 --- a/src/platform/plugins/shared/embeddable/public/mocks.tsx +++ b/src/platform/plugins/shared/embeddable/public/mocks.tsx @@ -19,6 +19,7 @@ import { savedObjectsManagementPluginMock } from '@kbn/saved-objects-management- import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; +import { BehaviorSubject, of } from 'rxjs'; import type { EmbeddableStateTransfer } from '.'; import { setKibanaServices } from './kibana_services'; import { EmbeddablePublicPlugin } from './plugin'; @@ -30,6 +31,8 @@ import type { EmbeddableStart, EmbeddableStartDependencies, } from './types'; +import type { DrilldownActionState, DrilldownsManager } from './drilldowns/types'; +import type { SerializedDrilldowns } from '../server'; export type Setup = jest.Mocked; export type Start = jest.Mocked; @@ -47,6 +50,7 @@ export const createEmbeddableStateTransferMock = (): Partial { const setupContract: Setup = { registerAddFromLibraryType: jest.fn().mockImplementation(registerAddFromLibraryType), + registerDrilldown: jest.fn(), registerReactEmbeddableFactory: jest.fn().mockImplementation(registerReactEmbeddableFactory), registerLegacyURLTransform: jest.fn(), }; @@ -119,3 +123,26 @@ export const setStubKibanaServices = () => { contentManagement: contentManagementMock.createStartContract(), }); }; + +export function mockDrilldownsManager(): DrilldownsManager { + return { + api: { + drilldowns$: new BehaviorSubject([]), + setDrilldowns: jest.fn(), + }, + cleanup: jest.fn(), + comparators: { + drilldowns: jest.fn(), + }, + anyStateChange$: of(), + getLatestState: jest.fn(), + reinitializeState: jest.fn(), + }; +} + +export async function mockInitializeDrilldownsManager( + embeddableUuid: string, + state: SerializedDrilldowns +): Promise { + return mockDrilldownsManager(); +} diff --git a/src/platform/plugins/shared/embeddable/public/plugin.tsx b/src/platform/plugins/shared/embeddable/public/plugin.tsx index 3cd11bdd8ed5d..a60ee726ebbae 100644 --- a/src/platform/plugins/shared/embeddable/public/plugin.tsx +++ b/src/platform/plugins/shared/embeddable/public/plugin.tsx @@ -16,6 +16,7 @@ import type { PublicAppInfo, } from '@kbn/core/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { CONTEXT_MENU_TRIGGER } from '@kbn/ui-actions-plugin/common/trigger_ids'; import { EmbeddableStateTransfer } from './state_transfer'; import { setKibanaServices } from './kibana_services'; import { registerReactEmbeddableFactory } from './react_embeddable_system'; @@ -31,6 +32,8 @@ import { hasLegacyURLTransform, getLegacyURLTransform, } from './bwc/legacy_url_transform'; +import { registerDrilldown } from './drilldowns/registry'; +import { OPEN_FLYOUT_ADD_DRILLDOWN, OPEN_FLYOUT_EDIT_DRILLDOWN } from './ui_actions/constants'; export class EmbeddablePublicPlugin implements Plugin { private stateTransferService: EmbeddableStateTransfer = {} as EmbeddableStateTransfer; @@ -40,7 +43,18 @@ export class EmbeddablePublicPlugin implements Plugin { + const { openCreateDrilldownFlyout } = await import('./async_module'); + return openCreateDrilldownFlyout; + }); + + uiActions.addTriggerActionAsync(CONTEXT_MENU_TRIGGER, OPEN_FLYOUT_EDIT_DRILLDOWN, async () => { + const { openManageDrilldownsFlyout } = await import('./async_module'); + return openManageDrilldownsFlyout; + }); + return { + registerDrilldown, registerReactEmbeddableFactory, registerAddFromLibraryType, registerLegacyURLTransform, diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx index 4052a7ae67549..14d9e73d6f6d2 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx @@ -62,6 +62,7 @@ describe('embeddable renderer', () => { ); await waitFor(() => { expect(buildEmbeddableSpy).toHaveBeenCalledWith({ + initializeDrilldownsManager: expect.any(Function), initialState: { bork: 'blorp?' }, parentApi: expect.any(Object), uuid: expect.any(String), @@ -85,6 +86,7 @@ describe('embeddable renderer', () => { ); await waitFor(() => { expect(buildEmbeddableSpy).toHaveBeenCalledWith({ + initializeDrilldownsManager: expect.any(Function), initialState: { bork: 'blorp?' }, parentApi: expect.any(Object), uuid: '12345', @@ -104,6 +106,7 @@ describe('embeddable renderer', () => { render( parentApi} />); await waitFor(() => { expect(buildEmbeddableSpy).toHaveBeenCalledWith({ + initializeDrilldownsManager: expect.any(Function), initialState: { bork: 'blorp?' }, parentApi, uuid: expect.any(String), diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx index 29fd49e256989..8b50073fb3c4d 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -19,6 +19,7 @@ import { PresentationPanel } from '@kbn/presentation-panel-plugin/public'; import { PhaseTracker } from './phase_tracker'; import { getReactEmbeddableFactory } from './react_embeddable_registry'; import type { DefaultEmbeddableApi, EmbeddableApiRegistration } from './types'; +import type { SerializedDrilldowns } from '../../server'; /** * Renders a component from the React Embeddable registry into a Presentation Panel. @@ -100,6 +101,13 @@ export const EmbeddableRenderer = < finalizeApi, uuid, parentApi, + initializeDrilldownsManager: async ( + embeddableUuid: string, + state: SerializedDrilldowns + ) => { + const { initializeDrilldownsManager } = await import('../async_module'); + return initializeDrilldownsManager(embeddableUuid, state); + }, }); phaseTracker.current.trackPhaseEvents(uuid, api); diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts index dbb66d5bfb62b..fa81d86b737cd 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts @@ -15,6 +15,8 @@ import type { PublishesPhaseEvents, } from '@kbn/presentation-publishing'; import type React from 'react'; +import type { initializeDrilldownsManager } from '../drilldowns/drilldowns_manager'; +import type { SerializedDrilldowns } from '../../server'; /** * The default embeddable API that all Embeddables must implement. @@ -59,6 +61,14 @@ export interface BuildEmbeddableProps< * An optional parent API. */ parentApi: unknown | undefined; + + /** + * + */ + initializeDrilldownsManager( + embeddableUuid: string, + state: SerializedDrilldowns + ): Promise>; } /** diff --git a/src/platform/plugins/shared/embeddable/public/types.ts b/src/platform/plugins/shared/embeddable/public/types.ts index 45417d44818c8..2ee11d7d579fe 100644 --- a/src/platform/plugins/shared/embeddable/public/types.ts +++ b/src/platform/plugins/shared/embeddable/public/types.ts @@ -14,11 +14,13 @@ import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-manag import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { registerAddFromLibraryType } from './add_from_library/registry'; import type { registerReactEmbeddableFactory } from './react_embeddable_system'; import type { EmbeddableStateTransfer } from './state_transfer'; import type { DrilldownTransforms, EmbeddableTransforms } from '../common'; import type { AddFromLibraryFormProps } from './add_from_library/add_from_library_flyout'; +import type { registerDrilldown } from './drilldowns/registry'; export interface EmbeddableSetupDependencies { uiActions: UiActionsSetup; @@ -31,6 +33,7 @@ export interface EmbeddableStartDependencies { contentManagement: ContentManagementPublicStart; savedObjectsManagement: SavedObjectsManagementPluginStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + licensing?: LicensingPluginStart; } export interface EmbeddableSetup { @@ -62,6 +65,11 @@ export interface EmbeddableSetup { */ registerAddFromLibraryType: typeof registerAddFromLibraryType; + /** + * Registers an async {@link DrilldownDefintion} getter. + */ + registerDrilldown: typeof registerDrilldown; + /** * Registers an async {@link ReactEmbeddableFactory} getter. */ diff --git a/src/platform/plugins/shared/embeddable/public/ui_actions/constants.ts b/src/platform/plugins/shared/embeddable/public/ui_actions/constants.ts new file mode 100644 index 0000000000000..4e439784eb878 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/ui_actions/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const DRILLDOWN_ACTION_GROUP = { id: 'drilldown', order: 3 } as const; +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; diff --git a/src/platform/plugins/shared/embeddable/public/ui_actions/get_embeddable_triggers.ts b/src/platform/plugins/shared/embeddable/public/ui_actions/get_embeddable_triggers.ts new file mode 100644 index 0000000000000..ba81e3611b65b --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/ui_actions/get_embeddable_triggers.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { HasSupportedTriggers } from '@kbn/presentation-publishing'; +import { + APPLY_FILTER_TRIGGER, + CONTEXT_MENU_TRIGGER, + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, +} from '@kbn/ui-actions-plugin/common/trigger_ids'; + +export function getEmbeddableTriggers(embeddable: HasSupportedTriggers) { + return [CONTEXT_MENU_TRIGGER, ...ensureNestedTriggers(embeddable.supportedTriggers())]; +} + +/** + * We know that VALUE_CLICK_TRIGGER and SELECT_RANGE_TRIGGER are also triggering APPLY_FILTER_TRIGGER. + * This function appends APPLY_FILTER_TRIGGER to the list of triggers if either VALUE_CLICK_TRIGGER + * or SELECT_RANGE_TRIGGER was executed. + * + * TODO: this probably should be part of uiActions infrastructure, + * but dynamic implementation of nested trigger doesn't allow to statically express such relations + * + * @param triggers + */ +function ensureNestedTriggers(triggers: string[]): string[] { + if ( + !triggers.includes(APPLY_FILTER_TRIGGER) && + (triggers.includes(VALUE_CLICK_TRIGGER) || triggers.includes(SELECT_RANGE_TRIGGER)) + ) { + return [...triggers, APPLY_FILTER_TRIGGER]; + } + + return triggers; +} diff --git a/src/platform/plugins/shared/embeddable/public/ui_actions/open_create_drilldown_flyout.ts b/src/platform/plugins/shared/embeddable/public/ui_actions/open_create_drilldown_flyout.ts new file mode 100644 index 0000000000000..3edfe2918fd66 --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/ui_actions/open_create_drilldown_flyout.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; +import { openLazyFlyout } from '@kbn/presentation-util'; +import type { PresentationContainer } from '@kbn/presentation-publishing'; +import { + apiCanAccessViewMode, + apiHasParentApi, + apiHasSupportedTriggers, + apiIsOfType, + getInheritedViewMode, + type CanAccessViewMode, + type EmbeddableApiContext, + type HasUniqueId, + type HasParentApi, + type HasSupportedTriggers, + type HasType, + apiHasUniqueId, +} from '@kbn/presentation-publishing'; +import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import type { ActionDefinition } from '@kbn/ui-actions-plugin/public/actions'; +import { asyncForEach } from '@kbn/std'; +import type { DrilldownRegistryEntry, HasDrilldowns } from '../drilldowns/types'; +import { getDrilldownRegistryEntries } from '../drilldowns/registry'; +import { getEmbeddableTriggers } from './get_embeddable_triggers'; +import { core, isCompatibleLicense } from '../kibana_services'; +import { OPEN_FLYOUT_ADD_DRILLDOWN, DRILLDOWN_ACTION_GROUP } from './constants'; +import { apiHasDrilldowns } from '../drilldowns/api_has_drilldowns'; + +export type CreateDrilldownActionApi = CanAccessViewMode & + Required & + HasParentApi> & + HasSupportedTriggers & + Partial; + +const isApiCompatible = (api: unknown | null): api is CreateDrilldownActionApi => + apiHasDrilldowns(api) && + apiHasParentApi(api) && + apiCanAccessViewMode(api) && + apiHasSupportedTriggers(api); + +export const openCreateDrilldownFlyout: ActionDefinition = { + id: OPEN_FLYOUT_ADD_DRILLDOWN, + type: OPEN_FLYOUT_ADD_DRILLDOWN, + order: 12, + getIconType: () => 'plusInCircle', + grouping: [DRILLDOWN_ACTION_GROUP], + getDisplayName: () => + i18n.translate('embeddableApi.createDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }), + isCompatible: async (context: EmbeddableApiContext) => { + const { embeddable } = context; + if (!isApiCompatible(embeddable)) return false; + if ( + getInheritedViewMode(embeddable) !== 'edit' || + !apiIsOfType(embeddable.parentApi, 'dashboard') + ) + return false; + + /** + * Check if there is an intersection between all registered drilldowns possible triggers that they could be attached to + * and triggers that current embeddable supports + */ + const drilldownTriggers = await getAllDrilldownTriggers(getDrilldownRegistryEntries(), context); + return getEmbeddableTriggers(embeddable).some((trigger) => drilldownTriggers.includes(trigger)); + }, + execute: async (context: EmbeddableApiContext) => { + const { embeddable } = context; + if (!isApiCompatible(embeddable)) throw new IncompatibleActionError(); + + openLazyFlyout({ + core, + parentApi: embeddable.parentApi, + loadContent: async ({ closeFlyout }) => { + const { getDrilldownManagerUi } = await import('../drilldowns/drilldown_manager_ui'); + return getDrilldownManagerUi({ + entries: getDrilldownRegistryEntries(), + closeAfterCreate: true, + initialRoute: '/new', + drilldowns$: embeddable.drilldowns$, + setDrilldowns: embeddable.setDrilldowns, + setupContext: context, + triggers: getEmbeddableTriggers(embeddable), + onClose: closeFlyout, + }); + }, + flyoutProps: { + 'data-test-subj': 'createDrilldownFlyout', + 'aria-labelledby': 'drilldownFlyoutTitleAriaId', + focusedPanelId: apiHasUniqueId(embeddable) ? embeddable.uuid : undefined, + }, + }); + }, +}; + +export async function getAllDrilldownTriggers(entries: DrilldownRegistryEntry[], context: object) { + const drilldownTriggers = new Set(); + await asyncForEach(entries, async ([, drilldownGetFn]: DrilldownRegistryEntry) => { + const { license, setup, supportedTriggers } = await drilldownGetFn(); + const isCompatible = setup.isCompatible ? setup.isCompatible(context) : true; + if (isCompatible && (await isCompatibleLicense(license?.minimalLicense))) { + supportedTriggers.forEach((trigger) => drilldownTriggers.add(trigger)); + } + }); + return Array.from(drilldownTriggers); +} diff --git a/src/platform/plugins/shared/embeddable/public/ui_actions/open_manage_drilldowns_flyout.tsx b/src/platform/plugins/shared/embeddable/public/ui_actions/open_manage_drilldowns_flyout.tsx new file mode 100644 index 0000000000000..79e06af027e3c --- /dev/null +++ b/src/platform/plugins/shared/embeddable/public/ui_actions/open_manage_drilldowns_flyout.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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo } from 'react'; +import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import { openLazyFlyout } from '@kbn/presentation-util'; +import type { PresentationContainer } from '@kbn/presentation-publishing'; +import { + apiCanAccessViewMode, + apiHasSupportedTriggers, + getInheritedViewMode, + type CanAccessViewMode, + type EmbeddableApiContext, + type HasUniqueId, + type HasParentApi, + type HasSupportedTriggers, + apiHasUniqueId, + useStateFromPublishingSubject, +} from '@kbn/presentation-publishing'; +import type { ActionDefinition } from '@kbn/ui-actions-plugin/public/actions'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiNotificationBadge } from '@elastic/eui'; +import type { HasDrilldowns } from '../drilldowns/types'; +import { DRILLDOWN_ACTION_GROUP, OPEN_FLYOUT_EDIT_DRILLDOWN } from './constants'; +import { core } from '../kibana_services'; +import { apiHasDrilldowns } from '../drilldowns/api_has_drilldowns'; +import { getEmbeddableTriggers } from './get_embeddable_triggers'; +import { getDrilldownRegistryEntries } from '../drilldowns/registry'; + +export type ManageDrilldownActionApi = CanAccessViewMode & + HasDrilldowns & + HasParentApi> & + HasSupportedTriggers & + Partial; + +const isApiCompatible = (api: unknown | null): api is ManageDrilldownActionApi => + apiHasDrilldowns(api) && apiCanAccessViewMode(api) && apiHasSupportedTriggers(api); + +const DISPLAY_NAME = i18n.translate('embeddableApi.manageDrilldownAction.displayName', { + defaultMessage: 'Manage drilldowns', +}); + +export const openManageDrilldownsFlyout: ActionDefinition = { + id: OPEN_FLYOUT_EDIT_DRILLDOWN, + type: OPEN_FLYOUT_EDIT_DRILLDOWN, + order: 10, + getIconType: () => 'list', + grouping: [DRILLDOWN_ACTION_GROUP], + getDisplayName: () => DISPLAY_NAME, + MenuItem: ({ context }: { context: EmbeddableApiContext }) => { + const drilldowns = useStateFromPublishingSubject( + (context.embeddable as HasDrilldowns).drilldowns$ + ); + + const count = useMemo(() => { + return (drilldowns ?? []).length; + }, [drilldowns]); + + return ( + + {DISPLAY_NAME} + {count > 0 && ( + + {count} + + )} + + ); + }, + isCompatible: async ({ embeddable }: EmbeddableApiContext) => { + if (!isApiCompatible(embeddable) || getInheritedViewMode(embeddable) !== 'edit') return false; + return (embeddable.drilldowns$.getValue() ?? []).length > 0; + }, + execute: async (context: EmbeddableApiContext) => { + const { embeddable } = context; + if (!isApiCompatible(embeddable)) throw new IncompatibleActionError(); + openLazyFlyout({ + core, + parentApi: embeddable.parentApi, + loadContent: async ({ closeFlyout }) => { + const { getDrilldownManagerUi } = await import('../drilldowns/drilldown_manager_ui'); + return getDrilldownManagerUi({ + entries: getDrilldownRegistryEntries(), + initialRoute: '/manage', + drilldowns$: embeddable.drilldowns$, + setDrilldowns: embeddable.setDrilldowns, + setupContext: context, + triggers: getEmbeddableTriggers(embeddable), + onClose: closeFlyout, + }); + }, + flyoutProps: { + 'data-test-subj': 'editDrilldownFlyout', + 'aria-labelledby': 'drilldownFlyoutTitleAriaId', + focusedPanelId: apiHasUniqueId(embeddable) ? embeddable.uuid : undefined, + }, + }); + }, +}; diff --git a/src/platform/plugins/shared/embeddable/server/drilldowns/types.ts b/src/platform/plugins/shared/embeddable/server/drilldowns/types.ts index af1d569e0413d..e3d9ebb8a4d7c 100644 --- a/src/platform/plugins/shared/embeddable/server/drilldowns/types.ts +++ b/src/platform/plugins/shared/embeddable/server/drilldowns/types.ts @@ -12,7 +12,7 @@ import type { Reference } from '@kbn/content-management-utils'; export type DrilldownState = { label: string; trigger: string; type: string }; -export type DrilldownsState = { +export type SerializedDrilldowns = { drilldowns?: DrilldownState[]; }; diff --git a/src/platform/plugins/shared/embeddable/server/index.ts b/src/platform/plugins/shared/embeddable/server/index.ts index 274cf22e515a8..9595855ae7459 100644 --- a/src/platform/plugins/shared/embeddable/server/index.ts +++ b/src/platform/plugins/shared/embeddable/server/index.ts @@ -15,7 +15,7 @@ export type { EmbeddableRegistryDefinition } from './types'; export type { DrilldownState, - DrilldownsState, + SerializedDrilldowns, GetDrilldownsSchemaFnType, } from './drilldowns/types'; diff --git a/src/platform/plugins/shared/embeddable/tsconfig.json b/src/platform/plugins/shared/embeddable/tsconfig.json index 635e783f73036..d3902f5ebc981 100644 --- a/src/platform/plugins/shared/embeddable/tsconfig.json +++ b/src/platform/plugins/shared/embeddable/tsconfig.json @@ -22,7 +22,11 @@ "@kbn/presentation-publishing", "@kbn/analytics", "@kbn/content-management-utils", - "@kbn/config-schema" + "@kbn/config-schema", + "@kbn/std", + "@kbn/licensing-types", + "@kbn/licensing-plugin", + "@kbn/presentation-util", ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/navigation/test/scout/.meta/ui/standard.json b/src/platform/plugins/shared/navigation/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..82ef4f320f099 --- /dev/null +++ b/src/platform/plugins/shared/navigation/test/scout/.meta/ui/standard.json @@ -0,0 +1,103 @@ +{ + "sha1": "84fc72b2bcf627984687cdd2a76ce499263015de", + "tests": [ + { + "id": "9d36407f6b29998-c5ea838e311eff6", + "title": "navigation has security serverless side nav", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/navigation/test/scout/ui/tests/navigation.spec.ts", + "line": 15, + "column": 7 + } + }, + { + "id": "9d36407f6b29998-50f436bf186356b", + "title": "navigation breadcrumbs reflect navigation state", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/navigation/test/scout/ui/tests/navigation.spec.ts", + "line": 21, + "column": 7 + } + }, + { + "id": "9d36407f6b29998-8b0cf971e47353f", + "title": "navigation navigate using search", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/navigation/test/scout/ui/tests/navigation.spec.ts", + "line": 35, + "column": 7 + } + }, + { + "id": "9d36407f6b29998-b43c4bfc123407e", + "title": "navigation shows cases in sidebar navigation", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/navigation/test/scout/ui/tests/navigation.spec.ts", + "line": 50, + "column": 7 + } + }, + { + "id": "9d36407f6b29998-6fca0a62e4a1daa", + "title": "navigation navigates to cases app", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/navigation/test/scout/ui/tests/navigation.spec.ts", + "line": 61, + "column": 7 + } + }, + { + "id": "9d36407f6b29998-c32ec49ee4034cb", + "title": "navigation navigates to maintenance windows", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/navigation/test/scout/ui/tests/navigation.spec.ts", + "line": 74, + "column": 7 + } + }, + { + "id": "9d36407f6b29998-1a35a08787a51e1", + "title": "navigation opens panel on legacy management landing page", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/navigation/test/scout/ui/tests/navigation.spec.ts", + "line": 85, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/src/platform/plugins/shared/saved_search/common/types.ts b/src/platform/plugins/shared/saved_search/common/types.ts index 190661ce2f092..ca85f572f54c6 100644 --- a/src/platform/plugins/shared/saved_search/common/types.ts +++ b/src/platform/plugins/shared/saved_search/common/types.ts @@ -77,7 +77,8 @@ export interface SavedSearchAttributes { } export type SavedSearchByValueAttributes = SavedSearchAttributes & { - references: Reference[]; + /** @deprecated References are now extracted/injected by server transforms */ + references?: Reference[]; }; /** @internal **/ diff --git a/src/platform/plugins/shared/saved_search/public/service/to_saved_search.ts b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.ts index 9844e6eb55a70..7f6338115535e 100644 --- a/src/platform/plugins/shared/saved_search/public/service/to_saved_search.ts +++ b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.ts @@ -32,10 +32,11 @@ export const byValueToSavedSearch = async < serializable?: Serialized ): Promise => { const { sharingSavedObjectProps, managed } = result.metaInfo ?? {}; - + const { references, ...attributes } = result.attributes; return await convertToSavedSearch( { - ...splitReferences(result.attributes), + attributes, + references: references ?? [], savedSearchId: undefined, sharingSavedObjectProps, managed, @@ -44,15 +45,3 @@ export const byValueToSavedSearch = async < serializable ); }; - -const splitReferences = (attributes: SavedSearchByValueAttributes) => { - const { references, ...attrs } = attributes; - - return { - references, - attributes: { - ...attrs, - description: attrs.description ?? '', - }, - }; -}; diff --git a/src/platform/plugins/shared/share/test/scout/.meta/ui/standard.json b/src/platform/plugins/shared/share/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..894ac0ff7292e --- /dev/null +++ b/src/platform/plugins/shared/share/test/scout/.meta/ui/standard.json @@ -0,0 +1,19 @@ +{ + "sha1": "a4969b223543b7d38833cf0ac7723b4e08cef3e5", + "tests": [ + { + "id": "7b7f796111b9b5f-f3bf9deeeb29aa8", + "title": "Short URLs shows Page for missing short URL", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/share/test/scout/ui/tests/short_urls.spec.ts", + "line": 15, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts deleted file mode 100644 index d447652fb6749..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { FC } from 'react'; -import type { LicenseType } from '@kbn/licensing-types'; -import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; -import type { - ActionFactoryDefinition, - BaseActionConfig, - BaseActionFactoryContext, - SerializedAction, -} from '../dynamic_actions'; - -/** - * This is a convenience interface to register a drilldown. Drilldown has - * ability to collect configuration from user. Once drilldown is executed it - * receives the collected information together with the context of the - * user's interaction. - * - * `Config` is a serializable object containing the configuration that the - * drilldown is able to collect using UI. - * - * `ExecutionContext` is an object created in response to user's interaction - * and provided to the `execute` function of the drilldown. This object contains - * information about the action user performed. - */ - -export interface DrilldownDefinition< - Config extends BaseActionConfig = BaseActionConfig, - ExecutionContext extends object = object, - FactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext -> { - /** - * Globally unique identifier for this drilldown. - */ - id: string; - - /** - * Is this action factory not GA? - * Adds a beta badge on a list item representing this ActionFactory - */ - readonly isBeta?: boolean; - - /** - * Minimal license level - * Empty means no restrictions - */ - minimalLicense?: LicenseType; - - /** - * Required when `minimalLicense` is used. - * Is a user-facing string. Has to be unique. Doesn't need i18n. - * The feature's name will be displayed to Cloud end-users when they're billed based on their feature usage. - */ - licenseFeatureName?: string; - - /** - * Determines the display order of the drilldowns in the flyout picker. - * Higher numbers are displayed first. - */ - order?: number; - - /** - * Function that returns default config for this drilldown. - */ - createConfig: ActionFactoryDefinition['createConfig']; - - /** - * Component that collects config for this drilldown. - * - * ```tsx - * import React from 'react'; - * import { CollectConfigProps } from 'src/platform/plugins/shared/kibana_utils/public'; - * - * type Props = CollectConfigProps; - * - * export const CollectConfig: React.FC = () => { - * return
Collecting config...'
; - * }; - * ``` - */ - CollectConfig: ActionFactoryDefinition['CollectConfig']; - - /** - * A validator function for the config object. Should always return a boolean. - */ - isConfigValid: ActionFactoryDefinition['isConfigValid']; - - /** - * Compatibility check during drilldown creation. - * Could be used to filter out a drilldown if it's not compatible with the current context. - */ - isConfigurable?(context: FactoryContext): boolean; - - /** - * Name of EUI icon to display when showing this drilldown to user. - */ - euiIcon?: string; - - /** - * Should return an internationalized name of the drilldown, which will be - * displayed to the user as the name of drilldown factory when configuring a drilldown. - */ - getDisplayName: () => string; - - /** - * Name of the drilldown instance displayed to the user at the moment of - * drilldown execution. Should be internationalized. - */ - readonly actionMenuItem?: FC<{ - config: Omit, 'factoryId'>; - context: ExecutionContext | ActionExecutionContext; - }>; - - /** - * isCompatible during execution - * Could be used to prevent drilldown from execution - */ - isCompatible?( - config: Config, - context: ExecutionContext | ActionExecutionContext - ): Promise; - - /** - * Implements the "navigation" action of the drilldown. This happens when - * user clicks something in the UI that executes a trigger to which this - * drilldown was attached. - * - * @param config Config object that user configured this drilldown with. - * @param context Object that represents context in which the underlying - * `UIAction` of this drilldown is being executed in. - */ - execute( - config: Config, - context: ExecutionContext | ActionExecutionContext - ): void; - - /** - * A link where drilldown should navigate on middle click or Ctrl + click. - */ - getHref?( - config: Config, - context: ExecutionContext | ActionExecutionContext - ): Promise; - - /** - * List of triggers supported by this drilldown type - * This is used in trigger picker when configuring drilldown - */ - supportedTriggers(): string[]; -} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/action_factory/action_factory.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/action_factory/action_factory.tsx deleted file mode 100644 index 4fcf3d2afae68..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/action_factory/action_factory.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - EuiBetaBadge, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIcon, - EuiLink, - EuiText, -} from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; - -const txtDrilldownAction = i18n.translate( - 'uiActionsEnhanced.components.DrilldownForm.drilldownAction', - { - defaultMessage: 'Action', - } -); - -const txtGetMoreActions = i18n.translate( - 'uiActionsEnhanced.components.DrilldownForm.getMoreActionsLinkLabel', - { - defaultMessage: 'Get more actions', - } -); - -const txtBetaActionFactoryLabel = i18n.translate( - 'uiActionsEnhanced.components.DrilldownForm.betaActionLabel', - { - defaultMessage: `Beta`, - } -); - -const txtBetaActionFactoryTooltip = i18n.translate( - 'uiActionsEnhanced.components.DrilldownForm.betaActionTooltip', - { - defaultMessage: `This action is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features. Please help us by reporting bugs or providing other feedback.`, - } -); - -const txtChangeButton = i18n.translate('uiActionsEnhanced.components.DrilldownForm.changeButton', { - defaultMessage: 'Change', -}); - -const GET_MORE_ACTIONS_LINK = 'https://www.elastic.co/subscriptions'; - -const moreActions = ( - - - {txtGetMoreActions} - - -); - -export interface ActionFactoryProps { - /** Action factory name. */ - name?: string; - - /** ID of EUI icon. */ - icon?: string; - - /** Whether the current drilldown type is in beta. */ - beta?: boolean; - - /** Whether to show "Get more actions" link to upgrade license. */ - showMoreLink?: boolean; - - /** On drilldown type change click. */ - onChange?: () => void; -} - -export const ActionFactory: React.FC = ({ - name, - icon, - beta, - showMoreLink, - onChange, -}) => { - return ( - -
- - {!!icon && ( - - - - )} - - -

- {name}{' '} - {beta && ( - - )} -

-
-
- {!!onChange && ( - - - {txtChangeButton} - - - )} -
-
-
- ); -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/drilldown_form.stories.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/drilldown_form.stories.tsx deleted file mode 100644 index 942039d11cd1c..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_form/drilldown_form.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import * as React from 'react'; -import { action } from '@storybook/addon-actions'; -import { DrilldownForm } from '.'; -import type { TriggerPickerProps } from '../trigger_picker'; - -const triggers: TriggerPickerProps = { - items: [ - { - id: 'RANGE_SELECT_TRIGGER', - title: 'Range selected', - description: 'On chart brush.', - }, - { - id: 'VALUE_CLICK_TRIGGER', - title: 'Value click', - description: 'On point click in chart', - }, - ], - selected: ['RANGE_SELECT_TRIGGER'], - docs: 'http://example.com', - onChange: () => {}, -}; - -export default { - title: 'components/DrilldownForm', -}; - -export const Default = () => { - return ( - - children... - - ); -}; - -export const WithLicenseLink = { - render: () => { - return ( - - children... - - ); - }, - - name: 'With license link', -}; - -export const NoTriggers = { - render: () => { - return ( - {}, - }} - onNameChange={action('onNameChange')} - > - children... - - ); - }, - - name: 'No triggers', -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.stories.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.stories.tsx deleted file mode 100644 index d766fedf42b8a..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/drilldown_table/drilldown_table.stories.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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import * as React from 'react'; -import { action } from '@storybook/addon-actions'; -import { DrilldownTable } from './drilldown_table'; -import { FlyoutFrame } from '../flyout_frame'; - -export default { - title: 'components/ListManageDrilldowns', -}; - -export const Default = () => ( - -); - -export const EmptyList = { - render: () => ( - - ), - - name: 'Empty list', -}; - -export const ASingleDrilldown = { - render: () => ( - - ), - - name: 'A single drilldown', -}; - -export const InsideAFlyoutFrame = { - render: () => ( - - - - ), - - name: 'Inside a flyout frame', -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/flyout_frame.stories.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/flyout_frame.stories.tsx deleted file mode 100644 index abe4fb622b707..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/flyout_frame/flyout_frame.stories.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout, EuiButton } from '@elastic/eui'; -import { FlyoutFrame } from '.'; - -export default { - title: 'components/FlyoutFrame', -}; - -export const Default = { - render: () => { - return test; - }, - - name: 'default', -}; - -export const WithTitle = { - render: () => { - return test; - }, - - name: 'with title', -}; - -export const WithOnClose = { - render: () => { - return console.log('onClose')}>test; - }, - - name: 'with onClose', -}; - -export const WithOnBack = { - render: () => { - return ( - console.log('onClose')} title={'Title'}> - test - - ); - }, - - name: 'with onBack', -}; - -export const CustomFooter = { - render: () => { - return click me!}>test; - }, - - name: 'custom footer', -}; - -export const OpenInFlyout = { - render: () => { - return ( - {}} session="start"> - Save
} - onClose={() => console.log('onClose')} - > - test - - - ); - }, - - name: 'open in flyout', -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker.stories.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker.stories.tsx deleted file mode 100644 index 38e372eb3823d..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/trigger_picker/trigger_picker.stories.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import * as React from 'react'; -import { action } from '@storybook/addon-actions'; -import { TriggerPicker } from '.'; - -const Demo: React.FC = () => { - const [triggers, setTriggers] = React.useState(['RANGE_SELECT_TRIGGER']); - - return ( - - ); -}; - -export default { - title: 'components/TriggerPicker', -}; - -export const Default = () => { - return ( - - ); -}; - -export const WithDocs = { - render: () => { - return ( - - ); - }, - - name: 'With docs', -}; - -export const SelectedTrigger = { - render: () => { - return ( - - ); - }, - - name: 'Selected trigger', -}; - -export const Interactive = () => { - return ; -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/types.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/types.ts deleted file mode 100644 index b8c7119a807c0..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/components/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export type { ActionFactoryPlaceContext } from '../types'; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx deleted file mode 100644 index 7c5de4ac4c81c..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { ActionFactoryPicker as ActionFactoryPickerUi } from '../../../../components/action_factory_picker'; -import { useDrilldownManager } from '../context'; -import { ActionFactoryView } from '../action_factory_view'; - -export const ActionFactoryPicker: React.FC = ({}) => { - const drilldowns = useDrilldownManager(); - const factory = drilldowns.useActionFactory(); - const context = React.useMemo(() => drilldowns.getActionFactoryContext(), [drilldowns]); - const compatibleFactories = drilldowns.useCompatibleActionFactories(context); - - if (!!factory) { - return ; - } - - if (!compatibleFactories) { - return ; - } - - return ( - { - drilldowns.setActionFactory(actionFactory); - }} - /> - ); -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_view/action_factory_view.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_view/action_factory_view.tsx deleted file mode 100644 index 545b9721f3457..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_view/action_factory_view.tsx +++ /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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { ActionFactory as ActionFactoryUi } from '../../components/action_factory'; -import type { ActionFactory, BaseActionFactoryContext } from '../../../../dynamic_actions'; -import { useDrilldownManager } from '../context'; - -export interface ActionFactoryViewProps { - factory: ActionFactory; - context: BaseActionFactoryContext; - constant?: boolean; -} - -export const ActionFactoryView: React.FC = ({ - factory, - context, - constant, -}) => { - const drilldowns = useDrilldownManager(); - const name = React.useMemo(() => factory.getDisplayName(context), [factory, context]); - const icon = React.useMemo(() => factory.getIconType(context), [factory, context]); - const handleChange = React.useMemo(() => { - if (constant) return undefined; - return () => drilldowns.setActionFactory(undefined); - }, [drilldowns, constant]); - - return ; -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/create_public_drilldown_manager.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/create_public_drilldown_manager.tsx deleted file mode 100644 index e5a201fdcadbd..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/create_public_drilldown_manager.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import * as React from 'react'; -import type { DrilldownManagerDependencies, PublicDrilldownManagerProps } from '../../types'; - -export type PublicDrilldownManagerComponent = React.FC; - -const LazyDrilldownManager = React.lazy(() => - import('./drilldown_manager_with_provider').then((m) => ({ - default: m.DrilldownManagerWithProvider, - })) -); - -/** - * This HOC creates a "public" `` component `PublicDrilldownManagerComponent`, - * which can be exported from plugin contract for other plugins to consume. - */ -export const createPublicDrilldownManager = ( - dependencies: DrilldownManagerDependencies -): PublicDrilldownManagerComponent => { - const PublicDrilldownManager: PublicDrilldownManagerComponent = (drilldownManagerProps) => { - const filteredActionFactories = dependencies.actionFactories.filter((factory) => { - const supportedTriggers = factory.supportedTriggers(); - for (const supportedTrigger of supportedTriggers) { - const supportsAtLeastOneTrigger = drilldownManagerProps.triggers.includes(supportedTrigger); - if (supportsAtLeastOneTrigger) return true; - } - return false; - }); - - return ( - - - - ); - }; - - return PublicDrilldownManager; -}; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/index.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/index.ts deleted file mode 100644 index 441b622a870ab..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export type { PublicDrilldownManagerComponent } from './drilldown_manager'; -export { createPublicDrilldownManager } from './drilldown_manager'; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/index.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/index.ts deleted file mode 100644 index 4b25b3e40714d..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export type * from './types'; -export * from './containers'; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.test.tsx b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.test.tsx deleted file mode 100644 index e371bae74e903..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.test.tsx +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { ActionFactory, MemoryActionStorage } from '../../../dynamic_actions'; -import type { DrilldownManagerStateDeps } from './drilldown_manager_state'; -import { DrilldownManagerState } from './drilldown_manager_state'; -import { DynamicActionManager } from '../../../dynamic_actions/dynamic_action_manager'; -import { uiActionsEnhancedPluginMock } from '../../../mocks'; -import type { AdvancedUiActionsStart } from '../../..'; -import type { Trigger } from '@kbn/ui-actions-plugin/public'; -import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; -import { notificationServiceMock } from '@kbn/core/public/mocks'; -import { DrilldownState } from './drilldown_state'; - -class StorageWrapperMock implements IStorageWrapper { - public _data = new Map(); - - get = (key: string) => { - if (!this._data.has(key)) return null; - return this._data.get(key); - }; - - set = (key: string, value: unknown) => { - this._data.set(key, value); - }; - - remove = (key: string) => { - this._data.delete(key); - }; - - clear = () => {}; -} - -const createDrilldownManagerState = () => { - type Mutable = { - -readonly [Property in keyof Type]: Type[Property]; - }; - const factory1 = new ActionFactory( - { - id: 'FACTORY1', - CollectConfig: () => null, - supportedTriggers: () => ['TRIGGER1', 'TRIGGER2'], - isConfigValid: () => true, - createConfig: () => ({}), - create: () => ({ - id: 'FACTOR1_ACTION', - execute: async () => {}, - }), - }, - { getFeatureUsageStart: () => undefined } - ); - const factory2 = new ActionFactory( - { - id: 'FACTORY2', - CollectConfig: () => null, - supportedTriggers: () => ['TRIGGER2', 'TRIGGER3'], - isConfigValid: () => true, - createConfig: () => ({}), - create: () => ({ - id: 'FACTOR2_ACTION', - execute: async () => {}, - }), - }, - { getFeatureUsageStart: () => undefined } - ); - const factory3 = new ActionFactory( - { - id: 'FACTORY3', - CollectConfig: () => null, - supportedTriggers: () => ['TRIGGER_MISSING'], - isConfigValid: () => true, - createConfig: () => ({}), - create: () => ({ - id: 'FACTOR3_ACTION', - execute: async () => {}, - }), - }, - { getFeatureUsageStart: () => undefined } - ); - const trigger1: Trigger = { - id: 'TRIGGER1', - }; - const trigger2: Trigger = { - id: 'TRIGGER2', - }; - const trigger3: Trigger = { - id: 'TRIGGER3', - }; - const uiActions = uiActionsEnhancedPluginMock.createPlugin(); - const uiActionsStart = uiActions.doStart(); - const uiActionsStartMutable = uiActionsStart as Mutable; - uiActionsStartMutable.attachAction = () => {}; - uiActionsStartMutable.detachAction = () => {}; - uiActionsStartMutable.hasActionFactory = (actionFactoryId: string): boolean => { - switch (actionFactoryId) { - case 'FACTORY1': - case 'FACTORY2': - case 'FACTORY3': - return true; - } - return false; - }; - uiActionsStartMutable.getActionFactory = (actionFactoryId: string): ActionFactory => { - switch (actionFactoryId) { - case 'FACTORY1': - return factory1; - case 'FACTORY2': - return factory2; - case 'FACTORY3': - return factory3; - } - throw new Error('Action factory not found.'); - }; - const dynamicActionManager = new DynamicActionManager({ - storage: new MemoryActionStorage(), - isCompatible: async () => true, - uiActions: uiActionsStart, - }); - const storage = new StorageWrapperMock(); - const toastService = notificationServiceMock.createStartContract().toasts; - const deps: DrilldownManagerStateDeps = { - actionFactories: [factory1, factory2, factory3], - dynamicActionManager, - getTrigger: (triggerId: string): Trigger => { - if (triggerId === trigger1.id) return trigger1; - if (triggerId === trigger2.id) return trigger2; - if (triggerId === trigger3.id) return trigger3; - throw new Error('Trigger not found'); - }, - onClose: () => {}, - storage, - toastService, - triggers: ['TRIGGER2', 'TRIGGER3'], - }; - const state = new DrilldownManagerState(deps); - - return { - state, - deps, - factory1, - factory2, - factory3, - trigger1, - trigger2, - trigger3, - uiActionsStart, - dynamicActionManager, - storage, - }; -}; - -test('can select action factory', () => { - const { state, factory1, factory2 } = createDrilldownManagerState(); - expect(state.actionFactory$.getValue()).toBe(undefined); - state.setActionFactory(factory1); - expect(state.actionFactory$.getValue()!.id).toBe(factory1.id); - state.setActionFactory(factory2); - expect(state.actionFactory$.getValue()!.id).toBe(factory2.id); -}); - -test('can edit drilldown draft once action factory is selected', () => { - const { state, factory1 } = createDrilldownManagerState(); - expect(state.getDrilldownState()).toBe(undefined); - state.setActionFactory(factory1); - expect(state.getDrilldownState()).toBeInstanceOf(DrilldownState); - const drilldownState = state.getDrilldownState()!; - expect(drilldownState.factory).toBe(factory1); - expect(drilldownState.name$.getValue()).toBe(''); - drilldownState.setName('My name'); - expect(drilldownState.name$.getValue()).toBe('My name'); -}); - -test('selects intersection of triggers for a drilldown', () => { - const { state, factory1, factory2 } = createDrilldownManagerState(); - state.setActionFactory(factory1); - expect(state.getDrilldownState()!.uiTriggers).toEqual(['TRIGGER2']); - state.setActionFactory(factory2); - expect(state.getDrilldownState()!.uiTriggers).toEqual(['TRIGGER2', 'TRIGGER3']); -}); - -test('when drilldown has only one possible trigger, that trigger is automatically selected', () => { - const { state, factory1 } = createDrilldownManagerState(); - state.setActionFactory(factory1); - const drilldownState = state.getDrilldownState()!; - expect(drilldownState.uiTriggers).toEqual(['TRIGGER2']); - expect(drilldownState.triggers$.getValue()).toEqual(['TRIGGER2']); -}); - -test('when drilldown has more than one possible trigger, the trigger should be selected', () => { - const { state, factory2 } = createDrilldownManagerState(); - state.setActionFactory(factory2); - const drilldownState = state.getDrilldownState()!; - expect(drilldownState.uiTriggers).toEqual(['TRIGGER2', 'TRIGGER3']); - expect(drilldownState.triggers$.getValue()).toEqual([]); - drilldownState.setTriggers(['TRIGGER3']); - expect(drilldownState.triggers$.getValue()).toEqual(['TRIGGER3']); -}); - -test('can change drilldown config', () => { - const { state, factory2 } = createDrilldownManagerState(); - state.setActionFactory(factory2); - const drilldownState = state.getDrilldownState()!; - expect(drilldownState.config$.getValue()).toEqual({}); - drilldownState.setConfig({ foo: 'bar' }); - expect(drilldownState.config$.getValue()).toEqual({ foo: 'bar' }); -}); - -test('can create a drilldown', async () => { - const { state, factory2 } = createDrilldownManagerState(); - state.setActionFactory(factory2); - const drilldownState = state.getDrilldownState()!; - drilldownState.setName('my drill'); - drilldownState.setTriggers(['TRIGGER3']); - drilldownState.setConfig({ foo: 'bar' }); - expect(state.deps.dynamicActionManager.state.get().events.length).toBe(0); - await state.createDrilldown(); - expect(state.deps.dynamicActionManager.state.get().events.length).toBe(1); - expect(state.deps.dynamicActionManager.state.get().events[0]).toEqual({ - eventId: expect.any(String), - triggers: ['TRIGGER3'], - action: { - factoryId: 'FACTORY2', - name: 'my drill', - config: { foo: 'bar' }, - }, - }); -}); - -test('can delete delete a drilldown', async () => { - const { state, factory2 } = createDrilldownManagerState(); - state.setActionFactory(factory2); - const drilldownState = state.getDrilldownState()!; - drilldownState.setName('my drill'); - drilldownState.setTriggers(['TRIGGER3']); - drilldownState.setConfig({ foo: 'bar' }); - expect(state.deps.dynamicActionManager.state.get().events.length).toBe(0); - await state.createDrilldown(); - expect(state.deps.dynamicActionManager.state.get().events.length).toBe(1); - const eventId = state.deps.dynamicActionManager.state.get().events[0].eventId; - await state.onDelete([eventId]); - expect(state.deps.dynamicActionManager.state.get().events.length).toBe(0); -}); - -test('can delete multiple drilldowns', async () => { - const { state, factory1, factory2 } = createDrilldownManagerState(); - - state.setActionFactory(factory2); - const drilldownState1 = state.getDrilldownState()!; - drilldownState1.setName('my drill 1'); - drilldownState1.setTriggers(['TRIGGER3']); - drilldownState1.setConfig({ foo: 'bar-1' }); - await state.createDrilldown(); - - state.setActionFactory(factory2); - const drilldownState2 = state.getDrilldownState()!; - drilldownState2.setName('my drill 2'); - drilldownState2.setTriggers(['TRIGGER2']); - drilldownState2.setConfig({ foo: 'bar-2' }); - await state.createDrilldown(); - - state.setActionFactory(factory1); - const drilldownState3 = state.getDrilldownState()!; - drilldownState3.setName('my drill 0'); - drilldownState3.setTriggers(['TRIGGER2']); - drilldownState3.setConfig({ foo: 'bar-3' }); - await state.createDrilldown(); - - expect(state.deps.dynamicActionManager.state.get().events.length).toBe(3); - const id1 = state.deps.dynamicActionManager.state.get().events[0].eventId; - const id2 = state.deps.dynamicActionManager.state.get().events[1].eventId; - const id3 = state.deps.dynamicActionManager.state.get().events[2].eventId; - await state.onDelete([id1, id3]); - expect(state.deps.dynamicActionManager.state.get().events.length).toBe(1); - expect(state.deps.dynamicActionManager.state.get().events[0]).toEqual({ - eventId: id2, - triggers: ['TRIGGER2'], - action: { - factoryId: 'FACTORY2', - name: 'my drill 2', - config: { foo: 'bar-2' }, - }, - }); -}); - -test('after switching between action factories state is restored', async () => { - const { state, factory1, factory2 } = createDrilldownManagerState(); - - state.setActionFactory(factory2); - const drilldownState1 = state.getDrilldownState()!; - drilldownState1.setName('my drill 1'); - drilldownState1.setTriggers(['TRIGGER3']); - drilldownState1.setConfig({ foo: 'bar-1' }); - - state.setActionFactory(factory1); - const drilldownState2 = state.getDrilldownState()!; - drilldownState2.setName('my drill 2'); - drilldownState2.setTriggers(['TRIGGER2']); - drilldownState2.setConfig({ foo: 'bar-2' }); - - state.setActionFactory(factory2); - const drilldownState3 = state.getDrilldownState()!; - expect(drilldownState3.name$.getValue()).toBe('my drill 1'); - expect(drilldownState3.triggers$.getValue()).toEqual(['TRIGGER3']); - expect(drilldownState3.config$.getValue()).toEqual({ foo: 'bar-1' }); -}); - -describe('welcome message', () => { - test('should show welcome message by default', async () => { - const { state } = createDrilldownManagerState(); - expect(state.hideWelcomeMessage$.getValue()).toBe(false); - }); - - test('can hide welcome message', async () => { - const { state, storage } = createDrilldownManagerState(); - state.hideWelcomeMessage(); - expect(state.hideWelcomeMessage$.getValue()).toBe(true); - expect(storage.get('drilldowns:hidWelcomeMessage')).toBe(true); - }); -}); - -test.todo('drilldown type is not shown if no supported triggers can be picked'); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts deleted file mode 100644 index 3317aa58a1b54..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts +++ /dev/null @@ -1,508 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import useObservable from 'react-use/lib/useObservable'; -import type { Observable } from 'rxjs'; -import { BehaviorSubject } from 'rxjs'; -import { map } from 'rxjs'; -import type { SerializableRecord } from '@kbn/utility-types'; -import { useMemo } from 'react'; -import type { - PublicDrilldownManagerProps, - DrilldownManagerDependencies, - DrilldownTemplate, -} from '../types'; -import type { - ActionFactory, - BaseActionFactoryContext, - SerializedAction, - SerializedEvent, -} from '../../../dynamic_actions'; -import { DrilldownState } from './drilldown_state'; -import { - toastDrilldownCreated, - toastDrilldownsCRUDError, - insufficientLicenseLevel, - invalidDrilldownType, - txtDefaultTitle, - toastDrilldownDeleted, - toastDrilldownsDeleted, - toastDrilldownEdited, -} from './i18n'; -import type { DrilldownTableItem } from '../components/drilldown_table'; - -const helloMessageStorageKey = `drilldowns:hidWelcomeMessage`; - -export interface DrilldownManagerStateDeps - extends DrilldownManagerDependencies, - PublicDrilldownManagerProps {} - -/** - * An instance of this class holds all the state necessary for Drilldown - * Manager. It also holds all the necessary controllers to change the state. - * - * `` and other container components access this state using - * the `useDrilldownManager()` React hook: - * - * ```ts - * const state = useDrilldownManager(); - * ``` - */ -export class DrilldownManagerState { - /** - * Title displayed at the top of flyout. - */ - private readonly title$ = new BehaviorSubject(txtDefaultTitle); - - /** - * Footer displayed at the bottom of flyout. - */ - private readonly footer$ = new BehaviorSubject(null); - - /** - * Route inside Drilldown Manager flyout that is displayed to the user. Some - * available routes are: - * - * - `['create']` - * - `['new']` - * - `['new', 'DASHBOARD_TO_DASHBOARD_DRILLDOWN']` - * - `['manage']` - * - `['manage', 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy']` - */ - public readonly route$: BehaviorSubject; - - /** - * Whether a drilldowns welcome message should be displayed to the user at - * the very top of the drilldowns manager flyout. - */ - public readonly hideWelcomeMessage$: BehaviorSubject; - - /** - * Currently selected action factory (drilldown type). - */ - public readonly actionFactory$: BehaviorSubject; - - private readonly mapEventToDrilldownItem = (event: SerializedEvent): DrilldownTableItem => { - const actionFactory = this.deps.actionFactories.find( - (factory) => factory.id === event.action.factoryId - ); - const drilldownFactoryContext: BaseActionFactoryContext = { - ...this.deps.placeContext, - triggers: event.triggers as string[], - }; - const firstTrigger = event.triggers[0]; - return { - id: event.eventId, - drilldownName: event.action.name, - actionName: actionFactory?.getDisplayName(drilldownFactoryContext) ?? event.action.factoryId, - icon: actionFactory?.getIconType(drilldownFactoryContext), - error: !actionFactory - ? invalidDrilldownType(event.action.factoryId) // this shouldn't happen for the end user, but useful during development - : !actionFactory.isCompatibleLicense() - ? insufficientLicenseLevel - : undefined, - triggers: event.triggers.map((trigger) => this.deps.getTrigger(trigger as string)), - triggerIncompatible: !this.deps.triggers.find((t) => t === firstTrigger), - }; - }; - public readonly events$: BehaviorSubject; - - /** - * State for each drilldown type used for new drilldown creation, so when user - * switched between drilldown types the configuration of the previous - * drilldown is preserved. - */ - public readonly drilldownStateByFactoryId = new Map(); - - /** - * Whether user can unlock more drilldown types if they subscribe to a higher - * license tier. - */ - public readonly canUnlockMoreDrilldowns: boolean; - - /** - * Used to show cloning success notification. - */ - public lastCloneRecord: null | { time: number; templateIds: string[] } = null; - - constructor(public readonly deps: DrilldownManagerStateDeps) { - const hideWelcomeMessage = deps.storage.get(helloMessageStorageKey); - this.hideWelcomeMessage$ = new BehaviorSubject(hideWelcomeMessage ?? false); - this.canUnlockMoreDrilldowns = deps.actionFactories.some( - (factory) => !factory.isCompatibleLicense - ); - - this.events$ = new BehaviorSubject( - this.deps.dynamicActionManager.state.get().events.map(this.mapEventToDrilldownItem) - ); - - deps.dynamicActionManager.state.state$ - .pipe(map((state) => state.events.map(this.mapEventToDrilldownItem))) - .subscribe(this.events$); - - let { initialRoute = '' } = deps; - if (!initialRoute) initialRoute = 'manage'; - else if (initialRoute[0] === '/') initialRoute = initialRoute.substr(1); - this.route$ = new BehaviorSubject(initialRoute.split('/')); - - this.actionFactory$ = new BehaviorSubject( - this.getActiveActionFactory() - ); - this.route$.pipe(map(() => this.getActiveActionFactory())).subscribe(this.actionFactory$); - } - - /** - * Set flyout main heading text. - * @param title New title. - */ - public setTitle(title: React.ReactNode) { - this.title$.next(title); - } - - /** - * Set the new flyout footer that renders at the very bottom of the Drilldown - * Manager flyout. - * @param footer New title. - */ - public setFooter(footer: React.ReactNode) { - this.footer$.next(footer); - } - - /** - * Set the flyout main heading back to its default state. - */ - public resetTitle() { - this.setTitle(txtDefaultTitle); - } - - /** - * Change the screen of Drilldown Manager. - */ - public setRoute(route: string[]): void { - if (route[0] === 'manage') this.deps.closeAfterCreate = false; - this.route$.next(route); - } - - /** - * Callback called to hide drilldowns welcome message, and remember in local - * storage that user opted to hide this message. - */ - public readonly hideWelcomeMessage = (): void => { - this.hideWelcomeMessage$.next(true); - this.deps.storage.set(helloMessageStorageKey, true); - }; - - /** - * Select a different action factory. - */ - public setActionFactory(actionFactory: undefined | ActionFactory): void { - if (!actionFactory) { - const route = this.route$.getValue(); - if (route[0] === 'new' && route.length > 1) this.setRoute(['new']); - return; - } - - if (!this.drilldownStateByFactoryId.has(actionFactory.id)) { - const oldActionFactory = this.getActiveActionFactory(); - const oldDrilldownState = !!oldActionFactory - ? this.drilldownStateByFactoryId.get(oldActionFactory.id) - : undefined; - const context = this.getActionFactoryContext(); - const drilldownState = new DrilldownState({ - factory: actionFactory, - placeTriggers: this.deps.triggers, - placeContext: this.deps.placeContext || {}, - name: this.pickName( - !!oldDrilldownState - ? oldDrilldownState.name$.getValue() - : actionFactory.getDisplayName(this.getActionFactoryContext()) - ), - triggers: [], - config: actionFactory.createConfig(context), - }); - this.drilldownStateByFactoryId.set(actionFactory.id, drilldownState); - } - - this.route$.next(['new', actionFactory.id]); - } - - public getActiveActionFactory(): undefined | ActionFactory { - const [step1, id] = this.route$.getValue(); - if (step1 !== 'new' || !id) return undefined; - return this.deps.actionFactories.find((factory) => factory.id === id); - } - - /** - * Close the drilldown flyout. - */ - public readonly close = (): void => { - this.deps.onClose(); - }; - - /** - * Get action factory context, which also contains a custom place context - * provided by the user who triggered rendering of the . - */ - public getActionFactoryContext(): BaseActionFactoryContext { - const placeContext = this.deps.placeContext ?? []; - const context: BaseActionFactoryContext = { - ...placeContext, - triggers: [], - }; - - return context; - } - - public getCompatibleActionFactories( - context: BaseActionFactoryContext - ): Observable { - const compatibleActionFactories$ = new BehaviorSubject(undefined); - Promise.allSettled( - this.deps.actionFactories.map((factory) => factory.isCompatible(context)) - ).then((factoryCompatibility) => { - compatibleActionFactories$.next( - this.deps.actionFactories.filter((_factory, i) => { - const result = factoryCompatibility[i]; - // treat failed isCompatible checks as non-compatible - return result.status === 'fulfilled' && result.value; - }) - ); - }); - return compatibleActionFactories$.asObservable(); - } - - /** - * Get state object of the drilldown which is currently being created. - */ - public getDrilldownState(): undefined | DrilldownState { - const actionFactory = this.getActiveActionFactory(); - if (!actionFactory) return undefined; - const drilldownState = this.drilldownStateByFactoryId.get(actionFactory.id); - return drilldownState; - } - - /** - * Called when user presses "Create drilldown" button to save the - * currently edited drilldown. - */ - public async createDrilldown(): Promise { - const { dynamicActionManager, toastService } = this.deps; - const drilldownState = this.getDrilldownState(); - - if (!drilldownState) return; - - try { - const event = drilldownState.serialize(); - const triggers = drilldownState.triggers$.getValue(); - - await dynamicActionManager.createEvent(event, triggers); - toastService.addSuccess({ - title: toastDrilldownCreated.title(drilldownState.name$.getValue()), - text: toastDrilldownCreated.text, - }); - this.drilldownStateByFactoryId.delete(drilldownState.factory.id); - if (this.deps.closeAfterCreate) { - this.deps.onClose(); - } else { - this.setRoute(['manage']); - } - } catch (error) { - toastService.addError(error, { - title: toastDrilldownsCRUDError, - }); - throw error; - } - } - - /** - * Deletes a list of drilldowns and shows toast notifications to the user. - * - * @param ids Drilldown IDs. - */ - public readonly onDelete = (ids: string[]) => { - (async () => { - const { dynamicActionManager, toastService } = this.deps; - try { - await dynamicActionManager.deleteEvents(ids); - this.deps.toastService.addSuccess( - ids.length === 1 - ? { - title: toastDrilldownDeleted.title, - text: toastDrilldownDeleted.text, - } - : { - title: toastDrilldownsDeleted.title(ids.length), - text: toastDrilldownsDeleted.text, - } - ); - } catch (error) { - toastService.addError(error, { - title: toastDrilldownsCRUDError, - }); - } - })().catch(console.error); // eslint-disable-line no-console - }; - - /** - * Clone a list of selected templates. - */ - public readonly onClone = async (templateIds: string[]) => { - const { templates } = this.deps; - if (!templates) return; - const templatesToClone: DrilldownTemplate[] = templateIds - .map((templateId) => templates.find(({ id }) => id === templateId)) - .filter(Boolean) as DrilldownTemplate[]; - - for (const template of templatesToClone) { - await this.cloneTemplate(template); - } - - this.lastCloneRecord = { - time: Date.now(), - templateIds, - }; - this.setRoute(['manage']); - }; - - private async cloneTemplate(template: DrilldownTemplate) { - const { dynamicActionManager } = this.deps; - const name = this.pickName(template.name); - const action: SerializedAction = { - factoryId: template.factoryId, - name, - config: (template.config || {}) as SerializableRecord, - }; - await dynamicActionManager.createEvent(action, template.triggers); - } - - /** - * Checks if drilldown with such a name already exists. - */ - private hasDrilldownWithName(name: string): boolean { - const { events } = this.deps.dynamicActionManager.state.get(); - for (const event of events) if (event.action.name === name) return true; - return false; - } - - /** - * Picks a unique name for the cloned drilldown. Adds "(copy)", "(copy 1)", - * "(copy 2)", etc. if drilldown with such name already exists. - */ - private pickName(name: string): string { - if (this.hasDrilldownWithName(name)) { - const matches = name.match(/(.*) (\(copy[^\)]*\))/); - if (matches) name = matches[1]; - for (let i = 0; i < 100; i++) { - const proposedName = !i ? `${name} (copy)` : `${name} (copy ${i})`; - const exists = this.hasDrilldownWithName(proposedName); - if (!exists) return proposedName; - } - } - return name; - } - - public readonly onCreateFromTemplate = async (templateId: string) => { - const { templates } = this.deps; - if (!templates) return; - const template = templates.find(({ id }) => id === templateId); - if (!template) return; - const actionFactory = this.deps.actionFactories.find(({ id }) => id === template.factoryId); - if (!actionFactory) return; - this.setActionFactory(actionFactory); - const drilldownState = this.getDrilldownState(); - if (drilldownState) { - drilldownState.setName(this.pickName(template.name)); - drilldownState.setTriggers(template.triggers); - drilldownState.setConfig(template.config as SerializableRecord); - } - }; - - public readonly onCreateFromDrilldown = async (eventId: string) => { - const { dynamicActionManager } = this.deps; - const { events } = dynamicActionManager.state.get(); - const event = events.find((ev) => ev.eventId === eventId); - if (!event) return; - const actionFactory = this.deps.actionFactories.find(({ id }) => id === event.action.factoryId); - if (!actionFactory) return; - this.setActionFactory(actionFactory); - const drilldownState = this.getDrilldownState(); - if (drilldownState) { - drilldownState.setName(this.pickName(event.action.name)); - drilldownState.setTriggers(event.triggers); - drilldownState.setConfig(event.action.config); - } - }; - - /** - * Returns the state object of an existing drilldown for editing purposes. - * - * @param eventId ID of the saved dynamic action event. - */ - public createEventDrilldownState(eventId: string): null | DrilldownState { - const { dynamicActionManager, actionFactories, triggers: placeTriggers } = this.deps; - const { events } = dynamicActionManager.state.get(); - const event = events.find((ev) => ev.eventId === eventId); - if (!event) return null; - const factory = actionFactories.find(({ id }) => id === event.action.factoryId); - if (!factory) return null; - const { action, triggers } = event; - const { name, config } = action; - const state = new DrilldownState({ - factory, - placeContext: this.getActionFactoryContext(), - placeTriggers, - name, - config, - triggers, - }); - return state; - } - - /** - * Save edits to an existing drilldown. - * - * @param eventId ID of the saved dynamic action event. - * @param drilldownState Latest state of the drilldown as edited by the user. - */ - public async updateEvent(eventId: string, drilldownState: DrilldownState): Promise { - const { dynamicActionManager, toastService } = this.deps; - const action = drilldownState.serialize(); - - try { - await dynamicActionManager.updateEvent(eventId, action, drilldownState.triggers$.getValue()); - toastService.addSuccess({ - title: toastDrilldownEdited.title(action.name), - text: toastDrilldownEdited.text, - }); - this.setRoute(['manage']); - } catch (error) { - toastService.addError(error, { - title: toastDrilldownsCRUDError, - }); - throw error; - } - } - - // Below are convenience React hooks for consuming observables in connected - // React components. - - public readonly useTitle = () => useObservable(this.title$, this.title$.getValue()); - public readonly useFooter = () => useObservable(this.footer$, this.footer$.getValue()); - public readonly useRoute = () => useObservable(this.route$, this.route$.getValue()); - public readonly useWelcomeMessage = () => - useObservable(this.hideWelcomeMessage$, this.hideWelcomeMessage$.getValue()); - public readonly useActionFactory = () => - useObservable(this.actionFactory$, this.actionFactory$.getValue()); - public readonly useEvents = () => useObservable(this.events$, this.events$.getValue()); - public readonly useCompatibleActionFactories = (context: BaseActionFactoryContext) => - useObservable( - useMemo(() => this.getCompatibleActionFactories(context), [context]), - undefined - ); -} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_state.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_state.ts deleted file mode 100644 index 9117aa99a3706..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_state.ts +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import useObservable from 'react-use/lib/useObservable'; -import type { Observable } from 'rxjs'; -import { BehaviorSubject, combineLatest } from 'rxjs'; -import { map } from 'rxjs'; -import type { - ActionFactory, - BaseActionConfig, - BaseActionFactoryContext, - SerializedAction, -} from '../../../dynamic_actions'; -import { useSyncObservable } from '../hooks/use_sync_observable'; -import type { ActionFactoryPlaceContext } from '../types'; - -export interface DrilldownStateDeps { - /** - * Action factory, i.e. drilldown, which we are creating. - */ - factory: ActionFactory; - - /** - * List of all triggers the current place supports. - */ - placeTriggers: string[]; - - /** - * Special opaque context object provided by the place from where the - * Drilldown Manager was opened. - */ - placeContext: ActionFactoryPlaceContext; - - /** - * Initial name of the drilldown instance. - */ - name?: string; - - /** - * Initially selected triggers of the drilldown instance. - */ - triggers?: string[]; - - /** - * Initial config of the drilldown instance. - */ - config?: BaseActionConfig; -} - -/** - * An instance of this class represents UI states of a single drilldown which - * is currently being created or edited. - */ -export class DrilldownState { - /** - * Drilldown type used to configure this drilldown. - */ - public readonly factory: ActionFactory; - - /** - * Opaque action factory context object excluding the `triggers` attribute. - */ - public readonly placeContext: ActionFactoryPlaceContext; - - /** - * User entered name of this drilldown. - */ - public readonly name$: BehaviorSubject; - - /** - * Whether the `name$` is valid or is in an error state. - */ - public readonly nameError$: Observable; - - /** - * List of all triggers the place which opened the Drilldown Manager supports. - */ - public readonly placeTriggers: string[]; - - /** - * List of all triggers from which the user can pick in UI for this specific - * drilldown. This is the selection list we show to the user. It is an - * intersection of all triggers supported by current place with the triggers - * that the action factory supports. - */ - public readonly uiTriggers: string[]; - - /** - * User selected triggers. (Currently in UI we support user picking just one trigger). - */ - public readonly triggers$: BehaviorSubject; - - /** - * Error identifier, in case `triggers$` is in an error state. - */ - public readonly triggersError$: Observable; - - /** - * Current action factory (drilldown) configuration, i.e. drilldown - * configuration object, which will be serialized and persisted in storage. - */ - public readonly config$: BehaviorSubject; - - /** - * Error identifier, in case `config$` is in an error state. - */ - public readonly configError$: Observable; - - /** - * Whether the drilldown state is in an error and should not be saved. I value - * is `undefined`, there is no error. - */ - public readonly error$: Observable; - - constructor({ - factory, - placeTriggers, - placeContext, - name = '', - triggers = [], - config = {}, - }: DrilldownStateDeps) { - this.factory = factory; - this.placeTriggers = placeTriggers; - this.placeContext = placeContext; - this.name$ = new BehaviorSubject(name); - this.triggers$ = new BehaviorSubject(triggers); - this.config$ = new BehaviorSubject(config); - - const triggersFactorySupports = this.factory.supportedTriggers(); - this.uiTriggers = triggersFactorySupports.filter((trigger) => - this.placeTriggers.includes(trigger) - ); - - // Pre-select a trigger if there is only one trigger for user to choose from. - // In case there is only one possible trigger, UI will not display a trigger picker. - if (this.uiTriggers.length === 1) this.triggers$.next([this.uiTriggers[0]]); - - this.nameError$ = this.name$.pipe( - map((currentName) => { - if (!currentName) return 'NAME_EMPTY'; - return undefined; - }) - ); - - this.triggersError$ = this.triggers$.pipe( - map((currentTriggers) => { - if (!currentTriggers.length) return 'NO_TRIGGERS_SELECTED'; - return undefined; - }) - ); - - this.configError$ = this.config$.pipe( - map((conf) => { - if (!this.factory.isConfigValid(conf, this.getFactoryContext())) return 'INVALID_CONFIG'; - return undefined; - }) - ); - - this.error$ = combineLatest([this.nameError$, this.triggersError$, this.configError$]).pipe( - map( - ([nameError, configError, triggersError]) => - nameError || triggersError || configError || undefined - ) - ); - } - - /** - * Change the name of the drilldown. - */ - public readonly setName = (name: string): void => { - this.name$.next(name); - }; - - /** - * Change the list of user selected triggers. - */ - public readonly setTriggers = (triggers: string[]): void => { - this.triggers$.next(triggers); - }; - - /** - * Update the current drilldown configuration. - */ - public readonly setConfig = (config: BaseActionConfig): void => { - this.config$.next(config); - }; - - public getFactoryContext(): BaseActionFactoryContext { - return { - ...this.placeContext, - triggers: this.triggers$.getValue(), - }; - } - - /** - * Serialize the current drilldown draft into a serializable action which - * is persisted to disk. - */ - public serialize(): SerializedAction { - return { - factoryId: this.factory.id, - name: this.name$.getValue(), - config: this.config$.getValue(), - }; - } - - /** - * Returns a list of all triggers from which user can pick in UI, for this - * specific drilldown. - */ - public getAllDrilldownTriggers(): string[] { - const triggersFactorySupports = this.factory.supportedTriggers(); - const uiTriggers = triggersFactorySupports.filter((trigger) => - this.placeTriggers.includes(trigger) - ); - return uiTriggers; - } - - public isValid(): boolean { - if (!this.name$.getValue()) return false; - const config = this.config$.getValue(); - if (!config) return false; - const triggers = this.triggers$.getValue(); - if (triggers.length < 1) return false; - if (!this.factory.isConfigValid(config, this.getFactoryContext())) return false; - return true; - } - - // Below are convenience React hooks for consuming observables in connected - // React components. - - public readonly useName = () => useObservable(this.name$, this.name$.getValue()); - public readonly useTriggers = () => useObservable(this.triggers$, this.triggers$.getValue()); - public readonly useConfig = () => useObservable(this.config$, this.config$.getValue()); - public readonly useError = () => useSyncObservable(this.error$); -} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/index.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/index.ts deleted file mode 100644 index 817e596e04688..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export * from './drilldown_state'; -export * from './drilldown_manager_state'; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/types.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/types.ts deleted file mode 100644 index de4b4de70caec..0000000000000 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/drilldown_manager/types.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { ToastsStart } from '@kbn/core/public'; -import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; -import type { Trigger } from '@kbn/ui-actions-plugin/public'; -import type { - ActionFactory, - BaseActionFactoryContext, - DynamicActionManager, -} from '../../dynamic_actions'; - -/** - * Interface used as piece of ActionFactoryContext that is passed in from - * drilldown wizard component to action factories. Omitted values are added - * inside the wizard and then full {@link BaseActionFactoryContext} passed into - * action factory methods - */ -export type ActionFactoryPlaceContext< - ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext -> = Omit; - -/** - * This are props of the public React component which is - * exposed from this plugin's contract, user can change these props every time - * the public is re-rendered. - */ -export interface PublicDrilldownManagerProps { - /** - * Implementation of reactive storage interface for drilldowns. Dynamic action - * manager is responsible for permanently persisting drilldowns, i.e. - * drilldown name, type, and config. It exposes observables for reactive UI - * updates. - */ - dynamicActionManager: DynamicActionManager; - - /** - * Initial screen which Drilldown Manager should display when it first opens. - * Afterwards the state of the currently visible screen is controlled by the - * Drilldown Manager. - * - * Possible values of the route: - * - * - `/create` --- opens with "Create new" tab selected. - * - `/new` --- opens with the "Create new" tab selected showing new drilldown form. - * - `/manage` --- opens with selected "Manage" tab. - * - `/manage/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy` --- opens in edit mode where - * drilldown with ID `yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy` is being edited. - */ - initialRoute?: string; - - /** - * Callback called when drilldown flyout should be closed. - */ - onClose: () => void; - - /** - * List of possible triggers in current context - */ - triggers: string[]; - - /** - * Extra action factory context passed into action factories CollectConfig, getIconType, getDisplayName and etc... - */ - placeContext?: ActionFactoryPlaceContext; - - /** - * List of drilldown templates, which will be displayed to user for fast - * drilldown creation flow. - */ - templates?: DrilldownTemplate[]; - - /** - * Whether to close the drilldown flyout after a drilldown was created - */ - closeAfterCreate?: boolean; -} - -/** - * Template for a pre-configured new drilldown, this gives ability to create a - * drilldown from a template instead of user creating a drilldown from scratch. - * This is used in "drilldown cloning" functionality, where drilldowns can be - * cloned from one dashboard panel to another. - */ -export interface DrilldownTemplate { - /** - * A string that uniquely identifies this item in a list of `DrilldownTemplate[]`. - */ - id: string; - - /** - * EUI icon display next to the description. - */ - icon?: string; - - /** - * A user facing text that provides information about the source of this template. - */ - description: string; - - /** - * Drilldown type, dynamic action factory ID. - */ - factoryId: string; - - /** - * Suggested new name of the cloned drilldown. If a drilldown with such suggested - * name already exists at current place, a suffix like " (copy 1)" will be added. - */ - name: string; - - /** - * Pre-selected triggers. - */ - triggers: string[]; - - /** - * Preliminary configuration of the new drilldown, to be used in the dynamicaction factory. - */ - config: unknown; -} - -/** - * These are static global dependencies of the wired in - * during the setup life-cycle of the plugin. - */ -export interface DrilldownManagerDependencies { - /** - * List of registered UI Actions action factories, i.e. drilldowns. - */ - actionFactories: ActionFactory[]; - - /** - * Trigger getter from UI Actions trigger registry. - */ - getTrigger: (triggerId: string) => Trigger; - - /** - * Implementation of local storage interface for persisting user preferences, - * e.g. user can dismiss the welcome message. - */ - storage: IStorageWrapper; - - /** - * Services for displaying user toast notifications. - */ - toastService: ToastsStart; - - /** - * Link to drilldowns user facing docs on corporate website. - */ - docsLink?: string; - - /** - * Link to trigger picker user facing docs on corporate website. - */ - triggerPickerDocsLink?: string; -} diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts index f5e9447f690ac..7a847e8ea2a37 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts +++ b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts @@ -66,12 +66,8 @@ describe('validateUrl', () => { describe('validateUrlTemplate', () => { test('domain in variable is allowed', async () => { expect( - ( - await validateUrlTemplate( - { template: '{{kibanaUrl}}/test' }, - { kibanaUrl: 'http://localhost:5601/app' } - ) - ).isValid + (await validateUrlTemplate('{{kibanaUrl}}/test', { kibanaUrl: 'http://localhost:5601/app' })) + .isValid ).toBe(true); }); @@ -79,7 +75,7 @@ describe('validateUrlTemplate', () => { expect( ( await validateUrlTemplate( - { template: '{{kibanaUrl}}/test' }, + '{{kibanaUrl}}/test', // eslint-disable-next-line no-script-url { kibanaUrl: 'javascript:evil()' } ) @@ -89,12 +85,8 @@ describe('validateUrlTemplate', () => { test('if missing variable then invalid', async () => { expect( - ( - await validateUrlTemplate( - { template: '{{url}}/test' }, - { kibanaUrl: 'http://localhost:5601/app' } - ) - ).isValid + (await validateUrlTemplate('{{url}}/test', { kibanaUrl: 'http://localhost:5601/app' })) + .isValid ).toBe(false); }); }); diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts index 64eaeb13c3a1b..bcfbde06299da 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts +++ b/src/platform/plugins/shared/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts @@ -8,7 +8,7 @@ */ import { i18n } from '@kbn/i18n'; -import type { UrlDrilldownConfig, UrlDrilldownScope } from './types'; +import type { UrlDrilldownScope } from './types'; import { compile } from './url_template'; const generalFormatError = i18n.translate( @@ -52,10 +52,10 @@ export function validateUrl(url: string): { } export async function validateUrlTemplate( - urlTemplate: UrlDrilldownConfig['url'], + url: string, scope: UrlDrilldownScope ): Promise<{ isValid: boolean; error?: string; invalidUrl?: string }> { - if (!urlTemplate.template) + if (!url) return { isValid: false, error: generalFormatError, @@ -64,12 +64,12 @@ export async function validateUrlTemplate( let compiledUrl: string; try { - compiledUrl = await compile(urlTemplate.template, scope); + compiledUrl = await compile(url, scope); } catch (e) { return { isValid: false, error: compileError(e.message), - invalidUrl: urlTemplate.template, + invalidUrl: url, }; } diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/index.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/index.ts index 5e6c53e6f56a4..3513801f427fa 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/index.ts +++ b/src/platform/plugins/shared/ui_actions_enhanced/public/index.ts @@ -38,10 +38,6 @@ export { export type { DynamicActionsState } from './services/ui_actions_service_enhancements'; -export type { - DrilldownDefinition as UiActionsEnhancedDrilldownDefinition, - DrilldownTemplate as UiActionsEnhancedDrilldownTemplate, -} from './drilldowns'; export type { UrlDrilldownConfig, UrlDrilldownGlobalScope, diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/mocks.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/mocks.ts index 6c5770affa858..6acbcf3383071 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/mocks.ts +++ b/src/platform/plugins/shared/ui_actions_enhanced/public/mocks.ts @@ -21,7 +21,6 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { ...uiActionsPluginMock.createSetupContract(), - registerDrilldown: jest.fn(), }; return setupContract; }; @@ -32,7 +31,6 @@ const createStartContract = (): Start => { getActionFactories: jest.fn(), getActionFactory: jest.fn(), hasActionFactory: jest.fn(), - DrilldownManager: jest.fn(), }; return startContract; diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/plugin.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/plugin.ts index e8706383e683c..92b6f03b6a71c 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/plugin.ts +++ b/src/platform/plugins/shared/ui_actions_enhanced/public/plugin.ts @@ -13,10 +13,8 @@ import type { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kb import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ILicense } from '@kbn/licensing-types'; -import { createStartServicesGetter, Storage } from '@kbn/kibana-utils-plugin/public'; +import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public'; import { UiActionsServiceEnhancements } from './services'; -import type { PublicDrilldownManagerComponent } from './drilldowns'; -import { createPublicDrilldownManager } from './drilldowns'; interface SetupDependencies { uiActions: UiActionsSetup; @@ -28,18 +26,14 @@ export interface StartDependencies { licensing?: LicensingPluginStart; } -export interface SetupContract - extends UiActionsSetup, - Pick {} +export type SetupContract = UiActionsSetup; export interface StartContract extends UiActionsStart, Pick< UiActionsServiceEnhancements, 'getActionFactory' | 'hasActionFactory' | 'getActionFactories' - > { - DrilldownManager: PublicDrilldownManagerComponent; -} + > {} export class AdvancedUiActionsPublicPlugin implements Plugin @@ -80,14 +74,6 @@ export class AdvancedUiActionsPublicPlugin return { ...uiActions, ...this.enhancements!, - DrilldownManager: createPublicDrilldownManager({ - actionFactories: this.enhancements!.getActionFactories(), - getTrigger: (triggerId) => uiActions.getTrigger(triggerId), - storage: new Storage(window?.localStorage), - toastService: core.notifications.toasts, - docsLink: core.docLinks.links.dashboard.drilldowns, - triggerPickerDocsLink: core.docLinks.links.dashboard.drilldownsTriggerPicker, - }), }; } diff --git a/src/platform/plugins/shared/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts b/src/platform/plugins/shared/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts index 710156d5913fb..26236bec07d23 100644 --- a/src/platform/plugins/shared/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts +++ b/src/platform/plugins/shared/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts @@ -7,11 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { createElement } from 'react'; import type { SerializableRecord } from '@kbn/utility-types'; import type { ILicense } from '@kbn/licensing-types'; import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/public'; -import type { DrilldownDefinition } from '../drilldowns'; import type { ActionFactoryDefinition, BaseActionConfig, @@ -92,63 +90,6 @@ export class UiActionsServiceEnhancements { return [...this.actionFactories.values()]; }; - /** - * Convenience method to register a {@link DrilldownDefinition | drilldown}. - */ - public readonly registerDrilldown = < - Config extends BaseActionConfig = BaseActionConfig, - ExecutionContext extends object = object, - FactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext - >({ - id: factoryId, - isBeta, - order, - CollectConfig, - createConfig, - isConfigValid, - getDisplayName, - actionMenuItem, - euiIcon, - execute, - getHref, - minimalLicense, - licenseFeatureName, - supportedTriggers, - isCompatible, - isConfigurable, - }: DrilldownDefinition): void => { - const actionFactory: ActionFactoryDefinition = { - id: factoryId, - isBeta, - minimalLicense, - licenseFeatureName, - order, - CollectConfig, - createConfig, - isConfigValid, - getDisplayName, - supportedTriggers, - getIconType: () => euiIcon, - isCompatible: async (context) => !isConfigurable || isConfigurable(context), - create: (serializedAction) => ({ - id: '', - type: factoryId, - getIconType: () => euiIcon, - getDisplayName: () => serializedAction.name, - MenuItem: actionMenuItem - ? ({ context }) => createElement(actionMenuItem, { context, config: serializedAction }) - : undefined, - execute: async (context) => await execute(serializedAction.config, context), - getHref: getHref ? async (context) => getHref(serializedAction.config, context) : undefined, - isCompatible: isCompatible - ? async (context) => isCompatible(serializedAction.config, context) - : undefined, - }), - } as ActionFactoryDefinition; - - this.registerActionFactory(actionFactory); - }; - private registerFeatureUsage = (definition: ActionFactoryDefinition): void => { if (!definition.minimalLicense || !definition.licenseFeatureName) return; if (this.deps.featureUsageSetup) { diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.test.tsx index f35fab4fd06a2..afc72a6375e00 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.test.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.test.tsx @@ -112,8 +112,7 @@ describe('DocViewerTable', () => { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/253440 - describe.skip('search', () => { + describe('search', () => { beforeEach(() => { storage.clear(); }); @@ -125,7 +124,8 @@ describe('DocViewerTable', () => { expect(screen.getByText('bytes')).toBeInTheDocument(); expect(screen.getByText('extension.keyword')).toBeInTheDocument(); - await user.type(screen.getByTestId('unifiedDocViewerFieldsSearchInput'), 'bytes'); + await user.click(screen.getByTestId('unifiedDocViewerFieldsSearchInput')); + await user.paste('bytes'); expect(screen.queryByText('@timestamp')).toBeNull(); expect(screen.queryByText('bytes')).toBeInTheDocument(); @@ -139,10 +139,8 @@ describe('DocViewerTable', () => { expect(screen.getByText('bytes')).toBeInTheDocument(); expect(screen.getByText('extension.keyword')).toBeInTheDocument(); - await user.type( - screen.getByTestId('unifiedDocViewerFieldsSearchInput'), - String(hit.flattened['extension.keyword']) - ); + await user.click(screen.getByTestId('unifiedDocViewerFieldsSearchInput')); + await user.paste(String(hit.flattened['extension.keyword'])); expect(screen.queryByText('@timestamp')).toBeNull(); expect(screen.queryByText('bytes')).toBeNull(); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_waterfall/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_waterfall/index.tsx index 58908b56bcd76..38bc4988bce61 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_waterfall/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_waterfall/index.tsx @@ -8,6 +8,7 @@ */ import { EuiDelayRender } from '@elastic/eui'; +import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; import React, { useState, useMemo } from 'react'; @@ -34,11 +35,12 @@ export const fullScreenButtonLabel = i18n.translate( ); const sectionTip = i18n.translate('unifiedDocViewer.observability.traces.trace.description', { - defaultMessage: 'Timeline of all spans in the trace, including their duration and hierarchy.', + defaultMessage: + 'A summary of key spans in the trace. Click the waterfall to view the full trace timeline.', }); const sectionTitle = i18n.translate('unifiedDocViewer.observability.traces.trace.title', { - defaultMessage: 'Trace', + defaultMessage: 'Trace summary', }); export function TraceWaterfall({ traceId, docId, serviceName, dataView }: Props) { @@ -62,6 +64,7 @@ export function TraceWaterfall({ traceId, docId, serviceName, dataView }: Props) tabLabel: sectionTitle, dataTestSubj: 'unifiedDocViewerObservabilityTracesOpenInDiscoverButton', }); + const actionId = 'traceWaterfallFullScreenAction'; const actions = useMemo( @@ -102,12 +105,31 @@ export function TraceWaterfall({ traceId, docId, serviceName, dataView }: Props) actions={actions} > {docId ? ( - +
setShowFullScreenWaterfall(true)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowFullScreenWaterfall(true); + } + }} + css={css` + &, + & * { + cursor: pointer; + } + `} + > + +
) : null} DynamicActionsSerializedState) | undefined; + drilldowns?: SerializedDrilldowns; timeRange?: VisualizeRuntimeState['timeRange']; }) => VisualizeEmbeddableState = ({ serializedVis, // Serialize the vis before passing it to this function for easier testing @@ -125,15 +125,14 @@ export const serializeState: (props: { id, savedObjectProperties, linkedToLibrary, - getDynamicActionsState, + drilldowns, timeRange, }) => { - const dynamicActionsState = getDynamicActionsState ? getDynamicActionsState() : {}; // save by reference if (linkedToLibrary && id) { return { ...(titles ? titles : {}), - ...dynamicActionsState, + ...(drilldowns ? drilldowns : {}), ...(!isEmpty(serializedVis.uiState) ? { uiState: serializedVis.uiState } : {}), ...(timeRange ? { timeRange } : {}), savedObjectId: id, @@ -143,7 +142,7 @@ export const serializeState: (props: { return { ...(titles ? titles : {}), ...savedObjectProperties, - ...dynamicActionsState, + ...(drilldowns ? drilldowns : {}), ...(timeRange ? { timeRange } : {}), savedVis: { ...serializedVis, diff --git a/src/platform/plugins/shared/visualizations/public/embeddable/types.ts b/src/platform/plugins/shared/visualizations/public/embeddable/types.ts index cda9491ca5a17..d59e12caa34f3 100644 --- a/src/platform/plugins/shared/visualizations/public/embeddable/types.ts +++ b/src/platform/plugins/shared/visualizations/public/embeddable/types.ts @@ -26,7 +26,7 @@ import type { } from '@kbn/presentation-publishing'; import type { DeepPartial } from '@kbn/utility-types'; import type { VisParams } from '@kbn/visualizations-common'; -import type { DrilldownsState } from '@kbn/embeddable-plugin/server'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { VisualizeEmbeddableState } from '../../common/embeddable/types'; import type { HasVisualizeConfig } from './interfaces/has_visualize_config'; import type { Vis, VisSavedObject } from '../types'; @@ -44,7 +44,7 @@ export type ExtraSavedObjectProperties = Pick< export type VisualizeRuntimeState = SerializedTitles & SerializedTimeRange & - DrilldownsState & { + SerializedDrilldowns & { serializedVis: SerializedVis; savedObjectId?: string; savedObjectProperties?: ExtraSavedObjectProperties; diff --git a/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx b/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx index 72fcbbc77e0bc..8da2eeaad53f8 100644 --- a/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx @@ -10,7 +10,6 @@ import { EuiEmptyPrompt, EuiFlexGroup, EuiLoadingChart, EuiText } from '@elastic/eui'; import { isChartSizeEvent } from '@kbn/chart-expressions-common'; import type { DataView } from '@kbn/data-views-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { EmbeddableStart, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import type { ExpressionRendererParams } from '@kbn/expressions-plugin/public'; import { useExpressionRenderer } from '@kbn/expressions-plugin/public'; @@ -59,26 +58,22 @@ import { checkForDuplicateTitle } from '../utils/saved_objects_utils'; export const getVisualizeEmbeddableFactory: (deps: { embeddableStart: EmbeddableStart; - embeddableEnhancedStart?: EmbeddableEnhancedPluginStart; -}) => EmbeddableFactory = ({ - embeddableStart, - embeddableEnhancedStart, -}) => ({ +}) => EmbeddableFactory = ({ embeddableStart }) => ({ type: VISUALIZE_EMBEDDABLE_TYPE, - buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + buildEmbeddable: async ({ + initializeDrilldownsManager, + initialState, + finalizeApi, + parentApi, + uuid, + }) => { // Runtime state may contain title loaded from saved object // Initialize titleManager with serialized state // to avoid tracking runtime state title as serialized state title const titleManager = initializeTitleManager(initialState); // Initialize dynamic actions - const dynamicActionsManager = await embeddableEnhancedStart?.initializeEmbeddableDynamicActions( - uuid, - () => titleManager.api.title$.getValue(), - initialState - ); - // if it is provided, start the dynamic actions manager - const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); const runtimeState = await deserializeState(initialState); @@ -188,7 +183,7 @@ export const getVisualizeEmbeddableFactory: (deps: { ...(runtimeState.savedObjectProperties ? { savedObjectProperties: runtimeState.savedObjectProperties } : {}), - getDynamicActionsState: dynamicActionsManager?.getLatestState, + drilldowns: drilldownsManager.getLatestState(), ...timeRangeManager.getLatestState(), }); }; @@ -200,7 +195,7 @@ export const getVisualizeEmbeddableFactory: (deps: { return serializeVisualizeEmbeddable(savedObjectId$.getValue(), linkedToLibrary); }, anyStateChange$: merge( - ...(dynamicActionsManager ? [dynamicActionsManager.anyStateChange$] : []), + drilldownsManager.anyStateChange$, savedObjectId$, serializedVis$, titleManager.anyStateChange$, @@ -208,7 +203,7 @@ export const getVisualizeEmbeddableFactory: (deps: { ).pipe(map(() => undefined)), getComparators: () => { return { - ...(dynamicActionsManager?.comparators ?? { drilldowns: 'skip', enhancements: 'skip' }), + ...drilldownsManager.comparators, ...titleComparators, ...timeRangeComparators, savedObjectId: 'skip', @@ -239,7 +234,7 @@ export const getVisualizeEmbeddableFactory: (deps: { }; }, onReset: async (lastSaved) => { - dynamicActionsManager?.reinitializeState(lastSaved ?? {}); + drilldownsManager.reinitializeState(lastSaved ?? {}); timeRangeManager.reinitializeState(lastSaved); titleManager.reinitializeState(lastSaved); @@ -252,7 +247,7 @@ export const getVisualizeEmbeddableFactory: (deps: { const api = finalizeApi({ ...timeRangeManager.api, ...titleManager.api, - ...(dynamicActionsManager?.api ?? {}), + ...drilldownsManager.api, ...unsavedChangesApi, defaultTitle$, dataLoading$, @@ -496,9 +491,9 @@ export const getVisualizeEmbeddableFactory: (deps: { useEffect(() => { return () => { + drilldownsManager.cleanup(); fetchSubscription.unsubscribe(); serializedVisSubscription.unsubscribe(); - maybeStopDynamicActions?.stopDynamicActions(); }; }, []); diff --git a/src/platform/plugins/shared/visualizations/public/plugin.ts b/src/platform/plugins/shared/visualizations/public/plugin.ts index 75fe723a52f66..3b0edadf6efb4 100644 --- a/src/platform/plugins/shared/visualizations/public/plugin.ts +++ b/src/platform/plugins/shared/visualizations/public/plugin.ts @@ -65,7 +65,6 @@ import type { ContentManagementPublicStart, } from '@kbn/content-management-plugin/public'; import type { NoDataPagePluginStart } from '@kbn/no-data-page-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import { css, injectGlobal } from '@emotion/css'; import { VisualizeConstants, VISUALIZE_EMBEDDABLE_TYPE } from '@kbn/visualizations-common'; @@ -178,7 +177,6 @@ export interface VisualizationsStartDeps { contentManagement: ContentManagementPublicStart; serverless?: ServerlessPluginStart; noDataPage?: NoDataPagePluginStart; - embeddableEnhanced?: EmbeddableEnhancedPluginStart; } const styles = { @@ -478,11 +476,11 @@ export class VisualizationsPlugin expressions.registerFunction(xyDimensionExpressionFunction); embeddable.registerReactEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, async () => { const { - plugins: { embeddable: embeddableStart, embeddableEnhanced: embeddableEnhancedStart }, + plugins: { embeddable: embeddableStart }, } = start(); const { getVisualizeEmbeddableFactory } = await import('./embeddable/embeddable_module'); - return getVisualizeEmbeddableFactory({ embeddableStart, embeddableEnhancedStart }); + return getVisualizeEmbeddableFactory({ embeddableStart }); }); embeddable.registerAddFromLibraryType({ onAdd: async (container, savedObject) => { diff --git a/src/platform/plugins/shared/visualizations/tsconfig.json b/src/platform/plugins/shared/visualizations/tsconfig.json index 00b1f8a1d44db..a9a43058e0c07 100644 --- a/src/platform/plugins/shared/visualizations/tsconfig.json +++ b/src/platform/plugins/shared/visualizations/tsconfig.json @@ -69,7 +69,6 @@ "@kbn/core-execution-context-common", "@kbn/core-mount-utils-browser", "@kbn/search-response-warnings", - "@kbn/embeddable-enhanced-plugin", "@kbn/content-management-utils", "@kbn/react-hooks", "@kbn/presentation-panel-plugin", diff --git a/src/platform/plugins/shared/workflows_execution_engine/moon.yml b/src/platform/plugins/shared/workflows_execution_engine/moon.yml index 2e3058c52b527..9942d39963d8e 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/moon.yml +++ b/src/platform/plugins/shared/workflows_execution_engine/moon.yml @@ -24,7 +24,7 @@ dependsOn: - '@kbn/workflows' - '@kbn/task-manager-plugin' - '@kbn/es-query' - - '@kbn/datemath' + - '@kbn/eval-kql' - '@kbn/core-elasticsearch-server' - '@kbn/cloud-plugin' - '@kbn/utility-types' diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/enter_foreach_node_impl.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/enter_foreach_node_impl.ts index d6b0c603d5999..2f4f8269123be 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/enter_foreach_node_impl.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/enter_foreach_node_impl.ts @@ -8,6 +8,7 @@ */ import type { EnterForeachNode } from '@kbn/workflows/graph'; +import { isTemplateExpression } from '../../utils'; import type { StepExecutionRuntime } from '../../workflow_context_manager/step_execution_runtime'; import type { WorkflowExecutionRuntimeManager } from '../../workflow_context_manager/workflow_execution_runtime_manager'; import type { IWorkflowEventLogger } from '../../workflow_event_logger'; @@ -31,7 +32,6 @@ export class EnterForeachNodeImpl implements NodeImplementation { private async enterForeach(): Promise { this.stepExecutionRuntime.startStep(); - let foreachState = this.stepExecutionRuntime.getCurrentStepState(); this.stepExecutionRuntime.setInput({ foreach: this.node.configuration.foreach, }); @@ -45,7 +45,6 @@ export class EnterForeachNodeImpl implements NodeImplementation { } ); this.stepExecutionRuntime.setCurrentStepState({ - items: [], total: 0, }); this.stepExecutionRuntime.finishStep(); @@ -60,39 +59,28 @@ export class EnterForeachNodeImpl implements NodeImplementation { } ); - // Initialize foreach state - foreachState = { - items: evaluatedItems, - item: evaluatedItems[0], + // Initialize foreach state — only store index and total to avoid + // persisting the entire items array on every iteration. + const foreachState = { index: 0, total: evaluatedItems.length, }; this.stepExecutionRuntime.setCurrentStepState(foreachState); // Enter a new scope for the first iteration - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.wfExecutionRuntimeManager.enterScope(foreachState.index!.toString()); + this.wfExecutionRuntimeManager.enterScope(foreachState.index.toString()); this.wfExecutionRuntimeManager.navigateToNextNode(); } private advanceIteration(): void { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - let foreachState = this.stepExecutionRuntime.getCurrentStepState()!; - // Update items and index if they have changed - const items = foreachState.items; + const foreachState = this.stepExecutionRuntime.getCurrentStepState()!; const index = foreachState.index + 1; - const item = items[index]; - const total = foreachState.total; - foreachState = { - items, - index, - item, - total, - }; + + // Only persist index and total — no need to store the full items array. + this.stepExecutionRuntime.setCurrentStepState({ index, total: foreachState.total }); // Enter a new scope for the new iteration - this.stepExecutionRuntime.setCurrentStepState(foreachState); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.wfExecutionRuntimeManager.enterScope(foreachState.index!.toString()); + this.wfExecutionRuntimeManager.enterScope(index.toString()); this.wfExecutionRuntimeManager.navigateToNextNode(); } @@ -135,11 +123,7 @@ export class EnterForeachNodeImpl implements NodeImplementation { ); } - if ( - typeof expression === 'string' && - expression.startsWith('{{') && - expression.endsWith('}}') - ) { + if (isTemplateExpression(expression)) { return this.stepExecutionRuntime.contextManager.evaluateExpressionInContext(expression); } diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/exit_foreach_node_impl.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/exit_foreach_node_impl.ts index 1cf76ba26c211..6e9173cca6c48 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/exit_foreach_node_impl.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/exit_foreach_node_impl.ts @@ -28,7 +28,7 @@ export class ExitForeachNodeImpl implements NodeImplementation { throw new Error(`Foreach state for step ${this.node.stepId} not found`); } - if (foreachState.items[foreachState.index + 1]) { + if (foreachState.index + 1 < foreachState.total) { this.wfExecutionRuntimeManager.navigateToNode(this.node.startNodeId); return; } diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/enter_foreach_node_impl.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/enter_foreach_node_impl.test.ts index 1b4afec6ce7d5..1ae5bb23a0804 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/enter_foreach_node_impl.test.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/enter_foreach_node_impl.test.ts @@ -94,8 +94,6 @@ describe('EnterForeachNodeImpl', () => { expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledTimes(1); expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledWith({ - items: ['item1', 'item2', 'item3'], - item: 'item1', index: 0, total: 3, }); @@ -140,8 +138,6 @@ describe('EnterForeachNodeImpl', () => { expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledTimes(1); expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledWith({ - items: ['item1', 'item2', 'item3'], - item: 'item1', index: 0, total: 3, }); @@ -198,8 +194,6 @@ describe('EnterForeachNodeImpl', () => { expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledTimes(1); expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledWith({ - items: ['item1', 'item2', 'item3'], - item: 'item1', index: 0, total: 3, }); @@ -229,10 +223,9 @@ describe('EnterForeachNodeImpl', () => { node.configuration.foreach = JSON.stringify([]); }); - it('should set empty items and total to 0', async () => { + it('should set total to 0', async () => { await underTest.run(); expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledWith({ - items: [], total: 0, }); }); @@ -286,8 +279,6 @@ describe('EnterForeachNodeImpl', () => { describe('on next iterations', () => { beforeEach(() => { (stepExecutionRuntime.getCurrentStepState as jest.Mock).mockReturnValue({ - items: ['item1', 'item2', 'item3'], - item: 'item1', index: 0, total: 3, }); @@ -311,13 +302,11 @@ describe('EnterForeachNodeImpl', () => { expect(stepExecutionRuntime.startStep).not.toHaveBeenCalledWith(); }); - it('should initialize foreach state', async () => { + it('should update foreach state with incremented index', async () => { await underTest.run(); expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledTimes(1); expect(stepExecutionRuntime.setCurrentStepState).toHaveBeenCalledWith({ - items: ['item1', 'item2', 'item3'], - item: 'item2', index: 1, total: 3, }); diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/exit_foreach_node_impl.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/exit_foreach_node_impl.test.ts index fd4cf9b053940..85f3c541aa381 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/exit_foreach_node_impl.test.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/foreach_step/tests/exit_foreach_node_impl.test.ts @@ -62,9 +62,7 @@ describe('ExitForeachNodeImpl', () => { describe('when there are more items to process', () => { beforeEach(() => { (stepExecutionRuntime.getCurrentStepState as jest.Mock).mockReturnValue({ - items: ['item1', 'item2', 'item3'], index: 1, - item: 'item2', total: 3, }); }); @@ -86,9 +84,7 @@ describe('ExitForeachNodeImpl', () => { describe('when no more items to process', () => { beforeEach(() => { (stepExecutionRuntime.getCurrentStepState as jest.Mock).mockReturnValue({ - items: ['item1', 'item2', 'item3'], index: 2, - item: 'item3', total: 3, }); }); diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/if_step/enter_if_node_impl.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/if_step/enter_if_node_impl.ts index a49db69dc7600..c434f49ec8c0f 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/if_step/enter_if_node_impl.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/if_step/enter_if_node_impl.ts @@ -8,8 +8,8 @@ */ import { KQLSyntaxError } from '@kbn/es-query'; +import { evaluateKql } from '@kbn/eval-kql'; import type { EnterConditionBranchNode, EnterIfNode, WorkflowGraph } from '@kbn/workflows/graph'; -import { evaluateKql } from '../../utils'; import type { StepExecutionRuntime } from '../../workflow_context_manager/step_execution_runtime'; import type { WorkflowExecutionRuntimeManager } from '../../workflow_context_manager/workflow_execution_runtime_manager'; import type { IWorkflowEventLogger } from '../../workflow_event_logger'; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/utils/index.ts b/src/platform/plugins/shared/workflows_execution_engine/server/utils/index.ts index 1e24fb5efae68..ee7d544c8f156 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/utils/index.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/utils/index.ts @@ -13,4 +13,4 @@ export { stringifyStackFrames } from './stringify_stack_frames'; export { getKibanaUrl, buildWorkflowExecutionUrl } from './get_kibana_url'; export { generateExecutionTaskScope } from './generate_execution_task_scope'; export { TimeoutAbortedError, abortableTimeout } from './abortable_timeout/abortable_timeout'; -export { evaluateKql } from './eval_kql/eval_kql'; +export { isTemplateExpression } from './templates'; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/utils/templates.ts b/src/platform/plugins/shared/workflows_execution_engine/server/utils/templates.ts new file mode 100644 index 0000000000000..ce4af7191ae20 --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/server/utils/templates.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export function isTemplateExpression(expression: string | unknown): expression is `{{${string}}}` { + return typeof expression === 'string' && expression.startsWith('{{') && expression.endsWith('}}'); +} diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/tests/workflow_context_manager.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/tests/workflow_context_manager.test.ts index 278c63b249d54..89144a97cf41d 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/tests/workflow_context_manager.test.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/tests/workflow_context_manager.test.ts @@ -26,24 +26,33 @@ import type { WorkflowExecutionState } from '../workflow_execution_state'; const dependencies = mockContextDependencies(); jest.mock('../../utils', () => ({ - buildStepExecutionId: jest.fn().mockImplementation((executionId, stepId, path) => { + ...jest.requireActual('../../utils'), + buildStepExecutionId: jest.fn().mockImplementation((executionId: string, stepId: string) => { return `${stepId}_generated`; }), getKibanaUrl: jest.fn().mockReturnValue('http://localhost:5601'), buildWorkflowExecutionUrl: jest .fn() - .mockImplementation((kibanaUrl, spaceId, workflowId, executionId, stepExecutionId) => { - const spacePrefix = spaceId === 'default' ? '' : `/s/${spaceId}`; - const baseUrl = `${kibanaUrl}${spacePrefix}/app/workflows/${workflowId}`; - const params = new URLSearchParams({ - executionId, - tab: 'executions', - }); - if (stepExecutionId) { - params.set('stepExecutionId', stepExecutionId); + .mockImplementation( + ( + kibanaUrl: string, + spaceId: string, + workflowId: string, + executionId: string, + stepExecutionId?: string + ) => { + const spacePrefix = spaceId === 'default' ? '' : `/s/${spaceId}`; + const baseUrl = `${kibanaUrl}${spacePrefix}/app/workflows/${workflowId}`; + const params = new URLSearchParams({ + executionId, + tab: 'executions', + }); + if (stepExecutionId) { + params.set('stepExecutionId', stepExecutionId); + } + return `${baseUrl}?${params.toString()}`; } - return `${baseUrl}?${params.toString()}`; - }), + ), })); describe('WorkflowContextManager', () => { @@ -70,7 +79,10 @@ describe('WorkflowContextManager', () => { .fn() .mockReturnValue({} as EsWorkflowStepExecution); const templatingEngineMock = {} as unknown as WorkflowTemplatingEngine; - templatingEngineMock.render = jest.fn().mockImplementation((template) => template); + templatingEngineMock.render = jest.fn().mockImplementation((...args: unknown[]) => args[0]); + templatingEngineMock.evaluateExpression = jest + .fn() + .mockImplementation((...args: unknown[]) => args[0]); // Provide a dummy esClient as required by ContextManagerInit const esClient = { @@ -507,10 +519,9 @@ describe('WorkflowContextManager', () => { if (stepExecutionId === 'outerForeachStep_generated') { return { stepType: 'foreach', + input: { foreach: JSON.stringify(['item1', 'item2', 'item3']) }, state: { - items: ['item1', 'item2', 'item3'], index: 0, - item: 'item1', total: 3, }, }; @@ -519,10 +530,9 @@ describe('WorkflowContextManager', () => { if (stepExecutionId === 'innerForeachStep_generated') { return { stepType: 'foreach', + input: { foreach: JSON.stringify(['1', '2', '3', '4']) }, state: { - items: ['1', '2', '3', '4'], index: 1, - item: '2', total: 4, }, }; @@ -601,10 +611,9 @@ describe('WorkflowContextManager', () => { if (stepExecutionId === 'outerForeachStep_generated') { return { stepType: 'foreach', + input: { foreach: JSON.stringify(['item1', 'item2', 'item3']) }, state: { - items: ['item1', 'item2', 'item3'], index: 0, - item: 'item1', total: 3, }, }; @@ -620,6 +629,95 @@ describe('WorkflowContextManager', () => { total: 3, }); }); + + describe('nested foreach with inner expression {{foreach.item}}', () => { + const outerItems = [ + ['innerA', 'innerB'], + ['innerC', 'innerD'], + ]; + const outerCurrentIndex = 0; + const outerCurrentItem = outerItems[outerCurrentIndex]; + const innerCurrentIndex = 1; + + beforeEach(() => { + testContainer.workflowExecutionState.getWorkflowExecution = jest.fn().mockReturnValue({ + workflowDefinition: workflow, + scopeStack: [ + { + stepId: 'outerForeachStep', + nestedScopes: [{ nodeId: 'enterForeach_outerForeachStep' }], + }, + { + stepId: 'innerForeachStep', + nestedScopes: [{ nodeId: 'enterForeach_innerForeachStep' }], + }, + ], + } as EsWorkflowExecution); + testContainer.workflowExecutionState.getStepExecution = jest + .fn() + .mockImplementation((stepExecutionId) => { + if (stepExecutionId === 'outerForeachStep_generated') { + return { + stepType: 'foreach', + input: { foreach: JSON.stringify(outerItems) }, + state: { index: outerCurrentIndex, total: outerItems.length }, + }; + } + if (stepExecutionId === 'innerForeachStep_generated') { + return { + stepType: 'foreach', + input: { foreach: '{{foreach.item}}' }, + state: { index: innerCurrentIndex, total: outerCurrentItem.length }, + }; + } + return undefined; + }); + }); + + it('should resolve inner foreach items from outer current item when inner expression is {{foreach.item}}', () => { + (testContainer.templatingEngineMock.evaluateExpression as jest.Mock).mockImplementation( + (expr: string, ctx: Record) => { + if (expr.includes('foreach.item')) { + return (ctx as { foreach?: { item: unknown } }).foreach?.item; + } + return expr; + } + ); + (testContainer.templatingEngineMock.render as jest.Mock).mockImplementation( + (...args: unknown[]) => args[0] + ); + + const context = testContainer.underTest.getContext(); + + expect(context.foreach).toEqual({ + items: outerCurrentItem, + item: outerCurrentItem[innerCurrentIndex], + index: innerCurrentIndex, + total: outerCurrentItem.length, + }); + }); + + it('should expose innermost foreach as context.foreach (current loop)', () => { + (testContainer.templatingEngineMock.evaluateExpression as jest.Mock).mockImplementation( + (expr: string, ctx: Record) => { + if (expr.includes('foreach.item')) { + return (ctx as { foreach?: { item: unknown } }).foreach?.item; + } + return expr; + } + ); + (testContainer.templatingEngineMock.render as jest.Mock).mockImplementation( + (...args: unknown[]) => args[0] + ); + + const context = testContainer.underTest.getContext(); + + expect(context.foreach?.item).toBe('innerB'); + expect(context.foreach?.index).toBe(innerCurrentIndex); + expect(context.foreach?.total).toBe(outerCurrentItem.length); + expect(context.foreach?.items).toEqual(['innerA', 'innerB']); + }); + }); }); describe('steps context', () => { diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_context_manager.ts b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_context_manager.ts index 15e0cac322139..6723642f2ced9 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_context_manager.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/workflow_context_manager/workflow_context_manager.ts @@ -10,7 +10,9 @@ import type { CoreStart, KibanaRequest } from '@kbn/core/server'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { KQLSyntaxError } from '@kbn/es-query'; +import { evaluateKql } from '@kbn/eval-kql'; import { + type EsWorkflowStepExecution, type SerializedError, type StackFrame, type StepContext, @@ -23,7 +25,7 @@ import type { ContextDependencies } from './types'; import type { WorkflowExecutionState } from './workflow_execution_state'; import { WorkflowScopeStack } from './workflow_scope_stack'; import type { WorkflowTemplatingEngine } from '../templating_engine'; -import { buildStepExecutionId, evaluateKql } from '../utils'; +import { buildStepExecutionId, isTemplateExpression } from '../utils'; export interface ContextManagerInit { // New properties for logging @@ -39,6 +41,11 @@ export interface ContextManagerInit { dependencies: ContextDependencies; } +interface ScopeEntry { + topFrame: NonNullable>; + stepExecution: EsWorkflowStepExecution | undefined; +} + export class WorkflowContextManager { private workflowExecutionGraph: WorkflowGraph; private workflowExecutionState: WorkflowExecutionState; @@ -307,40 +314,126 @@ export class WorkflowContextManager { this.workflowExecutionState.getWorkflowExecution().scopeStack ); + const executionId = this.workflowExecutionState.getWorkflowExecution().id; + const scopeEntries: Array = []; + const foreachEntries: Array = []; + while (!scopeStack.isEmpty()) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const topFrame = scopeStack.getCurrentScope()!; scopeStack = scopeStack.exitScope(); const stepExecution = this.workflowExecutionState.getStepExecution( - buildStepExecutionId( - this.workflowExecutionState.getWorkflowExecution().id, - topFrame.stepId, - scopeStack.stackFrames - ) + buildStepExecutionId(executionId, topFrame.stepId, scopeStack.stackFrames) ); + scopeEntries.push({ topFrame, stepExecution }); + if (stepExecution?.stepType === 'foreach') { + foreachEntries.push({ topFrame, stepExecution }); + } + } + + // When there is only one foreach frame (e.g. single-step run with subgraph), use + // contextOverride.foreach as the parent so inner expressions like {{foreach.item}} resolve. + const contextOverride = + this.workflowExecutionState.getWorkflowExecution().context?.contextOverride; + if (foreachEntries.length === 1 && contextOverride?.foreach != null) { + stepContext.foreach = contextOverride.foreach; + } + // Build foreach context in outer-to-inner order so inner expressions like + // {{foreach.item}} resolve against the outer foreach context. + for (const { stepExecution } of foreachEntries.reverse()) { if (stepExecution) { - switch (stepExecution.stepType) { - case 'foreach': - if (!stepContext.foreach) { - stepContext.foreach = stepExecution.state as StepContext['foreach']; - } - break; + stepContext.foreach = this.buildForeachContext(stepExecution, stepContext); + } + } + + // Apply fallback scope error in original (innermost-first) order. + for (const { topFrame, stepExecution } of scopeEntries) { + if (topFrame.scopeId === 'fallback' && stepExecution) { + // This is not good approach, but we can't do it better right now. + // The problem is that Context is dynamic depending on the step scopes (like whether the current step is inside foreach, fallback path, etc) + // but here we are trying to mutate the static StepContext object. + // Proper solution would be to have dynamic context object that would resolve properties on demand, + // but it requires significant changes in the codebase. + // So for now, we just set the error on the context when we are in fallback scope. + const stepContextGeneric = stepContext as Record; + if (!stepContextGeneric.error) { + stepContextGeneric.error = stepExecution.state?.error; } + } + } + } - if (topFrame.scopeId === 'fallback') { - // This is not good approach, but we can't do it better right now. - // The problem is that Context is dynamic depending on the step scopes (like whether the current step is inside foreach, fallback path, etc) - // but here we are trying to mutate the static StepContext object. - // Proper solution would be to have dynamic context object that would resolve properties on demand, - // but it requires significant changes in the codebase. - // So for now, we just set the error on the context when we are in fallback scope. - const stepContextGeneric = stepContext as Record; - if (!stepContextGeneric.error) { - stepContextGeneric.error = stepExecution.state?.error; - } + /** + * Builds the foreach context by combining the persisted state (index, total) + * with items derived by re-evaluating the foreach expression at resolution time. + * This avoids storing the entire items array in the step execution state on every iteration. + */ + private buildForeachContext( + stepExecution: EsWorkflowStepExecution, + stepContext: StepContext + ): StepContext['foreach'] { + const foreachState = stepExecution.state ?? {}; + const index = typeof foreachState.index === 'number' ? foreachState.index : 0; + const total = typeof foreachState.total === 'number' ? foreachState.total : 0; + + // Re-evaluate the foreach expression (stored in the step input at entry time) + // to derive the full items array and current item without persisting them in state. + const foreachExpression = this.extractForeachExpression(stepExecution.input); + const items = foreachExpression + ? this.resolveForeachItems(foreachExpression, stepContext) + : undefined; + + const availableItems = items ?? []; + + return { + items: availableItems, + item: availableItems[index], + index, + total, + }; + } + + /** + * Extracts the foreach expression string from a step execution's input. + * The input is typed as JsonValue, so we narrow it to a record and pull the `foreach` key. + */ + private extractForeachExpression(input: EsWorkflowStepExecution['input']): string | undefined { + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const { foreach: expression } = input; + return typeof expression === 'string' ? expression : undefined; + } + return undefined; + } + + /** + * Evaluates a foreach expression against the given context and returns the resulting array. + * Mirrors the evaluation logic in EnterForeachNodeImpl.processForeachConfiguration / getItems. + */ + private resolveForeachItems( + foreachExpression: string, + context: Record + ): unknown[] | undefined { + try { + let resolvedValue: unknown; + + if (isTemplateExpression(foreachExpression)) { + resolvedValue = this.templateEngine.evaluateExpression(foreachExpression, context); + } else { + resolvedValue = this.templateEngine.render(foreachExpression, context); + } + + if (typeof resolvedValue === 'string') { + try { + resolvedValue = JSON.parse(resolvedValue); + } catch { + return undefined; } } + + return Array.isArray(resolvedValue) ? resolvedValue : undefined; + } catch { + return undefined; } } diff --git a/src/platform/plugins/shared/workflows_execution_engine/tsconfig.json b/src/platform/plugins/shared/workflows_execution_engine/tsconfig.json index 3e54180bf9663..4ba2b1649a86c 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/tsconfig.json +++ b/src/platform/plugins/shared/workflows_execution_engine/tsconfig.json @@ -19,7 +19,7 @@ "@kbn/workflows", "@kbn/task-manager-plugin", "@kbn/es-query", - "@kbn/datemath", + "@kbn/eval-kql", "@kbn/core-elasticsearch-server", "@kbn/cloud-plugin", "@kbn/utility-types", @@ -29,9 +29,7 @@ "@kbn/es-mappings", "@kbn/workflows-extensions", "@kbn/licensing-plugin", - "@kbn/logging-mocks", + "@kbn/logging-mocks" ], - "exclude": [ - "target/**/*" - ] + "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/workflows_extensions/test/scout/.meta/api/standard.json b/src/platform/plugins/shared/workflows_extensions/test/scout/.meta/api/standard.json index ac1690e64bc87..19f6a8a1870d4 100644 --- a/src/platform/plugins/shared/workflows_extensions/test/scout/.meta/api/standard.json +++ b/src/platform/plugins/shared/workflows_extensions/test/scout/.meta/api/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-09T13:22:23.604Z", - "sha1": "15adbf56ba2dd9dfc9c8838a03e8e2616cd551d6", + "sha1": "ee41e2603dd078e69ea3280c7c4b609a077d7dd4", "tests": [ { "id": "2e9661be655a029-bbe4bc87d5491d8", @@ -23,6 +22,23 @@ "line": 34, "column": 12 } + }, + { + "id": "e9644e578f7a84b-ea81a422591a428", + "title": "Workflows Extensions - Custom Trigger Definitions Approval should validate that all registered custom trigger definitions are approved by workflows-eng team", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlSearch", + "@svlSecurity", + "@svlOblt", + "@svlWorkplaceAI" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_extensions/test/scout/api/tests/trigger_definitions_approval.spec.ts", + "line": 26, + "column": 12 + } } ] } \ No newline at end of file diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts index 9f4ba7f78c4e7..47c00dc7e93b3 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/selectors.ts @@ -51,11 +51,17 @@ export const selectWorkflowDefinition = createSelector( (computed) => computed?.workflowDefinition ); -// Only checks if the current workflow yaml can be parses, does check the schema, only the yaml syntax +// Only checks if the current workflow yaml can be parsed, does not check the schema, only the yaml syntax export const selectIsYamlSyntaxValid = createSelector(selectYamlComputed, (computed): boolean => Boolean(computed?.workflowDefinition) ); +// Checks whether validation errors (from strict schema + custom validations) are present +export const selectHasYamlSchemaValidationErrors = createSelector( + selectDetail, + (detail): boolean => detail.hasYamlSchemaValidationErrors +); + export const selectFocusedStepId = createSelector(selectDetail, (detail) => detail.focusedStepId); export const selectHighlightedStepId = createSelector( diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts index 404a98f6e877d..88aad8972d9e8 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts @@ -31,6 +31,7 @@ const initialState: WorkflowDetailState = { highlightedStepId: undefined, isTestModalOpen: false, loading: initialLoadingState, + hasYamlSchemaValidationErrors: false, connectorFlyout: { isOpen: false, connectorType: undefined, @@ -89,6 +90,10 @@ const workflowDetailSlice = createSlice({ state.activeTab = action.payload; }, + setHasYamlSchemaValidationErrors: (state, action: { payload: boolean }) => { + state.hasYamlSchemaValidationErrors = action.payload; + }, + // Connector flyout actions openCreateConnectorFlyout: ( state, @@ -151,6 +156,7 @@ export const { setExecution, clearExecution, setActiveTab, + setHasYamlSchemaValidationErrors, openCreateConnectorFlyout, openEditConnectorFlyout, closeConnectorFlyout, diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts index 8afa9ed57665c..abdf14d7c50a6 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts @@ -32,8 +32,10 @@ export const loadExecutionThunk = createAsyncThunk< try { const previousExecution = getState().detail.execution; - // Make the API call to load the execution - const response = await http.get(`/api/workflowExecutions/${id}`); + // Make the API call to load the execution (without input/output to reduce payload during polling) + const response = await http.get(`/api/workflowExecutions/${id}`, { + query: { includeInput: false, includeOutput: false }, + }); dispatch(setExecution(response)); if (id !== previousExecution?.id) { diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts index cf3fd51b051b9..7312ad9c68666 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/types.ts @@ -49,6 +49,8 @@ export interface WorkflowDetailState { schema: WorkflowZodSchemaType; /** Loading states for async operations */ loading: LoadingStates; + /** Whether the editor has validation errors (strict schema + custom validations) */ + hasYamlSchemaValidationErrors: boolean; /** Connector flyout state */ connectorFlyout: { isOpen: boolean; diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.test.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.test.ts new file mode 100644 index 0000000000000..19d25431289c4 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { ExecutionStatus } from '@kbn/workflows'; +import { useStepExecution } from './use_step_execution'; +import { useKibana } from '../../../hooks/use_kibana'; + +jest.mock('../../../hooks/use_kibana'); +const mockUseKibana = useKibana as jest.MockedFunction; + +const createWrapper = (queryClient: QueryClient) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return Wrapper; +}; + +describe('useStepExecution', () => { + let mockHttpGet: jest.Mock; + let queryClient: QueryClient; + + const stepResponse = { + stepId: 'step-1', + status: 'completed', + input: { arg: 'value' }, + output: { result: 'ok' }, + }; + + beforeEach(() => { + jest.useFakeTimers(); + mockHttpGet = jest.fn().mockResolvedValue(stepResponse); + mockUseKibana.mockReturnValue({ + services: { http: { get: mockHttpGet } }, + } as any); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + queryClient.clear(); + }); + + it('should not fetch when stepExecutionId is undefined', () => { + const { result } = renderHook( + () => useStepExecution('exec-1', undefined, ExecutionStatus.COMPLETED), + { wrapper: createWrapper(queryClient) } + ); + + expect(result.current.isFetching).toBe(false); + expect(mockHttpGet).not.toHaveBeenCalled(); + }); + + it('should fetch when both IDs are provided', async () => { + const { result } = renderHook( + () => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.COMPLETED), + { wrapper: createWrapper(queryClient) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockHttpGet).toHaveBeenCalledWith('/api/workflowExecutions/exec-1/steps/step-doc-1'); + expect(result.current.data).toEqual(stepResponse); + }); + + it('should set staleTime to Infinity for terminal step status', async () => { + const { result } = renderHook( + () => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.COMPLETED), + { wrapper: createWrapper(queryClient) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const cachedQuery = queryClient.getQueryCache().findAll({ + queryKey: ['stepExecution', 'exec-1', 'step-doc-1'], + })[0]; + expect(cachedQuery.state.isInvalidated).toBe(false); + expect(cachedQuery.state.dataUpdateCount).toBe(1); + + // After initial fetch, no refetch should happen even after the polling interval + mockHttpGet.mockClear(); + jest.advanceTimersByTime(10_000); + expect(mockHttpGet).not.toHaveBeenCalled(); + }); + + it('should poll for non-terminal step status', async () => { + const { result } = renderHook( + () => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.RUNNING), + { wrapper: createWrapper(queryClient) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockHttpGet).toHaveBeenCalledTimes(1); + + // Advance past the 5s refetch interval — should trigger another fetch + mockHttpGet.mockClear(); + jest.advanceTimersByTime(5_000); + await waitFor(() => expect(mockHttpGet).toHaveBeenCalled()); + }); + + it('should stop polling when step transitions to terminal status', async () => { + const { result, rerender } = renderHook( + ({ status }: { status: ExecutionStatus }) => useStepExecution('exec-1', 'step-doc-1', status), + { + wrapper: createWrapper(queryClient), + initialProps: { status: ExecutionStatus.RUNNING }, + } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Transition to terminal + rerender({ status: ExecutionStatus.COMPLETED }); + + mockHttpGet.mockClear(); + jest.advanceTimersByTime(15_000); + expect(mockHttpGet).not.toHaveBeenCalled(); + }); + + it('should use the correct query key structure', async () => { + renderHook(() => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.COMPLETED), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => + expect( + queryClient.getQueryCache().findAll({ + queryKey: ['stepExecution', 'exec-1', 'step-doc-1'], + }) + ).toHaveLength(1) + ); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts index e6b14858e54bf..2f9000cabcdf2 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts @@ -7,23 +7,35 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useQuery } from '@kbn/react-query'; -import type { EsWorkflowStepExecution } from '@kbn/workflows'; +import type { EsWorkflowStepExecution, ExecutionStatus } from '@kbn/workflows'; +import { isTerminalStatus } from '@kbn/workflows'; +import { useKibana } from '../../../hooks/use_kibana'; -export function useStepExecution(workflowExecutionId: string, stepExecutionId: string) { +const REFETCH_INTERVAL_MS = 5000; + +/** + * Fetches a single step execution with full data (input/output). + * Polls while the step is still running, stops once it reaches a terminal status. + */ +export function useStepExecution( + workflowExecutionId: string, + stepExecutionId: string | undefined, + stepStatus: ExecutionStatus | undefined +) { const { http } = useKibana().services; + const isStepFinished = stepStatus ? isTerminalStatus(stepStatus) : false; return useQuery({ queryKey: ['stepExecution', workflowExecutionId, stepExecutionId], queryFn: async () => { - const response = await http?.get( + const response = await http.get( `/api/workflowExecutions/${workflowExecutionId}/steps/${stepExecutionId}` ); return response; }, enabled: !!workflowExecutionId && !!stepExecutionId, - staleTime: 5000, // Refresh every 5 seconds for real-time logs - refetchInterval: 5000, // Auto-refresh logs + staleTime: isStepFinished ? Infinity : REFETCH_INTERVAL_MS, // will be cleared when switching to a different execution + refetchInterval: isStepFinished ? false : REFETCH_INTERVAL_MS, }); } diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.test.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.test.tsx new file mode 100644 index 0000000000000..130552471dec6 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { useQueryClient } from '@kbn/react-query'; +import type { WorkflowExecutionDto, WorkflowYaml } from '@kbn/workflows'; +import { ExecutionStatus } from '@kbn/workflows'; +import { WorkflowExecutionDetail } from './workflow_execution_detail'; + +jest.mock('@kbn/react-query', () => ({ + ...jest.requireActual('@kbn/react-query'), + useQueryClient: jest.fn(), +})); +const mockUseQueryClient = useQueryClient as jest.MockedFunction; + +jest.mock('./workflow_execution_panel', () => ({ + WorkflowExecutionPanel: () =>
, +})); + +jest.mock('./workflow_step_execution_details', () => ({ + WorkflowStepExecutionDetails: () =>
, +})); + +jest.mock('../model/use_step_execution', () => ({ + useStepExecution: jest.fn(() => ({ data: undefined, isLoading: false })), +})); + +const mockSetSelectedStepExecution = jest.fn(); +jest.mock('../../../hooks/use_workflow_url_state', () => ({ + useWorkflowUrlState: jest.fn(() => ({ + activeTab: 'executions', + setSelectedStepExecution: mockSetSelectedStepExecution, + selectedStepExecutionId: '__overview', + })), +})); + +const createMockExecution = (id: string): WorkflowExecutionDto => ({ + spaceId: 'default', + id, + status: ExecutionStatus.COMPLETED, + error: null, + isTestRun: false, + startedAt: '2024-01-01T00:00:00Z', + finishedAt: '2024-01-01T00:01:00Z', + workflowId: 'workflow-1', + workflowName: 'Test Workflow', + workflowDefinition: { + version: '1', + name: 'test', + enabled: true, + triggers: [], + steps: [], + } as WorkflowYaml, + stepId: undefined, + stepExecutions: [], + duration: 60000, + triggeredBy: 'manual', + yaml: 'version: "1"', +}); + +const mockUseWorkflowExecutionPolling = jest.fn((_executionId: string) => ({ + workflowExecution: createMockExecution('exec-1'), + isLoading: false, + error: null, +})); +jest.mock('../../../entities/workflows/model/use_workflow_execution_polling', () => ({ + useWorkflowExecutionPolling: (executionId: string) => + mockUseWorkflowExecutionPolling(executionId), +})); + +describe('WorkflowExecutionDetail - cache invalidation', () => { + let mockRemoveQueries: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockRemoveQueries = jest.fn(); + mockUseQueryClient.mockReturnValue({ + removeQueries: mockRemoveQueries, + } as any); + }); + + it('should call removeQueries on unmount with the current execution query key', () => { + const { unmount } = render( + + ); + + expect(mockRemoveQueries).not.toHaveBeenCalled(); + + unmount(); + + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: ['stepExecution', 'exec-1'], + }); + }); + + it('should call removeQueries for the previous execution when executionId changes', () => { + const { rerender } = render( + + ); + + expect(mockRemoveQueries).not.toHaveBeenCalled(); + + mockUseWorkflowExecutionPolling.mockReturnValue({ + workflowExecution: createMockExecution('exec-2'), + isLoading: false, + error: null, + }); + + rerender(); + + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: ['stepExecution', 'exec-1'], + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx index 8ef5dcb0acbcd..9564b07ab315a 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx @@ -11,12 +11,14 @@ import { EuiPanel } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { useQueryClient } from '@kbn/react-query'; import { ResizableLayout, ResizableLayoutDirection, ResizableLayoutMode, ResizableLayoutOrder, } from '@kbn/resizable-layout'; +import type { WorkflowStepExecutionDto } from '@kbn/workflows'; import { WorkflowExecutionPanel } from './workflow_execution_panel'; import { buildOverviewStepExecutionFromContext, @@ -25,6 +27,7 @@ import { import { WorkflowStepExecutionDetails } from './workflow_step_execution_details'; import { useWorkflowExecutionPolling } from '../../../entities/workflows/model/use_workflow_execution_polling'; import { useWorkflowUrlState } from '../../../hooks/use_workflow_url_state'; +import { useStepExecution } from '../model/use_step_execution'; const WidthStorageKey = 'WORKFLOWS_EXECUTION_DETAILS_WIDTH'; const DefaultSidebarWidth = 300; @@ -36,6 +39,7 @@ export interface WorkflowExecutionDetailProps { export const WorkflowExecutionDetail: React.FC = React.memo( ({ executionId, onClose }) => { const { workflowExecution, error } = useWorkflowExecutionPolling(executionId); + const queryClient = useQueryClient(); const { activeTab, setSelectedStepExecution, selectedStepExecutionId } = useWorkflowUrlState(); const [sidebarWidth = DefaultSidebarWidth, setSidebarWidth] = useLocalStorage( @@ -44,6 +48,13 @@ export const WorkflowExecutionDetail: React.FC = R ); const showBackButton = activeTab === 'executions'; + // Clear cached step I/O data when switching to a different execution + useEffect(() => { + return () => { + queryClient.removeQueries({ queryKey: ['stepExecution', executionId] }); + }; + }, [executionId, queryClient]); + useEffect(() => { if ( !selectedStepExecutionId && // no step execution selected @@ -68,7 +79,26 @@ export const WorkflowExecutionDetail: React.FC = R return null; }, [workflowExecution]); - const selectedStepExecution = useMemo(() => { + // For pseudo-steps (overview, trigger), build from execution context directly + const isPseudoStep = + selectedStepExecutionId === '__overview' || selectedStepExecutionId === 'trigger'; + + // Find the lightweight step from the polled execution (has status/duration but no I/O) + const lightweightStep = useMemo(() => { + if (!selectedStepExecutionId || isPseudoStep) { + return undefined; + } + return workflowExecution?.stepExecutions?.find((step) => step.id === selectedStepExecutionId); + }, [workflowExecution?.stepExecutions, selectedStepExecutionId, isPseudoStep]); + + // Lazy-load full step data (with input/output) for real steps + const { data: fullStepData, isLoading: isLoadingStepData } = useStepExecution( + executionId, + isPseudoStep ? undefined : selectedStepExecutionId ?? undefined, + lightweightStep?.status + ); + + const selectedStepExecution = useMemo(() => { if (!selectedStepExecutionId) { return undefined; } @@ -81,11 +111,17 @@ export const WorkflowExecutionDetail: React.FC = R return buildTriggerStepExecutionFromContext(workflowExecution) ?? undefined; } - if (!workflowExecution?.stepExecutions?.length) { + if (!lightweightStep) { return undefined; } - return workflowExecution.stepExecutions.find((step) => step.id === selectedStepExecutionId); - }, [workflowExecution, selectedStepExecutionId]); + + // Merge: use lightweight step for structure/status, overlay full I/O when available + if (fullStepData) { + return { ...lightweightStep, input: fullStepData.input, output: fullStepData.output }; + } + + return lightweightStep; + }, [workflowExecution, selectedStepExecutionId, lightweightStep, fullStepData]); return ( @@ -110,6 +146,7 @@ export const WorkflowExecutionDetail: React.FC = R workflowExecutionId={executionId} stepExecution={selectedStepExecution} workflowExecutionDuration={workflowExecution?.duration ?? undefined} + isLoadingStepData={isLoadingStepData && !isPseudoStep} /> } minFlexPanelSize={200} diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx index 34a83c24ee06f..c90a91bb764ca 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx @@ -30,10 +30,11 @@ interface WorkflowStepExecutionDetailsProps { workflowExecutionId: string; stepExecution?: WorkflowStepExecutionDto; workflowExecutionDuration?: number; + isLoadingStepData?: boolean; } export const WorkflowStepExecutionDetails = React.memo( - ({ workflowExecutionId, stepExecution, workflowExecutionDuration }) => { + ({ workflowExecutionId, stepExecution, workflowExecutionDuration, isLoadingStepData }) => { const isFinished = useMemo( () => Boolean(stepExecution?.status && isTerminalStatus(stepExecution.status)), [stepExecution?.status] @@ -47,10 +48,13 @@ export const WorkflowStepExecutionDetails = React.memo { if (isTriggerPseudoStep) { const pseudoTabs: { id: string; name: string }[] = []; - if (stepExecution?.input) { + if (hasInput) { pseudoTabs.push({ id: 'input', name: 'Input', @@ -61,14 +65,14 @@ export const WorkflowStepExecutionDetails = React.memo(tabs[0].id); @@ -128,68 +132,76 @@ export const WorkflowStepExecutionDetails = React.memo {isFinished ? ( - {selectedTabId === 'output' && ( + {isLoadingStepData ? ( + + + + ) : ( <> - {isTriggerPseudoStep && ( + {selectedTabId === 'output' && ( <> - - {`{{ }}`}, - }} - /> - - + {isTriggerPseudoStep && ( + <> + + {`{{ }}`}, + }} + /> + + + + )} + )} - - - )} - {selectedTabId === 'input' && ( - <> - {isTriggerPseudoStep && ( + {selectedTabId === 'input' && ( <> - - - {triggerType === 'manual' - ? `{{ inputs. }}` - : `{{ event. }}`} - - ), - }} - /> - - + {isTriggerPseudoStep && ( + <> + + + {triggerType === 'manual' + ? `{{ inputs. }}` + : `{{ event. }}`} + + ), + }} + /> + + + + )} + )} - )} diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx index 865421b7eea96..cd544f2d7b7ef 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx @@ -13,7 +13,8 @@ import type { WorkflowDetailHeaderProps } from './workflow_detail_header'; import { WorkflowDetailHeader } from './workflow_detail_header'; import { createMockStore } from '../../../entities/workflows/store/__mocks__/store.mock'; import { - _setComputedDataInternal, + _clearComputedData, + setHasYamlSchemaValidationErrors, setWorkflow, setYamlString, } from '../../../entities/workflows/store/workflow_detail/slice'; @@ -72,27 +73,33 @@ describe('WorkflowDetailHeader', () => { const renderWithProviders = ( component: React.ReactElement, - { isValid = true, hasChanges = false }: { isValid?: boolean; hasChanges?: boolean } = {} + { + isValid = true, + hasChanges = false, + hasYamlSchemaValidationErrors = false, + serverValid = true, + }: { + isValid?: boolean; + hasChanges?: boolean; + hasYamlSchemaValidationErrors?: boolean; + serverValid?: boolean; + } = {} ) => { const store = createMockStore(); - // Set up the workflow in the store - store.dispatch(setWorkflow(mockWorkflow)); + // Set up the workflow in the store (with server-side valid flag) + store.dispatch(setWorkflow({ ...mockWorkflow, valid: serverValid })); store.dispatch(setYamlString(hasChanges ? 'modified yaml' : mockWorkflow.yaml)); - // Set computed data to control syntax validation - if (isValid) { - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: { - version: '1', - name: 'Test Workflow', - enabled: true, - triggers: [], - steps: [], - }, - }) - ); + if (!isValid) { + // Clear the computed data that the middleware auto-generated from the yaml, + // so that selectIsYamlSyntaxValid returns false + store.dispatch(_clearComputedData()); + } + + // Simulate strict validation errors from Monaco + if (hasYamlSchemaValidationErrors) { + store.dispatch(setHasYamlSchemaValidationErrors(true)); } const wrapper = ({ children }: { children: React.ReactNode }) => { @@ -158,15 +165,39 @@ describe('WorkflowDetailHeader', () => { expect(container).toBeTruthy(); }); - // We shouldn't rely on parseResult to determine if the yaml is valid now - // instead we should move validationErrors to the store and use it to determine it - it.skip('disables run workflow button when yaml is invalid', () => { + it('disables run workflow button when yaml has syntax errors', () => { const result = renderWithProviders(, { isValid: false, }); expect(result.getByTestId('runWorkflowHeaderButton')).toBeDisabled(); }); + it('enables run workflow button when yaml has validation errors', () => { + const result = renderWithProviders(, { + isValid: true, + hasYamlSchemaValidationErrors: true, + }); + expect(result.getByTestId('runWorkflowHeaderButton')).toBeEnabled(); + }); + + it('disables enabled toggle when yaml has validation errors', () => { + const result = renderWithProviders(, { + isValid: true, + hasYamlSchemaValidationErrors: true, + }); + const toggle = result.getByRole('switch'); + expect(toggle).toBeDisabled(); + }); + + it('disables enabled toggle when server reports workflow as invalid (e.g. initial page load)', () => { + const result = renderWithProviders(, { + isValid: true, + serverValid: false, + }); + const toggle = result.getByRole('switch'); + expect(toggle).toBeDisabled(); + }); + it('enables run workflow button when yaml is valid', () => { const result = renderWithProviders(); const button = result.getByTestId('runWorkflowHeaderButton'); diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx index e4cf63209909b..85ab20917bd33 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx @@ -37,6 +37,7 @@ import { useSaveYaml } from '../../../entities/workflows/model/use_save_yaml'; import { useUpdateWorkflow } from '../../../entities/workflows/model/use_update_workflow'; import { selectHasChanges, + selectHasYamlSchemaValidationErrors, selectIsExecutionsTab, selectIsSavingYaml, selectIsYamlSynced, @@ -105,6 +106,7 @@ export const WorkflowDetailHeader = React.memo( const workflow = useSelector(selectWorkflow); const isSyntaxValid = useSelector(selectIsYamlSyntaxValid); + const hasYamlSchemaValidationErrors = useSelector(selectHasYamlSchemaValidationErrors); const hasUnsavedChanges = useSelector(selectHasChanges); const isExecutionsTab = useSelector(selectIsExecutionsTab); const isYamlSynced = useSelector(selectIsYamlSynced); @@ -135,6 +137,11 @@ export const WorkflowDetailHeader = React.memo( const [showRunConfirmation, setShowRunConfirmation] = useState(false); + // Combined validity: syntax must parse AND no strict validation errors AND server considers it valid. + // workflow?.valid !== false covers the initial page load before Monaco validates. + const isSchemaValid = + isSyntaxValid && !hasYamlSchemaValidationErrors && workflow?.valid !== false; + const runWorkflowTooltipContent = useMemo(() => { return getTestRunTooltipContent({ isExecutionsTab, @@ -262,9 +269,9 @@ export const WorkflowDetailHeader = React.memo( ? i18n.translate('workflows.workflowDetailHeader.unsaved', { defaultMessage: 'Save changes to enable/disable workflow', }) - : !isSyntaxValid + : !isSchemaValid ? i18n.translate('workflows.workflowDetailHeader.invalid', { - defaultMessage: 'Fix errors to enable workflow', + defaultMessage: 'Fix validation errors to enable workflow', }) : undefined } @@ -274,7 +281,7 @@ export const WorkflowDetailHeader = React.memo( !workflowId || isLoading || !canUpdateWorkflow || - !isSyntaxValid || + !isSchemaValid || hasUnsavedChanges } checked={isEnabled} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/execution_context/use_lazy_step_execution_fetcher.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/execution_context/use_lazy_step_execution_fetcher.ts new file mode 100644 index 0000000000000..9cc6f9127fb78 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/execution_context/use_lazy_step_execution_fetcher.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useRef } from 'react'; +import { useQueryClient } from '@kbn/react-query'; +import type { EsWorkflowStepExecution } from '@kbn/workflows'; +import { isTerminalStatus } from '@kbn/workflows'; +import type { WorkflowStepExecutionDto } from '@kbn/workflows/types/v1'; +import type { StepExecutionData } from './build_execution_context'; +import { useKibana } from '../../../../hooks/use_kibana'; + +const STEP_EXECUTION_QUERY_KEY = 'stepExecution'; + +function toStepExecutionData(step: EsWorkflowStepExecution): StepExecutionData { + return { + output: step.output, + error: step.error, + input: step.input, + status: step.status, + state: step.state as StepExecutionData['state'], + }; +} + +/** + * Provides a stable ref to a function that lazily fetches a step execution's + * I/O data. Checks the React Query cache first (shared with useStepExecution + * in the execution detail panel); falls back to an HTTP request and populates + * the cache so both directions stay in sync. + * + * The returned ref is updated every render so it always closes over the latest + * execution ID and step executions, matching the ref-pattern used by the + * Monaco hover provider. + */ +export function useLazyStepExecutionFetcher( + executionId: string | undefined, + stepExecutions: WorkflowStepExecutionDto[] | undefined +) { + const { http } = useKibana().services; + const queryClient = useQueryClient(); + + const executionIdRef = useRef(executionId); + executionIdRef.current = executionId; + + const stepExecutionsRef = useRef(stepExecutions); + stepExecutionsRef.current = stepExecutions; + + const fetchRef = useRef<(stepId: string) => Promise>(async () => null); + + fetchRef.current = async (stepId: string): Promise => { + const currentExecutionId = executionIdRef.current; + if (!currentExecutionId) { + return null; + } + + const stepDocId = stepExecutionsRef.current?.find((s) => s.stepId === stepId)?.id; + if (!stepDocId) { + return null; + } + + const queryKey = [STEP_EXECUTION_QUERY_KEY, currentExecutionId, stepDocId]; + const cached = queryClient.getQueryData(queryKey); + + // Only trust the cache for terminal steps — their data won't change. + // Running steps need a fresh fetch since output may have appeared. + const stepInfo = stepExecutionsRef.current?.find((s) => s.stepId === stepId); + const isStepTerminal = stepInfo?.status && isTerminalStatus(stepInfo.status); + + if (cached && isStepTerminal) { + return toStepExecutionData(cached); + } + + try { + const stepExecution = await http.get( + `/api/workflowExecutions/${currentExecutionId}/steps/${stepDocId}` + ); + if (!stepExecution) { + return null; + } + queryClient.setQueryData(queryKey, stepExecution); + return toStepExecutionData(stepExecution); + } catch { + return null; + } + }; + + return fetchRef; +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts index 87029594f0260..a6e0c04e1ff3d 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts @@ -12,7 +12,10 @@ import type YAML from 'yaml'; import type { Scalar, YAMLMap } from 'yaml'; import type { monaco } from '@kbn/monaco'; -import type { ExecutionContext } from '../execution_context/build_execution_context'; +import type { + ExecutionContext, + StepExecutionData, +} from '../execution_context/build_execution_context'; /** * Context information for hover providers @@ -143,6 +146,8 @@ export interface ProviderConfig { getYamlDocument: () => YAML.Document | null; /** Function to get the current execution context (for template expression hover) */ getExecutionContext?: () => ExecutionContext | null; + /** Lazily fetch a step's I/O data and merge into the execution context */ + fetchStepExecutionData?: (stepId: string) => Promise; /** Additional configuration options */ options?: Record; } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.test.ts new file mode 100644 index 0000000000000..fc8e55c45f63b --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.test.ts @@ -0,0 +1,223 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { monaco } from '@kbn/monaco'; +import type { ProviderConfig } from './provider_interfaces'; +import { UnifiedHoverProvider } from './unified_hover_provider'; +import type { + ExecutionContext, + StepExecutionData, +} from '../execution_context/build_execution_context'; + +jest.mock('../template_expression/parse_template_at_position'); +jest.mock('../template_expression/evaluate_expression'); +jest.mock('../hover/get_intercepted_hover', () => ({ + getInterceptedHover: jest.fn().mockResolvedValue(null), +})); + +const { parseTemplateAtPosition } = jest.requireMock( + '../template_expression/parse_template_at_position' +); +const { evaluateExpression } = jest.requireMock('../template_expression/evaluate_expression'); + +const createMockModel = () => + ({ + uri: { toString: () => 'inmemory://test' }, + getLineContent: jest.fn().mockReturnValue(' message: "{{ steps.search.output.hits }}"'), + getLineDecorations: jest.fn().mockReturnValue([]), + } as unknown as monaco.editor.ITextModel); + +const createMockPosition = (line = 1, column = 25) => new monaco.Position(line, column); + +describe('UnifiedHoverProvider - lazy-loading step I/O', () => { + let fetchStepExecutionData: jest.Mock; + let getExecutionContext: jest.Mock; + let provider: UnifiedHoverProvider; + + const stepOutputValue = { hits: [{ _id: '1', title: 'result' }] }; + + const baseExecutionContext: ExecutionContext = { + steps: { + search: { + status: 'completed', + }, + }, + }; + + const enrichedStepData: StepExecutionData = { + status: 'completed', + output: stepOutputValue, + input: { query: '*' }, + }; + + const templateInfo = { + isInsideTemplate: true, + expression: 'steps.search.output.hits', + variablePath: 'steps.search.output.hits', + pathSegments: ['steps', 'search', 'output', 'hits'], + cursorSegmentIndex: 3, + pathUpToCursor: ['steps', 'search', 'output', 'hits'], + filters: [], + isOnFilter: false, + templateRange: new monaco.Range(1, 20, 1, 42), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + fetchStepExecutionData = jest.fn().mockResolvedValue(enrichedStepData); + getExecutionContext = jest.fn().mockReturnValue(baseExecutionContext); + + // parseTemplateAtPosition returns our template info for every call + parseTemplateAtPosition.mockReturnValue(templateInfo); + // evaluateExpression resolves the value from the (enriched) context + evaluateExpression.mockImplementation( + ({ expression, context }: { expression: string; context: ExecutionContext }) => { + if (expression === 'steps.search.output.hits' && context.steps.search?.output) { + return (context.steps.search.output as Record).hits; + } + return undefined; + } + ); + + // Suppress validation markers and line decorations + (monaco.editor.getModelMarkers as jest.Mock) = jest.fn().mockReturnValue([]); + + const config: ProviderConfig = { + getYamlDocument: () => null, + getExecutionContext, + fetchStepExecutionData, + options: { http: {} as any, notifications: {} as any }, + }; + provider = new UnifiedHoverProvider(config); + }); + + it('should fetch step data and return enriched hover on first hover', async () => { + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(fetchStepExecutionData).toHaveBeenCalledWith('search'); + expect(result).not.toBeNull(); + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.stringContaining('steps.search.output.hits'), + }) + ); + // Should contain the resolved value, not "undefined" + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.not.stringContaining('undefined'), + }) + ); + }); + + it('should return enriched hover on second hover (cache hit, no duplicate fetch)', async () => { + // First hover + const result1 = await provider.provideCustomHover(createMockModel(), createMockPosition()); + expect(fetchStepExecutionData).toHaveBeenCalledTimes(1); + expect(result1).not.toBeNull(); + + // Second hover — same step, same execution context (context ref unchanged, no I/O in it) + const result2 = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + // fetchStepExecutionData is called again (cache dedup is in the caller, not the provider) + expect(fetchStepExecutionData).toHaveBeenCalledTimes(2); + expect(result2).not.toBeNull(); + // Value should still resolve correctly — this is the regression scenario + expect(result2!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.not.stringContaining('undefined'), + }) + ); + }); + + it('should not call fetchStepExecutionData when step output is already in context', async () => { + const contextWithOutput: ExecutionContext = { + steps: { + search: { + status: 'completed', + output: stepOutputValue, + }, + }, + }; + getExecutionContext.mockReturnValue(contextWithOutput); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(fetchStepExecutionData).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + }); + + it('should handle fetchStepExecutionData returning null gracefully', async () => { + fetchStepExecutionData.mockResolvedValue(null); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(result).not.toBeNull(); + // Value should be "undefined in the current execution context" + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.stringContaining('undefined'), + }) + ); + }); + + it('should work without fetchStepExecutionData configured', async () => { + const config: ProviderConfig = { + getYamlDocument: () => null, + getExecutionContext, + options: { http: {} as any, notifications: {} as any }, + }; + const providerWithoutFetch = new UnifiedHoverProvider(config); + + const result = await providerWithoutFetch.provideCustomHover( + createMockModel(), + createMockPosition() + ); + + expect(result).not.toBeNull(); + // No fetcher → no enrichment → value is undefined + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.stringContaining('undefined'), + }) + ); + }); + + it('should return null when execution context is not available', async () => { + getExecutionContext.mockReturnValue(null); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(result).toBeNull(); + expect(fetchStepExecutionData).not.toHaveBeenCalled(); + }); + + it('should resolve non-step expressions without calling fetchStepExecutionData', async () => { + const contextWithInputs: ExecutionContext = { + inputs: { name: 'test' }, + steps: {}, + }; + getExecutionContext.mockReturnValue(contextWithInputs); + + const inputTemplateInfo = { + ...templateInfo, + expression: 'inputs.name', + variablePath: 'inputs.name', + pathSegments: ['inputs', 'name'], + pathUpToCursor: ['inputs', 'name'], + }; + parseTemplateAtPosition.mockReturnValue(inputTemplateInfo); + evaluateExpression.mockReturnValue('test'); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(fetchStepExecutionData).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts index 3f846fb6c2ab0..545a1de2492f4 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts @@ -20,7 +20,10 @@ import { getMonacoConnectorHandler } from './provider_registry'; import { getPathAtOffset } from '../../../../../common/lib/yaml'; import { performComputation } from '../../../../entities/workflows/store/workflow_detail/utils/computation'; import { isYamlValidationMarkerOwner } from '../../../../features/validate_workflow_yaml/model/types'; -import type { ExecutionContext } from '../execution_context/build_execution_context'; +import type { + ExecutionContext, + StepExecutionData, +} from '../execution_context/build_execution_context'; import { getInterceptedHover } from '../hover/get_intercepted_hover'; import { evaluateExpression } from '../template_expression/evaluate_expression'; import { parseTemplateAtPosition } from '../template_expression/parse_template_at_position'; @@ -37,10 +40,12 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { private readonly getYamlDocument: () => YAML.Document | null; private readonly getExecutionContext?: () => ExecutionContext | null; + private readonly fetchStepExecutionData?: (stepId: string) => Promise; constructor(config: ProviderConfig) { this.getYamlDocument = config.getYamlDocument; this.getExecutionContext = config.getExecutionContext; + this.fetchStepExecutionData = config.fetchStepExecutionData; } async provideHover( @@ -62,9 +67,15 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { position: monaco.Position ): Promise { try { - // console.log('UnifiedHoverProvider: provideHover called at position', position); + // FIRST: Check if cursor is inside a template expression {{ }} + // Template expression hover (runtime values) takes priority over validation + // decorations and markers, so we check this before anything else. + const templateInfo = parseTemplateAtPosition(model, position); + if (templateInfo && templateInfo.isInsideTemplate) { + return await this.handleTemplateExpressionHover(model, position, templateInfo); + } - // FIRST: Check if there are validation errors at this position OR nearby + // Check if there are validation errors at this position OR nearby // If there are, let the validation-only hover provider handle it const markers = monaco.editor.getModelMarkers({ resource: model.uri }); const validationMarkersNearby = markers.filter( @@ -78,17 +89,10 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { ); if (validationMarkersNearby.length > 0) { - // console.log('UnifiedHoverProvider: Found validation errors nearby, skipping to let validation provider handle'); - // console.log('Nearby validation markers:', validationMarkersNearby.map(m => ({ - // message: m.message, - // startCol: m.startColumn, - // endCol: m.endColumn, - // currentCol: position.column - // }))); return null; } - // Second: check for decorations at this position, e.g. we don't want to show generic hover content over variables (valid or invalid) + // Check for decorations at this position, e.g. we don't want to show generic hover content over variables (valid or invalid) const decorations = model .getLineDecorations(position.lineNumber) .filter((decoration) => decoration.options.hoverMessage); @@ -96,34 +100,18 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { return null; } - // Third: Check if cursor is inside a template expression {{ }} - const templateInfo = parseTemplateAtPosition(model, position); - if (templateInfo && templateInfo.isInsideTemplate) { - // Handle template expression hover (only if execution context is available) - return this.handleTemplateExpressionHover(model, position, templateInfo); - } - // Get YAML document const yamlDocument = this.getYamlDocument(); if (!yamlDocument) { - // console.log('UnifiedHoverProvider: No YAML document available'); return null; } // Detect context at current position const context = await this.buildHoverContext(model, position, yamlDocument); if (!context) { - // console.log('UnifiedHoverProvider: Could not build hover context'); return null; } - // console.log('✅ UnifiedHoverProvider: Context detected', { - // connectorType: context.connectorType, - // yamlPath: context.yamlPath, - // stepContext: context.stepContext, - // parameterContext: context.parameterContext, - // }); - // Only show connector hover for specific fields (type, or connector parameters) // Don't show hover for arbitrary string values in the YAML if (!this.shouldShowConnectorHover(context)) { @@ -133,37 +121,21 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { // Find appropriate Monaco handler const handler = getMonacoConnectorHandler(context.connectorType); if (!handler) { - /* - console.log( - 'UnifiedHoverProvider: No Monaco handler found for connector type:', - context.connectorType - ); - */ return null; } - /* - console.log( - 'UnifiedHoverProvider: Found Monaco handler for connector type:', - context.connectorType - ); - */ - // Generate hover content const hoverContent = await handler.generateHoverContent(context); if (!hoverContent) { - // console.log('UnifiedHoverProvider: Handler returned no hover content'); return null; } - // console.log('UnifiedHoverProvider: Returning hover content'); // Don't return a range for connector hovers - this prevents Monaco from highlighting // Only template expression hovers should have ranges to show the blue highlight return { contents: [hoverContent], }; } catch (error) { - // console.warn('UnifiedHoverProvider: Error providing hover', error); return null; } } @@ -184,13 +156,11 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { // If no path found (e.g., cursor after colon), try to find it from the current line if (yamlPath.length === 0) { yamlPath = this.getPathFromCurrentLine(model, position, yamlDocument); - // console.log('🔍 buildHoverContext: Found path from current line:', yamlPath); } // Detect connector type and step context const stepContext = this.detectStepContext(model.getValue(), position); if (!stepContext?.stepType) { - // console.log('🔍 buildHoverContext: No stepContext found for path:', yamlPath); return null; } @@ -211,7 +181,6 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { parameterContext, }; } catch (error) { - // console.warn('UnifiedHoverProvider: Error building context', error); return null; } } @@ -228,17 +197,10 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { const lineContent = model.getLineContent(position.lineNumber); const beforeCursor = lineContent.substring(0, position.column - 1); - // console.log('🔍 getPathFromCurrentLine (hover):', { - // lineContent: JSON.stringify(lineContent), - // beforeCursor: JSON.stringify(beforeCursor), - // position: { line: position.lineNumber, column: position.column }, - // }); - // Check if we're after a colon (common case: "with:|") const colonMatch = beforeCursor.match(/(\w+)\s*:\s*$/); if (colonMatch) { const keyName = colonMatch[1]; - // console.log('🔍 Found key after colon:', keyName); // Try to find this key in the document by looking at nearby positions // Look at the start of the key on this line @@ -269,14 +231,12 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { const testPosition = lineStartPosition + offset; const testPath = getPathAtOffset(yamlDocument, testPosition); if (testPath.length > 0) { - // console.log('🔍 Found fallback path at offset', offset, ':', testPath); return testPath; } } return []; } catch (error) { - // console.warn('UnifiedHoverProvider: Error getting path from current line', error); return []; } } @@ -293,7 +253,6 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { if (!stepInfo) { return null; } - // console.log('🔍 detectStepContext: Step info:', stepInfo); return { stepName: stepInfo.stepId, stepType: stepInfo.stepType, @@ -354,14 +313,53 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { return false; } + /** + * Extract the step ID from a template expression path (e.g., "steps.search.output.hits" -> "search") + */ + private extractStepIdFromPath(pathSegments: string[]): string | null { + if (pathSegments.length >= 2 && pathSegments[0] === 'steps') { + return pathSegments[1]; + } + return null; + } + + /** + * Fetch step I/O data on demand if not already available. + * Returns the enriched step data (merged with fetched I/O), or the existing + * data if already present. Returns null if the step doesn't exist. + * Deduplication is handled by the React Query cache inside fetchStepExecutionData. + */ + private async fetchStepDataIfNeeded( + stepData: StepExecutionData | undefined, + stepId: string + ): Promise { + if (!stepData) { + return null; + } + + if (!this.fetchStepExecutionData) { + return stepData; + } + + if (stepData.output !== undefined) { + return stepData; + } + + const fullStepData = await this.fetchStepExecutionData(stepId); + if (fullStepData) { + return { ...stepData, ...fullStepData }; + } + return stepData; + } + /** * Handle hover for template expressions {{ }} */ - private handleTemplateExpressionHover( + private async handleTemplateExpressionHover( model: monaco.editor.ITextModel, position: monaco.Position, templateInfo: ReturnType - ): monaco.languages.Hover | null { + ): Promise { if (!templateInfo || !this.getExecutionContext) { return null; } @@ -372,23 +370,37 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { } try { + // Build a local context, enriching with lazily-fetched step I/O if needed + let evalContext: ExecutionContext = executionContext; + const stepId = this.extractStepIdFromPath(templateInfo.pathSegments); + if (stepId) { + const enrichedStep = await this.fetchStepDataIfNeeded( + executionContext.steps[stepId], + stepId + ); + if (enrichedStep && enrichedStep !== executionContext.steps[stepId]) { + evalContext = { + ...executionContext, + steps: { ...executionContext.steps, [stepId]: enrichedStep }, + }; + } + } + // Determine what to evaluate let value: JsonValue | undefined; let evaluatedPath: string; if (templateInfo.filters.length > 0 && templateInfo.isOnFilter) { - // Cursor is on the filter part - evaluate with filters evaluatedPath = templateInfo.expression; value = evaluateExpression({ expression: templateInfo.expression, - context: executionContext, + context: evalContext, }); } else { - // Cursor is on the variable path (not filter) - resolve path only evaluatedPath = templateInfo.pathUpToCursor.join('.'); value = evaluateExpression({ expression: evaluatedPath, - context: executionContext, + context: evalContext, }); } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx index 859865df19d6d..cb9dd505c340f 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx @@ -13,6 +13,7 @@ import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { I18nProviderMock } from '@kbn/core-i18n-browser-mocks/src/i18n_context_mock'; import { monaco, YAML_LANG_ID } from '@kbn/monaco'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import type { WorkflowYAMLEditorProps } from './workflow_yaml_editor'; import { WorkflowYAMLEditor } from './workflow_yaml_editor'; import { useSaveYaml } from '../../../entities/workflows/model/use_save_yaml'; @@ -221,17 +222,21 @@ describe('WorkflowYAMLEditor', () => { editorRef: { current: null }, }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const renderWithProviders = ( component: React.ReactElement, store?: ReturnType ) => { const testStore = store || createMockStore(); return render( - - - {component} - - + + + + {component} + + + ); }; @@ -254,13 +259,15 @@ describe('WorkflowYAMLEditor', () => { it('updates store when editor content changes', async () => { const store = createMockStore(); const { container } = render( - - - - - - - + + + + + + + + + ); const textarea = container.querySelector( diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index 8aba9baeff69a..eb575a651fa57 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -19,7 +19,6 @@ import type YAML from 'yaml'; import { FormattedMessage } from '@kbn/i18n-react'; import { monaco, YAML_LANG_ID } from '@kbn/monaco'; import { isTriggerType } from '@kbn/workflows'; -import type { WorkflowStepExecutionDto } from '@kbn/workflows/types/v1'; import type { z } from '@kbn/zod/v4'; import { ActionsMenuButton } from './actions_menu_button'; import { @@ -53,7 +52,10 @@ import { selectStepExecutions, selectWorkflow, } from '../../../entities/workflows/store/workflow_detail/selectors'; -import { setIsTestModalOpen } from '../../../entities/workflows/store/workflow_detail/slice'; +import { + setHasYamlSchemaValidationErrors, + setIsTestModalOpen, +} from '../../../entities/workflows/store/workflow_detail/slice'; import { ActionsMenuPopover } from '../../../features/actions_menu_popover'; import type { ActionOptionData } from '../../../features/actions_menu_popover/types'; import { useMonacoMarkersChangedInterceptor } from '../../../features/validate_workflow_yaml/lib/use_monaco_markers_changed_interceptor'; @@ -64,8 +66,11 @@ import { useKibana } from '../../../hooks/use_kibana'; import { UnsavedChangesPrompt, YamlEditor } from '../../../shared/ui'; import { triggerSchemas } from '../../../trigger_schemas'; import { interceptMonacoYamlProvider } from '../lib/autocomplete/intercept_monaco_yaml_provider'; -import { buildExecutionContext } from '../lib/execution_context/build_execution_context'; -import type { ExecutionContext } from '../lib/execution_context/build_execution_context'; +import { + buildExecutionContext, + type ExecutionContext, +} from '../lib/execution_context/build_execution_context'; +import { useLazyStepExecutionFetcher } from '../lib/execution_context/use_lazy_step_execution_fetcher'; import { interceptMonacoYamlHoverProvider } from '../lib/hover/intercept_monaco_yaml_hover_provider'; import { ElasticsearchMonacoConnectorHandler, @@ -178,13 +183,12 @@ export const WorkflowYAMLEditor = ({ const editorRef = useRef(null); const stepExecutions = useSelector(selectStepExecutions); - const stepExecutionsRef = useRef(stepExecutions); - stepExecutionsRef.current = stepExecutions; const execution = useSelector(selectExecution); const executionContextRef = useRef(null); // Build execution context when step executions are available + // Steps will have status/error/state but no I/O - those are lazy-loaded on hover useEffect(() => { if (isExecutionYaml && stepExecutions) { executionContextRef.current = buildExecutionContext(stepExecutions, execution?.context); @@ -193,6 +197,8 @@ export const WorkflowYAMLEditor = ({ } }, [isExecutionYaml, stepExecutions, execution?.context]); + const fetchStepExecutionDataRef = useLazyStepExecutionFetcher(execution?.id, stepExecutions); + // Ref to track saving state for keyboard handlers const isSavingRef = useRef(false); isSavingRef.current = isSaving; @@ -256,6 +262,12 @@ export const WorkflowYAMLEditor = ({ workflowYamlSchema: workflowYamlSchema as z.ZodSchema, }); + // Sync validation error state to Redux so sibling components (e.g. header toggle) can react + useEffect(() => { + const hasErrors = validationErrors.some((e) => e.severity === 'error'); + dispatch(setHasYamlSchemaValidationErrors(hasErrors)); + }, [validationErrors, dispatch]); + const handleErrorClick = useCallback((error: YamlValidationResult) => { if (!editorRef.current) { return; @@ -361,6 +373,7 @@ export const WorkflowYAMLEditor = ({ const providerConfig = { getYamlDocument: () => yamlDocumentRef.current || null, getExecutionContext: () => executionContextRef.current, + fetchStepExecutionData: (stepId: string) => fetchStepExecutionDataRef.current(stepId), options: { http, notifications, diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.test.ts new file mode 100644 index 0000000000000..ae2aac7658dbe --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ElasticsearchClient } from '@kbn/core/server'; +import { loggerMock } from '@kbn/logging-mocks'; +import { getWorkflowExecution } from './get_workflow_execution'; + +jest.mock('./search_step_executions', () => ({ + searchStepExecutions: jest.fn().mockResolvedValue([]), +})); + +const { searchStepExecutions } = jest.requireMock('./search_step_executions'); + +describe('getWorkflowExecution', () => { + let mockEsClient: jest.Mocked; + let mockLogger: ReturnType; + + const baseParams = { + workflowExecutionIndex: '.workflows-executions', + stepsExecutionIndex: '.workflows-steps', + workflowExecutionId: 'exec-1', + spaceId: 'default', + }; + + const baseExecutionDoc = { + spaceId: 'default', + workflowId: 'workflow-1', + status: 'completed', + startedAt: '2024-01-01T00:00:00Z', + stepExecutionIds: ['step-doc-1', 'step-doc-2'], + workflowDefinition: { version: '1', name: 'test', enabled: true, triggers: [], steps: [] }, + }; + + beforeEach(() => { + mockEsClient = { + get: jest.fn(), + mget: jest.fn(), + search: jest.fn(), + } as any; + mockLogger = loggerMock.create(); + jest.clearAllMocks(); + }); + + describe('source excludes with mget (stepExecutionIds present)', () => { + beforeEach(() => { + mockEsClient.get.mockResolvedValue({ + _source: baseExecutionDoc, + } as any); + mockEsClient.mget.mockResolvedValue({ + docs: [ + { found: true, _source: { stepId: 's1', status: 'completed', globalExecutionIndex: 0 } }, + { found: true, _source: { stepId: 's2', status: 'completed', globalExecutionIndex: 1 } }, + ], + } as any); + }); + + it('should not pass _source_excludes when both includeInput and includeOutput are true', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: true, + includeOutput: true, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.not.objectContaining({ _source_excludes: expect.anything() }) + ); + }); + + it('should pass _source_excludes: ["input", "output"] when both are false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: false, + includeOutput: false, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['input', 'output'], + }) + ); + }); + + it('should pass _source_excludes: ["input"] when only includeInput is false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: false, + includeOutput: true, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['input'], + }) + ); + }); + + it('should pass _source_excludes: ["output"] when only includeOutput is false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: true, + includeOutput: false, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['output'], + }) + ); + }); + + it('should default includeInput and includeOutput to false when omitted', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['input', 'output'], + }) + ); + }); + }); + + describe('source excludes with search fallback (no stepExecutionIds)', () => { + beforeEach(() => { + mockEsClient.get.mockResolvedValue({ + _source: { ...baseExecutionDoc, stepExecutionIds: undefined }, + } as any); + searchStepExecutions.mockResolvedValue([]); + }); + + it('should pass sourceExcludes to searchStepExecutions when includeInput/includeOutput are false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: false, + includeOutput: false, + }); + + expect(searchStepExecutions).toHaveBeenCalledWith( + expect.objectContaining({ + sourceExcludes: ['input', 'output'], + }) + ); + }); + + it('should pass empty sourceExcludes when both flags are true', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: true, + includeOutput: true, + }); + + expect(searchStepExecutions).toHaveBeenCalledWith( + expect.objectContaining({ + sourceExcludes: [], + }) + ); + }); + }); + + describe('basic behavior', () => { + it('should return null when document is not found (404)', async () => { + const notFoundError = new Error('Not found'); + Object.assign(notFoundError, { meta: { statusCode: 404 } }); + mockEsClient.get.mockRejectedValue(notFoundError); + + const result = await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(result).toBeNull(); + }); + + it('should return null when spaceId does not match', async () => { + mockEsClient.get.mockResolvedValue({ + _source: { ...baseExecutionDoc, spaceId: 'other-space' }, + } as any); + + const result = await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(result).toBeNull(); + }); + + it('should return the execution DTO with step executions', async () => { + mockEsClient.get.mockResolvedValue({ + _source: baseExecutionDoc, + } as any); + mockEsClient.mget.mockResolvedValue({ + docs: [ + { + found: true, + _source: { + stepId: 's1', + status: 'completed', + globalExecutionIndex: 1, + output: { result: 'ok' }, + }, + }, + { + found: true, + _source: { + stepId: 's2', + status: 'completed', + globalExecutionIndex: 0, + input: { arg: 1 }, + }, + }, + ], + } as any); + + const result = await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('exec-1'); + expect(result?.stepExecutions).toHaveLength(2); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts index 6b1f860df3856..76a24a7b627db 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts @@ -23,7 +23,8 @@ import { stringifyWorkflowDefinition } from '../../../common/lib/yaml'; async function getStepExecutionsByIds( esClient: ElasticsearchClient, stepsExecutionIndex: string, - stepExecutionIds: string[] + stepExecutionIds: string[], + sourceExcludes?: string[] ): Promise { if (stepExecutionIds.length === 0) { return []; @@ -32,6 +33,7 @@ async function getStepExecutionsByIds( const mgetResponse = await esClient.mget({ index: stepsExecutionIndex, ids: stepExecutionIds, + ...(sourceExcludes?.length ? { _source_excludes: sourceExcludes } : {}), }); const steps: EsWorkflowStepExecution[] = []; @@ -50,6 +52,8 @@ interface GetWorkflowExecutionParams { stepsExecutionIndex: string; workflowExecutionId: string; spaceId: string; + includeInput?: boolean; + includeOutput?: boolean; } export const getWorkflowExecution = async ({ @@ -59,6 +63,8 @@ export const getWorkflowExecution = async ({ stepsExecutionIndex, workflowExecutionId, spaceId, + includeInput = false, + includeOutput = false, }: GetWorkflowExecutionParams): Promise => { try { // Use direct GET by _id for O(1) lookup performance instead of search @@ -90,13 +96,18 @@ export const getWorkflowExecution = async ({ let stepExecutions: EsWorkflowStepExecution[]; + const sourceExcludes: string[] = []; + if (!includeInput) sourceExcludes.push('input'); + if (!includeOutput) sourceExcludes.push('output'); + // Use mget if we have step execution IDs - this is O(1) and real-time // (reads from translog, no refresh needed) if (doc.stepExecutionIds && doc.stepExecutionIds.length > 0) { stepExecutions = await getStepExecutionsByIds( esClient, stepsExecutionIndex, - doc.stepExecutionIds + doc.stepExecutionIds, + sourceExcludes ); } else { // Fallback to search for backward compatibility (old workflows without stepExecutionIds) @@ -106,6 +117,7 @@ export const getWorkflowExecution = async ({ stepsExecutionIndex, workflowExecutionId, spaceId, + sourceExcludes, }); } diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts index 4747b02749a45..5c121128bd51b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts @@ -18,6 +18,7 @@ interface SearchStepExecutionsParams { workflowExecutionId: string; additionalQuery?: estypes.QueryDslQueryContainer; spaceId: string; + sourceExcludes?: string[]; } export const searchStepExecutions = async ({ @@ -27,6 +28,7 @@ export const searchStepExecutions = async ({ workflowExecutionId, additionalQuery, spaceId, + sourceExcludes, }: SearchStepExecutionsParams): Promise => { try { logger.debug(`Searching workflows in index ${stepsExecutionIndex}`); @@ -47,6 +49,7 @@ export const searchStepExecutions = async ({ must: mustQueries, }, }, + ...(sourceExcludes?.length ? { _source: { excludes: sourceExcludes } } : {}), sort: 'startedAt:desc', from: 0, size: 1000, // TODO: without it, it returns up to 10 results by default. We should improve this. diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts index 19b8f4b6eb174..e358b21e8f78c 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts @@ -96,6 +96,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -103,7 +104,10 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { await routeHandler(mockContext, mockRequest, mockResponse); - expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default'); + expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default', { + includeInput: true, + includeOutput: true, + }); expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockExecution }); }); @@ -113,6 +117,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'non-existent-execution' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/non-existent-execution' }, }; @@ -122,7 +127,8 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith( 'non-existent-execution', - 'default' + 'default', + { includeInput: true, includeOutput: true } ); expect(mockResponse.notFound).toHaveBeenCalledWith(); }); @@ -134,6 +140,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -168,6 +175,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-456' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/s/custom-space/api/workflowExecutions/execution-456' }, }; @@ -177,7 +185,8 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith( 'execution-456', - 'custom-space' + 'custom-space', + { includeInput: true, includeOutput: true } ); expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockExecution }); }); @@ -191,6 +200,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -213,6 +223,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -227,5 +238,52 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { }, }); }); + + describe('includeInput / includeOutput query params', () => { + const mockExecution = { + id: 'execution-123', + status: 'completed', + steps: [], + }; + + it('should forward includeInput=false and includeOutput=false to the API', async () => { + workflowsApi.getWorkflowExecution = jest.fn().mockResolvedValue(mockExecution); + + const mockRequest = { + params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: false, includeOutput: false }, + headers: {}, + url: { pathname: '/api/workflowExecutions/execution-123' }, + }; + const mockResponse = createMockResponse(); + + await routeHandler({}, mockRequest, mockResponse); + + expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default', { + includeInput: false, + includeOutput: false, + }); + expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockExecution }); + }); + + it('should forward mixed includeInput=true and includeOutput=false to the API', async () => { + workflowsApi.getWorkflowExecution = jest.fn().mockResolvedValue(mockExecution); + + const mockRequest = { + params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: false }, + headers: {}, + url: { pathname: '/api/workflowExecutions/execution-123' }, + }; + const mockResponse = createMockResponse(); + + await routeHandler({}, mockRequest, mockResponse); + + expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default', { + includeInput: true, + includeOutput: false, + }); + }); + }); }); }); diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts index d8408ece1ac56..8eeb382bacc3b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts @@ -29,13 +29,21 @@ export function registerGetWorkflowExecutionByIdRoute({ params: schema.object({ workflowExecutionId: schema.string(), }), + query: schema.object({ + includeInput: schema.boolean({ defaultValue: false }), + includeOutput: schema.boolean({ defaultValue: false }), + }), }, }, withLicenseCheck(async (context, request, response) => { try { const { workflowExecutionId } = request.params; + const { includeInput, includeOutput } = request.query; const spaceId = spaces.getSpaceId(request); - const workflowExecution = await api.getWorkflowExecution(workflowExecutionId, spaceId); + const workflowExecution = await api.getWorkflowExecution(workflowExecutionId, spaceId, { + includeInput, + includeOutput, + }); if (!workflowExecution) { return response.notFound(); } diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts index 0e1725ad7cd77..b5ad095416e17 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts @@ -359,9 +359,10 @@ export class WorkflowsManagementApi { public async getWorkflowExecution( workflowExecutionId: string, - spaceId: string + spaceId: string, + options?: { includeInput?: boolean; includeOutput?: boolean } ): Promise { - return this.workflowsService.getWorkflowExecution(workflowExecutionId, spaceId); + return this.workflowsService.getWorkflowExecution(workflowExecutionId, spaceId, options); } public async getWorkflowExecutionLogs(params: { diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts index 7f794fd827fb0..f273ffc7f2c0b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts @@ -2163,7 +2163,7 @@ steps: }); describe('getWorkflowExecution', () => { - it('should return workflow execution with steps', async () => { + it('should return workflow execution with steps, excluding I/O by default', async () => { // Mock the get call for execution (using direct GET by ID) const mockExecutionGetResponse = { _id: 'execution-1', @@ -2214,7 +2214,7 @@ steps: id: 'execution-1', }); - // Verify the step executions search call + // Verify the step executions search call (includeInput/includeOutput default to false) expect(mockEsClient.search).toHaveBeenCalledWith({ index: WORKFLOWS_STEP_EXECUTIONS_INDEX, query: { @@ -2222,6 +2222,7 @@ steps: must: [{ match: { workflowRunId: 'execution-1' } }, { term: { spaceId: 'default' } }], }, }, + _source: { excludes: ['input', 'output'] }, sort: 'startedAt:desc', from: 0, size: 1000, diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts index d7e988092b25b..f4b86cd9d9df6 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts @@ -1004,7 +1004,8 @@ export class WorkflowsService { // Helper methods remain the same as they don't interact with SavedObjects public async getWorkflowExecution( executionId: string, - spaceId: string + spaceId: string, + options?: { includeInput?: boolean; includeOutput?: boolean } ): Promise { return getWorkflowExecution({ esClient: this.esClient, @@ -1013,6 +1014,8 @@ export class WorkflowsService { stepsExecutionIndex: WORKFLOWS_STEP_EXECUTIONS_INDEX, workflowExecutionId: executionId, spaceId, + includeInput: options?.includeInput, + includeOutput: options?.includeOutput, }); } diff --git a/src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/.meta/ui/parallel.json b/src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/.meta/ui/parallel.json new file mode 100644 index 0000000000000..9732752f27d84 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/.meta/ui/parallel.json @@ -0,0 +1,435 @@ +{ + "sha1": "e7ddc17a5fc05109ca09d3969cddb585cb0889a9", + "tests": [ + { + "id": "a8dc0a71dd65d42-65fd9c357892a20", + "title": "InternalActions/Cases should run create_get_update_case workflow successfully", + "expectedStatus": "skipped", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/internal_actions/create_get_update_case.spec.ts", + "line": 52, + "column": 9 + } + }, + { + "id": "b95282f37eebdc1-5f188557aa71c91", + "title": "InternalActions/Elasticsearch should run national park workflow successfully", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/internal_actions/national_parks.spec.ts", + "line": 43, + "column": 7 + } + }, + { + "id": "7f7258df8a1cda5-773061bd6f9afcd", + "title": "Event autocomplete should be dynamic based on triggers manual trigger: event.* should only suggest spaceId", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_editor_event_autocomplete.spec.ts", + "line": 33, + "column": 9 + } + }, + { + "id": "7f7258df8a1cda5-e10677c4ab18791", + "title": "Event autocomplete should be dynamic based on triggers alert trigger: event.* should suggest alerts, rule, params, and spaceId", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_editor_event_autocomplete.spec.ts", + "line": 52, + "column": 9 + } + }, + { + "id": "02966260206ed5b-1eaf3f4c5859ccb", + "title": "Sanity tests for workflows Create, save, run and view a dummy workflow", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_editor.spec.ts", + "line": 39, + "column": 9 + } + }, + { + "id": "02966260206ed5b-e6122565b6ea818", + "title": "Sanity tests for workflows should show validation errors for invalid workflow YAML and clear them when fixed", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_editor.spec.ts", + "line": 70, + "column": 9 + } + }, + { + "id": "02966260206ed5b-459fd6ac3eccef8", + "title": "Sanity tests for workflows should show step type autocompletion suggestions", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_editor.spec.ts", + "line": 93, + "column": 9 + } + }, + { + "id": "8a478a8cadc4edc-063497b8c9edff4", + "title": "Workflow execution - Alert triggers should trigger workflow from alert", + "expectedStatus": "skipped", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/alert_trigger.spec.ts", + "line": 77, + "column": 9 + } + }, + { + "id": "8a478a8cadc4edc-c4010c12909cde8", + "title": "Workflow execution - Alert triggers should not trigger a disabled workflow when alert fires", + "expectedStatus": "skipped", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/alert_trigger.spec.ts", + "line": 207, + "column": 9 + } + }, + { + "id": "daf2b965294bd38-4971c41c22e284c", + "title": "Workflow execution - Foreach iterations should display execution tree with foreach loops showing multiple iterations", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/foreach_iterations.spec.ts", + "line": 32, + "column": 9 + } + }, + { + "id": "daf2b965294bd38-a5ca930aaeec5bb", + "title": "Workflow execution - Foreach iterations should display post-foreach steps below all iterations after collapsing and re-expanding", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/foreach_iterations.spec.ts", + "line": 93, + "column": 9 + } + }, + { + "id": "83eb75963c3c731-4a99ff2edf7a25b", + "title": "Workflow execution - Test runs should run unsaved workflow as test run with isTestRun: true", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/test_run.spec.ts", + "line": 25, + "column": 7 + } + }, + { + "id": "83eb75963c3c731-296812a7a4fcbda", + "title": "Workflow execution - Test runs should run saved workflow from editor as test run with isTestRun: true", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/test_run.spec.ts", + "line": 48, + "column": 7 + } + }, + { + "id": "83eb75963c3c731-70c62d6d98f1350", + "title": "Workflow execution - Test runs should not allow running a disabled workflow, then enable and run it", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/test_run.spec.ts", + "line": 71, + "column": 7 + } + }, + { + "id": "83eb75963c3c731-b4376d9720aea17", + "title": "Workflow execution - Test runs should run individual step with custom context override", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflow_execution/test_run.spec.ts", + "line": 114, + "column": 7 + } + }, + { + "id": "611939e87af9d3e-ec8bf7897a8036c", + "title": "WorkflowsList/BulkActions should enable disabled workflows", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/bulk_actions.spec.ts", + "line": 25, + "column": 7 + } + }, + { + "id": "611939e87af9d3e-2fa309b19ed98e8", + "title": "WorkflowsList/BulkActions should disable enabled workflows", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/bulk_actions.spec.ts", + "line": 53, + "column": 7 + } + }, + { + "id": "611939e87af9d3e-339604897d68425", + "title": "WorkflowsList/BulkActions should delete workflows", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/bulk_actions.spec.ts", + "line": 81, + "column": 7 + } + }, + { + "id": "611939e87af9d3e-80de18da7957c9c", + "title": "WorkflowsList/BulkActions should clear selection", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/bulk_actions.spec.ts", + "line": 105, + "column": 7 + } + }, + { + "id": "7aeae3e58d5fa42-3e9a84b6df05b24", + "title": "WorkflowsList/FilterSortSearch should filter workflows by enabling state", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/filter_sort_search.spec.ts", + "line": 25, + "column": 7 + } + }, + { + "id": "7aeae3e58d5fa42-49df984d6fc1b0a", + "title": "WorkflowsList/FilterSortSearch should filter workflows by disabling state", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/filter_sort_search.spec.ts", + "line": 59, + "column": 7 + } + }, + { + "id": "7aeae3e58d5fa42-7ffd365afdc103e", + "title": "WorkflowsList/FilterSortSearch should search by name and description", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/filter_sort_search.spec.ts", + "line": 93, + "column": 7 + } + }, + { + "id": "cdab4a325e28891-56a4c4afb7d71f1", + "title": "WorkflowsList/SingleActions should run enabled workflow", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/single_actions.spec.ts", + "line": 25, + "column": 7 + } + }, + { + "id": "cdab4a325e28891-0eab26e057be6a4", + "title": "WorkflowsList/SingleActions should not run disabled workflow", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/single_actions.spec.ts", + "line": 52, + "column": 7 + } + }, + { + "id": "cdab4a325e28891-7ec92a0a3f0adc8", + "title": "WorkflowsList/SingleActions should enable disabled workflow via toggle", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/single_actions.spec.ts", + "line": 74, + "column": 7 + } + }, + { + "id": "cdab4a325e28891-8658309a41442e2", + "title": "WorkflowsList/SingleActions should disable enabled workflow via toggle", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/single_actions.spec.ts", + "line": 98, + "column": 7 + } + }, + { + "id": "cdab4a325e28891-aaa4b68f1a7dc6b", + "title": "WorkflowsList/SingleActions should open workflow for editing via edit action", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/single_actions.spec.ts", + "line": 122, + "column": 7 + } + }, + { + "id": "cdab4a325e28891-c8becd78b95d5ff", + "title": "WorkflowsList/SingleActions should clone workflow via three dots menu", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/single_actions.spec.ts", + "line": 149, + "column": 7 + } + }, + { + "id": "cdab4a325e28891-d99ea2e0aaa0b0d", + "title": "WorkflowsList/SingleActions should delete workflow via three dots menu", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/workflows_list/single_actions.spec.ts", + "line": 173, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/internal_actions/create_get_update_case.spec.ts b/src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/internal_actions/create_get_update_case.spec.ts index f84e34dd7f835..b163b1da4024a 100644 --- a/src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/internal_actions/create_get_update_case.spec.ts +++ b/src/platform/plugins/shared/workflows_management/test/scout_workflows_ui/ui/parallel_tests/internal_actions/create_get_update_case.spec.ts @@ -30,7 +30,8 @@ const getCaseOwner = (projectType: string | undefined) => { return 'securitySolution'; }; -test.describe( +// FLAKY: https://github.com/elastic/kibana/issues/254006 +test.describe.skip( 'InternalActions/Cases', { tag: [ diff --git a/src/platform/test/functional/apps/console/_misc_console_behavior.ts b/src/platform/test/functional/apps/console/_misc_console_behavior.ts index b69c944597290..50c0be04f45db 100644 --- a/src/platform/test/functional/apps/console/_misc_console_behavior.ts +++ b/src/platform/test/functional/apps/console/_misc_console_behavior.ts @@ -13,6 +13,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; import { resolve } from 'path'; import type { FtrProviderContext } from '../../ftr_provider_context'; import { LARGE_INPUT } from './large_input'; +import { QUOTE_HEAVY_INPUT } from './quote_heavy_input'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); @@ -331,21 +332,55 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); writeFileSync(filePath, LARGE_INPUT, 'utf8'); - // Set file to upload and wait for the editor to be updated - await PageObjects.console.setFileToUpload(filePath); - await PageObjects.console.acceptFileImport(); - await PageObjects.common.sleep(1000); - - // The autocomplete should still show up without causing stack overflow - await PageObjects.console.enterText(`GET _search\n`); - await PageObjects.console.enterText(`{\n\t"query": {`); - await PageObjects.console.pressEnter(); - await PageObjects.console.sleepForDebouncePeriod(); - await PageObjects.console.promptAutocomplete(); - expect(await PageObjects.console.isAutocompleteVisible()).to.be.eql(true); - - // Clean up input file - unlinkSync(filePath); + try { + // Set file to upload and wait for the editor to be updated + await PageObjects.console.setFileToUpload(filePath); + await PageObjects.console.acceptFileImport(); + await PageObjects.common.sleep(1000); + + // The autocomplete should still show up without causing stack overflow + await PageObjects.console.enterText(`GET _search\n`); + await PageObjects.console.enterText(`{\n\t"query": {`); + await PageObjects.console.pressEnter(); + await PageObjects.console.sleepForDebouncePeriod(); + await PageObjects.console.promptAutocomplete(); + expect(await PageObjects.console.isAutocompleteVisible()).to.be.eql(true); + } finally { + unlinkSync(filePath); + } + }); + + it('should remain responsive with quote-heavy JSON payloads', async () => { + // This test targets the specific freeze scenario fixed in the ES|QL context detection. + // Before the fix, pasting JSON with many escaped quotes (e.g., serialized error messages) + // caused super-linear runtime in checkForTripleQuotesAndEsqlQuery, freezing the editor. + await PageObjects.console.clearEditorText(); + + const filePath = resolve( + REPO_ROOT, + `target/functional-tests/downloads/console_import_quote_heavy_input` + ); + writeFileSync(filePath, QUOTE_HEAVY_INPUT, 'utf8'); + + try { + // Set file to upload and wait for the editor to be updated + await PageObjects.console.setFileToUpload(filePath); + await PageObjects.console.acceptFileImport(); + await PageObjects.common.sleep(1000); + + // Ensure we start a new request after the imported payload (it may not end with a newline). + await PageObjects.console.pressEnter(); + + // Reuse the same request shape as the existing "large content" test to guarantee suggestions exist. + await PageObjects.console.enterText(`GET _search\n`); + await PageObjects.console.enterText(`{\n\t"query": {`); + await PageObjects.console.pressEnter(); + await PageObjects.console.sleepForDebouncePeriod(); + await PageObjects.console.promptAutocomplete(); + expect(await PageObjects.console.isAutocompleteVisible()).to.be.eql(true); + } finally { + unlinkSync(filePath); + } }); }); } diff --git a/src/platform/test/functional/apps/console/quote_heavy_input.ts b/src/platform/test/functional/apps/console/quote_heavy_input.ts new file mode 100644 index 0000000000000..cb55f0b2c8bd7 --- /dev/null +++ b/src/platform/test/functional/apps/console/quote_heavy_input.ts @@ -0,0 +1,344 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * A quote-heavy payload that triggers super-linear ES|QL context detection + * before the fix in checkForTripleQuotesAndEsqlQuery. Used to verify the + * editor remains responsive after pasting large JSON with many escaped quotes. + */ +export const QUOTE_HEAVY_INPUT = String.raw`POST _ingest/pipeline/_simulate +{ + "docs": [ + { + "_source": { + "@timestamp": "xQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS", + "eventData": { + "__original": "5\"wYxQ9mP7vK\"2Zt\"R4nL1aB8c\"D\"6eF3gH\"0\"jS5wYxQ9\"mP\"7vK2ZtR4n\"L1aB8cD\"6eF3gH0\"jS5wYxQ9m\"P7vK2ZtR4\"nL1aB8c\"D6eF3gH\"0jS5wYxQ9m\"P7vK2ZtR4\"nL1\"aB8cD6e\"F3g\"H0jS5wYx\"Q9m\"P7vK2Z\"tR4nL1\"aB8cD6e\"F3g\"H0jS5wYx\"Q9m\"P7vK2Zt\"R4nL1a\"B8cD6eF\"3gH0j\"S5wYxQ9\"m\"P7vK2ZtR4nL1aB8c\"D6\"eF3gH\"0jS5w\"YxQ9mP7v\"K2Z\"tR4\"n\"L1aB8\"c\"D6eF\"3gH\"0jS5wYxQ9\"mP7vK\"2ZtR4nL1a\"B8cD\"6eF3gH0jS5\"wYxQ9mP\"7vK2ZtR4nL\"1aB8cD6\"eF3g\"H0jS5\"wYxQ\"9\"mP7vK2ZtR4nL1aB8cD6eF3g\"H\"0jS5wYx\"Q9mP7v\"K2ZtR4n\"L1aB8c\"D6eF3gH\"0\"jS5wYxQ9mP7vK2Z\"t\"R4nL1aB8cD\"6eF3gH\"0jS5wYxQ9mP7v\"K2ZtR4n\"L1aB\"8\"cD6eF\"3\"gH0jS5wY\"x\"Q9mP7vK2ZtR4nL1aB8cD\"6\"eF3gH0jS5w\"Y\"xQ9mP7vK2ZtR4\"n\"L1aB8cD6e\"F3g\"H0jS5wYxQ9mP7vK2ZtR\"4nL1aB8cD\"6eF3gH0jS\"5w\"YxQ9mP7v\"K2ZtR\"4nL1aB8cD6eF3gH\"0jS\"5wY\"xQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD\"6eF3g\"H0jS5w\"YxQ\"9mP7\"vK2Zt\"R4nL1\"aB8\"cD6e\"F3gH0\"jS5wYxQ\"9mP\"7vK2Z\"tR4nL\"1aB8cD\"6eF\"3gH0j\"S5wYx\"Q9mP7\"vK2\"ZtR4n\"L1aB8\"cD6eF\"3gH\"0jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1aB8\"cD6eF\"3gH0j\"S5wYxQ9mP7vK2Zt\"R4n\"L1a\"B8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD\"6eF3g\"H0jS5\"wYx\"Q9mP\"7vK2Z\"tR4nL\"1aB\"8cD6\"eF3gH\"0jS5w\"YxQ\"9mP7\"vK2Zt\"R4nL1\"aB8\"cD6eF\"3gH0j\"S5wYxQ9\"mP7\"vK2Zt\"R4nL1\"aB8cD\"6eF\"3gH0j\"S5wYx\"Q9mP7\"vK2\"ZtR4nL\"1aB8c\"D6eF3\"gH0\"jS5wYx\"Q9mP7\"vK2ZtR\"4nL1aB8cD\"6\"eF3gH0jS5wYxQ\"9\"mP7vK2ZtR\"4n\"L1aB8cD6\"eF3gH\"0jS5wYxQ9mP7v\"K2Z\"tR4\"nL1aB\"8cD6e\"F3gH0\"jS5\"wYxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5\"wYx\"Q9mP7\"vK2Zt\"R4nL1\"aB8\"cD6eF\"3gH0j\"S5wYxQ\"9mP\"7vK2Z\"tR4nL\"1aB8cD6\"eF3\"gH0jS\"5wYxQ\"9mP7v\"K2Z\"tR4nL\"1aB8c\"D6eF3\"gH0\"jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1\"aB8cD\"6eF3gH\"0jS5wY\"xQ9mP7\"vK2ZtR4nL\"1aB8c\"D6eF3gH\"0jS\"5wYxQ9mP7v\"K2Z\"tR4nL1aB8cD6e\"F3gH0jS\"5wY\"xQ9mP7vK2\"ZtR\"4\"nL\\1a\\\"\\B8cD6\"e\"F3gH\"0jS\"5wYxQ9mP7\"vK2Zt\"R4nL1aB8c\"D6eF3\"gH0jS5wYxQ\"9mP7vK2\"ZtR4nL1aB8\"cD6eF3g\"H0jS\"5wYxQ\"9mP7\"v\"K2ZtR4nL1aB8cD6\"e\"F3gH0jS\"5wYxQ9m\"P7vK2Zt\"R4nL1aB\"8cD6eF3\"g\"H0jS5wYxQ9mP7vK\"2\"ZtR4nL1aB8\"cD6eF3\"gH0jS5wYxQ9mP\"7vK2ZtR\"4nL1\"a\"B8cD6e\"F\"3gH0jS5w\"Y\"xQ9mP7vK2ZtR4nL1aB8c\"D\"6eF3gH0jS5\"w\"YxQ9mP7vK2ZtR\"4\"nL1aB8cD6\"eF3\"gH0jS5wYxQ9mP7vK2Zt\"R4nL1aB8c\"D6eF3gH0j\"S5\"wYxQ9mP7\"vK2Zt\"R4nL1aB8cD6eF3g\"H0j\"S5w\"YxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD\"6eF3g\"H0jS5\"wYx\"Q9mP7\"vK2Zt\"R4nL1aB\"8cD\"6eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Zt\"R4nL1\"aB8cD\"6eF\"3gH0jS\"5wYxQ\"9mP7v\"K2Z\"tR4nL1\"aB8cD\"6eF3g\"H0jS5wYxQ9mP7vK\"2Zt\"R4n\"L1a\"B8cD6\"eF3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS\"5wY\"xQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD6\"eF3gH\"0jS5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8\"cD6\"eF3gH\"0jS5w\"YxQ9m\"P7v\"K2ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS5w\"YxQ9m\"P7vK2Z\"tR4nL1aB8\"c\"D6eF3gH0jS5wY\"x\"Q9mP7vK2Z\"tR\"4nL1aB8c\"D6eF3\"gH0jS5wYxQ9mP\"7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD6\"eF3gH\"0jS5w\"YxQ\"9mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF3\"gH0jS\"5wYxQ9m\"P7v\"K2ZtR\"4nL1a\"B8cD6\"eF3\"gH0jS\"5wYxQ\"9mP7v\"K2Z\"tR4\"nL1aB\"8cD6eF\"3gH0jS\"5wYxQ9\"mP7vK2ZtR\"4nL1a\"B8cD6eF\"3gH\"0jS5wYxQ9m\"P7v\"K2Z\"tR4nL1\"aB8cD6eF3gH0j\"S5wYxQ9\"mP7\"vK2ZtR4n\"L1a\"B\"8cD6eF\"3\"gH0j\"S5w\"YxQ9mP7vK\"2ZtR4\"nL1aB8cD6\"eF3gH0j\"S5wYxQ9mP7\"vK2ZtR4\"nL1aB8cD6e\"F3gH0jS\"5wYx\"Q9mP7\"vK2Z\"t\"R4nL1aB8cD6eF3gH0jS5wYx\"Q\"9mP7vK2\"ZtR4nL1a\"B8cD6eF\"3gH0jS5wYx\"Q9mP7vK\"2\"ZtR4nL1aB8cD6eF\"3\"gH0jS5wYxQ\"9mP7vK\"2ZtR4nL1aB8cD\"6eF3gH0\"jS5w\"Y\"xQ9mP\"7\"vK2ZtR4n\"L\"1aB8cD6eF3gH0jS5wYxQ\"9\"mP7vK2ZtR4\"n\"L1aB8cD6eF3gH\"0\"jS5wYxQ9m\"P7v\"K2ZtR4nL1aB8cD6eF3g\"H0jS5wYxQ\"9mP7vK2Zt\"R4\"nL1aB8cD\"6eF3g\"H0jS5wYxQ9mP7vK\"2Zt\"R4n\"L1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5wY\"xQ9\"mP7vK\"2ZtR4\"nL1aB\"8cD\"6eF3g\"H0jS5\"wYxQ9\"mP7\"vK2ZtR\"4nL1a\"B8cD6\"eF3\"gH0jS5\"wYxQ9\"mP7vK\"2ZtR4nL1aB8cD6e\"F3g\"H0j\"S5w\"YxQ9m\"P7vK2Z\"tR4\"nL1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9m\"P7vK2\"ZtR4nL1\"aB8\"cD6eF\"3gH0j\"S5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8\"cD6\"eF3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR4n\"L1aB8\"cD6eF3\"gH0jS5wYx\"Q\"9mP7vK2ZtR4nL\"1\"aB8cD6eF3\"gH\"0jS5wYxQ\"9mP7v\"K2ZtR4nL1aB8c\"D6e\"F3g\"H0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9m\"P7vK2\"ZtR4n\"L1a\"B8cD6\"eF3gH\"0jS5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8c\"D6e\"F3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR4\"nL1aB\"8cD6e\"F3g\"H0j\"S5wYx\"Q9mP7v\"K2ZtR4\"nL1aB8\"cD6eF3gH0\"jS5wYxQ\"9mP7vK2\"ZtR\"4nL1aB8cD6\"eF3\"gH0jS5wYxQ9mP\"7vK2ZtR\"4nL\"1aB8cD6eF\"3gH\"0\"jS5wYx\"Q\"9mP7\"vK2\"ZtR4nL1aB\"8cD6e\"F3gH0jS5w\"YxQ9m\"P7vK2ZtR4n\"L1aB8cD6\"eF3gH0jS5w\"YxQ9mP7\"vK2Z\"tR4nL\"1aB8\"c\"D6eF3gH0jS5wYxQ9mP7vK2ZtR\"4\"nL1aB8c\"D6eF3gH0\"jS5wYxQ\"9mP7vK2\"ZtR4nL1\"a\"B8cD6eF3gH0jS5w\"Y\"xQ9mP7vK2Z\"tR4nL1\"aB8cD6eF3gH0j\"S5wYxQ9m\"P7vK\"2\"ZtR4n\"L\"1aB8cD6e\"F\"3gH0jS5wYxQ9mP7vK2Zt\"R\"4nL1aB8cD6\"e\"F3gH0jS5wYxQ9\"m\"P7vK2ZtR4\"nL1\"aB8cD6eF3gH0jS5wYxQ\"9mP7vK2Zt\"R4nL1aB8c\"D6\"eF3gH0jS\"5wYxQ\"9mP7vK2ZtR4nL1a\"B8c\"D6e\"F3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9m\"P7v\"K2Zt\"R4nL1\"aB8cD\"6eF\"3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR\"4nL1a\"B8cD6\"eF3\"gH0jS\"5wYxQ\"9mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3gH\"0jS\"5wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8cD\"6eF3g\"H0jS5\"wYx\"Q9mP7v\"K2ZtR\"4nL1a\"B8cD6eF3gH0jS5w\"YxQ\"9mP\"7vK\"2ZtR4\"nL1aB\"8cD\"6eF3\"gH0jS\"5wYxQ\"9mP\"7vK2\"ZtR4n\"L1aB8\"cD6\"eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9m\"P7v\"K2ZtR\"4nL1a\"B8cD6eF\"3gH\"0jS5w\"YxQ9m\"P7vK2\"ZtR\"4nL1a\"B8cD6\"eF3gH\"0jS\"5wYxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD6\"eF3gH\"0jS5wY\"xQ9mP7vK2\"Z\"tR4nL1aB8cD6e\"F\"3gH0jS5wY\"xQ\"9mP7vK2Z\"tR4nL\"1aB8cD6eF3gH0\"jS5\"wYx\"Q9mP7\"vK2Zt\"R4nL1\"aB8\"cD6eF\"3gH0j\"S5wYx\"Q9m\"P7vK2\"ZtR4n\"L1aB8cD\"6eF\"3gH0j\"S5wYx\"Q9mP7v\"K2Z\"tR4nL\"1aB8c\"D6eF3\"gH0\"jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1aB\"8cD6e\"F3gH0\"jS5\"wYxQ9\"mP7vK\"2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5\"wYx\"Q9m\"P7vK2\"ZtR4nL\"1aB8cD\"6eF3gH0\"jS5wYxQ9m\"P7vK2\"ZtR4nL1\"aB8\"cD6eF3gH0j\"S5w\"YxQ\"9mP7vK\"2ZtR4nL1aB8cD\"6eF3gH0\"jS5\"wYxQ9mP7v\"K2ZtR4n\"L1\"aB8cD6e\"F3gH\"0jS5wYxQ9\"m\"P7vK2ZtR4nL\"1\"aB8cD6eF3gH0j\"S5wYxQ9\"mP7vK2Zt\"R4\"nL1aB\"8cD\"6eF3g\"H0j\"S5wYxQ9\"mP7v\"K2ZtR4nL\"1aB8c\"D6eF3gH0jS\"5wY\"xQ9mP7vK2Z\"tR4n\"L1aB8cD6eF3gH0jS\"5wY\"xQ9\"mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF\"3gH0j\"S5wYx\"Q9m\"P7vK\"2ZtR4\"nL1aB\"8cD\"6eF3\"gH0jS\"5wYxQ\"9mP\"7vK2\"ZtR4n\"L1aB8\"cD6\"eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9m\"P7v\"K2Zt\"R4nL1\"aB8cD\"6eF3gH0\"jS5\"wYxQ9mP\"7\"vK2ZtR4nL1aB8cD6\"e\"F3gH0jS5wYxQ9mP7\"vK2\"ZtR\"4nL1\"aB8cD\"6eF3g\"H0j\"S5wY\"xQ9mP\"7vK2Z\"tR4\"nL1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5\"wYxQ\"9mP7v\"K2ZtR\"4nL\"1aB8\"cD6eF\"3gH0j\"S5w\"YxQ9\"mP7vK\"2ZtR4\"nL1\"aB8c\"D6eF3\"gH0jS5\"wYxQ9mP7vK2ZtR4\"nL1a\"B8cD6eF3gH\"0jS5wY\"xQ9mP7vK2\"Z\"tR4nL1\"a\"B8cD6eF3\"gH\"0jS5wYxQ9\"mP7vK2Z\"tR4nL1a\"B8cD6eF3gH\"0jS5wYxQ9\"mP7vK2\"ZtR4nL1\"aB8cD6eF\"3gH0jS5wY\"xQ9\"mP7vK2Z\"tR4\"nL1aB8cD\"6eF\"3gH0jS\"5wYxQ9m\"P7vK2Zt\"R4nL1\"aB8cD6eF\"3gH0j\"S5wYxQ9\"mP7vK2\"ZtR4nL1\"aB8cD6\"eF3gH0j\"S\"5wYxQ9mP7vK2ZtR4\"nL\"1aB8c\"D6eF3\"gH0jS5wY\"xQ9\"mP7\"v\"K2ZtR4\"n\"L1aB\"8cD\"6eF3gH0jS\"5wYxQ\"9mP7vK2Zt\"R4nL1a\"B8cD6eF3gH\"0jS5wYxQ\"9mP7vK2ZtR\"4nL1aB8c\"D6eF\"3gH0j\"S5wY\"x\"Q9mP7vK2Zt\"R\"4nL1aB8\"cD6eF3gH0j\"S5wYxQ9\"mP7vK2Zt\"R4nL1aB\"8\"cD6eF3gH0jS5wYx\"Q\"9mP7vK2ZtR\"4nL1aB\"8cD6eF3gH0jS5\"wYxQ9mP7\"vK2Z\"t\"R4nL1a\"B\"8cD6eF3g\"H\"0jS5wYxQ9mP7vK2ZtR4n\"L\"1aB8cD6eF3\"g\"H0jS5wYxQ9mP7\"v\"K2ZtR4nL1\"aB8\"cD6eF3gH0jS5wYxQ9mP\"7vK2ZtR4n\"L1aB8cD6e\"F3\"gH0jS5wY\"xQ9mP\"7vK2ZtR4nL1aB8c\"D6e\"F3g\"H0j\"S5wYx\"Q9mP7\"vK2\"ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS\"5wYxQ\"9mP7v\"K2Z\"tR4n\"L1aB8\"cD6eF\"3gH\"0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL\"1aB8c\"D6eF3\"gH0\"jS5wY\"xQ9mP\"7vK2Z\"tR4\"nL1aB\"8cD6e\"F3gH0\"jS5\"wYxQ9\"mP7vK\"2ZtR4nL\"1aB\"8cD6eF\"3gH0j\"S5wYxQ\"9mP\"7vK2Zt\"R4nL1\"aB8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2\"ZtR\"4nL1a\"B8cD6\"eF3\"gH0j\"S5wYx\"Q9mP7\"vK2\"ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS\"5wYxQ\"9mP7v\"K2Z\"tR4n\"L1aB8\"cD6eF\"3gH\"0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB8\"cD6eF\"3gH0jS5\"wYx\"Q9mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF3g\"H0jS5\"wYxQ9m\"P7vK2ZtR4\"n\"L1aB8cD6eF3gH\"0\"jS5wYxQ9m\"P7\"vK2ZtR4n\"L1aB8\"cD6eF3gH0jS5w\"YxQ\"9mP\"7vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH0\"jS5wY\"xQ9mP\"7vK\"2ZtR4\"nL1aB\"8cD6e\"F3g\"H0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL1\"aB8cD\"6eF3g\"H0j\"S5wYx\"Q9mP7\"vK2ZtR4\"nL1\"aB8cD\"6eF3g\"H0jS5w\"YxQ\"9mP7v\"K2ZtR\"4nL1aB\"8cD\"6eF3g\"H0jS5\"wYxQ9\"mP7\"vK2\"ZtR4n\"L1aB8c\"D6eF3g\"H0jS5wY\"xQ9mP7vK2\"ZtR4nL\"1aB8cD6\"eF3\"gH0jS5wYxQ\"9mP\"7vK\"2ZtR4n\"L1aB8cD6eF3gH\"0jS5wYxQ\"9mP\"7vK2ZtR4n\"L1aB8cD\"6e\"F3gH0jS\"5wYx\"Q9mP7vK2Z\"t\"R4nL1aB8cD6\"e\"F3gH0jS5wYxQ9\"mP7vK2Z\"tR4nL1aB\"8c\"D6eF3\"gH0\"jS5wY\"xQ9\"mP7vK2Z\"tR4n\"L1aB8cD6\"eF3gH\"0jS5wYxQ9m\"P7v\"K2ZtR4nL1a\"B8cD\"6eF3gH0jS5wYxQ9m\"P7v\"K2Z\"tR4n\"L1aB8\"cD6eF\"3gH\"0jS5\"wYxQ9\"mP7vK\"2Zt\"R4nL\"1aB8c\"D6eF3\"gH0\"jS5w\"YxQ9m\"P7vK2\"ZtR\"4nL1\"aB8cD\"6eF3g\"H0j\"S5wY\"xQ9mP\"7vK2Z\"tR4\"nL1a\"B8cD6\"eF3gH\"0jS\"5wYx\"Q9mP7\"vK2Zt\"R4n\"L1aB\"8cD6e\"F3gH0\"jS5wYxQ\"9mP\"7vK2ZtR\"4\"nL1aB8cD6eF3gH0j\"S\"5wYxQ9mP7vK2ZtR4\"nL1\"aB8\"cD6e\"F3gH0\"jS5wY\"xQ9\"mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF\"3gH0j\"S5wYx\"Q9m\"P7vK\"2ZtR4\"nL1aB\"8cD\"6eF3\"gH0jS\"5wYxQ\"9mP\"7vK2\"ZtR4n\"L1aB8\"cD6\"eF3g\"H0jS5\"wYxQ9\"mP7\"vK2Z\"tR4nL\"1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9mP\"7vK2ZtR4nL1aB8c\"D6eF\"3gH0jS5wYx\"Q9mP7v\"K2ZtR4nL1\"a\"B8cD6eF3gH0jS\"5wY\"xQ9mP7vK2\"Z\"tR4nL1aB8cD6e\"F3g\"H0jS5wYxQ\"9\"mP7vK2ZtR4nL1\"aB8\"cD6eF3gH0\"j\"S5wYxQ9mP7vK2Zt\"R4n\"L1aB8cD6e\"F\"3gH0jS5wYxQ9mP7\"vK2\"ZtR4nL1aB\"8\"cD6eF3gH\"0jS\"5wYxQ9\"mP7vK2ZtR\"4nL1aB8cD\"6eF\"3gH0jS\"5w\"YxQ9\"mP7vK2ZtR4n\"L1aB8cD6eF3g\"H0jS5wYxQ\"9mP7vK2ZtR4n\"L1aB8cD6e\"F3gH0jS5wY\"x\"Q9mP7vK2ZtR4nL1aB8\"c\"D6eF3g\"H0jS5wYxQ9\"mP7vK\"2ZtR4nL1aB8\"cD6eF3gH0jS5wYxQ9m\"P7vK2ZtR4\"nL1aB8cD6\"eF3gH0jS5wYx\"Q9mP7vK2ZtR\"4nL\"1aB8cD6eF\"3\"gH0jS5\"w\"YxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0j\"S5w\"YxQ9mP7vK\"2Zt\"R4nL1aB8cD6eF3g\"H0j\"S5wYxQ9mP7v\"K2Z\"tR4nL1a\"B8c\"D6eF3gH0j\"S5w\"YxQ9mP7v\"K2Z\"tR4nL1aB8\"cD6eF\"3gH0jS5wY\"x\"Q9mP7vK\"2\"ZtR4nL1\"aB8\"cD6eF3gH0\"jS5\"wYxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2ZtR4nL1a\"B8c\"D6eF3gH\"0jS\"5wYxQ9mP7\"vK2\"ZtR4nL1a\"B8c\"D6eF3gH0j\"S5wYx\"Q9mP7vK2Z\"t\"R4nL\"1\"aB8cD6e\"F3gH0jS5\"wYxQ9mP7v\"K2ZtR4\"nL1aB8cD\"6eF\"3gH0jS5wY\"xQ9\"mP7vK2ZtR4nL1aB\"8cD\"6eF3gH0jS5w\"YxQ\"9mP7vK2\"ZtR4nL1a\"B8cD6eF3g\"H0jS5w\"YxQ9mP7v\"K2Z\"tR4nL1aB8\"cD6eF\"3gH0jS5wY\"x\"Q9mP7vK\"2\"ZtR4nL1\"aB8\"cD6eF3gH0\"jS5\"wYxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2ZtR4nL1a\"B8c\"D6eF3gH\"0jS\"5wYxQ9mP7\"vK2\"ZtR4nL1a\"B8c\"D6eF3gH0j\"S5wYx\"Q9mP7vK2Z\"t\"R4nL1\"a\"B8cD6eF\"3gH\"0jS5wYxQ9\"mP7\"vK2ZtR4n\"L1a\"B8cD6eF3g\"H0j\"S5wYxQ9mP7vK2Zt\"R4n\"L1aB8cD6eF3\"gH0\"jS5wYxQ\"9mP\"7vK2ZtR4n\"L1a\"B8cD6eF3\"gH0\"jS5wYxQ9m\"P7vK2\"ZtR4nL1aB\"8\"cD6eF\"3\"gH0jS5w\"YxQ9mP7v\"K2ZtR4nL1\"aB8cD6\"eF3gH0jS\"5wY\"xQ9mP7vK2\"ZtR\"4nL1aB8cD6eF3gH\"0jS\"5wYxQ9mP7vK\"2ZtR\"4nL1aB8\"cD6eF3gH0j\"S5wYxQ9mP\"7vK2ZtR\"4nL1aB8c\"D6e\"F3gH0jS5w\"YxQ9m\"P7vK2ZtR4\"n\"L1aB8c\"D\"6eF3gH0\"jS5wYxQ9\"mP7vK2ZtR\"4nL1aB\"8cD6eF3g\"H0j\"S5wYxQ9mP\"7vK\"2ZtR4nL1aB8cD6e\"F3g\"H0jS5wYxQ9m\"P7v\"K2ZtR4n\"L1aB8cD6\"eF3gH0jS5\"wYxQ9m\"P7vK2ZtR\"4nL\"1aB8cD6eF\"3gH0j\"S5wYxQ9mP\"7\"vK2ZtR\"4\"nL1aB8c\"D6e\"F3gH0jS5w\"YxQ\"9mP7vK2Z\"tR4\"nL1aB8cD6\"eF3\"gH0jS5wYxQ9mP7v\"K2Z\"tR4nL1aB8cD\"6eF\"3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4\"nL1aB8cD\"6eF\"3gH0jS5wY\"xQ9mP\"7vK2ZtR4n\"L\"1aB8cD6\"e\"F3gH0jS\"5wY\"xQ9mP7vK2\"ZtR\"4nL1aB8c\"D6e\"F3gH0jS5w\"YxQ\"9mP7vK2ZtR4nL1a\"B8c\"D6eF3gH0jS5\"wYx\"Q9mP7vK\"2Zt\"R4nL1aB8c\"D6e\"F3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4nL\"1aB8cD6eF\"3\"gH0jS5\"w\"YxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0j\"S5w\"YxQ9mP7vK\"2Zt\"R4nL1aB8cD6eF3g\"H0j\"S5wYxQ9mP7v\"K2Z\"tR4nL1a\"B8c\"D6eF3gH0j\"S5w\"YxQ9mP7v\"K2Z\"tR4nL1aB8\"cD6eF\"3gH0jS5wY\"x\"Q9mP7vK\"2\"ZtR4nL1\"aB8\"cD6eF3gH0\"jS5\"wYxQ9mP7\"vK2\"ZtR4nL1aB\"8cD\"6eF3gH0jS5wYxQ9\"mP7\"vK2ZtR4nL1a\"B8c\"D6eF3gH\"0jS\"5wYxQ9mP7\"vK2\"ZtR4nL1a\"B8c\"D6eF3gH0j\"S5wYx\"Q9mP7vK2Z\"t\"R4nL\"1\"aB8cD6e\"F3g\"H0jS5wYxQ\"9mP\"7vK2ZtR4\"nL1\"aB8cD6eF3\"gH0\"jS5wYxQ9mP7vK2Z\"tR4\"nL1aB8cD6eF\"3gH\"0jS5wYx\"Q9m\"P7vK2ZtR4\"nL1\"aB8cD6eF\"3gH\"0jS5wYxQ9\"mP7vK\"2ZtR4nL1a\"B\"8cD6e\"F\"3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4\"nL1aB8cD\"6eF\"3gH0jS5wY\"xQ9\"mP7vK2ZtR4nL1aB\"8cD\"6eF3gH0jS5w\"YxQ\"9mP7vK2\"ZtR\"4nL1aB8cD\"6eF\"3gH0jS5w\"YxQ\"9mP7vK2Zt\"R4nL1\"aB8cD6eF3\"g\"H0jS5w\"Y\"xQ9mP7v\"K2ZtR4nL\"1aB8cD6eF\"3gH0jS\"5wYxQ9mP\"7vK\"2ZtR4nL1a\"B8c\"D6eF3gH0jS5wYxQ\"9mP\"7vK2ZtR4nL1\"aB8\"cD6eF3g\"H0jS5wYx\"Q9mP7vK2Z\"tR4nL1\"aB8cD6eF\"3gH\"0jS5wYxQ9\"mP7\"vK2ZtR4nL\"1a\"B8cD6eF3gH0\"jS5w\"YxQ9mP7vK\"2\"ZtR4nL\"1\"aB8cD6e\"F3g\"H0jS5wYxQ\"9mP\"7vK2ZtR4\"nL1\"aB8cD6eF3\"gH0\"jS5wYxQ9mP7vK2Z\"tR4\"nL1aB8cD6eF\"3gH\"0jS5wYx\"Q9m\"P7vK2ZtR4\"nL1\"aB8cD6eF\"3gH\"0jS5wYxQ9\"mP7vK\"2ZtR4nL1a\"B\"8cD6eF\"3\"gH0jS5w\"YxQ\"9mP7vK2Zt\"R4n\"L1aB8cD6\"eF3\"gH0jS5wYx\"Q9m\"P7vK2ZtR4nL1aB8\"cD6\"eF3gH0jS5wY\"xQ9\"mP7vK2Z\"tR4\"nL1aB8cD6\"eF3\"gH0jS5wY\"xQ9\"mP7vK2ZtR\"4nL1a\"B8cD6eF3g\"H\"0jS5wY\"x\"Q9mP7vK\"2Zt\"R4nL1aB8c\"D6e\"F3gH0jS5\"wYx\"Q9mP7vK2Z\"tR4\"nL1aB8cD6eF3gH0\"jS5\"wYxQ9mP7vK2\"ZtR\"4nL1aB8\"cD6\"eF3gH0jS5\"wYx\"Q9mP7vK2\"ZtR\"4nL1aB8cD\"6eF3g\"H0jS5wYxQ\"9\"mP7vK2Z\"t\"R4nL1aB\"8cD\"6eF3gH0jS\"5wY\"xQ9mP7vK\"2Zt\"R4nL1aB8c\"D6e\"F3gH0jS5wYxQ9mP\"7vK\"2ZtR4nL1aB8\"cD6\"eF3gH0j\"S5w\"YxQ9mP7vK\"2Zt\"R4nL1aB8\"cD6\"eF3gH0jS5\"wYxQ9\"mP7vK2ZtR\"4\"nL1aB8cD6\"e\"F3gH0jS\"5wYxQ9mP\"7vK2ZtR4n\"L1aB8c\"D6eF3gH0\"jS5\"wYxQ9mP7v\"K2Z\"tR4nL1aB8cD6eF3\"gH0\"jS5wYxQ9mP7\"vK2\"ZtR4nL1\"aB8cD6eF\"3gH0jS5wY\"xQ9mP7\"vK2ZtR4n\"L1a\"B8cD6eF3g\"H0j\"S5wYxQ9mP\"7v\"K2ZtR4nL1aB8c\"D6eF\"3gH0jS5wY\"x\"Q9mP7\"v\"K2ZtR4n\"L1aB8cD6eF\"3gH0jS5wY\"xQ9mP7v\"K2ZtR4nL\"1aB\"8cD6eF3gH\"0jS\"5wYxQ9mP7vK2ZtR\"4nL\"1aB8cD6eF3g\"H0jS5\"wYxQ9mP\"7vK2ZtR4n\"L1aB8cD6e\"F3gH0jS\"5wYxQ9mP\"7vK\"2ZtR4nL1a\"B8cD6\"eF3gH0jS5wY\"xQ9mP\"7vK2ZtR4nL1aB8\"cD\"6eF3g\"H0jS5\"wYxQ9mP7vK2\"ZtR4n\"L1aB8cD6eF3gH0\"jS5w\"YxQ9mP7vK2\"Zt\"R4nL1\"aB8cD6e\"F3gH0j\"S5wYx\"Q9mP7vK2\"ZtR\"4nL1aB8cD6eF\"3gH0\"jS5wYxQ9\"mP\"7vK2ZtR4\"nL1aB\"8cD6eF3gH0jS\"5wY\"xQ9\"mP7v\"K2ZtR\"4nL1a\"B8c\"D6eF\"3gH0j\"S5wYx\"Q9m\"P7vK\"2ZtR4\"nL1aB8c\"D6e\"F3gH\"0jS5w\"YxQ9mP\"7vK\"2Zt\"R4nL1\"aB8cD\"6eF3gH0\"jS5w\"YxQ9mP7v\"K2Z\"tR4nL1aB\"8cD\"6eF3gH0jS\"5wYx" + }, + "ingest_lag_in_seconds": 1, + "kafka": { + "eventId": "Q9mP7vK2ZtR4nL1aB8cD6", + "moduleId": "eF3gH0jS5wY" + }, + "customerInfo": { + "customerId": "xQ9mP7vK2Zt", + "deviceId": "R4nL1aB8cD6eF3gH0jS5w", + "lineId": "YxQ9mP7vK2ZtR4", + "publicIp": "nL1aB8cD6eF3" + } + } + }, + { + "_index": "gH0jS5wYxQ9mP7vK2Zt", + "_source": { + "kafka": { + "eventId": "R4nL1aB8cD6e", + "moduleId": "F3gH0jS5w" + }, + "customerInfo": { + "customerId": "YxQ9mP7vK2Z", + "deviceId": "tR4nL1aB8cD6eF3gH0jS5", + "lineId": "wYxQ9mP7vK2ZtR", + "publicIp": "4nL1aB8cD6eF" + }, + "eventData": { + "__original": "3\"gH0jS5w\"Yx\"Q9m\"P\"7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5\"w\"YxQ9mP7\"vK2\"ZtR4nL1aB8cD6e\"F3g\"H0jS5wYx\"Q9m\"P7vK2\"Z\"tR4nL1aB 8cD6eF 3g H0jS5wYx\"Q\"9mP7vK2ZtR4nL1aB8c\"D6eF3\"gH0jS5wY\"xQ9mP7\"vK2ZtR\"4nL1aB8\"cD6eF3gH0jS5wYxQ9mP7vK\"2ZtR\"4nL1aB8cD6eF3gH0j\"S5wYxQ9\"mP7vK2ZtR4n\"L1aB8cD\"6eF3gH\"0\"jS5wYxQ\"9\"mP7vK2ZtR4nL\"1aB8\"cD6eF3gH0\"j\"S5wYxQ9\"m\"P7vK2ZtR4nL1\"a\"B8cD6eF3g\"H\"0jS5wYxQ9mP7\"v\"K2ZtR4n\"L\"1aB8cD6eF3gH0\"jS5w\"YxQ9mP7vK2ZtR4nL1aB\"8cD6e\"F3gH0jS5wYx\"Q9mP7\"vK2ZtR4nL1\"aB8cD6" + } + } + }, + { + "_source": { + "eventData": { + "__original": "e\"F3gH0jS\"5w\"YxQ\"9\"mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYx\"Q\"9mP7vK2\"ZtR\"4nL1aB8cD6eF3g\"H0j\"S5wYxQ9m\"P7v\"K2ZtR\"4\"nL1aB8cD 6eF3gH 0j S5wYxQ9mP7v K\"2\"ZtR4nL1aB8cD6eF3gH\"0jS5w\"YxQ9mP7v\"K2ZtR4\"nL1aB8\"cD6eF3g\"H0jS5wYxQ9mP7vK2ZtR4nL\"1aB8\"cD6eF3gH0jS5wYxQ9\"mP7vK2Z\"tR4nL1aB8cD\"6eF3gH0\"jS5wYx\"Q\"9mP7vK2\"Z\"tR4nL1aB8cD6\"eF3g\"H0jS5wYxQ\"9\"mP7vK2ZtR4nL\"1\"aB8cD6eF3gH0\"j\"S5wYxQ9mP\"7\"vK2ZtR4nL1aB\"8\"cD6eF3gH0jS5w\"Y\"xQ9mP7vK2ZtR4\"nL1\"aB8cD6eF3gH0jS5wYxQ\"9mP7\"vK2ZtR4nL1a\"B8cD\"6eF3gH0jS5wY\"xQ\"9mP7vK2Z\"t\"R4n\"L\"1aB8cD6eF3gH0jS\"5wYxQ9mP\"7vK2ZtR4nL1aB8c\"D6eF3gH\"0jS5wYx\"Q9\"mP7vK2Zt\"R\"4nL1a\"B8c\"D6eF3gH0jS\"5wYxQ9m" + }, + "customerInfo": { + "customerId": "P7vK2ZtR4nL", + "deviceId": "1aB8cD6eF3gH0jS5wYxQ9", + "lineId": "mP7vK2ZtR4nL1a", + "publicIp": "B8cD6eF3gH0j" + }, + "kafka": { + "eventId": "S5wYxQ9mP7vK", + "moduleId": "2ZtR4nL1a" + } + } + }, + { + "_source": { + "@timestamp": "B8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4n", + "eventData": { + "__original": "L\"1aB8cD6eF3gH0jS5w\"YxQ9m\"P7vK2ZtR4nL1aB8cD6e\"F3gH0jS\"5wYxQ9mP7vK2ZtR4n\"L1aB8\"cD6eF3gH0jS5wYxQ9mP\"7vK2Zt\"R4nL1aB8\"cD6eF" + }, + "ingest_lag_in_seconds": 1, + "kafka": { + "eventId": "3gH0jS5w", + "moduleId": "YxQ9mP7vK" + }, + "@version": "2", + "customerInfo": { + "customerId": "ZtR4nL1aB8c", + "deviceId": "D6eF3gH0jS5wYxQ9mP7vK", + "lineId": "2ZtR4nL1aB8cD6", + "publicIp": "eF3gH0jS5wYx" + } + } + } + ], + "pipeline": { + "processors": [ + { + "rename": { + "field": "Q9mP7vK2ZtR4nL1aB8cD", + "target_field": "6eF3gH0jS5wYxQ" + } + }, + { + "lowercase": { + "field": "9mP7vK2ZtR4nL1", + "target_field": "aB8cD6eF3gH", + "ignore_missing": true + } + }, + { + "set": { + "field": "0jS5wYxQ9mP7", + "copy_from": "vK2ZtR4nL1aB8", + "ignore_empty_value": true + } + }, + { + "set": { + "field": "cD6eF3gH0", + "copy_from": "jS5wYxQ9mP7vK2ZtR4nL1", + "ignore_empty_value": true + } + }, + { + "script": { + "source": """ + aB8 cD6eF3gH0j S 5wYxQ9mP7vK2ZtR4 + nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP""", + "if": "7vK2ZtR4nL1aB8cD 6e F3gH 0j S5wYxQ9mP7vK2ZtR4nL 1a B8cD" + } + }, + { + "script": { + "description": "6eF3gH0 jS5wYxQ9 mP 7vK2ZtR4nL1 aB8cD6e F3gH 0jS", + "source": "5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0j", + "if": "S5wYxQ9mP7vK2ZtR4nL1aB8cD6 eF 3gH0 jS 5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3 gH0jS5wYxQ 9mP7vK2" + } + }, + { + "set": { + "field": "ZtR4nL1aB8cD6e", + "copy_from": "F3gH0jS5wYxQ9mP7vK2", + "ignore_empty_value": true + } + }, + { + "set": { + "field": "ZtR4nL1aB8cD6", + "copy_from": "eF3gH0jS5wYxQ9mP7vK2Zt", + "ignore_empty_value": true + } + }, + { + "rename": { + "field": "R4nL1aB8cD6eF3gH0jS5w", + "target_field": "YxQ9mP7vK2Zt", + "ignore_missing": true + } + }, + { + "script": { + "source": """ + R4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0 jS5wYxQ9mP7v + """, + "if": "K2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2Zt R4 nL1a" + } + }, + { + "rename": { + "field": "B8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6", + "target_field": "eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8", + "ignore_missing": true + } + }, + { + "rename": { + "field": "cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF", + "target_field": "3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8", + "ignore_missing": true + } + }, + { + "rename": { + "field": "cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3", + "target_field": "gH0jS5wYxQ9mP7vK2ZtR4nL1aB8cD6", + "ignore_missing": true + } + }, + { + "rename": { + "field": "eF3gH0jS5wYxQ9mP7v", + "target_field": "K2ZtR4nL1aB8cD6eF3gH0jS5w", + "ignore_missing": true + } + }, + { + "rename": { + "field": "YxQ9mP7vK2ZtR4nL1aB8cD6eF3g", + "target_field": "H0jS5wYxQ9mP7vK2ZtR4nL1aB8cD", + "ignore_missing": true + } + }, + { + "rename": { + "field": "6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1a", + "target_field": "B8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4n", + "ignore_missing": true + } + }, + { + "rename": { + "field": "L1aB8cD6eF3gH0jS5wYxQ9mP7vK", + "target_field": "2ZtR4nL1aB8cD6eF3gH0jS5wYxQ9", + "ignore_missing": true + } + }, + { + "rename": { + "field": "mP7vK2ZtR4nL1aB8cD6eF3gH0jS5w", + "target_field": "YxQ9mP7vK2ZtR4nL1aB8cD6eF3gH0j", + "ignore_missing": true + } + }, + { + "script": { + "description": "S5wYxQ9mP 7vK2ZtR 4nL1aB8 cD6eF 3gH 0jS5wYx Q9mP7vK 2ZtR", + "lang": "4nL1aB8c", + "source": """ + D6 eF3gH0jS5wYxQ9mP7vK2ZtR4nL 1a B8cD6 e + F3g H0j S 5wYxQ9mP7vK2ZtR4nL1aB8cD6 + eF3gH0jS5w Y xQ9mP7vK2Z tR 4nL1 a B8c D 6eF3gH0jS5w + + YxQ9mP7vK2ZtR4n L 1 + aB8c D6eF3gH0jS5wYxQ9mP7vK + 2ZtR 4nL1aB8cD6eF3gH0jS5wY + xQ9mP 7vK2ZtR4nL1aB8cD6eF3g + H0 + + jS 5wY xQ9mP7vK2ZtR4n L1 a B8cD6 eF3g H 0j S + 5wYxQ9mP 7 vK2ZtR4n L1 aB8c D 6eF 3 gH0jS5wYx + Q9mP7vK2ZtR4 n L + 1aB8cD6e F3gH0jS5wYxQ9mP7vK2 Z tR4nL + 1a + B + + 8c D6 eF3gH0 jS5wYxQ + 9m P7vK2ZtR4nL1aB8cD6eF3gH0 jS 5wYxQ 9 + mP7 vK2 Z tR4nL1aB8cD6eF3gH0jS5wY + xQ9mP7vK2ZtR4nL1a B 8 + cD6eF3gH 0jS5wYxQ9m + P7vK2Zt R4nL1aB8c + D6eF3gH0j S5wYxQ9m P7vK2ZtR4nL1aB8 + cD6eF3g H + 0jS5wYxQ 9mP7vK2Zt R 4nL1aB8cD6eF3g + H0jS5w YxQ9mP7vK2ZtR4nL1a B 8cD6eF3gH0jS5w Y xQ9mP7vK2 + Z + tR + 4 + + nL 1a B8c D6eF3gH 0jS5wYxQ9m P7vK2Z + tR 4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK 2Z tR4nL 1 + aB8 cD6eF 3g H0jS5wYxQ9mP7vK2ZtR4nL1aB8cD 6 + eF 3gH0jS5wYxQ9mP7 vK 2ZtR4nL1aB8c D + 6eF3gH0jS5w Y x + Q9mP7vK2 ZtR4nL1aB8cD6 eF 3g H 0jS5wYxQ9mP7v K2 ZtR + 4nL1aB8 cD6eF3gH0jS5wY + xQ + 9mP7vK2ZtR 4 nL1aB8cD6 eF3gH0jS5wYxQ9 + mP7vK2ZtR4nL1aB 8 cD6eF3gH0 jS5wYxQ9mP7vK2 + ZtR4nL1aB8cD6eF3gH0jS5wYxQ9mP7vK2ZtR4nL1a + B8cD6e + F + 3 + g + H0 jS5wYxQ9mP7vK2ZtR4nL1aB8cD6e F3 gH0jS 5 + wYx Q9m P 7vK2ZtR4nL1aB8cD6eF3gH0jS5w + YxQ9mP7vK2Z t R4nL1aB8cD6 eF 3gH0 j S5w Y xQ9mP7vK2ZtR + + 4nL1aB8cD6eF3g H 0jS5wYxQ9 + mP7vK2ZtR4nL1aB8cD6 e F + 3gH0jS5wYxQ9 mP7vK2ZtR4 + nL1aB8cD6eF3g H0jS5wYxQ9m + P7vK2ZtR4nL1aB8 cD6eF3gH0jS5w + YxQ9mP7vK2ZtR4nL1aB8 cD6eF3gH0jS5wYxQ + 9m + + P7 vK2ZtR4nL 1aB8c D6eF 3gH 0jS5wY xQ9mP7vK2ZtR + 4n L1aB8cD6eF 3 gH 0 + jS5wYxQ9mP7vK2ZtR4nL1aB8cD6eF3 g H0jS5wYxQ9mP7vK2Zt R 4nL1aB8cD6 + e + F + + """ + } + }, + { + "uri_parts": { + "field": "3gH0jS5wYxQ9", + "ignore_missing": true, + "ignore_failure": true + } + }, + { + "append": { + "field": "mP7vK2ZtR4n", + "value": [ + "L1aB8cD6eF3gH", + "0jS5wYxQ9mP7vK2ZtR", + "4nL1aB8cD6eF3", + "gH0jS5wYxQ9mP7vK2", + "ZtR4nL1aB8cD6" + ], + "allow_duplicates": false + } + }, + { + "script": { + "description": "eF3gH0 jS5w YxQ9mP", + "source": """ + 7vK2ZtR 4nL1aB8cD6e F3 g + H0 jS 5w YxQ9 mP 7 vK 2Zt R + 4nL1aB 8cD6e + F 3gH0 jS 5w YxQ9mP7vK2 ZtR4 n + L1aB8c D6eF3gH0jS5wYxQ9mP7vK2 Zt R4nL1aB8c + D6eF3g H0jS5wY xQ9mP7vK2 Zt R4n + L 1aB8 cD 6e F3gH0jS5wY xQ9mP 7 + vK2ZtR4 nL1aB8cD6eF3g H0 jS5wYxQ9m + P7vK2Z tR4nL1aB 8cD6eF3gH 0j S5w + Y + xQ9mP7 vK2ZtR + 4 + nL1aB8cD6e + """ + } + }, + { + "remove": { + "field": [ + "F3gH" + ], + "ignore_missing": true + } + } + ] + } +}`; diff --git a/src/platform/test/functional/apps/dashboard_elements/image_embeddable/image_embeddable.ts b/src/platform/test/functional/apps/dashboard_elements/image_embeddable/image_embeddable.ts index 8fac9de82171a..11366ce8f279b 100644 --- a/src/platform/test/functional/apps/dashboard_elements/image_embeddable/image_embeddable.ts +++ b/src/platform/test/functional/apps/dashboard_elements/image_embeddable/image_embeddable.ts @@ -57,7 +57,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardDrilldownPanelActions.clickCreateDrilldown(); await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen(); - await testSubjects.click('actionFactoryItem-DASHBOARD_TO_DASHBOARD_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-dashboard_drilldown'); await dashboardDrilldownsManage.fillInDashboardToDashboardDrilldownWizard({ drilldownName: `My drilldown`, destinationDashboardTitle: `few panels`, diff --git a/src/platform/test/functional/apps/discover/esql/_esql_view.ts b/src/platform/test/functional/apps/discover/esql/_esql_view.ts index 8e9007c602a2d..d5d4156ff6053 100644 --- a/src/platform/test/functional/apps/discover/esql/_esql_view.ts +++ b/src/platform/test/functional/apps/discover/esql/_esql_view.ts @@ -71,6 +71,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.uiSettings.replace(defaultSettings); await timePicker.setDefaultAbsoluteRangeViaUiSettings(); await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); }); after(async () => { @@ -78,7 +79,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('ES|QL in Discover', () => { + beforeEach(async () => { + await timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); + }); + it('should render esql view correctly', async function () { + await discover.waitUntilTabIsLoaded(); await unifiedFieldList.waitUntilSidebarHasLoaded(); expect(await testSubjects.exists('showQueryBarMenu')).to.be(true); @@ -99,6 +107,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.exists('discoverFieldListPanelEdit-@message')).to.be(true); await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); await unifiedFieldList.waitUntilSidebarHasLoaded(); expect(await testSubjects.exists('fieldListFiltersFieldSearch')).to.be(true); @@ -125,6 +134,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should not render the histogram for indices with no @timestamp field', async function () { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); await unifiedFieldList.waitUntilSidebarHasLoaded(); const testQuery = `from kibana_sample_data_flights | limit 10`; @@ -140,6 +150,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should render the histogram for indices with no @timestamp field when the ?_tstart, ?_tend params are in the query', async function () { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); await unifiedFieldList.waitUntilSidebarHasLoaded(); const testQuery = `from kibana_sample_data_flights | limit 10 | where timestamp >= ?_tstart and timestamp <= ?_tend`; @@ -203,7 +214,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testQuery = `from logstash* | sort @timestamp | limit 10 | stats countB = count(bytes) by geo.dest | sort countB`; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await header.waitUntilLoadingHasFinished(); + await discover.waitUntilTabIsLoaded(); const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); expect(await cell.getVisibleText()).to.be('1'); @@ -227,11 +238,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should work without a FROM statement', async function () { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const testQuery = `ROW a = 1, b = "two", c = null`; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await header.waitUntilLoadingHasFinished(); + await discover.waitUntilTabIsLoaded(); await discover.dragFieldToTable('a'); const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -240,6 +252,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should allow brushing time series', async () => { await timePicker.setDefaultAbsoluteRange(); + await discover.waitUntilTabIsLoaded(); await discover.selectTextBaseLang(); await discover.waitUntilTabIsLoaded(); await unifiedFieldList.waitUntilSidebarHasLoaded(); @@ -256,7 +269,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const renderingCount = await elasticChart.getVisualizationRenderingCount(); await discover.brushHistogram(); - await discover.waitUntilSearchingHasFinished(); + await discover.waitUntilTabIsLoaded(); // no filter pill created for time brush expect(await filterBar.getFilterCount()).to.be(0); // chart and time picker updated @@ -270,6 +283,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('errors', () => { it('should show error messages for syntax errors in query', async function () { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const brokenQueries = [ 'from logstash-* | limit 10*', 'from logstash-* | limit A', @@ -297,7 +311,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('switch modal', () => { beforeEach(async () => { await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); await timePicker.setDefaultAbsoluteRange(); + await discover.waitUntilTabIsLoaded(); }); it('should show switch modal when switching to a data view', async () => { @@ -327,7 +343,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await discover.saveSearch('esql_test'); + await discover.waitUntilTabIsLoaded(); await discover.selectDataViewMode(); + await discover.waitUntilTabIsLoaded(); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -368,6 +386,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('inspector', () => { beforeEach(async () => { await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); await timePicker.setDefaultAbsoluteRange(); await discover.waitUntilTabIsLoaded(); }); @@ -402,6 +421,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ensureCurrentUrl: false, }); await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | limit 10`; await monacoEditor.setCodeEditorValue(testQuery); @@ -409,7 +429,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { window.ELASTIC_ESQL_DELAY_SECONDS = 5; }); await testSubjects.click('querySubmitButton'); - await header.waitUntilLoadingHasFinished(); + await discover.waitUntilTabIsLoaded(); // for some reason the chart query is taking a very long time to return (3x the delay) // so wait for the chart to be loaded await discover.waitForChartLoadingComplete(1); @@ -431,7 +451,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('query history', () => { beforeEach(async () => { await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); await timePicker.setDefaultAbsoluteRange(); + await discover.waitUntilTabIsLoaded(); }); it('should see my current query in the history', async () => { @@ -497,7 +519,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('sorting', () => { beforeEach(async () => { await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); await timePicker.setDefaultAbsoluteRange(); + await discover.waitUntilTabIsLoaded(); }); it('should sort correctly', async () => { @@ -528,7 +552,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickDocSortDesc('bytes', 'Sort High-Low'); - await discover.waitUntilSearchingHasFinished(); + await discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the highest value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -579,7 +603,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickDocSortDesc('bytes', 'Sort Low-High'); - await discover.waitUntilSearchingHasFinished(); + await discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the lowest value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -627,6 +651,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await discover.saveSearch(savedSearchName); + await discover.waitUntilTabIsLoaded(); await common.navigateToApp('dashboard'); await dashboard.clickNewDashboard(); @@ -682,7 +707,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickDocSortDesc('var0', 'Sort High-Low'); - await discover.waitUntilSearchingHasFinished(); + await discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the highest value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 1); @@ -733,7 +758,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickDocSortDesc('var0', 'Sort Low-High'); - await discover.waitUntilSearchingHasFinished(); + await discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the lowest value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 1); @@ -750,11 +775,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('filtering by clicking on the table in Discover', () => { beforeEach(async () => { await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); await timePicker.setDefaultAbsoluteRange(); + await discover.waitUntilTabIsLoaded(); }); it('should append a where clause by clicking the table', async () => { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; await monacoEditor.setCodeEditorValue(testQuery); @@ -784,6 +812,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should append an end in existing where clause by clicking the table', async () => { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB | where countB > 0`; await monacoEditor.setCodeEditorValue(testQuery); @@ -803,6 +832,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should append a where clause by clicking the table without changing the chart type', async () => { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; await monacoEditor.setCodeEditorValue(testQuery); @@ -837,6 +867,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should append a where clause by clicking the table without changing the chart type nor the visualization state', async () => { await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; await monacoEditor.setCodeEditorValue(testQuery); @@ -889,12 +920,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('filtering by clicking on the table in Dashboards', () => { beforeEach(async () => { await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); await timePicker.setDefaultAbsoluteRange(); + await discover.waitUntilTabIsLoaded(); }); it('should append a filter badge by clicking the table', async () => { const savedSearchName = 'esql filter from table'; await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; await monacoEditor.setCodeEditorValue(testQuery); @@ -941,6 +975,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('histogram breakdown', () => { before(async () => { await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); await timePicker.setDefaultAbsoluteRange(); await discover.waitUntilTabIsLoaded(); }); @@ -955,7 +990,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); await discover.chooseBreakdownField('extension'); - await header.waitUntilLoadingHasFinished(); + await discover.waitUntilTabIsLoaded(); const list = await discover.getHistogramLegendList(); expect(list).to.eql(['css', 'gif', 'jpg', 'php', 'png']); }); @@ -977,6 +1012,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); await discover.saveSearch('esql view with breakdown'); + await discover.waitUntilTabIsLoaded(); await discover.clickNewSearchButton(); await header.waitUntilLoadingHasFinished(); @@ -984,7 +1020,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(prevList).to.eql([]); await discover.loadSavedSearch('esql view with breakdown'); - await header.waitUntilLoadingHasFinished(); + await discover.waitUntilTabIsLoaded(); const list = await discover.getHistogramLegendList(); expect(list).to.eql(['css', 'gif', 'jpg', 'php', 'png']); }); @@ -999,7 +1035,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); await unifiedFieldList.clickFieldListAddBreakdownField('extension'); - await header.waitUntilLoadingHasFinished(); + await discover.waitUntilTabIsLoaded(); const list = await discover.getHistogramLegendList(); expect(list).to.eql(['css', 'gif', 'jpg', 'php', 'png']); }); diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index bbb2e7dae2ae7..633cc4f5df861 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -146,12 +146,14 @@ export class DiscoverPageObject extends FtrService { await this.testSubjects.missingOrFail('loadingSpinner', { timeout: this.defaultFindTimeout * 10, }); - // TODO: Should we add a check for `discoverDataGridUpdating` too? } public async waitUntilTabIsLoaded() { await this.header.waitUntilLoadingHasFinished(); await this.waitUntilSearchingHasFinished(); + await this.testSubjects.missingOrFail('discoverDataGridUpdating', { + timeout: this.defaultFindTimeout * 10, + }); } public async getColumnHeaders() { diff --git a/tsconfig.base.json b/tsconfig.base.json index e924e2c5b7a44..0df0f85d43fa4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -840,6 +840,8 @@ "@kbn/core-user-settings-server-mocks/*": ["src/core/packages/user-settings/server-mocks/*"], "@kbn/cps": ["src/platform/plugins/shared/cps"], "@kbn/cps/*": ["src/platform/plugins/shared/cps/*"], + "@kbn/cps-server-utils": ["src/platform/packages/shared/kbn-cps-server-utils"], + "@kbn/cps-server-utils/*": ["src/platform/packages/shared/kbn-cps-server-utils/*"], "@kbn/cps-utils": ["src/platform/packages/shared/kbn-cps-utils"], "@kbn/cps-utils/*": ["src/platform/packages/shared/kbn-cps-utils/*"], "@kbn/cross-cluster-replication-plugin": ["x-pack/platform/plugins/private/cross_cluster_replication"], @@ -864,8 +866,6 @@ "@kbn/dashboard-agent-common/*": ["x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/*"], "@kbn/dashboard-agent-plugin": ["x-pack/platform/plugins/shared/dashboard_agent"], "@kbn/dashboard-agent-plugin/*": ["x-pack/platform/plugins/shared/dashboard_agent/*"], - "@kbn/dashboard-enhanced-plugin": ["x-pack/platform/plugins/shared/dashboard_enhanced"], - "@kbn/dashboard-enhanced-plugin/*": ["x-pack/platform/plugins/shared/dashboard_enhanced/*"], "@kbn/dashboard-markdown": ["src/platform/plugins/shared/dashboard_markdown"], "@kbn/dashboard-markdown/*": ["src/platform/plugins/shared/dashboard_markdown/*"], "@kbn/dashboard-plugin": ["src/platform/plugins/shared/dashboard"], @@ -1026,8 +1026,6 @@ "@kbn/elasticsearch-client-xpack-plugin/*": ["x-pack/platform/test/plugin_api_integration/plugins/elasticsearch_client/*"], "@kbn/embeddable-alerts-table-plugin": ["x-pack/platform/plugins/shared/embeddable_alerts_table"], "@kbn/embeddable-alerts-table-plugin/*": ["x-pack/platform/plugins/shared/embeddable_alerts_table/*"], - "@kbn/embeddable-enhanced-plugin": ["x-pack/platform/plugins/shared/embeddable_enhanced"], - "@kbn/embeddable-enhanced-plugin/*": ["x-pack/platform/plugins/shared/embeddable_enhanced/*"], "@kbn/embeddable-examples-plugin": ["examples/embeddable_examples"], "@kbn/embeddable-examples-plugin/*": ["examples/embeddable_examples/*"], "@kbn/embeddable-plugin": ["src/platform/plugins/shared/embeddable"], @@ -1112,6 +1110,8 @@ "@kbn/esql-validation-example-plugin/*": ["examples/esql_validation_example/*"], "@kbn/eui-provider-dev-warning": ["src/platform/test/plugin_functional/plugins/eui_provider_dev_warning"], "@kbn/eui-provider-dev-warning/*": ["src/platform/test/plugin_functional/plugins/eui_provider_dev_warning/*"], + "@kbn/eval-kql": ["src/platform/packages/shared/kbn-eval-kql"], + "@kbn/eval-kql/*": ["src/platform/packages/shared/kbn-eval-kql/*"], "@kbn/evals": ["x-pack/platform/packages/shared/kbn-evals"], "@kbn/evals/*": ["x-pack/platform/packages/shared/kbn-evals/*"], "@kbn/evals-suite-agent-builder": ["x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder"], @@ -1220,8 +1220,6 @@ "@kbn/flot-charts/*": ["src/platform/packages/shared/kbn-flot-charts/*"], "@kbn/flyout-system-example-plugin": ["examples/flyout_system"], "@kbn/flyout-system-example-plugin/*": ["examples/flyout_system/*"], - "@kbn/flyout-ui": ["src/platform/packages/shared/kbn-flyout-ui"], - "@kbn/flyout-ui/*": ["src/platform/packages/shared/kbn-flyout-ui/*"], "@kbn/foo-plugin": ["x-pack/platform/test/ui_capabilities/common/plugins/foo_plugin"], "@kbn/foo-plugin/*": ["x-pack/platform/test/ui_capabilities/common/plugins/foo_plugin/*"], "@kbn/fs": ["x-pack/platform/packages/shared/kbn-fs"], @@ -1854,6 +1852,8 @@ "@kbn/response-ops-rule-params/*": ["x-pack/platform/packages/shared/response-ops/rule_params/*"], "@kbn/response-ops-rules-apis": ["x-pack/platform/packages/shared/response-ops/rules-apis"], "@kbn/response-ops-rules-apis/*": ["x-pack/platform/packages/shared/response-ops/rules-apis/*"], + "@kbn/response-ops-scheduling-types": ["x-pack/platform/packages/shared/response-ops/scheduling-types"], + "@kbn/response-ops-scheduling-types/*": ["x-pack/platform/packages/shared/response-ops/scheduling-types/*"], "@kbn/response-stream-plugin": ["examples/response_stream"], "@kbn/response-stream-plugin/*": ["examples/response_stream/*"], "@kbn/restorable-state": ["src/platform/packages/shared/kbn-restorable-state"], @@ -2014,8 +2014,6 @@ "@kbn/security-plugin-types-server/*": ["x-pack/platform/packages/shared/security/plugin_types_server/*"], "@kbn/security-role-management-model": ["x-pack/platform/packages/private/security/role_management_model"], "@kbn/security-role-management-model/*": ["x-pack/platform/packages/private/security/role_management_model/*"], - "@kbn/security-solution-common": ["src/platform/packages/shared/kbn-security-solution-common"], - "@kbn/security-solution-common/*": ["src/platform/packages/shared/kbn-security-solution-common/*"], "@kbn/security-solution-connectors": ["x-pack/solutions/security/packages/connectors"], "@kbn/security-solution-connectors/*": ["x-pack/solutions/security/packages/connectors/*"], "@kbn/security-solution-distribution-bar": ["x-pack/solutions/security/packages/distribution-bar"], @@ -2026,8 +2024,6 @@ "@kbn/security-solution-features/*": ["x-pack/solutions/security/packages/features/*"], "@kbn/security-solution-fixtures-plugin": ["x-pack/platform/test/cases_api_integration/common/plugins/security_solution"], "@kbn/security-solution-fixtures-plugin/*": ["x-pack/platform/test/cases_api_integration/common/plugins/security_solution/*"], - "@kbn/security-solution-flyout": ["src/platform/packages/shared/kbn-security-solution-flyout"], - "@kbn/security-solution-flyout/*": ["src/platform/packages/shared/kbn-security-solution-flyout/*"], "@kbn/security-solution-navigation": ["x-pack/solutions/security/packages/navigation"], "@kbn/security-solution-navigation/*": ["x-pack/solutions/security/packages/navigation/*"], "@kbn/security-solution-plugin": ["x-pack/solutions/security/plugins/security_solution"], @@ -2130,6 +2126,8 @@ "@kbn/share-plugin/*": ["src/platform/plugins/shared/share/*"], "@kbn/shared-svg": ["src/platform/packages/shared/kbn-shared-svg"], "@kbn/shared-svg/*": ["src/platform/packages/shared/kbn-shared-svg/*"], + "@kbn/shared-ux-ai-components": ["src/platform/packages/shared/shared-ux/ai-components"], + "@kbn/shared-ux-ai-components/*": ["src/platform/packages/shared/shared-ux/ai-components/*"], "@kbn/shared-ux-avatar-solution": ["src/platform/packages/shared/shared-ux/avatar/solution"], "@kbn/shared-ux-avatar-solution/*": ["src/platform/packages/shared/shared-ux/avatar/solution/*"], "@kbn/shared-ux-button-exit-full-screen": ["src/platform/packages/shared/shared-ux/button/exit_full_screen"], @@ -2304,6 +2302,8 @@ "@kbn/styled-components-mapping-cli/*": ["packages/kbn-styled-components-mapping-cli/*"], "@kbn/synthetics-e2e": ["x-pack/solutions/observability/plugins/synthetics/e2e"], "@kbn/synthetics-e2e/*": ["x-pack/solutions/observability/plugins/synthetics/e2e/*"], + "@kbn/synthetics-forge": ["x-pack/solutions/observability/packages/kbn-synthetics-forge"], + "@kbn/synthetics-forge/*": ["x-pack/solutions/observability/packages/kbn-synthetics-forge/*"], "@kbn/synthetics-plugin": ["x-pack/solutions/observability/plugins/synthetics"], "@kbn/synthetics-plugin/*": ["x-pack/solutions/observability/plugins/synthetics/*"], "@kbn/synthetics-private-location": ["x-pack/packages/kbn-synthetics-private-location"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index cff3dea251fc5..b3b1b9a498e53 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -30,7 +30,6 @@ "packages/kbn-cloud-security-posture" ], "xpack.customBranding": "platform/plugins/private/custom_branding", - "xpack.dashboard": "platform/plugins/shared/dashboard_enhanced", "xpack.dataQuality": "platform/plugins/shared/data_quality", "xpack.datasetQuality": "platform/plugins/shared/dataset_quality", "xpack.dataUsage": "platform/plugins/private/data_usage", @@ -40,7 +39,6 @@ "xpack.elasticAssistantCommon": "platform/packages/shared/kbn-elastic-assistant-common", "xpack.elasticAssistantPlugin": "solutions/security/plugins/elastic_assistant", "xpack.ecsDataQualityDashboard": "solutions/security/plugins/ecs_data_quality_dashboard", - "xpack.embeddableEnhanced": "platform/plugins/shared/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", "xpack.enterpriseSearch": "solutions/search/plugins/enterprise_search", "xpack.features": "platform/plugins/shared/features", diff --git a/x-pack/packages/kbn-synthetics-private-location/src/run.ts b/x-pack/packages/kbn-synthetics-private-location/src/run.ts index b98f77f2c63e0..8bf96a1a0041c 100644 --- a/x-pack/packages/kbn-synthetics-private-location/src/run.ts +++ b/x-pack/packages/kbn-synthetics-private-location/src/run.ts @@ -23,7 +23,27 @@ export async function run(options: CliOptions, logger: ToolingLog) { const { item: { id: agentPolicyId }, } = await createElasticAgentPolicy(options, logger, kibanaClient); - await createPrivateLocation(options, logger, kibanaClient, agentPolicyId); + const privateLocationResponse = await createPrivateLocation( + options, + logger, + kibanaClient, + agentPolicyId + ); + + // Output private location ID for use with synthetics_forge + logger.info(` +════════════════════════════════════════════════════════════════ + SYNTHETICS PRIVATE LOCATION CREATED +════════════════════════════════════════════════════════════════ + Private Location ID: ${privateLocationResponse.id} + Private Location Label: ${privateLocationResponse.label} + Agent Policy ID: ${agentPolicyId} + + To use with synthetics_forge: + PRIVATE_LOCATION_ID="${privateLocationResponse.id}" +════════════════════════════════════════════════════════════════ +`); + const { list } = await fetchAgentPolicyEnrollmentToken( options, logger, diff --git a/x-pack/platform/packages/private/kbn-scout-release-testing/test/scout/.meta/ui/standard.json b/x-pack/platform/packages/private/kbn-scout-release-testing/test/scout/.meta/ui/standard.json index 5608b42726dc5..661ffa8316205 100644 --- a/x-pack/platform/packages/private/kbn-scout-release-testing/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/packages/private/kbn-scout-release-testing/test/scout/.meta/ui/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-03T22:04:47.440Z", "sha1": "03b1fb395ea4c6946062a3f541b3313e3e3ebc4e", "tests": [ { diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts index 317a5923c52bf..f9c852cce58bd 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts @@ -5,9 +5,55 @@ * 2.0. */ +import type { ReactNode } from 'react'; import type { IconType } from '@elastic/eui'; import type { UnknownAttachment, AttachmentVersion } from '@kbn/agent-builder-common/attachments'; +export enum ActionButtonType { + PRIMARY = 'primary', + SECONDARY = 'secondary', + OVERFLOW = 'overflow', +} +/** + * Props passed to custom attachment content renderers. + */ +export interface AttachmentRenderProps { + /** The attachment to render */ + attachment: TAttachment; + /** Whether the attachment is being rendered in a sidebar context */ + isSidebar: boolean; +} + +/** + * Parameters passed when requesting action buttons for an inline-rendered attachment. + */ +export interface GetActionButtonsParams { + /** The attachment for which to provide action buttons */ + attachment: TAttachment; + /** Whether the attachment is being rendered in a sidebar context */ + isSidebar: boolean; + /** Whether the attachment is being rendered in canvas mode (expanded flyout view) */ + isCanvas: boolean; + /** Function to update the attachment's origin reference */ + updateOrigin: (originId: string) => Promise; + /** Callback to open the attachment in canvas mode (expanded flyout view). Undefined when already in canvas mode. */ + openCanvas?: () => void; +} + +/** + * Action button definition for inline-rendered attachments. + */ +export interface ActionButton { + /** Button label text */ + label: string; + /** Optional icon to display in the button (EUI icon name or custom React element) */ + icon?: IconType; + /** Whether this is the primary action button */ + type: ActionButtonType; + /** Handler function called when the button is clicked */ + handler: () => void | Promise; +} + /** * UI definition for rendering attachments of a specific type. */ @@ -25,6 +71,22 @@ export interface AttachmentUIDefinition void; + /** + * Optional custom content renderer for inline attachment display. + * When provided, attachments can be rendered inline in the conversation + * using the tag. + */ + renderInlineContent?: (props: AttachmentRenderProps) => ReactNode; + /** + * Optional custom content renderer for canvas mode (expanded flyout view). + * When provided, attachments can be opened in an expanded view via action buttons. + */ + renderCanvasContent?: (props: AttachmentRenderProps) => ReactNode; + /** + * Optional function to provide action buttons for inline-rendered attachments. + * Buttons will appear alongside or below the rendered content. + */ + getActionButtons?: (params: GetActionButtonsParams) => ActionButton[]; } /** diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts index 6ea45d951d120..b590a82158ed3 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts @@ -5,4 +5,11 @@ * 2.0. */ -export type { AttachmentUIDefinition, AttachmentServiceStartContract } from './contract'; +export type { + AttachmentUIDefinition, + AttachmentServiceStartContract, + AttachmentRenderProps, + GetActionButtonsParams, + ActionButton, +} from './contract'; +export { ActionButtonType } from './contract'; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/custom_rendering.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/custom_rendering.ts index 69c2a435d3dcf..9d92d6c28ae18 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/custom_rendering.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/custom_rendering.ts @@ -30,3 +30,16 @@ export const dashboardElement = { toolResultId: 'tool-result-id', }, }; + +export interface RenderAttachmentElementAttributes { + attachmentId?: string; + version?: number | string; +} + +export const renderAttachmentElement = { + tagName: 'render_attachment', + attributes: { + attachmentId: 'id', + version: 'version', + }, +}; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/tools/utils/workflows/get_execution_state.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/tools/utils/workflows/get_execution_state.ts index 636b3b2653d4c..86788ad80e122 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/tools/utils/workflows/get_execution_state.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/tools/utils/workflows/get_execution_state.ts @@ -36,7 +36,9 @@ export const getExecutionState = async ({ spaceId: string; workflowApi: WorkflowApi; }): Promise => { - const execution = await workflowApi.getWorkflowExecution(executionId, spaceId); + const execution = await workflowApi.getWorkflowExecution(executionId, spaceId, { + includeOutput: true, + }); if (!execution) { return null; } diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts index d369b025fa365..9bfc9a81d51f9 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts @@ -22,14 +22,13 @@ export const AGENT_BUILDER_BUILTIN_TOOLS = [ `${internalNamespaces.observability}.get_log_groups`, `${internalNamespaces.observability}.get_alerts`, `${internalNamespaces.observability}.get_services`, - `${internalNamespaces.observability}.get_downstream_dependencies`, - `${internalNamespaces.observability}.get_correlated_logs`, `${internalNamespaces.observability}.get_hosts`, `${internalNamespaces.observability}.get_trace_metrics`, `${internalNamespaces.observability}.get_log_change_points`, `${internalNamespaces.observability}.get_metric_change_points`, `${internalNamespaces.observability}.get_index_info`, `${internalNamespaces.observability}.get_trace_change_points`, + `${internalNamespaces.observability}.get_service_topology`, `${internalNamespaces.observability}.get_traces`, `${internalNamespaces.observability}.get_runtime_metrics`, diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts index e9dca89c95051..fff5966d6f384 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts @@ -157,7 +157,7 @@ class AttachmentStateManagerImpl implements AttachmentStateManager { private getDefaultReadonly(type: string): boolean { const definition = this.options.getTypeDefinition(type); - return definition?.isReadonly ?? true; + return definition?.isReadonly ?? false; } private async validateAttachmentData(type: string, data: unknown): Promise { diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts index c18fb64c0ea80..0637fa2f25f0b 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts @@ -67,7 +67,7 @@ export interface AttachmentTypeDefinition< */ getAgentDescription?: () => string; /** - * Whether attachments of this type are read-only. Defaults to true. + * Whether attachments of this type are read-only. Defaults to false. */ isReadonly?: boolean; } diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/attachments_service.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/attachments_service.ts index ea443d747a0ab..ff44e4a1675d3 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/attachments_service.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/attachments_service.ts @@ -16,6 +16,10 @@ export interface AttachmentsService { * Returns the full definition for an attachment type */ getTypeDefinition(type: string): AttachmentTypeDefinition | undefined; + /** + * Returns the IDs of all registered attachment types. + */ + getRegisteredTypeIds(): string[]; /** * Convert an attachment-scoped tool to a generic executable tool */ diff --git a/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/README.md b/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/README.md index 3fadb95b0e961..210eb9a2deae6 100644 --- a/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/README.md +++ b/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/README.md @@ -112,31 +112,31 @@ Then run the evaluations: ```bash # Run all AgentBuilder evaluations -node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts # Run specific test file -node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts evals/kb/kb.spec.ts +node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts evals/kb/kb.spec.ts # Run with specific connector -node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts --project="my-connector" +node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts --project="my-connector" # Run with LLM-as-a-judge for consistent evaluation results -EVALUATION_CONNECTOR_ID=llm-judge-connector-id node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +EVALUATION_CONNECTOR_ID=llm-judge-connector-id node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts # Run only selected evaluators -SELECTED_EVALUATORS="Factuality,Relevance,Groundedness" node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +SELECTED_EVALUATORS="Factuality,Relevance,Groundedness" node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts # Override RAG evaluator K value (takes priority over config) -RAG_EVAL_K=5 node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +RAG_EVAL_K=5 node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts # Run RAG evaluators with multiple K values using patterns (Precision@K matches Precision@5, Precision@10, etc.) -SELECTED_EVALUATORS="Precision@K,Recall@K,F1@K,Factuality" RAG_EVAL_K=5,10,20 node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +SELECTED_EVALUATORS="Precision@K,Recall@K,F1@K,Factuality" RAG_EVAL_K=5,10,20 node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts # Override RAG evaluator K value (supports comma-separated values for multi-K evaluation) -RAG_EVAL_K=5,10,20 node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +RAG_EVAL_K=5,10,20 node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts # Retrieve traces from another (monitoring) cluster -TRACING_ES_URL=http://elastic:changeme@localhost:9200 EVALUATION_CONNECTOR_ID=llm-judge-connector-id node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +TRACING_ES_URL=http://elastic:changeme@localhost:9200 EVALUATION_CONNECTOR_ID=llm-judge-connector-id node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts ``` @@ -149,7 +149,7 @@ If you want to run evaluations against a dataset that exists in Phoenix and not ```bash DATASET_NAME="my-phoenix-dataset" \ -node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts evals/external/external_dataset.spec.ts +node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts evals/external/external_dataset.spec.ts ``` Notes: diff --git a/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/esql.playwright.config.ts b/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/esql.playwright.config.ts similarity index 91% rename from x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/esql.playwright.config.ts rename to x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/esql.playwright.config.ts index 4e2adde62edf0..3646502d2ffaa 100644 --- a/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/esql.playwright.config.ts +++ b/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/esql.playwright.config.ts @@ -13,7 +13,7 @@ import { createPlaywrightEvalsConfig } from '@kbn/evals'; * - separate Playwright config so it can be triggered/run on its own */ export default createPlaywrightEvalsConfig({ - testDir: Path.resolve(__dirname, '../../../evals/esql'), + testDir: Path.resolve(__dirname, './evals/esql'), repetitions: 1, timeout: 30 * 60_000, // 30 minutes timeout given large datasets in use }); diff --git a/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts b/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts similarity index 91% rename from x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts rename to x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts index 6fa554e961020..3c86b50f4ddbd 100644 --- a/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +++ b/x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts @@ -8,7 +8,7 @@ import Path from 'path'; import { createPlaywrightEvalsConfig } from '@kbn/evals'; export default createPlaywrightEvalsConfig({ - testDir: Path.resolve(__dirname, '../../../evals'), + testDir: Path.resolve(__dirname, './evals'), // CI job timeout is ~1h; keep default low and use EVALUATION_REPETITIONS // for longer/higher-confidence runs. repetitions: 1, diff --git a/x-pack/platform/packages/shared/ai-assistant/icon/icon.tsx b/x-pack/platform/packages/shared/ai-assistant/icon/icon.tsx index 1755de72454d4..bd54c7da07d04 100644 --- a/x-pack/platform/packages/shared/ai-assistant/icon/icon.tsx +++ b/x-pack/platform/packages/shared/ai-assistant/icon/icon.tsx @@ -10,7 +10,6 @@ import type { EuiIconProps } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; // TODO: can be removed once added to EUI. import assistantIcon from './svg/assistant'; -import robotIcon from './svg/robot'; /** * Props for the AI Assistant icon. @@ -23,10 +22,3 @@ export type AssistantIconProps = Omit; export const AssistantIcon = ({ size = 'm', ...rest }: AssistantIconProps) => { return ; }; - -/** - * Robot icon for AI Agent functionality. - */ -export const RobotIcon = ({ size = 'm', ...rest }: AssistantIconProps) => { - return ; -}; diff --git a/x-pack/platform/packages/shared/ai-assistant/icon/index.ts b/x-pack/platform/packages/shared/ai-assistant/icon/index.ts index b32937e0f3b75..dc2206988d3c1 100644 --- a/x-pack/platform/packages/shared/ai-assistant/icon/index.ts +++ b/x-pack/platform/packages/shared/ai-assistant/icon/index.ts @@ -8,4 +8,4 @@ export { AssistantAvatar, type AssistantAvatarProps } from './avatar'; export { AssistantBeacon, type AssistantBeaconProps } from './beacon'; export { useBeaconSize } from './beacon.styles'; -export { AssistantIcon, RobotIcon, type AssistantIconProps } from './icon'; +export { AssistantIcon, type AssistantIconProps } from './icon'; diff --git a/x-pack/platform/packages/shared/ai-assistant/icon/svg/robot.tsx b/x-pack/platform/packages/shared/ai-assistant/icon/svg/robot.tsx deleted file mode 100644 index 41df921f429ec..0000000000000 --- a/x-pack/platform/packages/shared/ai-assistant/icon/svg/robot.tsx +++ /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 type { SVGProps } from 'react'; -import React from 'react'; - -// This is a copy of x-pack/solutions/search/packages/shared-ui/src/v2_icons/robot.tsx -const RobotIcon = (props: SVGProps) => ( - - - - - - -); - -// eslint-disable-next-line import/no-default-export -export { RobotIcon as default }; diff --git a/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/README.md b/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/README.md index 279ba2e023365..ba51e48bdcbbc 100644 --- a/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/README.md +++ b/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/README.md @@ -9,7 +9,7 @@ Retriever-focused evaluation suites for the `@kbn/llm-tasks-plugin`. node scripts/scout.js start-server --arch stateful --domain classic # Run evaluations -node scripts/playwright test --config x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/test/scout/ui/playwright.config.ts +node scripts/playwright test --config x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/playwright.config.ts ``` ## Retrieve Documentation Task @@ -17,6 +17,6 @@ node scripts/playwright test --config x-pack/platform/packages/shared/ai-infra/k This suite evaluates the `retrieveDocumentation` task implementation directly (not a tool wrapper). ```bash -node scripts/playwright test --config x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/test/scout/ui/playwright.config.ts evals/retrieve_documentation/retrieve_documentation.spec.ts +node scripts/playwright test --config x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/playwright.config.ts evals/retrieve_documentation/retrieve_documentation.spec.ts ``` diff --git a/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/test/scout/ui/playwright.config.ts b/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/playwright.config.ts similarity index 88% rename from x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/test/scout/ui/playwright.config.ts rename to x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/playwright.config.ts index e84f40a5bfcfc..4b58b190c81d9 100644 --- a/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/test/scout/ui/playwright.config.ts +++ b/x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/playwright.config.ts @@ -8,6 +8,6 @@ import Path from 'path'; import { createPlaywrightEvalsConfig } from '@kbn/evals'; export default createPlaywrightEvalsConfig({ - testDir: Path.resolve(__dirname, '../../../evals'), + testDir: Path.resolve(__dirname, './evals'), timeout: 30 * 60_000, }); diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_actions_menu.tsx b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_actions_menu.tsx index a1de83fcad360..f1298ac472b1b 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_actions_menu.tsx +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_actions_menu.tsx @@ -11,6 +11,7 @@ import { EuiButton, EuiButtonIcon, EuiContextMenu, + EuiIcon, EuiPopover, EuiPopoverFooter, EuiToolTip, @@ -27,7 +28,6 @@ import { import type { ApplicationStart } from '@kbn/core/public'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import { isSupportedConnectorType } from '@kbn/inference-common'; -import { RobotIcon } from '@kbn/ai-assistant-icon'; import { GenerativeAIForObservabilityConnectorFeatureId } from '@kbn/actions-plugin/common'; import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors'; import { useKibana } from '../hooks/use_kibana'; @@ -153,7 +153,7 @@ export function ChatActionsMenu({ openAgentBuilderConfirmationModal(); }} > - + {i18n.translate('xpack.aiAssistant.chatHeader.actions.agentBuilderOptInButton', { defaultMessage: 'Try AI Agent', })} diff --git a/x-pack/platform/packages/shared/kbn-apm-types/es_fields.ts b/x-pack/platform/packages/shared/kbn-apm-types/es_fields.ts index b14171c319e32..0b83ce68507fc 100644 --- a/x-pack/platform/packages/shared/kbn-apm-types/es_fields.ts +++ b/x-pack/platform/packages/shared/kbn-apm-types/es_fields.ts @@ -7,3 +7,4 @@ export * from './src/es_fields/apm'; export * from './src/es_fields/otel'; +export * from './src/es_fields/common'; diff --git a/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/apm.ts b/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/apm.ts index 69101e64fb887..35a8b7a5912dd 100644 --- a/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/apm.ts +++ b/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/apm.ts @@ -123,6 +123,7 @@ export const ERROR_EXC_TYPE = 'error.exception.type'; export const ERROR_PAGE_URL = 'error.page.url'; export const ERROR_STACK_TRACE = 'error.stack_trace'; export const ERROR_TYPE = 'error.type'; +export const ERROR_CODE = 'error.code'; // METRICS export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; @@ -164,6 +165,7 @@ export const CONTAINER_IMAGE = 'container.image.name'; export const KUBERNETES = 'kubernetes'; export const KUBERNETES_POD_NAME = 'kubernetes.pod.name'; +export const KUBERNETES_POD_NAME_OTEL = 'k8s.pod.name'; export const KUBERNETES_POD_UID = 'kubernetes.pod.uid'; export const KUBERNETES_NAMESPACE = 'kubernetes.namespace'; export const KUBERNETES_NODE_NAME = 'kubernetes.node.name'; diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/index.ts b/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/common.ts similarity index 84% rename from x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/index.ts rename to x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/common.ts index dbf85c53a2006..8f5829276b099 100644 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/index.ts +++ b/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/common.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './dynamic_action_storage'; +export const ERROR_MESSAGE = 'error.message'; diff --git a/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/otel.ts b/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/otel.ts index cfe35bd6a614a..835c1b1656ee0 100644 --- a/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/otel.ts +++ b/x-pack/platform/packages/shared/kbn-apm-types/src/es_fields/otel.ts @@ -7,7 +7,6 @@ export const STATUS_CODE = 'status.code'; export const OTEL_EVENT_NAME = 'event_name'; -export const ERROR_MESSAGE = 'error.message'; export const EXCEPTION_TYPE = 'exception.type'; export const EXCEPTION_MESSAGE = 'exception.message'; export const DURATION = 'duration'; diff --git a/x-pack/platform/packages/shared/kbn-apm-types/src/es_schemas/raw/error_raw.ts b/x-pack/platform/packages/shared/kbn-apm-types/src/es_schemas/raw/error_raw.ts index 349dbec9e38eb..4ee603bdb55c2 100644 --- a/x-pack/platform/packages/shared/kbn-apm-types/src/es_schemas/raw/error_raw.ts +++ b/x-pack/platform/packages/shared/kbn-apm-types/src/es_schemas/raw/error_raw.ts @@ -61,6 +61,9 @@ export interface ErrorRaw extends APMBaseDoc { log?: Log; stack_trace?: string; custom?: Record; + message?: string; + code?: string; + type?: string; }; // Shared by errors and transactions diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/robot_icon.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/robot_icon.tsx deleted file mode 100644 index 2d0adcfd05556..0000000000000 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/robot_icon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SVGProps } from 'react'; -import React from 'react'; - -export const robotIconType: React.FC> = (props) => ( - - - - - - -); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/try_ai_agent_context_menu_item.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/try_ai_agent_context_menu_item.tsx index 7aebcfd04771a..bab72c9e39967 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/try_ai_agent_context_menu_item.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/try_ai_agent_context_menu_item.tsx @@ -11,7 +11,6 @@ import { css } from '@emotion/react'; import { AGENT_BUILDER_EVENT_TYPES } from '@kbn/agent-builder-common'; import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser'; import * as i18n from './translations'; -import { robotIconType } from './robot_icon'; export const TryAIAgentContextMenuItem: React.FC<{ analytics?: AnalyticsServiceStart; @@ -46,7 +45,7 @@ export const TryAIAgentContextMenuItem: React.FC<{ handleOpenAIAgentModal('security_settings_menu')} - iconType={robotIconType} + iconType="productAgent" color="accent" size="s" fullWidth @@ -64,7 +63,7 @@ export const TryAIAgentContextMenuItem: React.FC<{ handleOpenAIAgentModal('security_settings_menu')} - iconType={robotIconType} + iconType="productAgent" color="accent" size="s" fullWidth diff --git a/x-pack/platform/packages/shared/kbn-evals-suite-streams/README.md b/x-pack/platform/packages/shared/kbn-evals-suite-streams/README.md index 3953603cfb4fb..c1abe307fdb43 100644 --- a/x-pack/platform/packages/shared/kbn-evals-suite-streams/README.md +++ b/x-pack/platform/packages/shared/kbn-evals-suite-streams/README.md @@ -9,7 +9,7 @@ Evaluation suite for Elastic Streams pattern extraction quality. node scripts/scout.js start-server --arch stateful --domain classic # Run evaluations -node scripts/playwright test --config x-pack/platform/packages/shared/kbn-evals-suite-streams/test/scout/ui/playwright.config.ts +node scripts/playwright test --config x-pack/platform/packages/shared/kbn-evals-suite-streams/playwright.config.ts ``` ## Creating New Datasets diff --git a/x-pack/platform/packages/shared/kbn-evals-suite-streams/test/scout/ui/playwright.config.ts b/x-pack/platform/packages/shared/kbn-evals-suite-streams/playwright.config.ts similarity index 87% rename from x-pack/platform/packages/shared/kbn-evals-suite-streams/test/scout/ui/playwright.config.ts rename to x-pack/platform/packages/shared/kbn-evals-suite-streams/playwright.config.ts index b9feeb5cd04ac..4f219bac453b9 100644 --- a/x-pack/platform/packages/shared/kbn-evals-suite-streams/test/scout/ui/playwright.config.ts +++ b/x-pack/platform/packages/shared/kbn-evals-suite-streams/playwright.config.ts @@ -8,5 +8,5 @@ import Path from 'path'; import { createPlaywrightEvalsConfig } from '@kbn/evals'; export default createPlaywrightEvalsConfig({ - testDir: Path.resolve(__dirname, '../../../evals'), + testDir: Path.resolve(__dirname, './evals'), }); diff --git a/x-pack/platform/packages/shared/kbn-evals/README.md b/x-pack/platform/packages/shared/kbn-evals/README.md index 526bd88b78d63..43703ee7dada3 100644 --- a/x-pack/platform/packages/shared/kbn-evals/README.md +++ b/x-pack/platform/packages/shared/kbn-evals/README.md @@ -585,7 +585,7 @@ evaluate('compare model performance', async ({ evaluationAnalysisService }) => { Some of the evals will use LLM-as-a-judge. For consistent results, you should specify `EVALUATION_CONNECTOR_ID` as an environment variable, in order for the evaluations to always be judged by the same LLM: ```bash -EVALUATION_CONNECTOR_ID=bedrock-claude node scripts/playwright test --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts +EVALUATION_CONNECTOR_ID=bedrock-claude node scripts/playwright test --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts ``` ### Testing a specific connector @@ -593,7 +593,7 @@ EVALUATION_CONNECTOR_ID=bedrock-claude node scripts/playwright test --config x-p The helper will spin up one `local` project per available connector so results are isolated per model. Each project is named after the connector id. To run the evaluations only for a specific connector, use `--project`: ```bash -node scripts/playwright test --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts --project azure-gpt4o +node scripts/playwright test --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts --project azure-gpt4o ``` ### Skipping connector setup/teardown @@ -626,7 +626,7 @@ await executorClient.runExperiment( Then control which evaluators run using the `SELECTED_EVALUATORS` environment variable with a comma-separated list of evaluator names: ```bash -SELECTED_EVALUATORS="Factuality,Relevance" node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts +SELECTED_EVALUATORS="Factuality,Relevance" node scripts/playwright test --config x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts ``` **RAG Evaluator Patterns:** For RAG metrics, use pattern names (`Precision@K`, `Recall@K`, `F1@K`) to select evaluators. The actual K values are controlled by `RAG_EVAL_K`: @@ -666,7 +666,7 @@ To override the repetitions at runtime without modifying your configuration, use ```bash # Run each example 3 times -EVALUATION_REPETITIONS=3 node scripts/playwright test --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts +EVALUATION_REPETITIONS=3 node scripts/playwright test --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts ``` ### Running evaluations against your local/development Kibana instance diff --git a/x-pack/platform/packages/shared/kbn-evals/evals.suites.json b/x-pack/platform/packages/shared/kbn-evals/evals.suites.json index f67db07d853d2..b720765f18aa1 100644 --- a/x-pack/platform/packages/shared/kbn-evals/evals.suites.json +++ b/x-pack/platform/packages/shared/kbn-evals/evals.suites.json @@ -3,42 +3,42 @@ { "id": "agent-builder", "name": "Agent Builder", - "configPath": "x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/playwright.config.ts", + "configPath": "x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/playwright.config.ts", "tags": ["platform", "agent-builder"], "ciLabels": ["evals:agent-builder"] }, { "id": "esql-generation", "name": "ES|QL Generation Evaluations", - "configPath": "x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/test/scout/ui/esql.playwright.config.ts", + "configPath": "x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder/esql.playwright.config.ts", "tags": ["platform", "esql"], "ciLabels": ["evals:esql-generation"] }, { "id": "streams", "name": "Streams", - "configPath": "x-pack/platform/packages/shared/kbn-evals-suite-streams/test/scout/ui/playwright.config.ts", + "configPath": "x-pack/platform/packages/shared/kbn-evals-suite-streams/playwright.config.ts", "tags": ["platform", "streams"], "ciLabels": ["evals:streams"] }, { "id": "llm-tasks", "name": "LLM Tasks", - "configPath": "x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/test/scout/ui/playwright.config.ts", + "configPath": "x-pack/platform/packages/shared/ai-infra/kbn-evals-suite-llm-tasks/playwright.config.ts", "tags": ["ai-infra"], "ciLabels": ["evals:llm-tasks"] }, { "id": "obs-ai-assistant", "name": "Observability AI Assistant", - "configPath": "x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts", + "configPath": "x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts", "tags": ["observability", "ai-assistant"], "ciLabels": ["evals:obs-ai-assistant"] }, { "id": "obs-ai-assistant/ai_insights", "name": "Observability AI Assistant (AI Insights)", - "configPath": "x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/ai_insights.playwright.config.ts", + "configPath": "x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/playwright.config.ts", "tags": ["observability", "ai-assistant"], "ciLabels": ["evals:obs-ai-assistant-ai-insights"] } diff --git a/x-pack/platform/packages/shared/kbn-evals/scripts/ci/local_ci_env.sh b/x-pack/platform/packages/shared/kbn-evals/scripts/ci/local_ci_env.sh index a552572e7af29..727ec2e998342 100644 --- a/x-pack/platform/packages/shared/kbn-evals/scripts/ci/local_ci_env.sh +++ b/x-pack/platform/packages/shared/kbn-evals/scripts/ci/local_ci_env.sh @@ -45,6 +45,7 @@ TRACING_ES_URL="$(jq -r '.tracingEs.url // empty' <<<"$CONFIG_JSON")" TRACING_ES_API_KEY="$(jq -r '.tracingEs.apiKey // empty' <<<"$CONFIG_JSON")" TRACING_EXPORTERS_JSON="$(jq -c '.tracingExporters // empty' <<<"$CONFIG_JSON")" +GCS_CREDENTIALS="$(jq -c '.gcsDatasetAccessCredentials // empty' <<<"$CONFIG_JSON")" if [[ -z "$LITELLM_BASE_URL" || -z "$LITELLM_VIRTUAL_KEY" ]]; then die "Missing litellm.baseUrl or litellm.virtualKey in $CONFIG_PATH" @@ -62,6 +63,7 @@ export EVALUATIONS_ES_URL export EVALUATIONS_ES_API_KEY export TRACING_ES_URL export TRACING_ES_API_KEY +export GCS_CREDENTIALS if [[ -n "$TRACING_EXPORTERS_JSON" && "$TRACING_EXPORTERS_JSON" != "null" ]]; then export TRACING_EXPORTERS="$TRACING_EXPORTERS_JSON" fi @@ -119,5 +121,10 @@ if [[ -n "${TRACING_EXPORTERS:-}" ]]; then else echo " TRACING_EXPORTERS=" fi +if [[ -n "${GCS_CREDENTIALS:-}" ]]; then + echo " GCS_CREDENTIALS=" +else + echo " GCS_CREDENTIALS=" +fi echo " Generated connectors: $CONNECTOR_COUNT" diff --git a/x-pack/platform/packages/shared/kbn-evals/scripts/vault/config.example.json b/x-pack/platform/packages/shared/kbn-evals/scripts/vault/config.example.json index 33964ec4a8a38..e6b334aad3dc6 100644 --- a/x-pack/platform/packages/shared/kbn-evals/scripts/vault/config.example.json +++ b/x-pack/platform/packages/shared/kbn-evals/scripts/vault/config.example.json @@ -28,5 +28,15 @@ } } } - ] + ], + "gcsDatasetAccessCredentials": { + "type": "service_account", + "project_id": "REDACTED", + "private_key_id": "REDACTED", + "private_key": "REDACTED", + "client_email": "REDACTED", + "client_id": "REDACTED", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } } diff --git a/x-pack/platform/packages/shared/kbn-evals/scripts/vault/manage_secrets.ts b/x-pack/platform/packages/shared/kbn-evals/scripts/vault/manage_secrets.ts index 250d02b6f858b..cabdb42b93242 100644 --- a/x-pack/platform/packages/shared/kbn-evals/scripts/vault/manage_secrets.ts +++ b/x-pack/platform/packages/shared/kbn-evals/scripts/vault/manage_secrets.ts @@ -101,6 +101,7 @@ const configSchema = schema.object( { unknowns: 'allow' } ) ), + gcsDatasetAccessCredentials: schema.maybe(schema.object({}, { unknowns: 'allow' })), }, { unknowns: 'allow' } ); diff --git a/x-pack/platform/packages/shared/kbn-evals/scripts/vault/validate_config.js b/x-pack/platform/packages/shared/kbn-evals/scripts/vault/validate_config.js index 41bd8e523abe6..725158b33edfc 100644 --- a/x-pack/platform/packages/shared/kbn-evals/scripts/vault/validate_config.js +++ b/x-pack/platform/packages/shared/kbn-evals/scripts/vault/validate_config.js @@ -123,6 +123,73 @@ function assertOptionalNonEmptyString(obj, path) { } } +function validateGcsCredentials(config) { + const gcsCreds = config.gcsDatasetAccessCredentials; + if (gcsCreds === undefined || gcsCreds === null) return; + + if (typeof gcsCreds !== 'object' || Array.isArray(gcsCreds)) { + die( + 'Invalid kbn-evals CI config: "gcsDatasetAccessCredentials" must be an object when provided' + ); + } + + const requiredFields = [ + 'type', + 'project_id', + 'private_key_id', + 'private_key', + 'client_email', + 'client_id', + 'auth_uri', + 'token_uri', + ]; + for (const field of requiredFields) { + assertNonEmptyString(config, `gcsDatasetAccessCredentials.${field}`); + } +} + +function validateTracingEs(config) { + const tracingEs = config.tracingEs; + if (tracingEs === undefined || tracingEs === null) return; + + if (typeof tracingEs !== 'object' || Array.isArray(tracingEs)) { + die('Invalid kbn-evals CI config: "tracingEs" must be an object when provided'); + } + assertNonEmptyString(config, 'tracingEs.url'); + assertNonEmptyString(config, 'tracingEs.apiKey'); +} + +function validateTracingExporters(config) { + const tracingExporters = config.tracingExporters; + if (tracingExporters === undefined || tracingExporters === null) return; + + if (!Array.isArray(tracingExporters) || tracingExporters.length === 0) { + die('Invalid kbn-evals CI config: "tracingExporters" must be a non-empty array when provided'); + } + const allowedKeys = new Set(['http', 'grpc', 'phoenix', 'langfuse']); + for (let i = 0; i < tracingExporters.length; i++) { + const entry = tracingExporters[i]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + die(`Invalid kbn-evals CI config: "tracingExporters[${i}]" must be an object`); + } + const keys = Object.keys(entry); + if (keys.length !== 1) { + die( + `Invalid kbn-evals CI config: "tracingExporters[${i}]" must have exactly one key (one of: ${[ + ...allowedKeys, + ].join(', ')})` + ); + } + if (!allowedKeys.has(keys[0])) { + die( + `Invalid kbn-evals CI config: "tracingExporters[${i}]" has unknown key "${ + keys[0] + }" (allowed: ${[...allowedKeys].join(', ')})` + ); + } + } +} + function validateConfigShape(config) { if (!config || typeof config !== 'object' || Array.isArray(config)) { die('Invalid kbn-evals CI config: root must be a JSON object'); @@ -139,47 +206,9 @@ function validateConfigShape(config) { assertOptionalNonEmptyString(config, 'litellm.teamId'); assertOptionalNonEmptyString(config, 'litellm.teamName'); - // Optional tracingEs block (if present, require both fields) - const tracingEs = config.tracingEs; - if (tracingEs !== undefined && tracingEs !== null) { - if (!tracingEs || typeof tracingEs !== 'object' || Array.isArray(tracingEs)) { - die('Invalid kbn-evals CI config: "tracingEs" must be an object when provided'); - } - assertNonEmptyString(config, 'tracingEs.url'); - assertNonEmptyString(config, 'tracingEs.apiKey'); - } - - // Optional tracingExporters array (supports http, grpc, phoenix, langfuse exporters) - const tracingExporters = config.tracingExporters; - if (tracingExporters !== undefined && tracingExporters !== null) { - if (!Array.isArray(tracingExporters) || tracingExporters.length === 0) { - die( - 'Invalid kbn-evals CI config: "tracingExporters" must be a non-empty array when provided' - ); - } - const allowedKeys = new Set(['http', 'grpc', 'phoenix', 'langfuse']); - for (let i = 0; i < tracingExporters.length; i++) { - const entry = tracingExporters[i]; - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - die(`Invalid kbn-evals CI config: "tracingExporters[${i}]" must be an object`); - } - const keys = Object.keys(entry); - if (keys.length !== 1) { - die( - `Invalid kbn-evals CI config: "tracingExporters[${i}]" must have exactly one key (one of: ${[ - ...allowedKeys, - ].join(', ')})` - ); - } - if (!allowedKeys.has(keys[0])) { - die( - `Invalid kbn-evals CI config: "tracingExporters[${i}]" has unknown key "${ - keys[0] - }" (allowed: ${[...allowedKeys].join(', ')})` - ); - } - } - } + validateGcsCredentials(config); + validateTracingEs(config); + validateTracingExporters(config); } async function readStdin() { diff --git a/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/.meta/api/standard.json b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/.meta/api/standard.json index 9863e95a5944f..28e473a64b848 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/.meta/api/standard.json +++ b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/.meta/api/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-09T18:40:14.213Z", - "sha1": "cbe4c2cb0a79c005b5ce58e20a384593ed038d36", + "sha1": "25d4fbc2b145a5dcb8ccd57e5ae68dff8196f317", "tests": [ { "id": "7a50a1b508e10f4-3be34281d1d9c2c", @@ -1450,6 +1449,76 @@ "column": 12 } }, + { + "id": "7d93383f52ae0de-bcecfef60fd5284", + "title": "Cross-compatibility - Network Direction Processor should set network direction with internal networks in both ingest pipeline and ES|QL", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts", + "line": 17, + "column": 12 + } + }, + { + "id": "7d93383f52ae0de-ff78a9d6f09012c", + "title": "Cross-compatibility - Network Direction Processor should set network direction with target field in both ingest pipeline and ES|QL", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts", + "line": 54, + "column": 12 + } + }, + { + "id": "7d93383f52ae0de-7147ed6095709b7", + "title": "Cross-compatibility - Network Direction Processor should set network direction with internal networks field in both ingest pipeline and ES|QL", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts", + "line": 91, + "column": 12 + } + }, + { + "id": "7d93383f52ae0de-c6be35e884ea86d", + "title": "Cross-compatibility - Network Direction Processor should set network direction with ignore_missing option in both ingest pipeline and ES|QL", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts", + "line": 138, + "column": 12 + } + }, + { + "id": "7d93383f52ae0de-be5142196ccdc2b", + "title": "Cross-compatibility - Network Direction Processor should set network direction with where condition in both ingest pipeline and ES|QL", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts", + "line": 181, + "column": 12 + } + }, { "id": "a4680a65c2cc2c2-fcfaa8c15cd7dfa", "title": "Cross-compatibility - Operator Coercion eq boolean (strict): true matches boolean true, not string \"true\"", @@ -3301,6 +3370,62 @@ "column": 12 } }, + { + "id": "7c898b905192c9f-02610830a165f86", + "title": "Streamlang to ES|QL - Network Direction Processor should set network direction with internal networks", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/esql/network_direction.spec.ts", + "line": 17, + "column": 12 + } + }, + { + "id": "7c898b905192c9f-81f943a72d77176", + "title": "Streamlang to ES|QL - Network Direction Processor should set network direction with internal networks field", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/esql/network_direction.spec.ts", + "line": 41, + "column": 12 + } + }, + { + "id": "7c898b905192c9f-468d64363578bd6", + "title": "Streamlang to ES|QL - Network Direction Processor should set network direction with ignore_missing option", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/esql/network_direction.spec.ts", + "line": 74, + "column": 12 + } + }, + { + "id": "7c898b905192c9f-abcbf8c851de067", + "title": "Streamlang to ES|QL - Network Direction Processor should set network direction with where condition", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/esql/network_direction.spec.ts", + "line": 106, + "column": 12 + } + }, { "id": "c8c1438f4c7dcb1-91255b70d88bd6b", "title": "Streamlang to ES|QL - Redact Processor should redact IP address with EVAL replace() and default delimiters", @@ -5140,6 +5265,76 @@ "column": 12 } }, + { + "id": "7c5b9235216285d-03fce1c0e8aa750", + "title": "Streamlang to Ingest Pipeline - Network Direction Processor should set network direction with internal networks", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts", + "line": 17, + "column": 12 + } + }, + { + "id": "7c5b9235216285d-bac285eed8582ab", + "title": "Streamlang to Ingest Pipeline - Network Direction Processor should set network direction with internal networks field", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts", + "line": 41, + "column": 12 + } + }, + { + "id": "7c5b9235216285d-edc8521cef04cd8", + "title": "Streamlang to Ingest Pipeline - Network Direction Processor should set network direction with target field", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts", + "line": 71, + "column": 12 + } + }, + { + "id": "7c5b9235216285d-4164505bf353dac", + "title": "Streamlang to Ingest Pipeline - Network Direction Processor should set network direction conditionally with where condition", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts", + "line": 96, + "column": 12 + } + }, + { + "id": "7c5b9235216285d-5046d31c92994d0", + "title": "Streamlang to Ingest Pipeline - Network Direction Processor should omit network direction if any fields are missing with ignore_missing option", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts", + "line": 135, + "column": 12 + } + }, { "id": "557c777f7e45c03-d8954386ccafca2", "title": "Streamlang to Ingest Pipeline - Redact Processor should redact IP address with default prefix/suffix", diff --git a/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts new file mode 100644 index 0000000000000..cc177c729f5af --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/network_direction.spec.ts @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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/scout/api'; +import type { NetworkDirectionProcessor, StreamlangDSL } from '@kbn/streamlang'; +import { transpileEsql, transpileIngestPipeline } from '@kbn/streamlang'; +import { streamlangApiTest as apiTest } from '../..'; + +apiTest.describe( + 'Cross-compatibility - Network Direction Processor', + { tag: ['@ess', '@svlOblt'] }, + () => { + apiTest( + 'should set network direction with internal networks in both ingest pipeline and ES|QL', + async ({ testBed, esql }) => { + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpileIngestPipeline(streamlangDSL); + const { query } = transpileEsql(streamlangDSL); + + const docs = [ + { + source_ip: '128.232.110.120', + destination_ip: '192.168.1.1', + }, + ]; + + await testBed.ingest('ingest-e2e-test-network-direction-basic', docs, processors); + const ingestResult = await testBed.getDocs('ingest-e2e-test-network-direction-basic'); + + await testBed.ingest('esql-e2e-test-network-direction-basic', docs); + const esqlResult = await esql.queryOnIndex('esql-e2e-test-network-direction-basic', query); + + expect(ingestResult).toHaveLength(1); + expect(ingestResult[0]?.network.direction).toBe('inbound'); + expect(esqlResult.documents).toHaveLength(1); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + } + ); + + apiTest( + 'should set network direction with target field in both ingest pipeline and ES|QL', + async ({ testBed, esql }) => { + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + target_field: 'test_network_direction', + internal_networks: ['private'], + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpileIngestPipeline(streamlangDSL); + const { query } = transpileEsql(streamlangDSL); + + const docs = [{ source_ip: '128.232.110.120', destination_ip: '192.168.1.1' }]; + await testBed.ingest('ingest-e2e-test-network-direction-target-field', docs, processors); + const ingestResult = await testBed.getDocs( + 'ingest-e2e-test-network-direction-target-field' + ); + + await testBed.ingest('esql-e2e-test-network-direction-target-field', docs); + const esqlResult = await esql.queryOnIndex( + 'esql-e2e-test-network-direction-target-field', + query + ); + + expect(ingestResult).toHaveLength(1); + expect(ingestResult[0]?.test_network_direction).toBe('inbound'); + expect(esqlResult.documents).toHaveLength(1); + expect(esqlResult.documents[0]?.test_network_direction).toBe('inbound'); + } + ); + + apiTest( + 'should set network direction with internal networks field in both ingest pipeline and ES|QL', + async ({ testBed, esql }) => { + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks_field: 'internal_networks', + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpileIngestPipeline(streamlangDSL); + const { query } = transpileEsql(streamlangDSL); + + const docs = [ + { + source_ip: '128.232.110.120', + destination_ip: '192.168.1.1', + internal_networks: ['private'], + }, + ]; + + await testBed.ingest( + 'ingest-e2e-test-network-direction-internal-networks-field', + docs, + processors + ); + const ingestResult = await testBed.getDocs( + 'ingest-e2e-test-network-direction-internal-networks-field' + ); + + await testBed.ingest('esql-e2e-test-network-direction-internal-networks-field', docs); + const esqlResult = await esql.queryOnIndex( + 'esql-e2e-test-network-direction-internal-networks-field', + query + ); + + expect(ingestResult).toHaveLength(1); + expect(ingestResult[0]?.network.direction).toBe('inbound'); + expect(esqlResult.documents).toHaveLength(1); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + } + ); + + apiTest( + 'should set network direction with ignore_missing option in both ingest pipeline and ES|QL', + async ({ testBed, esql }) => { + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + ignore_missing: true, + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpileIngestPipeline(streamlangDSL); + const { query } = transpileEsql(streamlangDSL); + + const docs = [ + { source_ip: '128.232.110.120', destination_ip: '192.168.1.1' }, + { destination_ip: '192.168.1.1' }, + ]; + + await testBed.ingest('ingest-e2e-test-network-direction-ignore-missing', docs, processors); + const ingestResult = await testBed.getDocs( + 'ingest-e2e-test-network-direction-ignore-missing' + ); + + await testBed.ingest('esql-e2e-test-network-direction-ignore-missing', docs); + const esqlResult = await esql.queryOnIndex( + 'esql-e2e-test-network-direction-ignore-missing', + query + ); + + expect(ingestResult).toHaveLength(2); + expect(ingestResult[0]?.network.direction).toBe('inbound'); + expect(ingestResult[1]?.network?.direction).toBeUndefined(); + expect(esqlResult.documents).toHaveLength(2); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + expect(esqlResult.documents[1]?.['network.direction']).toBeNull(); + } + ); + + apiTest( + 'should set network direction with where condition in both ingest pipeline and ES|QL', + async ({ testBed, esql }) => { + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + where: { + field: 'event.kind', + eq: 'test', + }, + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpileIngestPipeline(streamlangDSL); + const { query } = transpileEsql(streamlangDSL); + + const docs = [ + { source_ip: '128.232.110.120', destination_ip: '192.168.1.1', event: { kind: 'test' } }, + { + source_ip: '128.232.110.120', + destination_ip: '192.168.1.1', + event: { kind: 'production' }, + }, + ]; + + await testBed.ingest('ingest-e2e-test-network-direction-where', docs, processors); + const ingestResult = await testBed.getDocs('ingest-e2e-test-network-direction-where'); + + await testBed.ingest('esql-e2e-test-network-direction-where', docs); + const esqlResult = await esql.queryOnIndex('esql-e2e-test-network-direction-where', query); + + expect(ingestResult).toHaveLength(2); + expect(ingestResult[0]?.network.direction).toBe('inbound'); + expect(ingestResult[1]?.network?.direction).toBeUndefined(); + expect(esqlResult.documents).toHaveLength(2); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + expect(esqlResult.documents[1]?.['network.direction']).toBeNull(); + } + ); + } +); diff --git a/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/esql/network_direction.spec.ts b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/esql/network_direction.spec.ts new file mode 100644 index 0000000000000..90be1f2627853 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/esql/network_direction.spec.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expect } from '@kbn/scout/api'; +import type { NetworkDirectionProcessor, StreamlangDSL } from '@kbn/streamlang'; +import { transpileEsql as transpile } from '@kbn/streamlang'; +import { streamlangApiTest as apiTest } from '../..'; + +apiTest.describe( + 'Streamlang to ES|QL - Network Direction Processor', + { tag: ['@ess', '@svlOblt'] }, + () => { + apiTest('should set network direction with internal networks', async ({ testBed, esql }) => { + const indexName = 'stream-e2e-test-network-direction-basic'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + } as NetworkDirectionProcessor, + ], + }; + + const { query } = transpile(streamlangDSL); + + const docs = [{ source_ip: '128.232.110.120', destination_ip: '192.168.1.1' }]; + await testBed.ingest(indexName, docs); + const esqlResult = await esql.queryOnIndex(indexName, query); + + expect(esqlResult.documents).toHaveLength(1); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + }); + + apiTest( + 'should set network direction with internal networks field', + async ({ testBed, esql }) => { + const indexName = 'stream-e2e-test-network-direction-internal-networks-field'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks_field: 'test_network_direction_field', + } as NetworkDirectionProcessor, + ], + }; + + const { query } = transpile(streamlangDSL); + + const docs = [ + { + source_ip: '128.232.110.120', + destination_ip: '192.168.1.1', + test_network_direction_field: ['private'], + }, + ]; + await testBed.ingest(indexName, docs); + const esqlResult = await esql.queryOnIndex(indexName, query); + + expect(esqlResult.documents).toHaveLength(1); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + } + ); + + apiTest( + 'should set network direction with ignore_missing option', + async ({ testBed, esql }) => { + const indexName = 'stream-e2e-test-network-direction-ignore-missing'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + ignore_missing: true, + } as NetworkDirectionProcessor, + ], + }; + + const { query } = transpile(streamlangDSL); + + const docs = [ + { source_ip: '128.232.110.120', destination_ip: '192.168.1.1' }, + { destination_ip: '192.168.1.1' }, + ]; + await testBed.ingest(indexName, docs); + const esqlResult = await esql.queryOnIndex(indexName, query); + + expect(esqlResult.documents).toHaveLength(2); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + expect(esqlResult.documents[1]?.['network.direction']).toBeNull(); + } + ); + + apiTest('should set network direction with where condition', async ({ testBed, esql }) => { + const indexName = 'stream-e2e-test-network-direction-where'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + where: { + field: 'event.kind', + eq: 'test', + }, + } as NetworkDirectionProcessor, + ], + }; + + const { query } = transpile(streamlangDSL); + + const docs = [ + { source_ip: '128.232.110.120', destination_ip: '192.168.1.1', event: { kind: 'test' } }, + { + source_ip: '128.232.110.120', + destination_ip: '192.168.1.1', + event: { kind: 'production' }, + }, + ]; + await testBed.ingest(indexName, docs); + const esqlResult = await esql.queryOnIndex(indexName, query); + + expect(esqlResult.documents).toHaveLength(2); + expect(esqlResult.documents[0]?.['network.direction']).toBe('inbound'); + expect(esqlResult.documents[1]?.['network.direction']).toBeNull(); + }); + } +); diff --git a/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts new file mode 100644 index 0000000000000..1f84aea94ab32 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/ingest_pipeline/network_direction.spec.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expect } from '@kbn/scout/api'; +import type { NetworkDirectionProcessor, StreamlangDSL } from '@kbn/streamlang'; +import { transpile } from '@kbn/streamlang/src/transpilers/ingest_pipeline'; +import { streamlangApiTest as apiTest } from '../..'; + +apiTest.describe( + 'Streamlang to Ingest Pipeline - Network Direction Processor', + { tag: ['@ess', '@svlOblt'] }, + () => { + apiTest('should set network direction with internal networks', async ({ testBed }) => { + const indexName = 'streams-e2e-test-network-direction-basic'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpile(streamlangDSL); + + const docs = [{ source_ip: '128.232.110.120', destination_ip: '192.168.1.1' }]; + await testBed.ingest(indexName, docs, processors); + + const ingestedDocs = await testBed.getDocs(indexName); + expect(ingestedDocs).toHaveLength(1); + expect(ingestedDocs[0]?.network.direction).toBe('inbound'); + }); + + apiTest('should set network direction with internal networks field', async ({ testBed }) => { + const indexName = 'streams-e2e-test-network-direction-internal-networks-field'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks_field: 'internal_networks', + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpile(streamlangDSL); + + const docs = [ + { + source_ip: '128.232.110.120', + destination_ip: '192.168.1.1', + internal_networks: ['private'], + }, + ]; + await testBed.ingest(indexName, docs, processors); + + const ingestedDocs = await testBed.getDocs(indexName); + expect(ingestedDocs).toHaveLength(1); + expect(ingestedDocs[0]?.network.direction).toBe('inbound'); + }); + + apiTest('should set network direction with target field', async ({ testBed }) => { + const indexName = 'streams-e2e-test-network-direction-target-field'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + target_field: 'test_network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpile(streamlangDSL); + + const docs = [{ source_ip: '128.232.110.120', destination_ip: '192.168.1.1' }]; + await testBed.ingest(indexName, docs, processors); + + const ingestedDocs = await testBed.getDocs(indexName); + expect(ingestedDocs).toHaveLength(1); + expect(ingestedDocs[0]?.test_network_direction).toBe('inbound'); + }); + + apiTest( + 'should set network direction conditionally with where condition', + async ({ testBed }) => { + const indexName = 'streams-e2e-test-network-direction-conditional-where'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + where: { + field: 'event.kind', + eq: 'test', + }, + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpile(streamlangDSL); + + const docs = [ + { source_ip: '128.232.110.120', destination_ip: '192.168.1.1', event: { kind: 'test' } }, + { + source_ip: '128.232.110.120', + destination_ip: '192.168.1.1', + event: { kind: 'production' }, + }, + ]; + await testBed.ingest(indexName, docs, processors); + + const ingestedDocs = await testBed.getDocs(indexName); + expect(ingestedDocs).toHaveLength(2); + expect(ingestedDocs[0]?.network.direction).toBe('inbound'); + expect(ingestedDocs[1]?.network?.direction).toBeUndefined(); + } + ); + + apiTest( + 'should omit network direction if any fields are missing with ignore_missing option', + async ({ testBed }) => { + const indexName = 'streams-e2e-test-network-direction-ignore-missing'; + + const streamlangDSL: StreamlangDSL = { + steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + ignore_missing: true, + } as NetworkDirectionProcessor, + ], + }; + + const { processors } = transpile(streamlangDSL); + + const docs = [ + { source_ip: '128.232.110.120', destination_ip: '192.168.1.1' }, + { source_ip: '128.232.110.120' }, + ]; + await testBed.ingest(indexName, docs, processors); + + const ingestedDocs = await testBed.getDocs(indexName); + expect(ingestedDocs).toHaveLength(2); + expect(ingestedDocs[0]?.network.direction).toBe('inbound'); + expect(ingestedDocs[1]?.network?.direction).toBeUndefined(); + } + ); + } +); diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/actions/action_metadata.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/actions/action_metadata.ts index e2bb0cca2e0d5..d392f254ca0e4 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/actions/action_metadata.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/actions/action_metadata.ts @@ -696,6 +696,36 @@ export const ACTION_METADATA_MAP: Record = { ], }, + network_direction: { + name: i18n.translate('xpack.streamlang.actionMetadata.networkDirection.name', { + defaultMessage: 'Network Direction', + }), + description: i18n.translate('xpack.streamlang.actionMetadata.networkDirection.description', { + defaultMessage: + 'Calculates the network direction given a source IP address, destination IP address, and a list of internal networks.', + }), + usage: i18n.translate('xpack.streamlang.actionMetadata.networkDirection.usage', { + defaultMessage: + 'Provide a `source_ip` and `destination_ip` field to specify the source and destination IP addresses. Use `internal_networks` or `internal_networks_field` to specify the list of internal networks.', + }), + examples: [ + { + description: i18n.translate( + 'xpack.streamlang.actionMetadata.networkDirection.examples.simple', + { + defaultMessage: + 'Calculate the network direction from a source IP address to a destination IP address', + } + ), + yaml: `action: network_direction + source_ip: attributes.source_ip + destination_ip: attributes.destination_ip + internal_networks: + - private`, + }, + ], + }, + manual_ingest_pipeline: { name: i18n.translate('xpack.streamlang.actionMetadata.manualIngestPipeline.name', { defaultMessage: 'Manual Ingest Pipeline', diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/__snapshots__/transpiler.test.ts.snap b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/__snapshots__/transpiler.test.ts.snap index 7b97a577bb8f2..a5319e9700df1 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/__snapshots__/transpiler.test.ts.snap +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/__snapshots__/transpiler.test.ts.snap @@ -21,7 +21,9 @@ Object { `; exports[`transpile - Streamlang DSL to ES|QL) should transpile a variety of processor steps and where blocks 1`] = ` -" | EVAL my_joined_field = CONCAT(field1, \\", \\", field2, \\", \\", field3) +" | WHERE NOT(source_ip IS NULL) AND NOT(destination_ip IS NULL) + | EVAL \`network.direction\` = NETWORK_DIRECTION(TO_IP(source_ip), TO_IP(destination_ip), [\\"private\\"]) + | EVAL my_joined_field = CONCAT(field1, \\", \\", field2, \\", \\", field3) | EVAL full_name = CASE(first_name IS NULL OR last_name IS NULL, NULL, CONCAT(first_name, \\" \\", last_name)) | WHERE NOT(message IS NULL) | EVAL message_uppercase = TO_UPPER(message) diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/conversions.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/conversions.ts index a7a8ba3a0e405..fd4d63ebb629e 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/conversions.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/conversions.ts @@ -29,6 +29,7 @@ import type { TrimProcessor, JoinProcessor, ConcatProcessor, + NetworkDirectionProcessor, } from '../../../types/processors'; import { type StreamlangProcessorDefinition } from '../../../types/processors'; import { convertRenameProcessorToESQL } from './processors/rename'; @@ -47,6 +48,7 @@ import { convertMathProcessorToESQL } from './processors/math'; import { createTransformStringESQL } from './transform_string'; import { convertJoinProcessorToESQL } from './processors/join'; import { convertConcatProcessorToESQL } from './processors/concat'; +import { convertNetworkDirectionProcessorToESQL } from './processors/network_direction'; function convertProcessorToESQL(processor: StreamlangProcessorDefinition): ESQLAstCommand[] | null { switch (processor.action) { @@ -107,6 +109,9 @@ function convertProcessorToESQL(processor: StreamlangProcessorDefinition): ESQLA case 'concat': return convertConcatProcessorToESQL(processor as ConcatProcessor); + case 'network_direction': + return convertNetworkDirectionProcessorToESQL(processor as NetworkDirectionProcessor); + case 'manual_ingest_pipeline': return [ Builder.command({ diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/common.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/common.ts index c87f53a4a3a6d..41cc1276a3aed 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/common.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/common.ts @@ -78,24 +78,43 @@ export function combineOr(predicates: ESQLAstItem[]): ESQLAstItem | null { * - ES|QL can only simulate the missing field case with WHERE filtering * - Pattern mismatch failures cannot be (in a reasonable fashion) simulated in ES|QL (they nullify target fields instead) * - * @param sourceField - The source field name to check for NULL/missing values * @param ignoreMissing - If false, returns WHERE command to filter missing fields + * @param sourceFields - The source field names to check for NULL/missing values * @returns WHERE command if filtering needed, undefined otherwise + * + * @example + * // Single field + * buildIgnoreMissingFilter(false, 'message') + * // → WHERE NOT(`message` IS NULL) + * + * @example + * // Multiple fields (e.g., network_direction) + * buildIgnoreMissingFilter(false, 'source.ip', 'destination.ip') + * // → WHERE NOT(`source.ip` IS NULL) AND NOT(`destination.ip` IS NULL) */ export function buildIgnoreMissingFilter( - sourceField: string, - ignoreMissing: boolean + ignoreMissing: boolean, + ...sourceFields: string[] ): ESQLAstCommand | undefined { - if (ignoreMissing) { + if (ignoreMissing || sourceFields.length === 0) { return undefined; // No filtering needed when ignore_missing = true } - const fromColumn = Builder.expression.column(sourceField); + const notNullPredicates = sourceFields.map((field) => + Builder.expression.func.call('NOT', [ + Builder.expression.func.postfix('IS NULL', Builder.expression.column(field)), + ]) + ); + + const combinedPredicate = combineAnd(notNullPredicates); + + if (!combinedPredicate) { + return undefined; + } + return Builder.command({ name: 'where', - args: [ - Builder.expression.func.call('NOT', [Builder.expression.func.postfix('IS NULL', fromColumn)]), - ], + args: [combinedPredicate], }); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/convert.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/convert.ts index 6e1d9fbb09d83..aac5fbf36b69d 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/convert.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/convert.ts @@ -58,7 +58,7 @@ export function convertConvertProcessorToESQL(processor: ConvertProcessor): ESQL const convertAssignment = Builder.expression.func.call(typeConversionFunction, [fromColumn]); // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/dissect.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/dissect.ts index cb9e6e9b0be75..e5c500a141f02 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/dissect.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/dissect.ts @@ -69,7 +69,7 @@ export function convertDissectProcessorToESQL(processor: DissectProcessor): ESQL const commands: ESQLAstCommand[] = []; // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/grok.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/grok.ts index 95494abc82133..afdd617dfdc72 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/grok.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/grok.ts @@ -67,7 +67,7 @@ export function convertGrokProcessorToESQL(processor: GrokProcessor): ESQLAstCom const commands: ESQLAstCommand[] = []; // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/network_direction.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/network_direction.ts new file mode 100644 index 0000000000000..4a909001e73bd --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/network_direction.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Builder, type ESQLAstCommand, type ESQLAstItem } from '@kbn/esql-language'; +import type { NetworkDirectionProcessor } from '../../../../types/processors'; +import { buildIgnoreMissingFilter } from './common'; +import { conditionToESQLAst } from '../condition_to_esql'; + +const DEFAULT_TARGET_FIELD = 'network.direction'; + +/** + * Converts a Streamlang NetworkDirectionProcessor into a list of ES|QL AST commands. + * + * @param processor - The NetworkDirectionProcessor to convert + * @returns A list of ES|QL AST commands + * @example + * Input: + * ``` + * { + * source_ip: '128.232.110.120', + * destination_ip: '192.168.1.1', + * internal_networks: ['private'], + * } + * ``` + * Output: + * ``` + * | EVAL network.direction = NETWORK_DIRECTION('128.232.110.120', '192.168.1.1', ['private']) + */ +export const convertNetworkDirectionProcessorToESQL = ( + processor: NetworkDirectionProcessor +): ESQLAstCommand[] => { + const { + source_ip, + destination_ip, + target_field = DEFAULT_TARGET_FIELD, + ignore_missing = false, + } = processor; + + const commands: ESQLAstCommand[] = []; + + const networkDirectionFuncArgs: ESQLAstItem[] = []; + // Wrap IP fields with TO_IP() to ensure type compatibility + // ES|QL NETWORK_DIRECTION requires ip type + networkDirectionFuncArgs.push( + Builder.expression.func.call('TO_IP', [Builder.expression.column(source_ip)]) + ); + networkDirectionFuncArgs.push( + Builder.expression.func.call('TO_IP', [Builder.expression.column(destination_ip)]) + ); + const networksArg = + 'internal_networks' in processor + ? Builder.expression.list.literal({ + values: processor.internal_networks.map((network) => + Builder.expression.literal.string(network) + ), + }) + : Builder.expression.column(processor.internal_networks_field); + networkDirectionFuncArgs.push(networksArg); + + const toColumn = Builder.expression.column(target_field); + + const networkDirectionFunc = Builder.expression.func.call('NETWORK_DIRECTION', [ + ...networkDirectionFuncArgs, + ]); + + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, source_ip, destination_ip); + if (missingFieldFilter) { + commands.push(missingFieldFilter); + } + + if ('where' in processor && processor.where && !('always' in processor.where)) { + const conditionExpression = conditionToESQLAst(processor.where); + const caseExpression = Builder.expression.func.call('CASE', [ + conditionExpression, + networkDirectionFunc, + Builder.expression.literal.nil(), + ]); + commands.push( + Builder.command({ + name: 'eval', + args: [Builder.expression.func.binary('=', [toColumn, caseExpression])], + }) + ); + } else { + commands.push( + Builder.command({ + name: 'eval', + args: [Builder.expression.func.binary('=', [toColumn, networkDirectionFunc])], + }) + ); + } + + return commands; +}; diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/redact.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/redact.ts index 7118a12c5d720..9aef906392d45 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/redact.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/redact.ts @@ -106,7 +106,7 @@ export function convertRedactProcessorToESQL(processor: RedactProcessor): ESQLAs const commands: ESQLAstCommand[] = []; // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/remove.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/remove.ts index eeb177bc306ff..effe196fead96 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/remove.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/remove.ts @@ -91,7 +91,7 @@ export function convertRemoveProcessorToESQL(processor: RemoveProcessor): ESQLAs } else { // Unconditional removal: use DROP command // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/rename.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/rename.ts index b1067a37e60cb..c4aad8b6508e1 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/rename.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/rename.ts @@ -35,7 +35,7 @@ export function convertRenameProcessorToESQL(processor: RenameProcessor): ESQLAs const commands: ESQLAstCommand[] = []; // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/replace.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/replace.ts index 1abf4d97bc84e..4914cc8ec851f 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/replace.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/processors/replace.ts @@ -101,7 +101,7 @@ export function convertReplaceProcessorToESQL(processor: ReplaceProcessor): ESQL const commands: ESQLAstCommand[] = []; // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/transform_string.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/transform_string.ts index 3385d80379bee..280c1cfbf2ed8 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/transform_string.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/esql/transform_string.ts @@ -22,7 +22,7 @@ export const createTransformStringESQL = (esqlFunc: string) => { const commands: ESQLAstCommand[] = []; // Add missing field filter if needed (ignore_missing = false) - const missingFieldFilter = buildIgnoreMissingFilter(from, ignore_missing); + const missingFieldFilter = buildIgnoreMissingFilter(ignore_missing, from); if (missingFieldFilter) { commands.push(missingFieldFilter); } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/__snapshots__/transpiler.test.ts.snap b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/__snapshots__/transpiler.test.ts.snap index 47fa36fc0dffa..163739d045961 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/__snapshots__/transpiler.test.ts.snap +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/__snapshots__/transpiler.test.ts.snap @@ -222,6 +222,15 @@ Object { exports[`transpile (Streamlang DSL to ingest pipeline) should transpile a variety of processor steps and where blocks 1`] = ` Object { "processors": Array [ + Object { + "network_direction": Object { + "destination_ip": "destination_ip", + "internal_networks": Array [ + "private", + ], + "source_ip": "source_ip", + }, + }, Object { "script": Object { "if": undefined, diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/pre_processing.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/pre_processing.ts index d4c7c06804eef..0a7796dae723d 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/pre_processing.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/pre_processing.ts @@ -31,6 +31,7 @@ export const processorFieldRenames: Record> = { trim: { from: 'field', to: 'target_field', where: 'if' }, join: { to: 'field', where: 'if' }, concat: { to: 'field', where: 'if' }, + network_direction: { where: 'if' }, manual_ingest_pipeline: { where: 'if' }, }; diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/processor.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/processor.ts index 95044b0cda50f..6df63e7accad5 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/processor.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/ingest_pipeline/processors/processor.ts @@ -25,6 +25,7 @@ import type { IngestPipelineTrimProcessor, IngestPipelineJoinProcessor, IngestPipelineConcatProcessor, + IngestPipelineNetworkDirectionProcessor, } from '../../../../types/processors/ingest_pipeline_processors'; type WithOptionalTracingTag = T & { tag?: string }; @@ -48,5 +49,6 @@ export interface ActionToIngestType { replace: WithOptionalTracingTag; redact: WithOptionalTracingTag; concat: WithOptionalTracingTag; + network_direction: WithOptionalTracingTag; manual_ingest_pipeline: WithOptionalTracingTag; } diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/shared/mocks/test_dsls.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/shared/mocks/test_dsls.ts index 0cfed7cc9400e..f686b058f65bf 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/shared/mocks/test_dsls.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/transpilers/shared/mocks/test_dsls.ts @@ -22,11 +22,18 @@ import type { TrimProcessor, JoinProcessor, ConcatProcessor, + NetworkDirectionProcessor, } from '../../../../types/processors'; import type { StreamlangDSL } from '../../../../types/streamlang'; export const comprehensiveTestDSL: StreamlangDSL = { steps: [ + { + action: 'network_direction', + source_ip: 'source_ip', + destination_ip: 'destination_ip', + internal_networks: ['private'], + } as NetworkDirectionProcessor, { action: 'join', from: ['field1', 'field2', 'field3'], diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_field_names.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_field_names.ts index d47a6c603e2f0..4561ec7715390 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_field_names.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_field_names.ts @@ -90,6 +90,13 @@ export function extractAllFieldNames(processor: StreamlangProcessorDefinition): if (from.type === 'field') fields.push(from.value); }); break; + case 'network_direction': + fields.push(processor.source_ip, processor.destination_ip); + if (processor.target_field) fields.push(processor.target_field); + if ('internal_networks_field' in processor && processor.internal_networks_field) { + fields.push(processor.internal_networks_field); + } + break; case 'drop_document': case 'manual_ingest_pipeline': // No field names to validate diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_processor_values.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_processor_values.ts index 2ac4ec6cb8bf9..a86969d03e039 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_processor_values.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_processor_values.ts @@ -65,6 +65,7 @@ export function validateProcessorValues( case 'trim': case 'join': case 'concat': + case 'network_direction': case 'manual_ingest_pipeline': // No value validation implemented for these processors yet break; diff --git a/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_types.ts b/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_types.ts index eeda07139ee33..ff8896e0ae2af 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_types.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/src/validation/validate_types.ts @@ -126,6 +126,12 @@ export function extractModifiedFields(processor: StreamlangProcessorDefinition): } break; + case 'network_direction': + if (processor.target_field) { + fields.push(processor.target_field); + } + break; + case 'remove': case 'remove_by_prefix': case 'drop_document': @@ -244,6 +250,9 @@ export function getProcessorOutputType( case 'join': return 'string'; + case 'network_direction': + return 'string'; + case 'remove': case 'remove_by_prefix': case 'drop_document': @@ -329,6 +338,7 @@ export function getExpectedInputType( case 'remove': case 'remove_by_prefix': case 'drop_document': + case 'network_direction': case 'manual_ingest_pipeline': return null; default: { @@ -390,6 +400,12 @@ export function trackFieldTypesAndValidate(flattenedSteps: StreamlangProcessorDe ...step.from.filter((from) => from.type === 'field').map((from) => from.value) ); break; + case 'network_direction': + fieldsUsed.push(step.source_ip, step.destination_ip); + if ('internal_networks_field' in step && step.internal_networks_field) { + fieldsUsed.push(step.internal_networks_field); + } + break; case 'append': case 'drop_document': case 'manual_ingest_pipeline': diff --git a/x-pack/platform/packages/shared/kbn-streamlang/types/processors/index.ts b/x-pack/platform/packages/shared/kbn-streamlang/types/processors/index.ts index 869e3e5dfdf6d..4173c5640a48e 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/types/processors/index.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/types/processors/index.ts @@ -557,6 +557,53 @@ export const concatProcessorSchema = processorBaseWithWhereSchema.extend({ ignore_missing: z.optional(z.boolean()), }) satisfies z.Schema; +/** + * Network direction processor + */ + +export interface NetworkDirectionWithInternalNetworks { + internal_networks: string[]; +} + +const networkDirectionWithInternalNetworksSchema = z.object({ + internal_networks: z.array(z.string()), +}) satisfies z.Schema; + +export interface NetworkDirectionWithInternalNetworksField { + internal_networks_field: string; +} + +const networkDirectionWithInternalNetworksFieldSchema = z.object({ + internal_networks_field: StreamlangSourceField, +}) satisfies z.Schema; + +export interface NetworkDirectionCommonFields extends ProcessorBaseWithWhere { + action: 'network_direction'; + source_ip: string; + destination_ip: string; + target_field?: string; + ignore_missing?: boolean; +} + +const networkDirectionCommonFieldsSchema = processorBaseWithWhereSchema.extend({ + action: z.literal('network_direction'), + source_ip: StreamlangSourceField, + destination_ip: StreamlangSourceField, + target_field: z.optional(StreamlangTargetField), + ignore_missing: z.optional(z.boolean()), +}) satisfies z.Schema; + +export type NetworkDirectionProcessor = NetworkDirectionCommonFields & + (NetworkDirectionWithInternalNetworks | NetworkDirectionWithInternalNetworksField); + +export const networkDirectionProcessorSchema = z.intersection( + networkDirectionCommonFieldsSchema, + z.union([ + networkDirectionWithInternalNetworksSchema, + networkDirectionWithInternalNetworksFieldSchema, + ]) +) satisfies z.Schema; + export type StreamlangProcessorDefinition = | DateProcessor | DissectProcessor @@ -576,6 +623,7 @@ export type StreamlangProcessorDefinition = | TrimProcessor | JoinProcessor | ConcatProcessor + | NetworkDirectionProcessor | ManualIngestPipelineProcessor; export const streamlangProcessorSchema = z.union([ @@ -597,6 +645,7 @@ export const streamlangProcessorSchema = z.union([ joinProcessorSchema, convertProcessorSchema, concatProcessorSchema, + networkDirectionProcessorSchema, manualIngestPipelineProcessorSchema, ]); @@ -654,6 +703,11 @@ export const processorTypes: ProcessorType[] = ( // Handle ZodEffects (from .refine()) by unwrapping to get the base schema let baseSchema = '_def' in schema && 'schema' in schema._def ? schema._def.schema : schema; + // Handle ZodIntersection (from z.intersection()) by getting the left side which contains the action + if ('_def' in baseSchema && 'left' in baseSchema._def) { + baseSchema = baseSchema._def.left; + } + // Handle ZodUnion (from z.union()) by getting the first option's action // All options in the union should have the same action value if ('_def' in baseSchema && 'options' in baseSchema._def) { diff --git a/x-pack/platform/packages/shared/kbn-streamlang/types/processors/ingest_pipeline_processors.ts b/x-pack/platform/packages/shared/kbn-streamlang/types/processors/ingest_pipeline_processors.ts index c941fd9767aa0..bfab2a3c3f837 100644 --- a/x-pack/platform/packages/shared/kbn-streamlang/types/processors/ingest_pipeline_processors.ts +++ b/x-pack/platform/packages/shared/kbn-streamlang/types/processors/ingest_pipeline_processors.ts @@ -26,6 +26,7 @@ import type { LowercaseProcessor, JoinProcessor, ConcatProcessor, + NetworkDirectionProcessor, } from '.'; import type { Condition } from '../conditions'; @@ -141,6 +142,12 @@ export type IngestPipelineConcatProcessor = RenameFieldsAndRemoveAction< { to: 'field'; where: 'if' } >; +// Network Direction +export type IngestPipelineNetworkDirectionProcessor = RenameFieldsAndRemoveAction< + NetworkDirectionProcessor, + { where: 'if' } +>; + // Manual Ingest Pipeline (escape hatch) export type IngestPipelineManualIngestPipelineProcessor = RenameFieldsAndRemoveAction< ManualIngestPipelineProcessor, @@ -166,4 +173,5 @@ export type IngestPipelineProcessor = | IngestPipelineTrimProcessor | IngestPipelineJoinProcessor | IngestPipelineConcatProcessor + | IngestPipelineNetworkDirectionProcessor | IngestPipelineManualIngestPipelineProcessor; diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/index.ts b/x-pack/platform/packages/shared/kbn-streams-schema/index.ts index 536208d5f1b9e..3e393142dfae1 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/index.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/index.ts @@ -39,7 +39,9 @@ export { export { keepFields, namespacePrefixes, + otelReservedFields, isNamespacedEcsField, + isOtelReservedField, getRegularEcsField, } from './src/helpers/namespaced_ecs'; export { getAdvancedParameters } from './src/helpers/get_advanced_parameters'; @@ -81,10 +83,12 @@ export { export { type StreamQuery, - type StreamQueryKql, + type StreamQueryInput, + type QueriesGetResponse, + type QueriesOccurrencesGetResponse, upsertStreamQueryRequestSchema, - streamQueryKqlSchema, streamQuerySchema, + streamQueryInputSchema, } from './src/queries'; export { @@ -148,6 +152,11 @@ export type { } from './src/api/significant_events'; export { emptyAssets } from './src/helpers/empty_assets'; +export { + validateStreamName, + MAX_STREAM_NAME_LENGTH, + INVALID_STREAM_NAME_CHARACTERS, +} from './src/helpers/stream_name_validation'; export { type Feature, diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/api/significant_events/index.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/api/significant_events/index.ts index 16ac8f4f7e4ec..ff5fd8b117f11 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/api/significant_events/index.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/api/significant_events/index.ts @@ -9,7 +9,7 @@ import type { Observable } from 'rxjs'; import type { ServerSentEventBase } from '@kbn/sse-utils'; import type { Condition } from '@kbn/streamlang'; import type { ChatCompletionTokenCount } from '@kbn/inference-common'; -import type { StreamQueryKql } from '../../queries'; +import type { StreamQuery } from '../../queries'; import type { TaskStatus } from '../../tasks/types'; /** @@ -36,7 +36,7 @@ interface SignificantEventOccurrence { count: number; } -type SignificantEventsResponse = StreamQueryKql & { +type SignificantEventsResponse = StreamQuery & { stream_name: string; occurrences: SignificantEventOccurrence[]; change_points: { @@ -63,6 +63,9 @@ interface GeneratedSignificantEventQuery { filter: Condition; type: 'system'; }; + esql: { + query: string; + }; severity_score: number; evidence?: string[]; } diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/empty_assets.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/empty_assets.ts index f37abc33e5173..274b776a2864d 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/empty_assets.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/empty_assets.ts @@ -5,10 +5,10 @@ * 2.0. */ -import type { StreamQueryKql } from '../queries'; +import type { StreamQuery } from '../queries'; export const emptyAssets = { dashboards: [] as string[], rules: [] as string[], - queries: [] as StreamQueryKql[], + queries: [] as StreamQuery[], } as const; diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/namespaced_ecs.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/namespaced_ecs.ts index 45745c745eec5..2118aaba68a63 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/namespaced_ecs.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/namespaced_ecs.ts @@ -11,6 +11,23 @@ import { KEEP_FIELDS, NAMESPACE_PREFIXES } from '@kbn/streamlang'; export const keepFields: readonly string[] = KEEP_FIELDS; export const namespacePrefixes: readonly string[] = NAMESPACE_PREFIXES; +/** + * Field names that are reserved for OTel compatibility mode. + * These are either passthrough objects or alias fields that cannot be used as custom field names. + * IMPORTANT: This list must match the keys of baseMappings in logs_layer.ts. + * A test in logs_layer.test.ts ensures these stay in sync. + */ +export const otelReservedFields = [ + 'body', + 'attributes', + 'scope', + 'resource', + 'span.id', + 'message', + 'trace.id', + 'log.level', +] as const; + export const aliases: Record = { trace_id: 'trace.id', span_id: 'span.id', @@ -38,3 +55,11 @@ export function isNamespacedEcsField(field: string): boolean { KEEP_FIELDS.includes(field as any) ); } + +/** + * Checks if a field name is reserved for OTel compatibility mode. + * Reserved fields are either passthrough objects or alias fields that cannot be redefined. + */ +export function isOtelReservedField(field: string): boolean { + return otelReservedFields.includes(field as (typeof otelReservedFields)[number]); +} diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.test.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.test.ts index e6cb9c0504f25..2dc064448ab5c 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.test.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.test.ts @@ -7,19 +7,16 @@ import type { Condition } from '@kbn/streamlang'; import { buildEsqlQuery } from './query'; -import type { StreamQuery } from '../queries'; describe('buildEsqlQuery', () => { - const createTestQuery = ( + const createTestInput = ( kqlQuery: string, featureFilter: Condition = { field: 'some.field', eq: 'some value' } - ): StreamQuery => ({ - id: 'irrelevant', - title: 'irrelevant', + ) => ({ feature: { name: 'irrelevant', filter: featureFilter, - type: 'system', + type: 'system' as const, }, kql: { query: kqlQuery, @@ -29,7 +26,7 @@ describe('buildEsqlQuery', () => { describe('basic functionality', () => { it('should build a valid ESQL query with multiple indices', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('message: "error" or message: "failed"'); + const query = createTestInput('message: "error" or message: "failed"'); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( @@ -46,7 +43,7 @@ describe('buildEsqlQuery', () => { lte: '2025-12-31T23:59:59.999Z', }, }; - const query = createTestQuery('level: "INFO"', rangeFilter); + const query = createTestInput('level: "INFO"', rangeFilter); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( @@ -58,7 +55,7 @@ describe('buildEsqlQuery', () => { describe('includeMetadata parameter', () => { it('should build query without metadata when includeMetadata is false', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('status: "success"'); + const query = createTestInput('status: "success"'); const esqlQuery = buildEsqlQuery(indices, query, false); expect(esqlQuery).toBe( @@ -68,7 +65,7 @@ describe('buildEsqlQuery', () => { it('should build query with metadata when includeMetadata is true', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('host.name: "server-01"'); + const query = createTestInput('host.name: "server-01"'); const esqlQuery = buildEsqlQuery(indices, query, true); expect(esqlQuery).toBe( @@ -79,21 +76,19 @@ describe('buildEsqlQuery', () => { it('should build query without feature filter', () => { const indices = ['logs.child', 'logs.child.*']; - const query: StreamQuery = { - id: 'irrelevant', - title: 'irrelevant', + const input = { kql: { query: 'event.type: "access"', }, }; - const esqlQuery = buildEsqlQuery(indices, query); + const esqlQuery = buildEsqlQuery(indices, input); expect(esqlQuery).toBe('FROM logs.child,logs.child.* | WHERE KQL("event.type: \\"access\\"")'); }); it('should build query with simple feature filter', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('event.type: "access"', { + const query = createTestInput('event.type: "access"', { field: 'some.field', eq: 'some value', }); @@ -106,7 +101,7 @@ describe('buildEsqlQuery', () => { it('should build query with `or` feature filter', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('event.type: "access"', { + const query = createTestInput('event.type: "access"', { or: [ { field: 'some.field', eq: 'some value' }, { field: 'some.other.field', eq: 'some other value' }, @@ -122,7 +117,7 @@ describe('buildEsqlQuery', () => { describe('KQL query variations', () => { it('should handle simple field queries', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('message: "hello world"'); + const query = createTestInput('message: "hello world"'); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( @@ -132,7 +127,7 @@ describe('buildEsqlQuery', () => { it('should handle complex KQL queries with boolean operators', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('(level: "ERROR" or level: "WARN") and service.name: "api"'); + const query = createTestInput('(level: "ERROR" or level: "WARN") and service.name: "api"'); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( @@ -142,7 +137,7 @@ describe('buildEsqlQuery', () => { it('should handle KQL queries with wildcards', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('message: *error* and host.name: web-*'); + const query = createTestInput('message: *error* and host.name: web-*'); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( @@ -152,7 +147,7 @@ describe('buildEsqlQuery', () => { it('should handle KQL queries with special characters', () => { const indices = ['logs.child', 'logs.child.*']; - const query = createTestQuery('url.path: "/api/v1/users" and response.status: 404'); + const query = createTestInput('url.path: "/api/v1/users" and response.status: 404'); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( @@ -164,7 +159,7 @@ describe('buildEsqlQuery', () => { describe('KQL query escaping (security)', () => { it('should properly escape double quotes in KQL queries', () => { const indices = ['logs.child']; - const query = createTestQuery('message: "test "quoted" sentence"'); + const query = createTestInput('message: "test "quoted" sentence"'); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( @@ -174,7 +169,7 @@ describe('buildEsqlQuery', () => { it('should properly escape backslashes in KQL queries', () => { const indices = ['logs.child']; - const query = createTestQuery('file.path: "C:\\Program Files\\App"'); + const query = createTestInput('file.path: "C:\\Program Files\\App"'); const esqlQuery = buildEsqlQuery(indices, query); expect(esqlQuery).toBe( diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.ts index 694f424944a2f..1b87e3fd92733 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/query.ts @@ -7,11 +7,11 @@ import { conditionToESQLAst } from '@kbn/streamlang'; import { BasicPrettyPrinter, Builder } from '@kbn/esql-language'; -import type { StreamQuery } from '../queries'; +import type { StreamQueryInput } from '../queries'; export const buildEsqlQuery = ( indices: string[], - query: StreamQuery, + input: Pick, includeMetadata: boolean = false ): string => { const fromCommand = Builder.command({ @@ -37,11 +37,11 @@ export const buildEsqlQuery = ( }); const kqlQuery = Builder.expression.func.call('KQL', [ - Builder.expression.literal.string(query.kql.query), + Builder.expression.literal.string(input.kql.query), ]); - const whereCondition = query.feature?.filter - ? Builder.expression.func.binary('and', [kqlQuery, conditionToESQLAst(query.feature.filter)]) + const whereCondition = input.feature?.filter + ? Builder.expression.func.binary('and', [kqlQuery, conditionToESQLAst(input.feature.filter)]) : kqlQuery; const whereCommand = Builder.command({ diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/stream_name_validation.test.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/stream_name_validation.test.ts new file mode 100644 index 0000000000000..32e7c1da6022c --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/stream_name_validation.test.ts @@ -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 { + validateStreamName, + MAX_STREAM_NAME_LENGTH, + INVALID_STREAM_NAME_CHARACTERS, +} from './stream_name_validation'; + +describe('validateStreamName', () => { + it('returns valid for a valid stream name', () => { + expect(validateStreamName('logs')).toEqual({ valid: true }); + expect(validateStreamName('logs.nginx')).toEqual({ valid: true }); + expect(validateStreamName('my-stream-name')).toEqual({ valid: true }); + expect(validateStreamName('logs_2024')).toEqual({ valid: true }); + }); + + it('returns invalid for empty name', () => { + expect(validateStreamName('')).toEqual({ + valid: false, + message: 'Stream name must not be empty.', + }); + }); + + it('returns invalid for name exceeding max length', () => { + const longName = 'a'.repeat(MAX_STREAM_NAME_LENGTH + 1); + expect(validateStreamName(longName)).toEqual({ + valid: false, + message: `Stream name cannot be longer than ${MAX_STREAM_NAME_LENGTH} characters.`, + }); + }); + + it('returns valid for name at max length', () => { + const maxLengthName = 'a'.repeat(MAX_STREAM_NAME_LENGTH); + expect(validateStreamName(maxLengthName)).toEqual({ valid: true }); + }); + + it('returns invalid for uppercase characters', () => { + expect(validateStreamName('Logs')).toEqual({ + valid: false, + message: 'Stream name cannot contain uppercase characters.', + }); + expect(validateStreamName('LOGS')).toEqual({ + valid: false, + message: 'Stream name cannot contain uppercase characters.', + }); + expect(validateStreamName('loGs')).toEqual({ + valid: false, + message: 'Stream name cannot contain uppercase characters.', + }); + }); + + it('returns invalid for space character', () => { + expect(validateStreamName('my stream')).toEqual({ + valid: false, + message: 'Stream name cannot contain spaces.', + }); + }); + + describe('invalid characters', () => { + const testCases = [ + { char: '"', display: '"\\""' }, + { char: '\\', display: '"\\\\"' }, + { char: '*', display: '"*"' }, + { char: ',', display: '","' }, + { char: '/', display: '"/"' }, + { char: '<', display: '"<"' }, + { char: '>', display: '">"' }, + { char: '?', display: '"?"' }, + { char: '|', display: '"|"' }, + ]; + + testCases.forEach(({ char, display }) => { + it(`returns invalid for "${char}" character`, () => { + const result = validateStreamName(`logs${char}nginx`); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toContain('Stream name cannot contain'); + } + }); + }); + }); + + it('exports the correct invalid characters list', () => { + expect(INVALID_STREAM_NAME_CHARACTERS).toEqual([ + ' ', + '"', + '\\', + '*', + ',', + '/', + '<', + '>', + '?', + '|', + ]); + }); + + it('exports the correct max length', () => { + expect(MAX_STREAM_NAME_LENGTH).toBe(200); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/stream_name_validation.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/stream_name_validation.ts new file mode 100644 index 0000000000000..b19f8ce3a17e0 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/stream_name_validation.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const MAX_STREAM_NAME_LENGTH = 200; + +/** + * Characters that are not allowed in stream names. + * These are the characters that Elasticsearch does not allow in index template/data stream names. + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html#indices-create-api-path-params + */ +export const INVALID_STREAM_NAME_CHARACTERS = [' ', '"', '\\', '*', ',', '/', '<', '>', '?', '|']; + +/** + * Validates a stream name against Elasticsearch naming requirements. + * Returns an object indicating validity and an error message if invalid. + */ +export const validateStreamName = ( + name: string +): { valid: true } | { valid: false; message: string } => { + if (!name || name.length === 0) { + return { valid: false, message: 'Stream name must not be empty.' }; + } + + if (name.length > MAX_STREAM_NAME_LENGTH) { + return { + valid: false, + message: `Stream name cannot be longer than ${MAX_STREAM_NAME_LENGTH} characters.`, + }; + } + + if (name !== name.toLowerCase()) { + return { valid: false, message: 'Stream name cannot contain uppercase characters.' }; + } + + for (const char of INVALID_STREAM_NAME_CHARACTERS) { + if (name.includes(char)) { + const charDisplay = char === ' ' ? 'spaces' : `"${char}"`; + return { valid: false, message: `Stream name cannot contain ${charDisplay}.` }; + } + } + + return { valid: true }; +}; diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/queries/index.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/queries/index.ts index 08708399e8924..9ca7ce8109219 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/queries/index.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/queries/index.ts @@ -11,35 +11,48 @@ import { NonEmptyString } from '@kbn/zod-helpers'; import type { Condition } from '@kbn/streamlang'; import { conditionSchema } from '@kbn/streamlang'; import { primitive } from '../shared/record_types'; -import { createIsNarrowSchema } from '../shared/type_guards'; +import type { SignificantEventsResponse } from '../api/significant_events'; interface StreamQueryBase { id: string; title: string; } -export interface StreamQueryKql extends StreamQueryBase { +export interface StreamQuery extends StreamQueryBase { + /** + * @deprecated Use esql.query instead. Will be removed in a future version. + */ feature?: { name: string; filter: Condition; type: 'system'; }; + /** + * @deprecated Use esql.query instead. Will be removed in a future version. + */ kql: { query: string; }; + /** + * Full ES|QL query built from the stream indices, KQL query, and feature filter. + * Example: FROM stream,stream.* | WHERE KQL("message: error") + */ + esql: { + query: string; + }; // from 0 to 100. aligned with anomaly detection scoring severity_score?: number; evidence?: string[]; } -export type StreamQuery = StreamQueryKql; - const streamQueryBaseSchema: z.Schema = z.object({ id: NonEmptyString, title: NonEmptyString, }); -export const streamQueryKqlSchema: z.Schema = z.intersection( +export type StreamQueryInput = Omit; + +export const streamQueryInputSchema: z.Schema = z.intersection( streamQueryBaseSchema, z.object({ feature: z @@ -57,12 +70,19 @@ export const streamQueryKqlSchema: z.Schema = z.intersection( }) ); +export const streamQuerySchema: z.Schema = z.intersection( + streamQueryInputSchema, + z.object({ + esql: z.object({ + query: z.string().describe('Full ES|QL query.'), + }), + }) +); + export const querySchema: z.ZodType = z.lazy(() => z.record(z.union([primitive, z.array(z.union([primitive, querySchema])), querySchema])) ); -export const streamQuerySchema: z.Schema = streamQueryKqlSchema; - export const upsertStreamQueryRequestSchema = z.object({ title: NonEmptyString, feature: z @@ -79,4 +99,14 @@ export const upsertStreamQueryRequestSchema = z.object({ evidence: z.array(z.string()).optional(), }); -export const isStreamQueryKql = createIsNarrowSchema(streamQuerySchema, streamQueryKqlSchema); +export interface QueriesGetResponse { + queries: SignificantEventsResponse[]; + page: number; + perPage: number; + total: number; +} + +export interface QueriesOccurrencesGetResponse { + occurrences_histogram: Array<{ x: string; y: number }>; + total_occurrences: number; +} diff --git a/x-pack/platform/packages/shared/ml/trained_models_utils/src/constants/trained_models.ts b/x-pack/platform/packages/shared/ml/trained_models_utils/src/constants/trained_models.ts index 94dbb7a345186..31beb2c01c6e8 100644 --- a/x-pack/platform/packages/shared/ml/trained_models_utils/src/constants/trained_models.ts +++ b/x-pack/platform/packages/shared/ml/trained_models_utils/src/constants/trained_models.ts @@ -327,6 +327,12 @@ export type InferenceServiceSettings = provider: string; model: string; }; + } + | { + service: 'elastic'; + service_settings: { + model_id: string; + }; }; export type InferenceAPIConfigResponse = InferenceInferenceEndpointInfo & InferenceServiceSettings; diff --git a/x-pack/platform/packages/shared/response-ops/scheduling-types/index.ts b/x-pack/platform/packages/shared/response-ops/scheduling-types/index.ts new file mode 100644 index 0000000000000..05b53896e99d6 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/scheduling-types/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + IntervalSchedule, + RruleSchedule, + Schedule, + Rrule, + RruleCommon, + RruleMonthly, + RruleWeekly, + RruleDaily, + RruleHourly, +} from './schedule_types'; diff --git a/x-pack/platform/packages/shared/response-ops/scheduling-types/kibana.jsonc b/x-pack/platform/packages/shared/response-ops/scheduling-types/kibana.jsonc new file mode 100644 index 0000000000000..ab183a73c79ab --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/scheduling-types/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/response-ops-scheduling-types", + "owner": ["@elastic/response-ops"], + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/response-ops/scheduling-types/moon.yml b/x-pack/platform/packages/shared/response-ops/scheduling-types/moon.yml new file mode 100644 index 0000000000000..3f921aed3c0be --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/scheduling-types/moon.yml @@ -0,0 +1,32 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/response-ops-scheduling-types' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/response-ops-scheduling-types' +type: unknown +owners: + defaultOwner: '@elastic/response-ops' +toolchain: + default: node +language: typescript +project: + name: '@kbn/response-ops-scheduling-types' + description: Moon project for @kbn/response-ops-scheduling-types + channel: '' + owner: '@elastic/response-ops' + metadata: + sourceRoot: x-pack/platform/packages/shared/response-ops/scheduling-types +dependsOn: + - '@kbn/rrule' +tags: + - shared-common + - package + - prod + - group-platform + - shared +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' +tasks: {} diff --git a/x-pack/platform/packages/shared/response-ops/scheduling-types/package.json b/x-pack/platform/packages/shared/response-ops/scheduling-types/package.json new file mode 100644 index 0000000000000..6418f78efab6e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/scheduling-types/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/response-ops-scheduling-types", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} \ No newline at end of file diff --git a/x-pack/platform/packages/shared/response-ops/scheduling-types/schedule_types.ts b/x-pack/platform/packages/shared/response-ops/scheduling-types/schedule_types.ts new file mode 100644 index 0000000000000..f4dd62392bfd9 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/scheduling-types/schedule_types.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Frequency } from '@kbn/rrule'; + +/** + * Interval-based schedule. Use this or RruleSchedule, never both. + */ +export interface IntervalSchedule { + /** + * An interval string (e.g. '5m', '30s'). If specified, this is a recurring schedule. + */ + interval: string; + rrule?: never; +} + +/** + * RRule-based schedule. Use this or IntervalSchedule, never both. + */ +export interface RruleSchedule { + rrule: Rrule; + interval?: never; +} + +/** Schedule is either interval-based or rrule-based, never both, never neither. */ +export type Schedule = IntervalSchedule | RruleSchedule; + +export type Rrule = RruleMonthly | RruleWeekly | RruleDaily | RruleHourly; + +export interface RruleCommon { + dtstart?: string; + freq: Frequency; + interval: number; + tzid: string; +} + +export interface RruleMonthly extends RruleCommon { + freq: Frequency.MONTHLY; + bymonthday?: number[]; + byhour?: number[]; + byminute?: number[]; + byweekday?: string[]; +} + +export interface RruleWeekly extends RruleCommon { + freq: Frequency.WEEKLY; + byweekday?: string[]; + byhour?: number[]; + byminute?: number[]; + bymonthday?: never; +} + +export interface RruleDaily extends RruleCommon { + freq: Frequency.DAILY; + byhour?: number[]; + byminute?: number[]; + byweekday?: string[]; + bymonthday?: never; +} + +export interface RruleHourly extends RruleCommon { + freq: Frequency.HOURLY; + byhour?: never; + byminute?: number[]; + byweekday?: never; + bymonthday?: never; +} diff --git a/x-pack/platform/packages/shared/response-ops/scheduling-types/tsconfig.json b/x-pack/platform/packages/shared/response-ops/scheduling-types/tsconfig.json new file mode 100644 index 0000000000000..cd316b76ed4ea --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/scheduling-types/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": ["jest", "node"] + }, + "include": ["**/*.ts"], + "exclude": ["target/**/*"], + "kbn_references": ["@kbn/rrule"] +} diff --git a/x-pack/platform/packages/shared/security/plugin_types_server/index.ts b/x-pack/platform/packages/shared/security/plugin_types_server/index.ts index 699e48c328cad..dfb4e3d53ba58 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_server/index.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_server/index.ts @@ -23,6 +23,11 @@ export type { UpdateRestAPIKeyWithKibanaPrivilegesParams, GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams, + ConvertUiamAPIKeyParams, + ConvertUiamAPIKeyResult, + ConvertUiamAPIKeyResultSuccess, + ConvertUiamAPIKeyResultFailed, + ConvertUiamAPIKeysResponse, UiamAPIKeysType, ClientAuthentication, } from './src/authentication'; diff --git a/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts b/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts index 4da9dd4a9c37a..4245a01347416 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts @@ -17,6 +17,11 @@ export type { UpdateRestAPIKeyWithKibanaPrivilegesParams, GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams, + ConvertUiamAPIKeyParams, + ConvertUiamAPIKeyResult, + ConvertUiamAPIKeyResultSuccess, + ConvertUiamAPIKeyResultFailed, + ConvertUiamAPIKeysResponse, UiamAPIKeysType, } from '@kbn/core-security-server'; diff --git a/x-pack/platform/plugins/private/banners/test/scout_banners/.meta/ui/standard.json b/x-pack/platform/plugins/private/banners/test/scout_banners/.meta/ui/standard.json new file mode 100644 index 0000000000000..f5811d90c27c4 --- /dev/null +++ b/x-pack/platform/plugins/private/banners/test/scout_banners/.meta/ui/standard.json @@ -0,0 +1,61 @@ +{ + "sha1": "657b31e1084b78e57340c5a825aaa793b6709f88", + "tests": [ + { + "id": "eea7c4462a87227-22c66376801ebaf", + "title": "global pages displays the global banner on the login page", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/banners/test/scout_banners/ui/tests/global.spec.ts", + "line": 13, + "column": 7 + } + }, + { + "id": "6a38dc61304ae22-4baa903d0b1913a", + "title": "per-spaces banners displays the space-specific banner within the space", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/banners/test/scout_banners/ui/tests/spaces.spec.ts", + "line": 36, + "column": 7 + } + }, + { + "id": "6a38dc61304ae22-6f47a785e146b6a", + "title": "per-spaces banners displays the global banner within another space", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/banners/test/scout_banners/ui/tests/spaces.spec.ts", + "line": 47, + "column": 7 + } + }, + { + "id": "6a38dc61304ae22-e99540c846b16da", + "title": "per-spaces banners displays the global banner on the login page", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/banners/test/scout_banners/ui/tests/spaces.spec.ts", + "line": 54, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/private/custom_branding/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/private/custom_branding/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..3f4ee8f3ab2cc --- /dev/null +++ b/x-pack/platform/plugins/private/custom_branding/test/scout/.meta/ui/standard.json @@ -0,0 +1,44 @@ +{ + "sha1": "f71d52c05492d34f4075b37befb6e0a81cc4d7db", + "tests": [ + { + "id": "5cd4dda9894a586-bbbff268a455d11", + "title": "custom branding should allow setting custom page title through advanced settings", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/custom_branding/test/scout/ui/tests/settings.spec.ts", + "line": 17, + "column": 7 + } + }, + { + "id": "5cd4dda9894a586-13bdccfd34a5fae", + "title": "custom branding should allow setting custom logo through advanced settings", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/custom_branding/test/scout/ui/tests/settings.spec.ts", + "line": 34, + "column": 7 + } + }, + { + "id": "5cd4dda9894a586-ee55e6ee4c6efaa", + "title": "custom branding should allow setting custom logo text through advanced settings", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/custom_branding/test/scout/ui/tests/settings.spec.ts", + "line": 51, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/parallel.json b/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/parallel.json index 61f03519126f8..3e659aa3a13d1 100644 --- a/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/parallel.json +++ b/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/parallel.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-06T14:35:07.931Z", - "sha1": "488aced1a94e02074aec4073cc1fcdf93552fc4e", + "sha1": "4f0c5c041e4229813210d3dea972cbae39b8f585", "tests": [ { "id": "448e82090e00e69-f2cd2d3cdc77643", @@ -33,6 +32,138 @@ "column": 12 } }, + { + "id": "96dc43247652ad2-5dffb57f64bb9de", + "title": "Metrics in Discover - Grid should render metrics grid with cards", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts", + "line": 37, + "column": 14 + } + }, + { + "id": "96dc43247652ad2-f2ef49ddfad8ddc", + "title": "Metrics in Discover - Grid should render grid with WHERE filter", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts", + "line": 51, + "column": 14 + } + }, + { + "id": "96dc43247652ad2-67aeead5ce34d63", + "title": "Metrics in Discover - Grid should render grid with LIMIT", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts", + "line": 58, + "column": 14 + } + }, + { + "id": "96dc43247652ad2-d62abe4ab8aa5fe", + "title": "Metrics in Discover - Grid should render grid with SORT", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts", + "line": 65, + "column": 14 + } + }, + { + "id": "96dc43247652ad2-14688950b91de99", + "title": "Metrics in Discover - Grid should not render grid with FROM command", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts", + "line": 72, + "column": 14 + } + }, + { + "id": "96dc43247652ad2-02d09836901d31f", + "title": "Metrics in Discover - Grid should not render grid with STATS command", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-stateful-search", + "@cloud-stateful-search", + "@local-stateful-observability_complete", + "@cloud-stateful-observability_complete", + "@local-stateful-security_complete", + "@cloud-stateful-security_complete", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts", + "line": 77, + "column": 14 + } + }, { "id": "1c8d238ba178b3f-d0060d150395e69", "title": "Discover app - saved searches should customize time range on dashboards", diff --git a/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/standard.json index 727fb474c7db6..1e17b9891307a 100644 --- a/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/plugins/private/discover_enhanced/test/scout/.meta/ui/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-06T14:35:10.401Z", - "sha1": "488aced1a94e02074aec4073cc1fcdf93552fc4e", + "sha1": "4f0c5c041e4229813210d3dea972cbae39b8f585", "tests": [ { "id": "451ff2b9982142c-9a17f71a4f5ef91", diff --git a/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts b/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts index 3e4677996d4f1..ed6419193b080 100644 --- a/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts +++ b/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/parallel_tests/metrics_experience/grid.spec.ts @@ -50,7 +50,7 @@ spaceTest.describe( spaceTest('should render grid with WHERE filter', async ({ pageObjects }) => { await pageObjects.metricsExperience.runEsqlQuery( - `${testData.ESQL_QUERIES.TS_TSDB_LOGS} | WHERE @timestamp > NOW() - 1 DAY` + `${testData.ESQL_QUERIES.TS_TSDB_LOGS} | WHERE @timestamp > "${testData.TSDB_LOGS_DEFAULT_END_TIME}" - 100 DAYS` ); await expect(pageObjects.metricsExperience.grid).toBeVisible(); }); diff --git a/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/tests/discover_cdp_perf.spec.ts b/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/tests/discover_cdp_perf.spec.ts index bba35021146ac..4713e66f288fa 100644 --- a/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/tests/discover_cdp_perf.spec.ts +++ b/x-pack/platform/plugins/private/discover_enhanced/test/scout/ui/tests/discover_cdp_perf.spec.ts @@ -71,7 +71,7 @@ test.describe( ).toStrictEqual([ 'aiops', 'discover', - 'embeddableEnhanced', + 'embeddable', 'eventAnnotation', 'expressionXY', 'kbn-ui-shared-deps-npm', diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/moon.yml b/x-pack/platform/plugins/private/drilldowns/url_drilldown/moon.yml index 4c20673178a96..ae890504646ce 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/moon.yml +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/moon.yml @@ -29,9 +29,7 @@ dependsOn: - '@kbn/es-query' - '@kbn/monaco' - '@kbn/std' - - '@kbn/core-ui-settings-browser-mocks' - '@kbn/core-ui-settings-browser' - - '@kbn/core-theme-browser-mocks' - '@kbn/presentation-publishing' - '@kbn/config-schema' tags: diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/get_url_drilldown.tsx b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/get_url_drilldown.tsx new file mode 100644 index 0000000000000..6cb6abdc136b0 --- /dev/null +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/get_url_drilldown.tsx @@ -0,0 +1,246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import type { SettingsStart } from '@kbn/core-ui-settings-browser'; +import type { IExternalUrl, ThemeServiceStart } from '@kbn/core/public'; +import { + type ChartActionContext, + type DrilldownDefinition, + type DrilldownEditorProps, +} from '@kbn/embeddable-plugin/public'; +import { i18n } from '@kbn/i18n'; +import type { + UrlDrilldownConfig, + UrlDrilldownGlobalScope, +} from '@kbn/ui-actions-enhanced-plugin/public'; +import { + UrlDrilldownCollectConfig, + urlDrilldownCompileUrl, + urlDrilldownValidateUrlTemplate, +} from '@kbn/ui-actions-enhanced-plugin/public'; +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { getInheritedViewMode } from '@kbn/presentation-publishing'; +import type { UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { + CONTEXT_MENU_TRIGGER, + IMAGE_CLICK_TRIGGER, + ROW_CLICK_TRIGGER, + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, +} from '@kbn/ui-actions-plugin/common/trigger_ids'; +import { + DEFAULT_ENCODE_URL, + DEFAULT_OPEN_IN_NEW_TAB, + URL_DRILLDOWN_SUPPORTED_TRIGGERS, +} from '../../common/constants'; +import type { UrlDrilldownState } from '../../server'; +import { getEventScopeValues, getEventVariableList } from './variables/event_variables'; +import { getContextScopeValues, getContextVariableList } from './variables/context_variables'; +import { getGlobalVariableList } from './variables/global_variables'; + +type ExecutionContext = ChartActionContext & EmbeddableApiContext; +type SetupContext = EmbeddableApiContext; + +export function getUrlDrilldown(deps: { + externalUrl: IExternalUrl; + getGlobalScope: () => UrlDrilldownGlobalScope; + navigateToUrl: (url: string) => Promise; + getSyntaxHelpDocsLink: () => string; + getVariablesHelpDocsLink: () => string; + settings: SettingsStart; + theme: () => ThemeServiceStart; +}): DrilldownDefinition { + function getRuntimeVariables(context: ExecutionContext) { + return { + event: getEventScopeValues(context), + context: getContextScopeValues(context), + ...deps.getGlobalScope(), + }; + } + + async function getHref( + drilldownState: UrlDrilldownState, + context: ExecutionContext + ): Promise { + try { + const url = await buildUrl(drilldownState, context); + return url; + } catch (e) { + // eslint-disable-next-line no-console + console.warn(e); + return undefined; + } + } + + async function buildUrl( + drilldownState: UrlDrilldownState, + context: ExecutionContext + ): Promise { + const scope = getRuntimeVariables(context); + const { isValid, error, invalidUrl } = await urlDrilldownValidateUrlTemplate( + drilldownState.url, + scope + ); + + if (!isValid) { + const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', { + defaultMessage: + 'Error building URL: {error} Use drilldown editor to check your URL template. Invalid URL: {invalidUrl}', + values: { + error, + invalidUrl, + }, + }); + throw new Error(errorMessage); + } + + const doEncode = drilldownState.encode_url ?? DEFAULT_ENCODE_URL; + + const url = await urlDrilldownCompileUrl( + drilldownState.url, + getRuntimeVariables(context), + doEncode + ); + + const validUrl = deps.externalUrl.validateUrl(url); + if (!validUrl) { + const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', { + defaultMessage: + 'Error building URL: external URL was denied. Administrator can configure external URL policies using "externalUrl.policy" setting in kibana.yml. Invalid URL: {invalidUrl}', + values: { + invalidUrl: url, + }, + }); + throw new Error(errorMessage); + } + + return url; + } + + function getVariableList(context: SetupContext, trigger?: string): UrlTemplateEditorVariable[] { + const eventVariables = getEventVariableList(trigger); + const contextVariables = getContextVariableList(context); + const globalVariables = getGlobalVariableList(deps.getGlobalScope()); + + return [...eventVariables, ...contextVariables, ...globalVariables]; + } + + function getExampleUrl(trigger?: string): string { + switch (trigger) { + case SELECT_RANGE_TRIGGER: + return 'https://www.example.com/?from={{event.from}}&to={{event.to}}'; + case CONTEXT_MENU_TRIGGER: + case IMAGE_CLICK_TRIGGER: + return 'https://www.example.com/?panel={{context.panel.title}}'; + case ROW_CLICK_TRIGGER: + return 'https://www.example.com/keys={{event.keys}}&values={{event.values}}'; + case VALUE_CLICK_TRIGGER: + default: + return 'https://www.example.com/?{{event.key}}={{event.value}}'; + } + } + + return { + displayName: i18n.translate('xpack.urlDrilldown.DisplayName', { + defaultMessage: 'Go to URL', + }), + euiIcon: 'link', + license: { + minimalLicense: 'gold', + featureName: 'URL drilldown', + }, + supportedTriggers: URL_DRILLDOWN_SUPPORTED_TRIGGERS, + action: { + execute: async (drilldownState: UrlDrilldownState, context: ExecutionContext) => { + const url = await getHref(drilldownState, context); + if (!url) return; + + if (drilldownState.open_in_new_tab) { + window.open(url, '_blank', 'noopener'); + } else { + await deps.navigateToUrl(url); + } + }, + getHref, + isCompatible: async (drilldownState: UrlDrilldownState, context: ExecutionContext) => { + const viewMode = getInheritedViewMode(context.embeddable); + if (viewMode === 'edit') { + // check if context is compatible by building the scope + const scope = getRuntimeVariables(context); + return !!scope; + } + + try { + await buildUrl(drilldownState, context); + return true; + } catch (e) { + // eslint-disable-next-line no-console + console.warn(e); + return false; + } + }, + }, + setup: { + Editor: (props: DrilldownEditorProps) => { + const { variables, exampleUrl, config } = useMemo( + () => ({ + variables: getVariableList(props.context, props.state.trigger), + exampleUrl: getExampleUrl(props.state.trigger), + config: { + url: { + template: props.state.url ?? '', + }, + encodeUrl: props.state.encode_url ?? DEFAULT_ENCODE_URL, + openInNewTab: props.state.open_in_new_tab ?? DEFAULT_OPEN_IN_NEW_TAB, + }, + }), + [props] + ); + + const onConfigChange = useCallback( + (nextConfig: UrlDrilldownConfig) => { + props.onChange({ + ...props.state, + encode_url: nextConfig.encodeUrl, + open_in_new_tab: nextConfig.openInNewTab, + url: nextConfig.url.template, + }); + }, + [props] + ); + + return ( + + + + ); + }, + getInitialState: () => ({ + open_in_new_tab: DEFAULT_OPEN_IN_NEW_TAB, + encode_url: DEFAULT_ENCODE_URL, + }), + isStateValid: (state: Partial) => { + return Boolean(state.url); + }, + order: 8, + }, + }; +} diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/url_drilldown.test.tsx b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/url_drilldown.test.tsx deleted file mode 100644 index dd4ffc73851ab..0000000000000 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/url_drilldown.test.tsx +++ /dev/null @@ -1,649 +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 { BehaviorSubject } from 'rxjs'; -import type { IExternalUrl } from '@kbn/core/public'; -import { render, waitFor } from '@testing-library/react'; -import type { Config } from './url_drilldown'; -import { UrlDrilldown } from './url_drilldown'; -import type { ValueClickContext } from '@kbn/embeddable-plugin/public'; -import type { DatatableColumnType } from '@kbn/expressions-plugin/common'; -import { createPoint, rowClickData } from './test/data'; -import { - CONTEXT_MENU_TRIGGER, - ROW_CLICK_TRIGGER, - SELECT_RANGE_TRIGGER, - VALUE_CLICK_TRIGGER, -} from '@kbn/ui-actions-plugin/common/trigger_ids'; -import { settingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; -import { themeServiceMock } from '@kbn/core-theme-browser-mocks'; -import React from 'react'; - -const mockDataPoints = [ - { - table: { - columns: [ - { - name: 'test', - id: '1-1', - meta: { - type: 'number' as DatatableColumnType, - field: 'bytes', - index: 'logstash-*', - sourceParams: { - indexPatternId: 'logstash-*', - type: 'histogram', - params: { - field: 'bytes', - interval: 30, - otherBucket: true, - }, - }, - }, - }, - ], - rows: [ - { - '1-1': '2048', - }, - ], - }, - column: 0, - row: 0, - value: 'test', - }, -]; - -const mockEmbeddableApi = { - parentApi: { - filters$: new BehaviorSubject([]), - query$: new BehaviorSubject({ query: 'test', language: 'kuery' }), - timeRange$: new BehaviorSubject({ from: 'now-15m', to: 'now' }), - viewMode$: new BehaviorSubject('edit'), - }, -}; - -const mockNavigateToUrl = jest.fn(() => Promise.resolve()); - -class TextExternalUrl implements IExternalUrl { - constructor(private readonly isCorrect: boolean = true) {} - - public isInternalUrl(url: string): boolean { - return false; - } - - public validateUrl(url: string): URL | null { - return this.isCorrect ? new URL(url) : null; - } -} - -const createDrilldown = (isExternalUrlValid: boolean = true) => { - const drilldown = new UrlDrilldown({ - externalUrl: new TextExternalUrl(isExternalUrlValid), - getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }), - getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', - getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', - navigateToUrl: mockNavigateToUrl, - settings: settingsServiceMock.createSetupContract(), - theme: () => { - return themeServiceMock.createStartContract(); - }, - }); - return drilldown; -}; - -const renderActionMenuItem = async ( - drilldown: UrlDrilldown, - config: Config, - context: ValueClickContext -) => { - const { getByTestId } = render( - - ); - await waitFor(() => null); // wait for effects to complete - return { - getError: () => getByTestId('urlDrilldown-error'), - }; -}; - -describe('UrlDrilldown', () => { - const urlDrilldown = createDrilldown(); - - test('license', () => { - expect(urlDrilldown.minimalLicense).toBe('gold'); - }); - - describe('isCompatible', () => { - test('throws if no embeddable', async () => { - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - }; - - await expect(urlDrilldown.isCompatible(config, context)).rejects.toThrowError(); - }); - - test('compatible in edit mode if url is valid', async () => { - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - const result = urlDrilldown.isCompatible(config, context); - await expect(result).resolves.toBe(true); - }); - - test('compatible in edit mode if url is invalid', async () => { - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(true); - }); - - test('compatible in edit mode if external URL is denied', async () => { - const drilldown1 = createDrilldown(true); - const drilldown2 = createDrilldown(false); - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - const result1 = await drilldown1.isCompatible(config, context); - const result2 = await drilldown2.isCompatible(config, context); - - expect(result1).toBe(true); - expect(result2).toBe(true); - }); - - test('compatible in view mode if url is valid', async () => { - mockEmbeddableApi.parentApi.viewMode$.next('view'); - - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - const result = urlDrilldown.isCompatible(config, context); - await expect(result).resolves.toBe(true); - }); - - test('not compatible in view mode if url is invalid', async () => { - mockEmbeddableApi.parentApi.viewMode$.next('view'); - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false); - }); - - test('not compatible in view mode if external URL is denied', async () => { - mockEmbeddableApi.parentApi.viewMode$.next('view'); - const drilldown1 = createDrilldown(true); - const drilldown2 = createDrilldown(false); - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - const result1 = await drilldown1.isCompatible(config, context); - const result2 = await drilldown2.isCompatible(config, context); - - expect(result1).toBe(true); - expect(result2).toBe(false); - }); - }); - - describe('getHref & execute & title', () => { - beforeEach(() => { - mockNavigateToUrl.mockReset(); - }); - - test('valid url', async () => { - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - const url = await urlDrilldown.getHref(config, context); - expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`); - - await urlDrilldown.execute(config, context); - expect(mockNavigateToUrl).toBeCalledWith(url); - - const { getError } = await renderActionMenuItem(urlDrilldown, config, context); - expect(() => getError()).toThrow(); - }); - - test('invalid url', async () => { - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.invalid}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - await expect(urlDrilldown.getHref(config, context)).resolves.toBeUndefined(); - await expect(urlDrilldown.execute(config, context)).resolves.toBeUndefined(); - expect(mockNavigateToUrl).not.toBeCalled(); - - const { getError } = await renderActionMenuItem(urlDrilldown, config, context); - expect(getError()).toHaveTextContent( - `Error building URL: The URL template is not valid in the given context.` - ); - }); - - test('should not throw on denied external URL', async () => { - const drilldown1 = createDrilldown(true); - const drilldown2 = createDrilldown(false); - const config: Config = { - url: { - template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, - }, - openInNewTab: false, - encodeUrl: true, - }; - - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - const url = await drilldown1.getHref(config, context); - await drilldown1.execute(config, context); - - expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`); - expect(mockNavigateToUrl).toBeCalledWith(url); - - await expect(drilldown2.getHref(config, context)).resolves.toBeUndefined(); - await expect(drilldown2.execute(config, context)).resolves.toBeUndefined(); - - const { getError } = await renderActionMenuItem(drilldown2, config, context); - expect(getError()).toHaveTextContent(`Error building URL: external URL was denied.`); - }); - }); - - describe('variables', () => { - const embeddable1 = { - dataViews$: new BehaviorSubject([{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }]), - title$: new BehaviorSubject('The Title'), - savedObjectId$: new BehaviorSubject('SAVED_OBJECT_IDxx'), - uuid: 'test', - }; - const data = { - data: [ - createPoint({ field: 'field0', value: 'value0' }), - createPoint({ field: 'field1', value: 'value1' }), - createPoint({ field: 'field2', value: 'value2' }), - ], - }; - - const embeddable2 = { - dataViews$: new BehaviorSubject([ - { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, - { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, - ]), - parentApi: { - filters$: new BehaviorSubject([ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ]), - query$: new BehaviorSubject({ - language: 'C++', - query: 'std::cout << 123;', - }), - timeRange$: new BehaviorSubject({ from: 'FROM', to: 'TO' }), - }, - title$: new BehaviorSubject('The Title'), - savedObjectId$: new BehaviorSubject('SAVED_OBJECT_ID'), - uuid: 'the-id', - }; - - describe('getRuntimeVariables()', () => { - test('builds runtime variables for VALUE_CLICK_TRIGGER trigger', () => { - const variables = urlDrilldown.getRuntimeVariables({ - embeddable: embeddable1, - data, - }); - - expect(variables).toMatchObject({ - kibanaUrl: 'http://localhost:5601/', - context: { - panel: { - id: 'test', - title: 'The Title', - savedObjectId: 'SAVED_OBJECT_IDxx', - indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - }, - }, - event: { - key: 'field0', - value: 'value0', - negate: false, - points: [ - { - value: 'value0', - key: 'field0', - }, - { - value: 'value1', - key: 'field1', - }, - { - value: 'value2', - key: 'field2', - }, - ], - }, - }); - }); - - test('builds runtime variables for ROW_CLICK_TRIGGER trigger', () => { - const variables = urlDrilldown.getRuntimeVariables({ - embeddable: embeddable2, - data: rowClickData as any, - }); - - expect(variables).toMatchObject({ - kibanaUrl: 'http://localhost:5601/', - context: { - panel: { - id: 'the-id', - title: 'The Title', - savedObjectId: 'SAVED_OBJECT_ID', - query: { - language: 'C++', - query: 'std::cout << 123;', - }, - timeRange: { - from: 'FROM', - to: 'TO', - }, - filters: [ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ], - }, - }, - event: { - rowIndex: 1, - values: ['IT', '2.25', 3, 0, 2], - keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], - columnNames: [ - 'Top values of DestCountry', - 'Top values of FlightTimeHour', - 'Count of records', - 'Average of DistanceMiles', - 'Unique count of OriginAirportID', - ], - }, - }); - }); - }); - - describe('getVariableList()', () => { - test('builds variable list for VALUE_CLICK_TRIGGER trigger', () => { - const list = urlDrilldown.getVariableList({ - triggers: [VALUE_CLICK_TRIGGER], - embeddable: embeddable1, - }); - - const expectedList = [ - 'event.key', - 'event.value', - 'event.negate', - 'event.points', - - 'context.panel.id', - 'context.panel.title', - 'context.panel.indexPatternId', - 'context.panel.savedObjectId', - - 'kibanaUrl', - ]; - - for (const expectedItem of expectedList) { - expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); - } - }); - - test('builds variable list for ROW_CLICK_TRIGGER trigger', () => { - const list = urlDrilldown.getVariableList({ - triggers: [ROW_CLICK_TRIGGER], - embeddable: embeddable2, - }); - - const expectedList = [ - 'event.columnNames', - 'event.keys', - 'event.rowIndex', - 'event.values', - - 'context.panel.id', - 'context.panel.title', - 'context.panel.filters', - 'context.panel.query.language', - 'context.panel.query.query', - 'context.panel.indexPatternIds', - 'context.panel.savedObjectId', - 'context.panel.timeRange.from', - 'context.panel.timeRange.to', - - 'kibanaUrl', - ]; - - for (const expectedItem of expectedList) { - expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); - } - }); - }); - }); - - describe('example url', () => { - it('provides the expected example urls based on the trigger', () => { - expect(urlDrilldown.getExampleUrl({ triggers: [] })).toMatchInlineSnapshot( - `"https://www.example.com/?{{event.key}}={{event.value}}"` - ); - - expect(urlDrilldown.getExampleUrl({ triggers: ['unknown'] })).toMatchInlineSnapshot( - `"https://www.example.com/?{{event.key}}={{event.value}}"` - ); - - expect(urlDrilldown.getExampleUrl({ triggers: [VALUE_CLICK_TRIGGER] })).toMatchInlineSnapshot( - `"https://www.example.com/?{{event.key}}={{event.value}}"` - ); - - expect( - urlDrilldown.getExampleUrl({ triggers: [SELECT_RANGE_TRIGGER] }) - ).toMatchInlineSnapshot(`"https://www.example.com/?from={{event.from}}&to={{event.to}}"`); - - expect(urlDrilldown.getExampleUrl({ triggers: [ROW_CLICK_TRIGGER] })).toMatchInlineSnapshot( - `"https://www.example.com/keys={{event.keys}}&values={{event.values}}"` - ); - - expect( - urlDrilldown.getExampleUrl({ triggers: [CONTEXT_MENU_TRIGGER] }) - ).toMatchInlineSnapshot(`"https://www.example.com/?panel={{context.panel.title}}"`); - }); - }); -}); - -describe('encoding', () => { - const urlDrilldown = createDrilldown(); - const context: ValueClickContext = { - data: { - data: mockDataPoints, - }, - embeddable: mockEmbeddableApi, - }; - - test('encodes URL by default', async () => { - const config: Config = { - url: { - template: 'https://elastic.co?foo=head%26shoulders', - }, - openInNewTab: false, - encodeUrl: true, - }; - const url = await urlDrilldown.getHref(config, context); - - expect(url).toBe('https://elastic.co?foo=head%2526shoulders'); - }); - - test('encodes URL when encoding is enabled', async () => { - const config: Config = { - url: { - template: 'https://elastic.co?foo=head%26shoulders', - }, - openInNewTab: false, - encodeUrl: true, - }; - const url = await urlDrilldown.getHref(config, context); - - expect(url).toBe('https://elastic.co?foo=head%2526shoulders'); - }); - - test('does not encode URL when encoding is not enabled', async () => { - const config: Config = { - url: { - template: 'https://elastic.co?foo=head%26shoulders', - }, - openInNewTab: false, - encodeUrl: false, - }; - const url = await urlDrilldown.getHref(config, context); - - expect(url).toBe('https://elastic.co?foo=head%26shoulders'); - }); - - test('can encode URI component using "encodeURIComponent" Handlebars helper', async () => { - const config: Config = { - url: { - template: 'https://elastic.co?foo={{encodeURIComponent "head%26shoulders@gmail.com"}}', - }, - openInNewTab: false, - encodeUrl: false, - }; - const url = await urlDrilldown.getHref(config, context); - - expect(url).toBe('https://elastic.co?foo=head%2526shoulders%40gmail.com'); - }); - - test('can encode URI component using "encodeURIQuery" Handlebars helper', async () => { - const config: Config = { - url: { - template: 'https://elastic.co?foo={{encodeURIQuery "head%26shoulders@gmail.com"}}', - }, - openInNewTab: false, - encodeUrl: false, - }; - const url = await urlDrilldown.getHref(config, context); - - expect(url).toBe('https://elastic.co?foo=head%2526shoulders@gmail.com'); - }); -}); diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/url_drilldown.tsx deleted file mode 100644 index 98949d6ee25b7..0000000000000 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import type { IExternalUrl, ThemeServiceStart } from '@kbn/core/public'; -import { - type EmbeddableApiContext, - getInheritedViewMode, - apiCanAccessViewMode, -} from '@kbn/presentation-publishing'; -import type { ChartActionContext } from '@kbn/embeddable-plugin/public'; -import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; -import { - CONTEXT_MENU_TRIGGER, - IMAGE_CLICK_TRIGGER, - ROW_CLICK_TRIGGER, - SELECT_RANGE_TRIGGER, - VALUE_CLICK_TRIGGER, -} from '@kbn/ui-actions-plugin/common/trigger_ids'; -import type { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public'; -import type { UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import type { - UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, - UiActionsEnhancedDrilldownDefinition as Drilldown, - UrlDrilldownConfig, - UrlDrilldownGlobalScope, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import { - UrlDrilldownCollectConfig, - urlDrilldownCompileUrl, - urlDrilldownValidateUrlTemplate, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import type { SerializedAction } from '@kbn/ui-actions-enhanced-plugin/common/types'; -import type { SettingsStart } from '@kbn/core-ui-settings-browser'; -import { EuiText, EuiTextBlockTruncate } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { - DEFAULT_ENCODE_URL, - DEFAULT_OPEN_IN_NEW_TAB, - URL_DRILLDOWN_SUPPORTED_TRIGGERS, -} from '../../common/constants'; -import { txtUrlDrilldownDisplayName } from './i18n'; -import { getEventScopeValues, getEventVariableList } from './variables/event_variables'; -import { getContextScopeValues, getContextVariableList } from './variables/context_variables'; -import { getGlobalVariableList } from './variables/global_variables'; - -interface UrlDrilldownDeps { - externalUrl: IExternalUrl; - getGlobalScope: () => UrlDrilldownGlobalScope; - navigateToUrl: (url: string) => Promise; - getSyntaxHelpDocsLink: () => string; - getVariablesHelpDocsLink: () => string; - settings: SettingsStart; - theme: () => ThemeServiceStart; -} - -export type Config = UrlDrilldownConfig; -export type UrlTrigger = - | typeof VALUE_CLICK_TRIGGER - | typeof SELECT_RANGE_TRIGGER - | typeof ROW_CLICK_TRIGGER - | typeof CONTEXT_MENU_TRIGGER - | typeof IMAGE_CLICK_TRIGGER; - -export type ActionFactoryContext = Partial & BaseActionFactoryContext; - -export type CollectConfigProps = CollectConfigPropsBase; - -const URL_DRILLDOWN = 'URL_DRILLDOWN'; - -const getViewMode = (context: ChartActionContext) => { - if (apiCanAccessViewMode(context.embeddable)) { - return getInheritedViewMode(context.embeddable); - } - throw new Error('Cannot access view mode'); -}; - -export class UrlDrilldown implements Drilldown { - public readonly id = URL_DRILLDOWN; - - constructor(private readonly deps: UrlDrilldownDeps) {} - - public readonly order = 8; - - readonly minimalLicense = 'gold'; - readonly licenseFeatureName = 'URL drilldown'; - - public readonly getDisplayName = () => txtUrlDrilldownDisplayName; - - public readonly actionMenuItem: React.FC<{ - config: Omit, 'factoryId'>; - context: ChartActionContext | ActionExecutionContext; - }> = ({ config, context }) => { - const [title, setTitle] = React.useState(config.name); - const [error, setError] = React.useState(); - React.useEffect(() => { - const variables = this.getRuntimeVariables(context); - urlDrilldownCompileUrl(title, variables, false) - .then((result) => { - if (title !== result) setTitle(result); - }) - .catch(() => {}); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - React.useEffect(() => { - this.buildUrl(config.config, context).catch((e) => { - setError(e.message); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - /* title is used as a tooltip, EuiToolTip doesn't work in this context menu due to hacky zIndex */ - - {title} - {/* note: ideally we'd use EuiIconTip for the error, but it doesn't play well with this context menu*/} - {error ? ( - - - {error} - - - ) : null} - - ); - }; - - public readonly euiIcon = 'link'; - - supportedTriggers(): UrlTrigger[] { - return URL_DRILLDOWN_SUPPORTED_TRIGGERS as UrlTrigger[]; - } - public readonly CollectConfig: React.FC = ({ config, onConfig, context }) => { - const [variables, exampleUrl] = React.useMemo( - () => [this.getVariableList(context), this.getExampleUrl(context)], - [context] - ); - - return ( - - - - ); - }; - - public readonly createConfig = () => ({ - url: { - template: '', - }, - openInNewTab: DEFAULT_OPEN_IN_NEW_TAB, - encodeUrl: DEFAULT_ENCODE_URL, - }); - - public readonly isConfigValid = (config: Config): config is Config => { - return !!config.url.template; - }; - - public readonly isCompatible = async (config: Config, context: ChartActionContext) => { - const viewMode = getViewMode(context); - - if (viewMode === 'edit') { - // check if context is compatible by building the scope - const scope = this.getRuntimeVariables(context); - return !!scope; - } - - try { - await this.buildUrl(config, context); - return true; - } catch (e) { - // eslint-disable-next-line no-console - console.warn(e); - return false; - } - }; - - private async buildUrl(config: Config, context: ChartActionContext): Promise { - const scope = this.getRuntimeVariables(context); - const { isValid, error, invalidUrl } = await urlDrilldownValidateUrlTemplate(config.url, scope); - - if (!isValid) { - const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', { - defaultMessage: - 'Error building URL: {error} Use drilldown editor to check your URL template. Invalid URL: {invalidUrl}', - values: { - error, - invalidUrl, - }, - }); - throw new Error(errorMessage); - } - - const doEncode = config.encodeUrl ?? DEFAULT_ENCODE_URL; - - const url = await urlDrilldownCompileUrl( - config.url.template, - this.getRuntimeVariables(context), - doEncode - ); - - const validUrl = this.deps.externalUrl.validateUrl(url); - if (!validUrl) { - const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', { - defaultMessage: - 'Error building URL: external URL was denied. Administrator can configure external URL policies using "externalUrl.policy" setting in kibana.yml. Invalid URL: {invalidUrl}', - values: { - invalidUrl: url, - }, - }); - throw new Error(errorMessage); - } - - return url; - } - - public readonly getHref = async ( - config: Config, - context: ChartActionContext - ): Promise => { - try { - const url = await this.buildUrl(config, context); - return url; - } catch (e) { - // eslint-disable-next-line no-console - console.warn(e); - return undefined; - } - }; - - public readonly execute = async (config: Config, context: ChartActionContext) => { - const url = await this.getHref(config, context); - if (!url) return; - - if (config.openInNewTab) { - window.open(url, '_blank', 'noopener'); - } else { - await this.deps.navigateToUrl(url); - } - }; - - public readonly getRuntimeVariables = (context: ChartActionContext) => { - return { - event: getEventScopeValues(context), - context: getContextScopeValues(context), - ...this.deps.getGlobalScope(), - }; - }; - - public readonly getVariableList = ( - context: ActionFactoryContext - ): UrlTemplateEditorVariable[] => { - const globalScopeValues = this.deps.getGlobalScope(); - const eventVariables = getEventVariableList(context); - const contextVariables = getContextVariableList(context); - const globalVariables = getGlobalVariableList(globalScopeValues); - - return [...eventVariables, ...contextVariables, ...globalVariables]; - }; - - public readonly getExampleUrl = (context: ActionFactoryContext): string => { - switch (context.triggers[0]) { - case SELECT_RANGE_TRIGGER: - return 'https://www.example.com/?from={{event.from}}&to={{event.to}}'; - case CONTEXT_MENU_TRIGGER: - case IMAGE_CLICK_TRIGGER: - return 'https://www.example.com/?panel={{context.panel.title}}'; - case ROW_CLICK_TRIGGER: - return 'https://www.example.com/keys={{event.keys}}&values={{event.values}}'; - case VALUE_CLICK_TRIGGER: - default: - return 'https://www.example.com/?{{event.key}}={{event.value}}'; - } - }; -} diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/context_variables.ts b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/context_variables.ts index 10cb64260be66..7376ed6dae194 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/context_variables.ts +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/context_variables.ts @@ -22,7 +22,6 @@ import { getTitle } from '@kbn/presentation-publishing'; import type { UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; import { txtValue } from './i18n'; import { deleteUndefinedKeys } from './util'; -import type { ActionFactoryContext } from '../url_drilldown'; /** * Part of context scope extracted from an api @@ -215,7 +214,7 @@ const getPanelVariableList = (values: PanelValues): UrlTemplateEditorVariable[] }; export const getContextVariableList = ( - context: ActionFactoryContext + context: Partial ): UrlTemplateEditorVariable[] => { const values = getContextScopeValues(context); const variables: UrlTemplateEditorVariable[] = getPanelVariableList(values.panel); diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts index 8697956faf6ef..1af8dfc743e79 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts @@ -73,9 +73,7 @@ describe('VALUE_CLICK_TRIGGER', () => { describe('ROW_CLICK_TRIGGER', () => { test('getEventVariableList() returns correct list of runtime variables', () => { - const vars = getEventVariableList({ - triggers: [ROW_CLICK_TRIGGER], - }); + const vars = getEventVariableList(ROW_CLICK_TRIGGER); expect(vars.map(({ label }) => label)).toEqual([ 'event.values', 'event.keys', diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.ts b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.ts index 43b15b8efc09c..163307c0ed7f9 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.ts +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/variables/event_variables.ts @@ -23,13 +23,12 @@ import { isRowClickTriggerContext, } from '@kbn/embeddable-plugin/public'; import type { RowClickContext } from '@kbn/ui-actions-plugin/public'; -import { ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/common/trigger_ids'; -import type { UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; import { - VALUE_CLICK_TRIGGER, + ROW_CLICK_TRIGGER, SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, } from '@kbn/ui-actions-plugin/common/trigger_ids'; -import type { ActionFactoryContext } from '../url_drilldown'; +import type { UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; import type { Primitive } from './util'; import { deleteUndefinedKeys, toPrimitiveOrUndefined } from './util'; @@ -283,11 +282,7 @@ const selectRangeVariables: readonly UrlTemplateEditorVariable[] = [ }, ]; -export const getEventVariableList = ( - context: ActionFactoryContext -): UrlTemplateEditorVariable[] => { - const [trigger] = context.triggers; - +export const getEventVariableList = (trigger?: string): UrlTemplateEditorVariable[] => { switch (trigger) { case VALUE_CLICK_TRIGGER: return [...valueClickVariables]; diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/plugin.ts b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/plugin.ts index 3d3d910070f33..1a9d64ef72b83 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/plugin.ts +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/plugin.ts @@ -6,24 +6,17 @@ */ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; -import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import type { - AdvancedUiActionsSetup, - AdvancedUiActionsStart, -} from '@kbn/ui-actions-enhanced-plugin/public'; +import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; import { urlDrilldownGlobalScopeProvider } from '@kbn/ui-actions-enhanced-plugin/public'; import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import { UrlDrilldown } from './lib'; +import { URL_DRILLDOWN_TYPE } from '../common/constants'; export interface SetupDependencies { embeddable: EmbeddableSetup; - uiActionsEnhanced: AdvancedUiActionsSetup; } -export interface StartDependencies { - embeddable: EmbeddableStart; - uiActionsEnhanced: AdvancedUiActionsStart; -} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface StartDependencies {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SetupContract {} @@ -38,8 +31,10 @@ export class UrlDrilldownPlugin public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { const startServices = createStartServicesGetter(core.getStartServices); - plugins.uiActionsEnhanced.registerDrilldown( - new UrlDrilldown({ + + plugins.embeddable.registerDrilldown(URL_DRILLDOWN_TYPE, async () => { + const { getUrlDrilldown } = await import('./lib/get_url_drilldown'); + return getUrlDrilldown({ externalUrl: core.http.externalUrl, getGlobalScope: urlDrilldownGlobalScopeProvider({ core }), navigateToUrl: (url: string) => @@ -50,8 +45,8 @@ export class UrlDrilldownPlugin startServices().core.docLinks.links.dashboard.urlDrilldownVariables, settings: core.settings, theme: () => startServices().core.theme, - }) - ); + }); + }); return {}; } diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/server/index.ts b/x-pack/platform/plugins/private/drilldowns/url_drilldown/server/index.ts index f9b08a47297df..06c3fbdc5fae0 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/server/index.ts +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/server/index.ts @@ -7,6 +7,8 @@ import type { PluginInitializerContext } from '@kbn/core/server'; +export type { UrlDrilldownState } from './types'; + export const plugin = async (context: PluginInitializerContext) => { const { UrlDrilldownPlugin } = await import('./plugin'); return new UrlDrilldownPlugin(); diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/server/types.ts b/x-pack/platform/plugins/private/drilldowns/url_drilldown/server/types.ts new file mode 100644 index 0000000000000..8cd8b62581461 --- /dev/null +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/server/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 type { TypeOf } from '@kbn/config-schema'; +import type { DrilldownState } from '@kbn/embeddable-plugin/server'; +import type { urlDrilldownSchema } from './schemas'; + +export type UrlDrilldownState = DrilldownState & TypeOf; diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/tsconfig.json b/x-pack/platform/plugins/private/drilldowns/url_drilldown/tsconfig.json index 84b2b478c11de..3be484a12efcd 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/tsconfig.json +++ b/x-pack/platform/plugins/private/drilldowns/url_drilldown/tsconfig.json @@ -16,9 +16,7 @@ "@kbn/es-query", "@kbn/monaco", "@kbn/std", - "@kbn/core-ui-settings-browser-mocks", "@kbn/core-ui-settings-browser", - "@kbn/core-theme-browser-mocks", "@kbn/presentation-publishing", "@kbn/config-schema", ], diff --git a/x-pack/platform/plugins/private/gen_ai_settings/test/scout/.meta/ui/parallel.json b/x-pack/platform/plugins/private/gen_ai_settings/test/scout/.meta/ui/parallel.json new file mode 100644 index 0000000000000..cb2a9c910d817 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/test/scout/.meta/ui/parallel.json @@ -0,0 +1,220 @@ +{ + "sha1": "4847542db72a231fa1a56ea8d7082c5282080571", + "tests": [ + { + "id": "30ebe9a34bf659d-a1567c5f112eb4c", + "title": "GenAI Settings - Agent Mode Complete Flow should switch to Agent mode and show Agent button", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt", + "@svlSecurity" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/agent_mode_complete_flow.spec.ts", + "line": 26, + "column": 14 + } + }, + { + "id": "9e027472a4d5a66-af60253378cee15", + "title": "GenAI Settings - Agent Nav Button without Agent Builder Privileges should not display nav buttons without Agent Builder privileges", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt", + "@svlSecurity", + "@svlSearch" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/agent_nav_button.no_ab_privilege.spec.ts", + "line": 27, + "column": 14 + } + }, + { + "id": "eea8aed76d5ebee-74159a9bdad67c4", + "title": "GenAI Settings - AI Assistant Visibility without AI Assistants Privileges should not display AI Assistant elements without privileges", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt", + "@svlSecurity" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/ai_assistant_visibility.no_assistant_privilege.spec.ts", + "line": 34, + "column": 14 + } + }, + { + "id": "41648fafd99df04-2f777a05c614670", + "title": "GenAI Settings - AI Assistant Visibility should hide AI Assistant Visibility setting in Agent mode", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/ai_assistant_visibility.spec.ts", + "line": 23, + "column": 12 + } + }, + { + "id": "4efc85dbb40716c-9961aec1448fe75", + "title": "GenAI Settings - Confirmation Modal should cancel Agent selection without saving changes", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt", + "@svlSecurity" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/confirmation_modal.spec.ts", + "line": 27, + "column": 14 + } + }, + { + "id": "09dd6e1d637aebe-90a30207a358704", + "title": "GenAI Settings - Documentation Section should show Documentation section in Agent mode", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt", + "@svlSecurity" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/documentation_section.spec.ts", + "line": 26, + "column": 14 + } + }, + { + "id": "cc5a0237ff66706-e71616709be1a8a", + "title": "GenAI Settings - Page Display without Agent Builder Privileges should display correct UI elements without Agent Builder privileges", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt", + "@svlSecurity", + "@svlSearch" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/page_display.no_ab_privilege.spec.ts", + "line": 20, + "column": 14 + } + }, + { + "id": "602bc405b492080-a055534c34b87c6", + "title": "GenAI Settings - Page Display without AI Assistants Privileges should display correct UI elements without AI Assistants privileges", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt", + "@svlSecurity", + "@svlSearch" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/page_display.no_assistant_privilege.spec.ts", + "line": 27, + "column": 14 + } + }, + { + "id": "4c939ca2b72e878-ee032ba495e2030", + "title": "GenAI Settings - Page Display should display correct UI elements", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlSecurity", + "@svlOblt", + "@svlSearch" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/page_display.spec.ts", + "line": 20, + "column": 14 + } + }, + { + "id": "cdceb40652fc7b9-56020806f0e0dd5", + "title": "GenAI Settings - Selection Modal without Agent Builder Privileges AI Agent card selection modal is disabled without Agent Builder privileges", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/selection_modal.no_ab_privilege.spec.ts", + "line": 21, + "column": 14 + } + }, + { + "id": "fa82b6a9601a92f-a266df6362889b2", + "title": "GenAI Settings - Selection Modal without AI Assistants Privileges AI Assistant cards should be disabled without AI Assistants privileges", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/selection_modal.no_assistant_privilege.spec.ts", + "line": 20, + "column": 14 + } + }, + { + "id": "775241661a05d50-1766da9440bd32c", + "title": "GenAI Settings - AI Selection Modal Changes should open card selection modal when AI Assistant visibility is set to default", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/selection_modal.spec.ts", + "line": 24, + "column": 12 + } + }, + { + "id": "75d3cbd6024f87a-9778307a26a7782", + "title": "GenAI Settings - Change Chat Experience to Agent in Observability Space should change Chat Experience to Agent", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/solution_space.observability.spec.ts", + "line": 31, + "column": 14 + } + }, + { + "id": "5b36b8da87d3695-7fdded14d854486", + "title": "GenAI Settings - Change Chat Experience to Classic in Search Space should change Chat Experience to Classic", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/solution_space.search.spec.ts", + "line": 31, + "column": 14 + } + }, + { + "id": "3808f991759e937-dc2fc3a60f810d3", + "title": "GenAI Settings - Change Chat Experience to Agent in Security Space should change Chat Experience to Agent", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/private/gen_ai_settings/test/scout/ui/parallel_tests/solution_space.security.spec.ts", + "line": 31, + "column": 14 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/private/intercepts/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/private/intercepts/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..3712637dde176 --- /dev/null +++ b/x-pack/platform/plugins/private/intercepts/test/scout/.meta/ui/standard.json @@ -0,0 +1,44 @@ +{ + "sha1": "7b3f2a853be9c3daf4653cb015c2961a05d037fa", + "tests": [ + { + "id": "614e6351a14a9bc-7eda28ec0661f6a", + "title": "Standard Product intercept on initial page load - presents all available navigable steps", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/intercepts/test/scout/ui/tests/product_intercept_standard.spec.ts", + "line": 16, + "column": 7 + } + }, + { + "id": "614e6351a14a9bc-c9cd3d40f5c462a", + "title": "Standard Product intercept page transitions - transitions from one tab to another and back again will cause the intercept to be displayed if the intercept interval has elapsed on transitioning", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/intercepts/test/scout/ui/tests/product_intercept_standard.spec.ts", + "line": 55, + "column": 7 + } + }, + { + "id": "738f1ebca94f4f6-a412f6dfe18fc71", + "title": "Product intercept for upgrade event displays the upgrade intercept if it's display condition is met", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/private/intercepts/test/scout/ui/tests/product_intercepts_upgrade.spec.ts", + "line": 22, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/api/standard.json b/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/api/standard.json index 674ce1af82d50..9a9468b7808cc 100644 --- a/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/api/standard.json +++ b/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/api/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T13:21:42.243Z", "sha1": "116b63b2ba9c94a191c676f5282614fe83b6fe70", "tests": [ { diff --git a/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/ui/standard.json index 709f6df0fca4f..988103d37e164 100644 --- a/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/plugins/private/painless_lab/test/scout/.meta/ui/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-04T18:59:47.685Z", "sha1": "7640ad320ed836d2b522d142d57150adf03e214f", "tests": [ { diff --git a/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.test.ts b/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.test.ts index ba2eaecc7b1be..0630dacf87cd2 100644 --- a/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.test.ts @@ -2379,7 +2379,7 @@ describe('ScheduledReportsService', () => { expect(taskManager.bulkUpdateSchedules).toHaveBeenCalledWith( [savedObjects[0].id], mockSchedule, - { request: fakeRawRequest } + { request: fakeRawRequest, regenerateApiKey: true } ); expect(auditLogger.log).toHaveBeenCalledTimes(1); @@ -2466,7 +2466,7 @@ describe('ScheduledReportsService', () => { expect(taskManager.bulkUpdateSchedules).toHaveBeenCalledWith( [savedObjects[0].id], mockSchedule, - { request: fakeRawRequest } + { request: fakeRawRequest, regenerateApiKey: true } ); expect(auditLogger.log).toHaveBeenCalledTimes(1); diff --git a/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.ts b/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.ts index 0e470f67e4cdd..e4194ce3158be 100644 --- a/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.ts +++ b/x-pack/platform/plugins/private/reporting/server/services/scheduled_reports/scheduled_reports_service.ts @@ -493,7 +493,10 @@ export class ScheduledReportsService { schedule, }: { id: string } & UpdateScheduledReportParams) { if (schedule) { - await this.taskManager.bulkUpdateSchedules([id], schedule, { request: this.request }); + await this.taskManager.bulkUpdateSchedules([id], schedule, { + request: this.request, + regenerateApiKey: true, + }); } } diff --git a/x-pack/platform/plugins/private/telemetry_collection_xpack/schema/xpack_platform.json b/x-pack/platform/plugins/private/telemetry_collection_xpack/schema/xpack_platform.json index 07272c6cf16f4..70e092aef7a5f 100644 --- a/x-pack/platform/plugins/private/telemetry_collection_xpack/schema/xpack_platform.json +++ b/x-pack/platform/plugins/private/telemetry_collection_xpack/schema/xpack_platform.json @@ -7239,6 +7239,108 @@ }, "total_filled_gap_duration_ms": { "type": "long" + }, + "gap_auto_fill_scheduler_runs_per_day": { + "type": "long", + "_meta": { + "description": "The total number of gap auto-fill scheduler runs per day" + } + }, + "gap_auto_fill_scheduler_runs_by_status_per_day": { + "properties": { + "DYNAMIC_KEY": { + "type": "long", + "_meta": { + "description": "The number of gap auto-fill scheduler runs by dynamic status key per day" + } + }, + "success": { + "type": "long", + "_meta": { + "description": "The number of successful gap auto-fill scheduler runs per day" + } + }, + "error": { + "type": "long", + "_meta": { + "description": "The number of errored gap auto-fill scheduler runs per day" + } + }, + "skipped": { + "type": "long", + "_meta": { + "description": "The number of skipped gap auto-fill scheduler runs per day" + } + }, + "no_gaps": { + "type": "long", + "_meta": { + "description": "The number of gap auto-fill scheduler runs with no gaps found per day" + } + } + } + }, + "gap_auto_fill_scheduler_duration_ms_per_day": { + "properties": { + "min": { + "type": "long", + "_meta": { + "description": "The minimum duration in milliseconds of gap auto-fill scheduler runs per day" + } + }, + "max": { + "type": "long", + "_meta": { + "description": "The maximum duration in milliseconds of gap auto-fill scheduler runs per day" + } + }, + "avg": { + "type": "float", + "_meta": { + "description": "The average duration in milliseconds of gap auto-fill scheduler runs per day" + } + }, + "sum": { + "type": "float", + "_meta": { + "description": "The total duration in milliseconds of gap auto-fill scheduler runs per day" + } + } + } + }, + "gap_auto_fill_scheduler_unique_rule_count_per_day": { + "type": "long", + "_meta": { + "description": "The number of unique rules processed by the gap auto-fill scheduler per day" + } + }, + "gap_auto_fill_scheduler_processed_gaps_total_per_day": { + "type": "long", + "_meta": { + "description": "The total number of gaps processed by the gap auto-fill scheduler per day" + } + }, + "gap_auto_fill_scheduler_results_by_status_per_day": { + "properties": { + "DYNAMIC_KEY": { + "type": "long", + "_meta": { + "description": "The number of gap auto-fill scheduler results by dynamic status key per day" + } + }, + "success": { + "type": "long", + "_meta": { + "description": "The number of successful gap auto-fill scheduler results per day" + } + }, + "error": { + "type": "long", + "_meta": { + "description": "The number of errored gap auto-fill scheduler results per day" + } + } + } } } }, diff --git a/x-pack/platform/plugins/private/transform/test/scout/.meta/api/standard.json b/x-pack/platform/plugins/private/transform/test/scout/.meta/api/standard.json index ac56dbcd9fa36..1f30f7afb5efd 100644 --- a/x-pack/platform/plugins/private/transform/test/scout/.meta/api/standard.json +++ b/x-pack/platform/plugins/private/transform/test/scout/.meta/api/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T13:21:45.388Z", "sha1": "1f65d4ee46b4fb568ab3cfd8c79609675d146fe4", "tests": [ { diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 8791aff6f87f2..8caa12caccc0f 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -1349,6 +1349,7 @@ "dashboard.addPanel.newEmbeddableWithNoTitleAddedSuccessMessageTitle": "Ein Panel wurde hinzugefügt", "dashboard.badge.readOnly.text": "Nur lesen", "dashboard.badge.readOnly.tooltip": "Dashboards können nicht gespeichert werden", + "dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "Wählen Sie das Ziel-Dashboard aus.", "dashboard.createConfirmModal.cancelButtonLabel": "Abbrechen", "dashboard.createConfirmModal.confirmButtonLabel": "Von vorne anfangen", "dashboard.createConfirmModal.continueButtonLabel": "Mit der Bearbeitung fortfahren", @@ -1361,6 +1362,8 @@ "dashboard.dashboardWasSavedSuccessMessage": "Dashboard „{title}“ wurde gespeichert", "dashboard.deleteError.toastDescription": "Beim Löschen des Dashboards ist ein Fehler aufgetreten", "dashboard.discardChangesConfirmModal.discardChangesDescription": "Alle ungespeicherten Änderungen gehen verloren.", + "dashboard.drilldown.errorDestinationDashboardIsMissing": "Das Ziel-Dashboard („{dashboardId}“) existiert nicht mehr. Wählen Sie ein anderes Dashboard.", + "dashboard.drilldown.goToDashboard": "Zum Dashboard gehen", "dashboard.editorMenu.addPanelFlyout.searchLabelText": "Suchfeld für Felder", "dashboard.editorMenu.deprecatedTag": "Veraltet", "dashboard.embeddableApi.showSettings.flyout.applyButtonTitle": "Anwenden", @@ -2465,7 +2468,6 @@ "discover.esqlMode.selectedColumnsCallout": "Es werden {selectedColumnsNumber} von {esqlQueryColumnsNumber} Feldern angezeigt. Fügen Sie weitere Felder aus der Liste „Verfügbare Felder“ hinzu.", "discover.esqlToDataViewTransitionModal.closeButtonLabel": "Verwerfen und wechseln", "discover.esqlToDataViewTransitionModal.dismissButtonLabel": "Fragen Sie mich nicht noch einmal", - "discover.esqlToDataViewTransitionModal.feedbackLink": "ES|QL-Feedback einreichen", "discover.esqlToDataViewTransitionModal.saveButtonLabel": "Speichern und wechseln", "discover.esqlToDataViewTransitionModal.title": "Nicht gespeicherte Änderungen", "discover.esqlToDataviewTransitionModalBody": "Beim Wechseln der Datenquellen wird die aktuelle ES|QL-Abfrage entfernt. Speichern Sie diese Sitzung, um einen Arbeitsverlust zu vermeiden.", @@ -2681,8 +2683,61 @@ "embeddableApi.common.constants.grouping.annotations": "Anmerkungen und Navigation", "embeddableApi.common.constants.grouping.other": "Andere", "embeddableApi.common.constants.grouping.visualizations": "Visualisierungen", + "embeddableApi.components.DrilldownForm.changeButton": "Ändern", + "embeddableApi.components.DrilldownForm.drilldownAction": "Aktion", + "embeddableApi.components.DrilldownForm.nameOfDrilldown": "Name", + "embeddableApi.components.DrilldownForm.trigger": "Auslösen", + "embeddableApi.components.DrilldownForm.untitledDrilldown": "Unbenannter Drilldown", + "embeddableApi.components.DrilldownTable.actionColumnTitle": "Aktion", + "embeddableApi.components.DrilldownTable.copyDrilldownButtonLabel": "Kopieren", + "embeddableApi.components.DrilldownTable.createDrilldownButtonLabel": "Neu erstellen", + "embeddableApi.components.DrilldownTable.deleteDrilldownsButtonLabel": "Löschen ({count})", + "embeddableApi.components.DrilldownTable.editDrilldownButtonLabel": "Bearbeiten", + "embeddableApi.components.DrilldownTable.nameColumnTitle": "Name", + "embeddableApi.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "Wählen Sie diesen Drilldown", + "embeddableApi.components.DrilldownTable.triggerColumnTitle": "Trigger", + "embeddableApi.components.DrilldownTemplateTable.actionColumnTitle": "Aktion", + "embeddableApi.components.DrilldownTemplateTable.copyButtonLabel": "Kopieren ({count})", + "embeddableApi.components.DrilldownTemplateTable.nameColumnTitle": "Name", + "embeddableApi.components.DrilldownTemplateTable.selectableMessage": "Diese Vorlage auswählen", + "embeddableApi.components.DrilldownTemplateTable.singleItemCopyAction": "Kopieren", + "embeddableApi.components.DrilldownTemplateTable.sourceColumnTitle": "Panel", + "embeddableApi.components.DrilldownTemplateTable.triggerColumnTitle": "Trigger", + "embeddableApi.components.TriggerLineItem.incompatibleTooltip": "Dieser Triggertyp wird von diesem Panel nicht unterstützt", + "embeddableApi.components.TriggerPickerItem.unknown": "Unbekannt", + "embeddableApi.createDrilldownAction.displayName": "Drilldown erstellen", + "embeddableApi.drilldownManager.containers.TemplatePicker.label": "Vorhandenen Drilldown kopieren", + "embeddableApi.drilldowns.components.DrilldownHelloBar.helpText": "Mit Drilldowns können Sie neue Verhaltensweisen für die Interaktion mit Panels definieren. Sie können mehrere Aktionen hinzufügen und den Standardfilter überschreiben.", + "embeddableApi.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "Ausblenden", + "embeddableApi.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "Dokumentation ansehen", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "Drilldown erstellen", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "Drilldown löschen", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "Drilldown bearbeiten", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "Unzureichendes Lizenzniveau", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "Drilldown-Typ {type} existiert nicht", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "Drilldown „{drilldownName}“ wurde erstellt.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "Drilldown gelöscht", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "Drilldown \"{drilldownName}\" wurde aktualisiert", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "Fehler beim Speichern des Drilldowns", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n} Drilldowns gelöscht", + "embeddableApi.drilldowns.components.FlyoutFrame.BackButtonLabel": "Zurück", + "embeddableApi.drilldowns.components.FlyoutFrame.CloseButtonLabel": "Schließen", + "embeddableApi.drilldowns.containers.createDrilldownForm.primaryButton": "Drilldown erstellen", + "embeddableApi.drilldowns.containers.createDrilldownForm.title": "Drilldown erstellen", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.body": "{count, number} {count, plural, one {drilldown} other {drilldowns}} kopiert.", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.dismiss": "Verwerfen", + "embeddableApi.drilldowns.containers.DrilldownManager.createNew": "Neu erstellen", + "embeddableApi.drilldowns.containers.DrilldownManager.manage": "Verwalten", + "embeddableApi.drilldowns.containers.editDrilldownForm.primaryButton": "Speichern", + "embeddableApi.drilldowns.containers.editDrilldownForm.title": "Drilldown bearbeiten", + "embeddableApi.drilldowns.drilldownManager.state.defaultTitle": "Drilldowns", "embeddableApi.errors.paneldoesNotExist": "Panel nicht gefunden", "embeddableApi.errors.panelIncompatibleError": "Panel-API ist nicht kompatibel", + "embeddableApi.manageDrilldownAction.displayName": "Drilldowns verwalten", "embeddableApi.reactEmbeddable.factoryAlreadyExistsError": "Eine einbettbare Fabrik für den Typ: {key} ist bereits registriert.", "embeddableApi.reactEmbeddable.factoryNotFoundError": "Keine einbettbare Fabrik für Typ: {key} gefunden.", "embeddableExamples.dataTable.ariaLabel": "Datentabellen", @@ -2754,6 +2809,9 @@ "esqlEditor.discardStarredQueryModal.dismissButtonLabel": "Fragen Sie mich nicht erneut", "esqlEditor.discardStarredQueryModal.title": "Markierte Abfrage löschen", "esqlEditor.history.starredItemslimit": "Es werden {starredItemsCount} Abfragen angezeigt (maximal {starredItemsLimit})", + "esqlEditor.menu.exampleQueries": "Empfohlene Abfragen", + "esqlEditor.menu.helpLabel": "ES|QL-Hilfe", + "esqlEditor.menu.quickReference": "Kurzreferenz", "esqlEditor.query.aborted": "Anfrage wurde abgebrochen", "esqlEditor.query.cancel": "Abbrechen", "esqlEditor.query.collapseLabel": "Minimieren", @@ -7251,61 +7309,7 @@ "uiActionsEnhanced.components.actionWizard.triggerPickerHelpText": "Was ist das?", "uiActionsEnhanced.components.actionWizard.triggerPickerHelpTooltip": "Legt fest, wann der Drilldown im Kontextmenü erscheint", "uiActionsEnhanced.components.actionWizard.triggerPickerLabel": "Option anzeigen für:", - "uiActionsEnhanced.components.DrilldownForm.betaActionLabel": "Beta", - "uiActionsEnhanced.components.DrilldownForm.betaActionTooltip": "Diese Aktion befindet sich in der Beta-Phase und kann sich ändern. Das Design und der Code sind weniger ausgereift als die offiziellen GA-Features und werden unverändert ohne Gewährleistungen zur Verfügung gestellt. Beta-Features unterliegen nicht dem Support-SLA der offiziellen GA-Features. Bitte helfen Sie uns, indem Sie Fehler melden oder uns weiteres Feedback geben.", - "uiActionsEnhanced.components.DrilldownForm.changeButton": "Ändern", - "uiActionsEnhanced.components.DrilldownForm.drilldownAction": "Aktion", - "uiActionsEnhanced.components.DrilldownForm.getMoreActionsLinkLabel": "Weitere Aktionen erhalten", - "uiActionsEnhanced.components.DrilldownForm.nameOfDrilldown": "Name", - "uiActionsEnhanced.components.DrilldownForm.trigger": "Auslösen", - "uiActionsEnhanced.components.DrilldownForm.untitledDrilldown": "Unbenannter Drilldown", - "uiActionsEnhanced.components.DrilldownTable.actionColumnTitle": "Aktion", - "uiActionsEnhanced.components.DrilldownTable.copyDrilldownButtonLabel": "Kopieren", - "uiActionsEnhanced.components.DrilldownTable.createDrilldownButtonLabel": "Neu erstellen", - "uiActionsEnhanced.components.DrilldownTable.deleteDrilldownsButtonLabel": "Löschen ({count})", - "uiActionsEnhanced.components.DrilldownTable.editDrilldownButtonLabel": "Bearbeiten", - "uiActionsEnhanced.components.DrilldownTable.nameColumnTitle": "Name", - "uiActionsEnhanced.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "Wählen Sie diesen Drilldown", - "uiActionsEnhanced.components.DrilldownTable.triggerColumnTitle": "Trigger", - "uiActionsEnhanced.components.DrilldownTemplateTable.actionColumnTitle": "Aktion", - "uiActionsEnhanced.components.DrilldownTemplateTable.copyButtonLabel": "Kopieren ({count})", - "uiActionsEnhanced.components.DrilldownTemplateTable.nameColumnTitle": "Name", - "uiActionsEnhanced.components.DrilldownTemplateTable.selectableMessage": "Diese Vorlage auswählen", - "uiActionsEnhanced.components.DrilldownTemplateTable.singleItemCopyAction": "Kopieren", - "uiActionsEnhanced.components.DrilldownTemplateTable.sourceColumnTitle": "Panel", - "uiActionsEnhanced.components.DrilldownTemplateTable.triggerColumnTitle": "Trigger", - "uiActionsEnhanced.components.TriggerLineItem.incompatibleTooltip": "Dieser Triggertyp wird von diesem Panel nicht unterstützt", - "uiActionsEnhanced.components.TriggerPickerItem.unknown": "Unbekannt", "uiActionsEnhanced.CustomActions": "Benutzerdefinierte Aktionen", - "uiActionsEnhanced.drilldownManager.containers.TemplatePicker.label": "Vorhandenen Drilldown kopieren", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText": "Mit Drilldowns können Sie neue Verhaltensweisen für die Interaktion mit Panels definieren. Sie können mehrere Aktionen hinzufügen und den Standardfilter überschreiben.", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "Ausblenden", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "Dokumentation ansehen", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "Drilldown erstellen", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "Drilldown löschen", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "Drilldown bearbeiten", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "Unzureichendes Lizenzniveau", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "Drilldown-Typ {type} existiert nicht", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "Drilldown „{drilldownName}“ wurde erstellt.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "Drilldown gelöscht", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "Drilldown \"{drilldownName}\" wurde aktualisiert", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "Fehler beim Speichern des Drilldowns", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "Speichern Sie Ihr Dashboard, bevor Sie es testen.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n} Drilldowns gelöscht", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel": "Zurück", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel": "Schließen", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.primaryButton": "Drilldown erstellen", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.title": "Drilldown erstellen", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.body": "{count, number} {count, plural, one {drilldown} other {drilldowns}} kopiert.", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.dismiss": "Verwerfen", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.createNew": "Neu erstellen", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.manage": "Verwalten", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.primaryButton": "Speichern", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.title": "Drilldown bearbeiten", - "uiActionsEnhanced.drilldowns.drilldownManager.state.defaultTitle": "Drilldowns", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.additionalOptions": "Zusätzliche Optionen", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle": "Variable hinzufügen", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "Falls aktiviert, wird die URL mit Prozent-Codierung maskiert", @@ -7662,9 +7666,6 @@ "unifiedSearch.optionsList.popover.sortDirections": "Richtungen sortieren", "unifiedSearch.optionsList.popover.sortOrder.asc": "Aufsteigend", "unifiedSearch.optionsList.popover.sortOrder.desc": "Absteigend", - "esqlEditor.menu.exampleQueries": "Empfohlene Abfragen", - "esqlEditor.menu.helpLabel": "ES|QL-Hilfe", - "esqlEditor.menu.quickReference": "Kurzreferenz", "unifiedSearch.query.queryBar.esqlMenu.switcherLabelTitle": "Datenansicht", "unifiedSearch.query.queryBar.indexPattern.addFieldButton": "Feld zu dieser Datenansicht hinzufügen", "unifiedSearch.query.queryBar.indexPattern.addNewDataView": "Datenansicht erstellen", @@ -10205,7 +10206,6 @@ "xpack.apm.home.dashboardsTabLabel": "Dashboards", "xpack.apm.home.infraTabLabel": "Infrastruktur", "xpack.apm.home.profilingTabLabel": "Universelles Profiling", - "xpack.apm.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "Aktive Warnungen", "xpack.apm.home.serviceGroups.tooltip.activeAlertsExplanation": "Aktive Alerts", "xpack.apm.home.serviceLogsTabLabel": "Logs", "xpack.apm.home.serviceMapTabLabel": "Shard", @@ -10920,7 +10920,6 @@ "xpack.apm.setupInstructionsButtonLabel": "Einrichtungsanweisungen", "xpack.apm.slo.callout.createButton": "SLO erstellen", "xpack.apm.slo.callout.description": "Sorgen Sie mit einem Service Level Objective (SLO) für eine hohe Leistung, Geschwindigkeit und Nutzererfahrung Ihres Services.", - "xpack.apm.slo.callout.dimissButton": "Dies ausblenden", "xpack.apm.slo.callout.title": "Reagieren Sie schneller mit SLOs", "xpack.apm.spanLinks.callout.description": "Ein Link ist ein Zeiger vom aktuellen Bereich zu einem anderen Bereich in der gleichen oder einer anderen Spur. Zum Beispiel. Dies kann bei Batch-Vorgängen verwendet werden, bei denen ein einzelner Batch-Handler mehrere Anforderungen aus verschiedenen Traces verarbeitet oder wenn der Handler eine Anforderung aus einem anderen Projekt erhält.", "xpack.apm.spanLinks.callout.dimissButton": "Verwerfen", @@ -14031,11 +14030,6 @@ "xpack.customBranding.settings.subscriptionRequiredLink.text": "Ein Abonnement ist erforderlich.", "xpack.customBranding.uiSettings.validate.customLogo.badFile": "Entschuldigung, diese Datei wird nicht funktionieren. Bitte versuchen Sie es mit einer anderen Bilddatei.", "xpack.customBranding.uiSettings.validate.customLogo.tooLarge": "Entschuldigung, die Datei ist zu groß. Die Bilddatei muss weniger als 200 Kilobyte umfassen.", - "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "Wählen Sie das Ziel-Dashboard aus.", - "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "Das Ziel-Dashboard („{dashboardId}“) existiert nicht mehr. Wählen Sie ein anderes Dashboard.", - "xpack.dashboard.drilldown.goToDashboard": "Zum Dashboard gehen", - "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "Drilldown erstellen", - "xpack.dashboard.panel.openFlyoutEditDrilldown.displayName": "Drilldowns verwalten", "xpack.dataQuality.details.Initializing": "Initialisieren der Seite „Details zur Datensatzqualität“", "xpack.dataQuality.Initializing": "Seite „Initialisieren der Datensatzqualität“", "xpack.dataQuality.name": "Qualität des Datensatzes", @@ -17604,7 +17598,6 @@ "xpack.fleet.policyDetails.addAgentButton": "Agent hinzufügen.", "xpack.fleet.policyDetails.addFleetServerButton": "Fleet-Server hinzufügen", "xpack.fleet.policyDetails.addPackagePolicyButtonText": "Integration hinzufügen", - "xpack.fleet.policyDetails.ErrorGettingFullAgentPolicy": "Fehler beim Laden der Agent Policy", "xpack.fleet.policyDetails.packagePoliciesTable.actionsColumnTitle": "Aktionen", "xpack.fleet.policyDetails.packagePoliciesTable.deleteActionTitle": "Integration löschen", "xpack.fleet.policyDetails.packagePoliciesTable.editActionTitle": "Integration bearbeiten", @@ -17638,7 +17631,6 @@ "xpack.fleet.policyDetails.viewAgentListTitle": "Alle Agent Policies anzeigen", "xpack.fleet.policyDetails.yamlDownloadButtonLabel": "Download-Richtlinie herunterladen", "xpack.fleet.policyDetails.yamlFlyoutCloseButtonLabel": "Schließen", - "xpack.fleet.policyDetails.yamlflyoutTitleWithName": "''{name}'' Agent Policy", "xpack.fleet.policyDetails.yamlflyoutTitleWithoutName": "Agenten-Richtlinie", "xpack.fleet.policyDetailsPackagePolicies.createFirstButtonText": "Integration hinzufügen", "xpack.fleet.policyDetailsPackagePolicies.createFirstMessage": "Diese Richtlinie hat noch keine Integrationen.", @@ -24340,7 +24332,6 @@ "xpack.metricsData.metrics.noDataConfig.beatsCard.title": "Integration von Metriken hinzufügen", "xpack.metricsData.metrics.noDataConfig.promptTitle": "Metrikdaten hinzufügen", "xpack.metricsData.metricsTable.container.averageCpuUsagePercentColumnHeader": "CPU-Auslastung (durchschnittlich)", - "xpack.metricsData.metricsTable.container.averageMemoryUsageMegabytesColumnHeader": "Speichernutzung (Durchschnitt)", "xpack.metricsData.metricsTable.container.idColumnHeader": "ID", "xpack.metricsData.metricsTable.container.paginationAriaLabel": "Paginierung von Containermetriken", "xpack.metricsData.metricsTable.container.tableCaption": "Infrastrukturmetriken für Container", @@ -24360,7 +24351,6 @@ "xpack.metricsData.metricsTable.noResultsIllustrationAlternativeText": "Eine Lupe mit einem Ausrufezeichen", "xpack.metricsData.metricsTable.numberCell.metricNotAvailableLabel": "–", "xpack.metricsData.metricsTable.pod.averageCpuUsagePercentColumnHeader": "CPU-Auslastung (durchschnittlich)", - "xpack.metricsData.metricsTable.pod.averageMemoryUsageMegabytesColumnHeader": "Speicherauslastung (Durchschnitt)", "xpack.metricsData.metricsTable.pod.nameColumnHeader": "Name", "xpack.metricsData.metricsTable.pod.paginationAriaLabel": "Pod-Metriken-Paginierung", "xpack.metricsData.metricsTable.pod.tableCaption": "Infrastrukturmetriken für Pods", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 67a08d3dc178f..da7d8c72665de 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -1368,6 +1368,7 @@ "dashboard.addPanel.newEmbeddableWithNoTitleAddedSuccessMessageTitle": "Un panneau a été ajouté", "dashboard.badge.readOnly.text": "Lecture seule", "dashboard.badge.readOnly.tooltip": "Impossible d'enregistrer les tableaux de bord", + "dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "Choisir le tableau de bord de destination", "dashboard.createConfirmModal.cancelButtonLabel": "Annuler", "dashboard.createConfirmModal.confirmButtonLabel": "Redémarrer", "dashboard.createConfirmModal.continueButtonLabel": "Poursuivre les modifications", @@ -1380,6 +1381,8 @@ "dashboard.dashboardWasSavedSuccessMessage": "Le tableau de bord \"{title}\" a été enregistré", "dashboard.deleteError.toastDescription": "Erreur rencontrée lors de la suppression du tableau de bord", "dashboard.discardChangesConfirmModal.discardChangesDescription": "Toutes les modifications non enregistrées seront perdues.", + "dashboard.drilldown.errorDestinationDashboardIsMissing": "Le tableau de bord de destination (\"{dashboardId}\") n'existe plus. Choisissez un autre tableau de bord.", + "dashboard.drilldown.goToDashboard": "Accéder à Dashboard", "dashboard.editorMenu.addPanelFlyout.searchLabelText": "champ de recherche pour les panneaux", "dashboard.editorMenu.deprecatedTag": "Déclassé", "dashboard.embeddableApi.showSettings.flyout.applyButtonTitle": "Appliquer", @@ -2484,7 +2487,6 @@ "discover.esqlMode.selectedColumnsCallout": "Affichage de {selectedColumnsNumber} champ(s) sur {esqlQueryColumnsNumber}. Ajoutez-en d'autres depuis la liste des champs disponibles.", "discover.esqlToDataViewTransitionModal.closeButtonLabel": "Annuler et changer", "discover.esqlToDataViewTransitionModal.dismissButtonLabel": "Ne plus me demander", - "discover.esqlToDataViewTransitionModal.feedbackLink": "Soumettre des commentaires ES|QL", "discover.esqlToDataViewTransitionModal.saveButtonLabel": "Sauvegarder et basculer", "discover.esqlToDataViewTransitionModal.title": "Modifications non enregistrées", "discover.esqlToDataviewTransitionModalBody": "Un changement de vue de données supprime la requête ES|QL en cours. Enregistrez cette session pour éviter de perdre votre travail.", @@ -2700,8 +2702,61 @@ "embeddableApi.common.constants.grouping.annotations": "Annotations et Navigation", "embeddableApi.common.constants.grouping.other": "Autre", "embeddableApi.common.constants.grouping.visualizations": "Visualisations", + "embeddableApi.components.DrilldownForm.changeButton": "Modifier", + "embeddableApi.components.DrilldownForm.drilldownAction": "Action", + "embeddableApi.components.DrilldownForm.nameOfDrilldown": "Nom", + "embeddableApi.components.DrilldownForm.trigger": "Déclencher", + "embeddableApi.components.DrilldownForm.untitledDrilldown": "Recherche sans titre", + "embeddableApi.components.DrilldownTable.actionColumnTitle": "Action", + "embeddableApi.components.DrilldownTable.copyDrilldownButtonLabel": "Copier", + "embeddableApi.components.DrilldownTable.createDrilldownButtonLabel": "Créer", + "embeddableApi.components.DrilldownTable.deleteDrilldownsButtonLabel": "Supprimer ({count})", + "embeddableApi.components.DrilldownTable.editDrilldownButtonLabel": "Modifier", + "embeddableApi.components.DrilldownTable.nameColumnTitle": "Nom", + "embeddableApi.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "Sélectionner cette recherche", + "embeddableApi.components.DrilldownTable.triggerColumnTitle": "Déclencher", + "embeddableApi.components.DrilldownTemplateTable.actionColumnTitle": "Action", + "embeddableApi.components.DrilldownTemplateTable.copyButtonLabel": "Copier ({count})", + "embeddableApi.components.DrilldownTemplateTable.nameColumnTitle": "Nom", + "embeddableApi.components.DrilldownTemplateTable.selectableMessage": "Sélectionner ce modèle", + "embeddableApi.components.DrilldownTemplateTable.singleItemCopyAction": "Copier", + "embeddableApi.components.DrilldownTemplateTable.sourceColumnTitle": "Panneau", + "embeddableApi.components.DrilldownTemplateTable.triggerColumnTitle": "Déclencher", + "embeddableApi.components.TriggerLineItem.incompatibleTooltip": "Ce type de déclenchement n'est pas pris en charge par ce panneau", + "embeddableApi.components.TriggerPickerItem.unknown": "Inconnu", + "embeddableApi.createDrilldownAction.displayName": "Créer une recherche", + "embeddableApi.drilldownManager.containers.TemplatePicker.label": "Copier la recherche existante", + "embeddableApi.drilldowns.components.DrilldownHelloBar.helpText": "Les recherches vous permettent de définir de nouveaux comportements pour l'interaction avec les panneaux. Vous pouvez ajouter plusieurs actions et remplacer le filtre par défaut.", + "embeddableApi.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "Masquer", + "embeddableApi.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "Afficher les documents", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "Créer une recherche", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "Supprimer une recherche", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "Modifier une recherche", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "Niveau de licence insuffisant", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "Le type de recherche {type} n'existe pas", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "Enregistrez votre tableau de bord avant de tester.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "Recherche \"{drilldownName}\" créée", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "Enregistrez votre tableau de bord avant de tester.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "Recherche supprimée", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "Enregistrez votre tableau de bord avant de tester.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "Recherche \"{drilldownName}\" mise à jour", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "Erreur lors de l'enregistrement de l'exploration", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "Enregistrez votre tableau de bord avant de tester.", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n} recherches supprimées", + "embeddableApi.drilldowns.components.FlyoutFrame.BackButtonLabel": "Retour", + "embeddableApi.drilldowns.components.FlyoutFrame.CloseButtonLabel": "Fermer", + "embeddableApi.drilldowns.containers.createDrilldownForm.primaryButton": "Créer une recherche", + "embeddableApi.drilldowns.containers.createDrilldownForm.title": "Créer une recherche", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.body": "{count, number} {count, plural, one {drilldown} other {explorations}} copiés.", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.dismiss": "Rejeter", + "embeddableApi.drilldowns.containers.DrilldownManager.createNew": "Créer nouvelle", + "embeddableApi.drilldowns.containers.DrilldownManager.manage": "Gérer", + "embeddableApi.drilldowns.containers.editDrilldownForm.primaryButton": "Enregistrer", + "embeddableApi.drilldowns.containers.editDrilldownForm.title": "Modifier l'exploration", + "embeddableApi.drilldowns.drilldownManager.state.defaultTitle": "Explorations", "embeddableApi.errors.paneldoesNotExist": "Panneau introuvable", "embeddableApi.errors.panelIncompatibleError": "L'API du panneau n'est pas compatible", + "embeddableApi.manageDrilldownAction.displayName": "Gérer les recherches", "embeddableApi.reactEmbeddable.factoryAlreadyExistsError": "Une usine incorporable pour le type : {key} est déjà enregistrée.", "embeddableApi.reactEmbeddable.factoryNotFoundError": "Aucune usine incorporable n'a été trouvée pour le type : {key}", "embeddableExamples.dataTable.ariaLabel": "Tableau de données", @@ -2772,6 +2827,9 @@ "esqlEditor.discardStarredQueryModal.dismissButtonLabel": "Ne plus me demander", "esqlEditor.discardStarredQueryModal.title": "Abandonner la requête avec étoile", "esqlEditor.history.starredItemslimit": "Affichage de {starredItemsCount} requêtes (max {starredItemsLimit})", + "esqlEditor.menu.exampleQueries": "Requêtes recommandées", + "esqlEditor.menu.helpLabel": "Aide sur ES|QL", + "esqlEditor.menu.quickReference": "Référence rapide", "esqlEditor.query.aborted": "La demande a été annulée", "esqlEditor.query.cancel": "Annuler", "esqlEditor.query.collapseLabel": "Réduire", @@ -7403,61 +7461,7 @@ "uiActionsEnhanced.components.actionWizard.triggerPickerHelpText": "Qu'est-ce que c'est ?", "uiActionsEnhanced.components.actionWizard.triggerPickerHelpTooltip": "Détermine quand la recherche s'affiche dans le menu contextuel", "uiActionsEnhanced.components.actionWizard.triggerPickerLabel": "Afficher l'option sur :", - "uiActionsEnhanced.components.DrilldownForm.betaActionLabel": "Bêta", - "uiActionsEnhanced.components.DrilldownForm.betaActionTooltip": "Cette action est en version bêta et susceptible d'être modifiée. La conception et le code sont moins matures que les fonctionnalités officielles en disponibilité générale et sont fournis tels quels sans aucune garantie. Les fonctionnalités bêta ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale. Nous vous remercions de bien vouloir nous aider en nous signalant les bugs ou en nous envoyant d'autres commentaires.", - "uiActionsEnhanced.components.DrilldownForm.changeButton": "Modifier", - "uiActionsEnhanced.components.DrilldownForm.drilldownAction": "Action", - "uiActionsEnhanced.components.DrilldownForm.getMoreActionsLinkLabel": "Obtenir plus d'actions", - "uiActionsEnhanced.components.DrilldownForm.nameOfDrilldown": "Nom", - "uiActionsEnhanced.components.DrilldownForm.trigger": "Déclencher", - "uiActionsEnhanced.components.DrilldownForm.untitledDrilldown": "Recherche sans titre", - "uiActionsEnhanced.components.DrilldownTable.actionColumnTitle": "Action", - "uiActionsEnhanced.components.DrilldownTable.copyDrilldownButtonLabel": "Copier", - "uiActionsEnhanced.components.DrilldownTable.createDrilldownButtonLabel": "Créer", - "uiActionsEnhanced.components.DrilldownTable.deleteDrilldownsButtonLabel": "Supprimer ({count})", - "uiActionsEnhanced.components.DrilldownTable.editDrilldownButtonLabel": "Modifier", - "uiActionsEnhanced.components.DrilldownTable.nameColumnTitle": "Nom", - "uiActionsEnhanced.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "Sélectionner cette recherche", - "uiActionsEnhanced.components.DrilldownTable.triggerColumnTitle": "Déclencher", - "uiActionsEnhanced.components.DrilldownTemplateTable.actionColumnTitle": "Action", - "uiActionsEnhanced.components.DrilldownTemplateTable.copyButtonLabel": "Copier ({count})", - "uiActionsEnhanced.components.DrilldownTemplateTable.nameColumnTitle": "Nom", - "uiActionsEnhanced.components.DrilldownTemplateTable.selectableMessage": "Sélectionner ce modèle", - "uiActionsEnhanced.components.DrilldownTemplateTable.singleItemCopyAction": "Copier", - "uiActionsEnhanced.components.DrilldownTemplateTable.sourceColumnTitle": "Panneau", - "uiActionsEnhanced.components.DrilldownTemplateTable.triggerColumnTitle": "Déclencher", - "uiActionsEnhanced.components.TriggerLineItem.incompatibleTooltip": "Ce type de déclenchement n'est pas pris en charge par ce panneau", - "uiActionsEnhanced.components.TriggerPickerItem.unknown": "Inconnu", "uiActionsEnhanced.CustomActions": "Actions personnalisées", - "uiActionsEnhanced.drilldownManager.containers.TemplatePicker.label": "Copier la recherche existante", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText": "Les recherches vous permettent de définir de nouveaux comportements pour l'interaction avec les panneaux. Vous pouvez ajouter plusieurs actions et remplacer le filtre par défaut.", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "Masquer", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "Afficher les documents", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "Créer une recherche", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "Supprimer une recherche", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "Modifier une recherche", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "Niveau de licence insuffisant", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "Le type de recherche {type} n'existe pas", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "Enregistrez votre tableau de bord avant de tester.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "Recherche \"{drilldownName}\" créée", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "Enregistrez votre tableau de bord avant de tester.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "Recherche supprimée", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "Enregistrez votre tableau de bord avant de tester.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "Recherche \"{drilldownName}\" mise à jour", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "Erreur lors de l'enregistrement de l'exploration", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "Enregistrez votre tableau de bord avant de tester.", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n} recherches supprimées", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel": "Retour", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel": "Fermer", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.primaryButton": "Créer une recherche", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.title": "Créer une recherche", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.body": "{count, number} {count, plural, one {drilldown} other {explorations}} copiés.", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.dismiss": "Rejeter", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.createNew": "Créer nouvelle", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.manage": "Gérer", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.primaryButton": "Enregistrer", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.title": "Modifier l'exploration", - "uiActionsEnhanced.drilldowns.drilldownManager.state.defaultTitle": "Explorations", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.additionalOptions": "Options supplémentaires", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle": "Ajouter une variable", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "Si elle est activée, l'URL sera précédée de l’encodage-pourcent comme caractère d'échappement", @@ -7810,9 +7814,6 @@ "unifiedSearch.optionsList.popover.sortDirections": "Sens de tri", "unifiedSearch.optionsList.popover.sortOrder.asc": "Croissant", "unifiedSearch.optionsList.popover.sortOrder.desc": "Décroissant", - "esqlEditor.menu.exampleQueries": "Requêtes recommandées", - "esqlEditor.menu.helpLabel": "Aide sur ES|QL", - "esqlEditor.menu.quickReference": "Référence rapide", "unifiedSearch.query.queryBar.esqlMenu.switcherLabelTitle": "Vue de données", "unifiedSearch.query.queryBar.indexPattern.addFieldButton": "Ajouter un champ à cette vue de données", "unifiedSearch.query.queryBar.indexPattern.addNewDataView": "Créer une vue de données", @@ -10362,7 +10363,6 @@ "xpack.apm.home.dashboardsTabLabel": "Tableaux de bord", "xpack.apm.home.infraTabLabel": "Infrastructure", "xpack.apm.home.profilingTabLabel": "Universal Profiling", - "xpack.apm.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "Alertes actives", "xpack.apm.home.serviceGroups.tooltip.activeAlertsExplanation": "Alertes actives", "xpack.apm.home.serviceLogsTabLabel": "Logs", "xpack.apm.home.serviceMapTabLabel": "Carte des services", @@ -11076,7 +11076,6 @@ "xpack.apm.setupInstructionsButtonLabel": "Instructions de configuration", "xpack.apm.slo.callout.createButton": "Créer un SLO", "xpack.apm.slo.callout.description": "Maintenir les performances, la vitesse et l'expérience utilisateur de votre service à un niveau élevé grâce à un Objectif de niveau de service (SLO).", - "xpack.apm.slo.callout.dimissButton": "Cacher ceci", "xpack.apm.slo.callout.title": "Répondre plus vite avec des SLO", "xpack.apm.spanLinks.callout.description": "Un lien est un pointeur allant de l'intervalle actuel vers un autre intervalle de la même trace ou d'une trace différente. Par exemple, vous pouvez l'utiliser dans des opérations de mise en lots, où un gestionnaire de lot unique traite plusieurs requêtes de différentes traces ou lorsque le gestionnaire reçoit une requête d'un autre projet.", "xpack.apm.spanLinks.callout.dimissButton": "Rejeter", @@ -14281,11 +14280,6 @@ "xpack.customBranding.settings.subscriptionRequiredLink.text": "Abonnement requis.", "xpack.customBranding.uiSettings.validate.customLogo.badFile": "Désolé, ce fichier ne convient pas. Veuillez essayer un autre fichier image.", "xpack.customBranding.uiSettings.validate.customLogo.tooLarge": "Désolé, ce fichier est trop volumineux. Le fichier image doit être inférieur à 200 kilo-octets.", - "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "Choisir le tableau de bord de destination", - "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "Le tableau de bord de destination (\"{dashboardId}\") n'existe plus. Choisissez un autre tableau de bord.", - "xpack.dashboard.drilldown.goToDashboard": "Accéder à Dashboard", - "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "Créer une recherche", - "xpack.dashboard.panel.openFlyoutEditDrilldown.displayName": "Gérer les recherches", "xpack.dataQuality.details.Initializing": "Initialisation de la page des détails sur la qualité de l'ensemble de données", "xpack.dataQuality.Initializing": "Page Initialisation de la qualité de l'ensemble de données", "xpack.dataQuality.name": "Qualité de l’ensemble de données", @@ -17864,7 +17858,6 @@ "xpack.fleet.policyDetails.addAgentButton": "Ajouter un agent", "xpack.fleet.policyDetails.addFleetServerButton": "Ajouter un serveur Fleet", "xpack.fleet.policyDetails.addPackagePolicyButtonText": "Ajouter une intégration", - "xpack.fleet.policyDetails.ErrorGettingFullAgentPolicy": "Erreur lors du chargement de la stratégie d'agent", "xpack.fleet.policyDetails.packagePoliciesTable.actionsColumnTitle": "Actions", "xpack.fleet.policyDetails.packagePoliciesTable.deleteActionTitle": "Supprimer l'intégration", "xpack.fleet.policyDetails.packagePoliciesTable.editActionTitle": "Modifier l'intégration", @@ -17898,7 +17891,6 @@ "xpack.fleet.policyDetails.viewAgentListTitle": "Afficher toutes les stratégies d'agent", "xpack.fleet.policyDetails.yamlDownloadButtonLabel": "Télécharger la stratégie", "xpack.fleet.policyDetails.yamlFlyoutCloseButtonLabel": "Fermer", - "xpack.fleet.policyDetails.yamlflyoutTitleWithName": "Stratégie d'agent \"{name}\"", "xpack.fleet.policyDetails.yamlflyoutTitleWithoutName": "Politique d'agent", "xpack.fleet.policyDetailsPackagePolicies.createFirstButtonText": "Ajouter une intégration", "xpack.fleet.policyDetailsPackagePolicies.createFirstMessage": "Cette stratégie ne comporte pas encore d'intégration.", @@ -24682,7 +24674,6 @@ "xpack.metricsData.metrics.noDataConfig.beatsCard.title": "Ajouter une intégration d’indicateur", "xpack.metricsData.metrics.noDataConfig.promptTitle": "Ajouter des données d'indicateurs", "xpack.metricsData.metricsTable.container.averageCpuUsagePercentColumnHeader": "Utilisation CPU (moy.)", - "xpack.metricsData.metricsTable.container.averageMemoryUsageMegabytesColumnHeader": "Utilisation de la mémoire (moy.)", "xpack.metricsData.metricsTable.container.idColumnHeader": "Id", "xpack.metricsData.metricsTable.container.paginationAriaLabel": "Pagination des indicateurs de conteneur", "xpack.metricsData.metricsTable.container.tableCaption": "Indicateurs d’infrastructure pour les conteneurs", @@ -24702,7 +24693,6 @@ "xpack.metricsData.metricsTable.noResultsIllustrationAlternativeText": "Une loupe avec un point d'exclamation", "xpack.metricsData.metricsTable.numberCell.metricNotAvailableLabel": "N/A", "xpack.metricsData.metricsTable.pod.averageCpuUsagePercentColumnHeader": "Utilisation CPU (moy.)", - "xpack.metricsData.metricsTable.pod.averageMemoryUsageMegabytesColumnHeader": "Utilisation de la mémoire (moy.)", "xpack.metricsData.metricsTable.pod.nameColumnHeader": "Nom", "xpack.metricsData.metricsTable.pod.paginationAriaLabel": "Pagination des indicateurs du pod", "xpack.metricsData.metricsTable.pod.tableCaption": "Indicateurs d’infrastructure pour les pods", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index c43df2c04bac1..4d7ef2f4c41a2 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -1369,6 +1369,7 @@ "dashboard.addPanel.newEmbeddableWithNoTitleAddedSuccessMessageTitle": "パネルが追加されました", "dashboard.badge.readOnly.text": "読み取り専用", "dashboard.badge.readOnly.tooltip": "ダッシュボードを保存できません", + "dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "対象ダッシュボードを選択", "dashboard.createConfirmModal.cancelButtonLabel": "キャンセル", "dashboard.createConfirmModal.confirmButtonLabel": "やり直す", "dashboard.createConfirmModal.continueButtonLabel": "編集を続行", @@ -1381,6 +1382,8 @@ "dashboard.dashboardWasSavedSuccessMessage": "ダッシュボード''{title}''が保存されました", "dashboard.deleteError.toastDescription": "ダッシュボードの削除中にエラーが発生しました", "dashboard.discardChangesConfirmModal.discardChangesDescription": "保存されていない変更は、すべて失われます。", + "dashboard.drilldown.errorDestinationDashboardIsMissing": "対象ダッシュボード(''{dashboardId}'')は存在しません。別のダッシュボードを選択してください。", + "dashboard.drilldown.goToDashboard": "ダッシュボードに移動", "dashboard.editorMenu.addPanelFlyout.searchLabelText": "パネルの検索フィールド", "dashboard.editorMenu.deprecatedTag": "非推奨", "dashboard.embeddableApi.showSettings.flyout.applyButtonTitle": "適用", @@ -2484,7 +2487,6 @@ "discover.esqlMode.selectedColumnsCallout": "{esqlQueryColumnsNumber}フィールド中{selectedColumnsNumber}フィールドを表示します。利用可能なフィールドリストからさらに追加します。", "discover.esqlToDataViewTransitionModal.closeButtonLabel": "破棄して切り替える", "discover.esqlToDataViewTransitionModal.dismissButtonLabel": "次回以降確認しない", - "discover.esqlToDataViewTransitionModal.feedbackLink": "ES|QLフィードバックを送信", "discover.esqlToDataViewTransitionModal.saveButtonLabel": "保存して切り替え", "discover.esqlToDataViewTransitionModal.title": "保存されていない変更", "discover.esqlToDataviewTransitionModalBody": "データビューを切り替えると、現在のES|QLクエリが削除されます。作業が失われないようにするには、このセッションを保存してください。", @@ -2700,8 +2702,61 @@ "embeddableApi.common.constants.grouping.annotations": "注釈とナビゲーション", "embeddableApi.common.constants.grouping.other": "その他", "embeddableApi.common.constants.grouping.visualizations": "ビジュアライゼーション", + "embeddableApi.components.DrilldownForm.changeButton": "変更", + "embeddableApi.components.DrilldownForm.drilldownAction": "アクション", + "embeddableApi.components.DrilldownForm.nameOfDrilldown": "名前", + "embeddableApi.components.DrilldownForm.trigger": "トリガー", + "embeddableApi.components.DrilldownForm.untitledDrilldown": "無題のドリルダウン", + "embeddableApi.components.DrilldownTable.actionColumnTitle": "アクション", + "embeddableApi.components.DrilldownTable.copyDrilldownButtonLabel": "コピー", + "embeddableApi.components.DrilldownTable.createDrilldownButtonLabel": "新規作成", + "embeddableApi.components.DrilldownTable.deleteDrilldownsButtonLabel": "削除({count})", + "embeddableApi.components.DrilldownTable.editDrilldownButtonLabel": "編集", + "embeddableApi.components.DrilldownTable.nameColumnTitle": "名前", + "embeddableApi.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "このドリルダウンを選択", + "embeddableApi.components.DrilldownTable.triggerColumnTitle": "トリガー", + "embeddableApi.components.DrilldownTemplateTable.actionColumnTitle": "アクション", + "embeddableApi.components.DrilldownTemplateTable.copyButtonLabel": "コピー({count})", + "embeddableApi.components.DrilldownTemplateTable.nameColumnTitle": "名前", + "embeddableApi.components.DrilldownTemplateTable.selectableMessage": "このテンプレートを選択", + "embeddableApi.components.DrilldownTemplateTable.singleItemCopyAction": "コピー", + "embeddableApi.components.DrilldownTemplateTable.sourceColumnTitle": "パネル", + "embeddableApi.components.DrilldownTemplateTable.triggerColumnTitle": "トリガー", + "embeddableApi.components.TriggerLineItem.incompatibleTooltip": "このトリガータイプはこのパネルでサポートされていません", + "embeddableApi.components.TriggerPickerItem.unknown": "不明", + "embeddableApi.createDrilldownAction.displayName": "ドリルダウンを作成", + "embeddableApi.drilldownManager.containers.TemplatePicker.label": "既存のドリルダウンをコピー", + "embeddableApi.drilldowns.components.DrilldownHelloBar.helpText": "ドリルダウンにより、パネルと連携する新しい動作を定義できます。複数のアクションを追加し、デフォルトフィルターを無効化できます。", + "embeddableApi.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "非表示", + "embeddableApi.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "ドキュメントを表示", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "ドリルダウンを作成", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "ドリルダウンを削除", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "ドリルダウンを編集", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "不十分なライセンスレベル", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "ドリルダウンタイプ{type}が存在しません", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "テストする前にダッシュボードを保存してください。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "ドリルダウン「{drilldownName}」が作成されました", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "テストする前にダッシュボードを保存してください。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "ドリルダウンが削除されました", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "テストする前にダッシュボードを保存してください。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "ドリルダウン「{drilldownName}」が更新されました", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "ドリルダウンの保存エラー", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "テストする前にダッシュボードを保存してください。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n}個のドリルダウンが削除されました", + "embeddableApi.drilldowns.components.FlyoutFrame.BackButtonLabel": "戻る", + "embeddableApi.drilldowns.components.FlyoutFrame.CloseButtonLabel": "閉じる", + "embeddableApi.drilldowns.containers.createDrilldownForm.primaryButton": "ドリルダウンを作成", + "embeddableApi.drilldowns.containers.createDrilldownForm.title": "ドリルダウンを作成", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.body": "{count, number} {count, plural, one {drilldown} other {個のドリルダウン}} をコピーしました。", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.dismiss": "閉じる", + "embeddableApi.drilldowns.containers.DrilldownManager.createNew": "新規作成", + "embeddableApi.drilldowns.containers.DrilldownManager.manage": "管理", + "embeddableApi.drilldowns.containers.editDrilldownForm.primaryButton": "保存", + "embeddableApi.drilldowns.containers.editDrilldownForm.title": "ドリルダウンを編集", + "embeddableApi.drilldowns.drilldownManager.state.defaultTitle": "ドリルダウン", "embeddableApi.errors.paneldoesNotExist": "パネルが見つかりません", "embeddableApi.errors.panelIncompatibleError": "パネルAPIに互換性がありません", + "embeddableApi.manageDrilldownAction.displayName": "ドリルダウンを管理", "embeddableApi.reactEmbeddable.factoryAlreadyExistsError": "タイプ\"{key}\"の埋め込み可能ファクトリはすでに登録されています。", "embeddableApi.reactEmbeddable.factoryNotFoundError": "タイプ\"{key}\"の埋め込み可能ファクトリが見つかりません", "embeddableExamples.dataTable.ariaLabel": "データテーブル", @@ -2773,6 +2828,9 @@ "esqlEditor.discardStarredQueryModal.dismissButtonLabel": "次回以降確認しない", "esqlEditor.discardStarredQueryModal.title": "スター付きのクエリを破棄", "esqlEditor.history.starredItemslimit": "{starredItemsCount}件のクエリを表示中(最大{starredItemsLimit})", + "esqlEditor.menu.exampleQueries": "推奨クエリ", + "esqlEditor.menu.helpLabel": "ES|QLヘルプ", + "esqlEditor.menu.quickReference": "クイックリファレンス", "esqlEditor.query.aborted": "リクエストが中断されました", "esqlEditor.query.cancel": "キャンセル", "esqlEditor.query.collapseLabel": "折りたたみ", @@ -7410,61 +7468,7 @@ "uiActionsEnhanced.components.actionWizard.triggerPickerHelpText": "概要", "uiActionsEnhanced.components.actionWizard.triggerPickerHelpTooltip": "ドリルダウンがコンテキストメニューに表示されるタイミングを決定します。", "uiActionsEnhanced.components.actionWizard.triggerPickerLabel": "オプションを表示:", - "uiActionsEnhanced.components.DrilldownForm.betaActionLabel": "ベータ", - "uiActionsEnhanced.components.DrilldownForm.betaActionTooltip": "このアクションはベータ段階で、変更される可能性があります。デザインとコードはオフィシャルGA機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャル GA 機能の SLA が適用されません。バグを報告したり、その他のフィードバックを提供したりして、当社を支援してください。", - "uiActionsEnhanced.components.DrilldownForm.changeButton": "変更", - "uiActionsEnhanced.components.DrilldownForm.drilldownAction": "アクション", - "uiActionsEnhanced.components.DrilldownForm.getMoreActionsLinkLabel": "さらにアクションを表示", - "uiActionsEnhanced.components.DrilldownForm.nameOfDrilldown": "名前", - "uiActionsEnhanced.components.DrilldownForm.trigger": "トリガー", - "uiActionsEnhanced.components.DrilldownForm.untitledDrilldown": "無題のドリルダウン", - "uiActionsEnhanced.components.DrilldownTable.actionColumnTitle": "アクション", - "uiActionsEnhanced.components.DrilldownTable.copyDrilldownButtonLabel": "コピー", - "uiActionsEnhanced.components.DrilldownTable.createDrilldownButtonLabel": "新規作成", - "uiActionsEnhanced.components.DrilldownTable.deleteDrilldownsButtonLabel": "削除({count})", - "uiActionsEnhanced.components.DrilldownTable.editDrilldownButtonLabel": "編集", - "uiActionsEnhanced.components.DrilldownTable.nameColumnTitle": "名前", - "uiActionsEnhanced.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "このドリルダウンを選択", - "uiActionsEnhanced.components.DrilldownTable.triggerColumnTitle": "トリガー", - "uiActionsEnhanced.components.DrilldownTemplateTable.actionColumnTitle": "アクション", - "uiActionsEnhanced.components.DrilldownTemplateTable.copyButtonLabel": "コピー({count})", - "uiActionsEnhanced.components.DrilldownTemplateTable.nameColumnTitle": "名前", - "uiActionsEnhanced.components.DrilldownTemplateTable.selectableMessage": "このテンプレートを選択", - "uiActionsEnhanced.components.DrilldownTemplateTable.singleItemCopyAction": "コピー", - "uiActionsEnhanced.components.DrilldownTemplateTable.sourceColumnTitle": "パネル", - "uiActionsEnhanced.components.DrilldownTemplateTable.triggerColumnTitle": "トリガー", - "uiActionsEnhanced.components.TriggerLineItem.incompatibleTooltip": "このトリガータイプはこのパネルでサポートされていません", - "uiActionsEnhanced.components.TriggerPickerItem.unknown": "不明", "uiActionsEnhanced.CustomActions": "カスタムアクション", - "uiActionsEnhanced.drilldownManager.containers.TemplatePicker.label": "既存のドリルダウンをコピー", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText": "ドリルダウンにより、パネルと連携する新しい動作を定義できます。複数のアクションを追加し、デフォルトフィルターを無効化できます。", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "非表示", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "ドキュメントを表示", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "ドリルダウンを作成", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "ドリルダウンを削除", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "ドリルダウンを編集", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "不十分なライセンスレベル", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "ドリルダウンタイプ{type}が存在しません", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "テストする前にダッシュボードを保存してください。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "ドリルダウン「{drilldownName}」が作成されました", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "テストする前にダッシュボードを保存してください。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "ドリルダウンが削除されました", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "テストする前にダッシュボードを保存してください。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "ドリルダウン「{drilldownName}」が更新されました", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "ドリルダウンの保存エラー", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "テストする前にダッシュボードを保存してください。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n}個のドリルダウンが削除されました", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel": "戻る", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel": "閉じる", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.primaryButton": "ドリルダウンを作成", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.title": "ドリルダウンを作成", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.body": "{count, number} {count, plural, one {drilldown} other {個のドリルダウン}} をコピーしました。", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.dismiss": "閉じる", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.createNew": "新規作成", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.manage": "管理", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.primaryButton": "保存", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.title": "ドリルダウンを編集", - "uiActionsEnhanced.drilldowns.drilldownManager.state.defaultTitle": "ドリルダウン", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.additionalOptions": "その他のオプション", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle": "変数を追加", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "有効な場合、URLはパーセントエンコーディングを使用してエスケープされます", @@ -7822,9 +7826,6 @@ "unifiedSearch.optionsList.popover.sortDirections": "並べ替え方向", "unifiedSearch.optionsList.popover.sortOrder.asc": "昇順", "unifiedSearch.optionsList.popover.sortOrder.desc": "降順", - "esqlEditor.menu.exampleQueries": "推奨クエリ", - "esqlEditor.menu.helpLabel": "ES|QLヘルプ", - "esqlEditor.menu.quickReference": "クイックリファレンス", "unifiedSearch.query.queryBar.esqlMenu.switcherLabelTitle": "データビュー", "unifiedSearch.query.queryBar.indexPattern.addFieldButton": "フィールドをこのデータビューに追加", "unifiedSearch.query.queryBar.indexPattern.addNewDataView": "データビューを作成", @@ -10375,7 +10376,6 @@ "xpack.apm.home.dashboardsTabLabel": "ダッシュボード", "xpack.apm.home.infraTabLabel": "インフラストラクチャー", "xpack.apm.home.profilingTabLabel": "ユニバーサルプロファイリング", - "xpack.apm.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "アクティブアラート", "xpack.apm.home.serviceGroups.tooltip.activeAlertsExplanation": "アクティブアラート", "xpack.apm.home.serviceLogsTabLabel": "ログ", "xpack.apm.home.serviceMapTabLabel": "サービスマップ", @@ -11091,7 +11091,6 @@ "xpack.apm.setupInstructionsButtonLabel": "セットアップの手順", "xpack.apm.slo.callout.createButton": "SLOの作成", "xpack.apm.slo.callout.description": "サービスレベル目標(SLO)を設定することで、サービスのパフォーマンス、スピード、ユーザーエクスペリエンスを高く維持できます。", - "xpack.apm.slo.callout.dimissButton": "これを非表示", "xpack.apm.slo.callout.title": "SLOで迅速に対応", "xpack.apm.spanLinks.callout.description": "リンクは、同じトレースまたは別のトレースにおける、現在のスパンから別のスパンへのポインターです。たとえば、1つのバッチハンドラーが別のトレースからの複数の要求を処理する場合や、ハンドラーが別のプロジェクトからの要求を受信するときに、バッチ処理で使用できます。", "xpack.apm.spanLinks.callout.dimissButton": "閉じる", @@ -14300,11 +14299,6 @@ "xpack.customBranding.settings.subscriptionRequiredLink.text": "サブスクリプションが必要です。", "xpack.customBranding.uiSettings.validate.customLogo.badFile": "このファイルは動作しません。 別の画像ファイルを試してください。", "xpack.customBranding.uiSettings.validate.customLogo.tooLarge": "このファイルは大きすぎます。画像ファイルは200キロバイト未満でなければなりません。", - "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "対象ダッシュボードを選択", - "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "対象ダッシュボード(''{dashboardId}'')は存在しません。別のダッシュボードを選択してください。", - "xpack.dashboard.drilldown.goToDashboard": "ダッシュボードに移動", - "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "ドリルダウンを作成", - "xpack.dashboard.panel.openFlyoutEditDrilldown.displayName": "ドリルダウンを管理", "xpack.dataQuality.details.Initializing": "データセット品質詳細ページを初期化中", "xpack.dataQuality.Initializing": "データセット品質ページを初期化中", "xpack.dataQuality.name": "データセット品質", @@ -17887,7 +17881,6 @@ "xpack.fleet.policyDetails.addAgentButton": "エージェントの追加", "xpack.fleet.policyDetails.addFleetServerButton": "Fleetサーバーの追加", "xpack.fleet.policyDetails.addPackagePolicyButtonText": "統合の追加", - "xpack.fleet.policyDetails.ErrorGettingFullAgentPolicy": "エージェントポリシーの読み込みエラー", "xpack.fleet.policyDetails.packagePoliciesTable.actionsColumnTitle": "アクション", "xpack.fleet.policyDetails.packagePoliciesTable.deleteActionTitle": "統合の削除", "xpack.fleet.policyDetails.packagePoliciesTable.editActionTitle": "統合の編集", @@ -17921,7 +17914,6 @@ "xpack.fleet.policyDetails.viewAgentListTitle": "すべてのエージェントポリシーを表示", "xpack.fleet.policyDetails.yamlDownloadButtonLabel": "ダウンロードポリシー", "xpack.fleet.policyDetails.yamlFlyoutCloseButtonLabel": "閉じる", - "xpack.fleet.policyDetails.yamlflyoutTitleWithName": "''{name}''エージェントポリシー", "xpack.fleet.policyDetails.yamlflyoutTitleWithoutName": "エージェントポリシー", "xpack.fleet.policyDetailsPackagePolicies.createFirstButtonText": "統合の追加", "xpack.fleet.policyDetailsPackagePolicies.createFirstMessage": "このポリシーにはまだ統合がありません。", @@ -24728,7 +24720,6 @@ "xpack.metricsData.metrics.noDataConfig.beatsCard.title": "メトリック統合の追加", "xpack.metricsData.metrics.noDataConfig.promptTitle": "メトリックデータを追加", "xpack.metricsData.metricsTable.container.averageCpuUsagePercentColumnHeader": "CPU使用状況(平均)", - "xpack.metricsData.metricsTable.container.averageMemoryUsageMegabytesColumnHeader": "メモリー使用状況(平均)", "xpack.metricsData.metricsTable.container.idColumnHeader": "Id", "xpack.metricsData.metricsTable.container.paginationAriaLabel": "コンテナーメトリックページネーション", "xpack.metricsData.metricsTable.container.tableCaption": "コンテナーのインフラストラクチャーメトリック", @@ -24748,7 +24739,6 @@ "xpack.metricsData.metricsTable.noResultsIllustrationAlternativeText": "感嘆符が付いた虫眼鏡", "xpack.metricsData.metricsTable.numberCell.metricNotAvailableLabel": "N/A", "xpack.metricsData.metricsTable.pod.averageCpuUsagePercentColumnHeader": "CPU使用状況(平均)", - "xpack.metricsData.metricsTable.pod.averageMemoryUsageMegabytesColumnHeader": "メモリー使用状況(平均)", "xpack.metricsData.metricsTable.pod.nameColumnHeader": "名前", "xpack.metricsData.metricsTable.pod.paginationAriaLabel": "ポッドメトリックページネーション", "xpack.metricsData.metricsTable.pod.tableCaption": "ポッドのインフラストラクチャーメトリック", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index e22f953fc5e63..370c7ecbbea3e 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -1363,6 +1363,7 @@ "dashboard.addPanel.newEmbeddableWithNoTitleAddedSuccessMessageTitle": "已添加一个面板", "dashboard.badge.readOnly.text": "只读", "dashboard.badge.readOnly.tooltip": "无法保存仪表板", + "dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "选择目标仪表板", "dashboard.createConfirmModal.cancelButtonLabel": "取消", "dashboard.createConfirmModal.confirmButtonLabel": "重头开始", "dashboard.createConfirmModal.continueButtonLabel": "继续编辑", @@ -1375,6 +1376,8 @@ "dashboard.dashboardWasSavedSuccessMessage": "仪表板“{title}”已保存", "dashboard.deleteError.toastDescription": "删除仪表板时发生错误", "dashboard.discardChangesConfirmModal.discardChangesDescription": "所有未保存的更改将会丢失。", + "dashboard.drilldown.errorDestinationDashboardIsMissing": "目标仪表板(“{dashboardId}”)已不存在。选择其他仪表板。", + "dashboard.drilldown.goToDashboard": "前往仪表板", "dashboard.editorMenu.addPanelFlyout.searchLabelText": "搜索面板的字段", "dashboard.editorMenu.deprecatedTag": "已弃用", "dashboard.embeddableApi.showSettings.flyout.applyButtonTitle": "应用", @@ -2477,7 +2480,6 @@ "discover.esqlMode.selectedColumnsCallout": "显示 {selectedColumnsNumber} 个字段,共 {esqlQueryColumnsNumber} 个。从可用字段列表中添加更多字段。", "discover.esqlToDataViewTransitionModal.closeButtonLabel": "丢弃并切换", "discover.esqlToDataViewTransitionModal.dismissButtonLabel": "不再询问我", - "discover.esqlToDataViewTransitionModal.feedbackLink": "提交 ES|QL 反馈", "discover.esqlToDataViewTransitionModal.saveButtonLabel": "保存并切换", "discover.esqlToDataViewTransitionModal.title": "未保存的更改", "discover.esqlToDataviewTransitionModalBody": "切换数据视图会移除当前的 ES|QL 查询。保存此会话以避免丢失工作。", @@ -2693,6 +2695,57 @@ "embeddableApi.common.constants.grouping.annotations": "标注和导航", "embeddableApi.common.constants.grouping.other": "其他", "embeddableApi.common.constants.grouping.visualizations": "可视化", + "embeddableApi.components.DrilldownForm.changeButton": "更改", + "embeddableApi.components.DrilldownForm.drilldownAction": "操作", + "embeddableApi.components.DrilldownForm.nameOfDrilldown": "名称", + "embeddableApi.components.DrilldownForm.trigger": "触发", + "embeddableApi.components.DrilldownForm.untitledDrilldown": "未命名向下钻取", + "embeddableApi.components.DrilldownTable.actionColumnTitle": "操作", + "embeddableApi.components.DrilldownTable.copyDrilldownButtonLabel": "复制", + "embeddableApi.components.DrilldownTable.createDrilldownButtonLabel": "新建", + "embeddableApi.components.DrilldownTable.deleteDrilldownsButtonLabel": "删除 ({count})", + "embeddableApi.components.DrilldownTable.editDrilldownButtonLabel": "编辑", + "embeddableApi.components.DrilldownTable.nameColumnTitle": "名称", + "embeddableApi.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "选择此向下钻取", + "embeddableApi.components.DrilldownTable.triggerColumnTitle": "触发", + "embeddableApi.components.DrilldownTemplateTable.actionColumnTitle": "操作", + "embeddableApi.components.DrilldownTemplateTable.copyButtonLabel": "复制 ({count})", + "embeddableApi.components.DrilldownTemplateTable.nameColumnTitle": "名称", + "embeddableApi.components.DrilldownTemplateTable.selectableMessage": "选择此模板", + "embeddableApi.components.DrilldownTemplateTable.singleItemCopyAction": "复制", + "embeddableApi.components.DrilldownTemplateTable.sourceColumnTitle": "面板", + "embeddableApi.components.DrilldownTemplateTable.triggerColumnTitle": "触发", + "embeddableApi.components.TriggerLineItem.incompatibleTooltip": "此触发类型不受此面板支持", + "embeddableApi.components.TriggerPickerItem.unknown": "未知", + "embeddableApi.drilldownManager.containers.TemplatePicker.label": "复制现有向下钻取", + "embeddableApi.drilldowns.components.DrilldownHelloBar.helpText": "向下钻取允许您定义与面板交互的新行为。您可以添加多个操作并覆盖默认筛选。", + "embeddableApi.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "隐藏", + "embeddableApi.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "查看文档", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "创建向下钻取", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "删除向下钻取", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "编辑向下钻取", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "许可证级别不够", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "向下钻取类型 {type} 不存在", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "在测试前保存仪表板。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "向下钻取“{drilldownName}”已创建", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "在测试前保存仪表板。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "向下钻取已删除", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "在测试前保存仪表板。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "向下钻取“{drilldownName}”已更新", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "保存向下钻取时出错", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "在测试前保存仪表板。", + "embeddableApi.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n} 个向下钻取已删除", + "embeddableApi.drilldowns.components.FlyoutFrame.BackButtonLabel": "返回", + "embeddableApi.drilldowns.components.FlyoutFrame.CloseButtonLabel": "关闭", + "embeddableApi.drilldowns.containers.createDrilldownForm.primaryButton": "创建向下钻取", + "embeddableApi.drilldowns.containers.createDrilldownForm.title": "创建向下钻取", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.body": "已复制 {count, number} 个向下钻取。", + "embeddableApi.drilldowns.containers.drilldownList.copyingNotification.dismiss": "关闭", + "embeddableApi.drilldowns.containers.DrilldownManager.createNew": "新建", + "embeddableApi.drilldowns.containers.DrilldownManager.manage": "管理", + "embeddableApi.drilldowns.containers.editDrilldownForm.primaryButton": "保存", + "embeddableApi.drilldowns.containers.editDrilldownForm.title": "编辑向下钻取", + "embeddableApi.drilldowns.drilldownManager.state.defaultTitle": "向下钻取", "embeddableApi.errors.paneldoesNotExist": "未找到面板", "embeddableApi.errors.panelIncompatibleError": "面板 API 不兼容", "embeddableApi.reactEmbeddable.factoryAlreadyExistsError": "已注册类型为 {key} 的可嵌入工厂。", @@ -2766,6 +2819,9 @@ "esqlEditor.discardStarredQueryModal.dismissButtonLabel": "不再询问我", "esqlEditor.discardStarredQueryModal.title": "丢弃带星标查询", "esqlEditor.history.starredItemslimit": "正在显示 {starredItemsCount} 个查询(最多 {starredItemsLimit} 个)", + "esqlEditor.menu.exampleQueries": "建议的查询", + "esqlEditor.menu.helpLabel": "ES|QL 帮助", + "esqlEditor.menu.quickReference": "快速参考", "esqlEditor.query.aborted": "请求已中止", "esqlEditor.query.cancel": "取消", "esqlEditor.query.collapseLabel": "折叠", @@ -7403,61 +7459,7 @@ "uiActionsEnhanced.components.actionWizard.triggerPickerHelpText": "这是什么?", "uiActionsEnhanced.components.actionWizard.triggerPickerHelpTooltip": "确定向下钻取显示在上下文菜单中的时间", "uiActionsEnhanced.components.actionWizard.triggerPickerLabel": "显示相关选项:", - "uiActionsEnhanced.components.DrilldownForm.betaActionLabel": "公测版", - "uiActionsEnhanced.components.DrilldownForm.betaActionTooltip": "此操作处于公测版阶段,可能会有所更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束。请通过报告错误或提供其他反馈来帮助我们。", - "uiActionsEnhanced.components.DrilldownForm.changeButton": "更改", - "uiActionsEnhanced.components.DrilldownForm.drilldownAction": "操作", - "uiActionsEnhanced.components.DrilldownForm.getMoreActionsLinkLabel": "获取更多的操作", - "uiActionsEnhanced.components.DrilldownForm.nameOfDrilldown": "名称", - "uiActionsEnhanced.components.DrilldownForm.trigger": "触发", - "uiActionsEnhanced.components.DrilldownForm.untitledDrilldown": "未命名向下钻取", - "uiActionsEnhanced.components.DrilldownTable.actionColumnTitle": "操作", - "uiActionsEnhanced.components.DrilldownTable.copyDrilldownButtonLabel": "复制", - "uiActionsEnhanced.components.DrilldownTable.createDrilldownButtonLabel": "新建", - "uiActionsEnhanced.components.DrilldownTable.deleteDrilldownsButtonLabel": "删除 ({count})", - "uiActionsEnhanced.components.DrilldownTable.editDrilldownButtonLabel": "编辑", - "uiActionsEnhanced.components.DrilldownTable.nameColumnTitle": "名称", - "uiActionsEnhanced.components.DrilldownTable.selectThisDrilldownCheckboxLabel": "选择此向下钻取", - "uiActionsEnhanced.components.DrilldownTable.triggerColumnTitle": "触发", - "uiActionsEnhanced.components.DrilldownTemplateTable.actionColumnTitle": "操作", - "uiActionsEnhanced.components.DrilldownTemplateTable.copyButtonLabel": "复制 ({count})", - "uiActionsEnhanced.components.DrilldownTemplateTable.nameColumnTitle": "名称", - "uiActionsEnhanced.components.DrilldownTemplateTable.selectableMessage": "选择此模板", - "uiActionsEnhanced.components.DrilldownTemplateTable.singleItemCopyAction": "复制", - "uiActionsEnhanced.components.DrilldownTemplateTable.sourceColumnTitle": "面板", - "uiActionsEnhanced.components.DrilldownTemplateTable.triggerColumnTitle": "触发", - "uiActionsEnhanced.components.TriggerLineItem.incompatibleTooltip": "此触发类型不受此面板支持", - "uiActionsEnhanced.components.TriggerPickerItem.unknown": "未知", "uiActionsEnhanced.CustomActions": "定制操作", - "uiActionsEnhanced.drilldownManager.containers.TemplatePicker.label": "复制现有向下钻取", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText": "向下钻取允许您定义与面板交互的新行为。您可以添加多个操作并覆盖默认筛选。", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "隐藏", - "uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "查看文档", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "创建向下钻取", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "删除向下钻取", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "编辑向下钻取", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError": "许可证级别不够", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType": "向下钻取类型 {type} 不存在", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText": "在测试前保存仪表板。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle": "向下钻取“{drilldownName}”已创建", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "在测试前保存仪表板。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "向下钻取已删除", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText": "在测试前保存仪表板。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle": "向下钻取“{drilldownName}”已更新", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "保存向下钻取时出错", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText": "在测试前保存仪表板。", - "uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle": "{n} 个向下钻取已删除", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel": "返回", - "uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel": "关闭", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.primaryButton": "创建向下钻取", - "uiActionsEnhanced.drilldowns.containers.createDrilldownForm.title": "创建向下钻取", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.body": "已复制 {count, number} 个向下钻取。", - "uiActionsEnhanced.drilldowns.containers.drilldownList.copyingNotification.dismiss": "关闭", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.createNew": "新建", - "uiActionsEnhanced.drilldowns.containers.DrilldownManager.manage": "管理", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.primaryButton": "保存", - "uiActionsEnhanced.drilldowns.containers.editDrilldownForm.title": "编辑向下钻取", - "uiActionsEnhanced.drilldowns.drilldownManager.state.defaultTitle": "向下钻取", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.additionalOptions": "其他选项", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle": "添加变量", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "如果启用,将使用百分比编码转义 URL", @@ -7814,9 +7816,6 @@ "unifiedSearch.optionsList.popover.sortDirections": "排序方向", "unifiedSearch.optionsList.popover.sortOrder.asc": "升序", "unifiedSearch.optionsList.popover.sortOrder.desc": "降序", - "esqlEditor.menu.exampleQueries": "建议的查询", - "esqlEditor.menu.helpLabel": "ES|QL 帮助", - "esqlEditor.menu.quickReference": "快速参考", "unifiedSearch.query.queryBar.esqlMenu.switcherLabelTitle": "数据视图", "unifiedSearch.query.queryBar.indexPattern.addFieldButton": "将字段添加到此数据视图", "unifiedSearch.query.queryBar.indexPattern.addNewDataView": "创建数据视图", @@ -10369,7 +10368,6 @@ "xpack.apm.home.dashboardsTabLabel": "仪表板", "xpack.apm.home.infraTabLabel": "基础设施", "xpack.apm.home.profilingTabLabel": "Universal Profiling", - "xpack.apm.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "活动告警", "xpack.apm.home.serviceGroups.tooltip.activeAlertsExplanation": "活动告警", "xpack.apm.home.serviceLogsTabLabel": "日志", "xpack.apm.home.serviceMapTabLabel": "服务地图", @@ -11085,7 +11083,6 @@ "xpack.apm.setupInstructionsButtonLabel": "设置说明", "xpack.apm.slo.callout.createButton": "创建 SLO", "xpack.apm.slo.callout.description": "通过服务水平目标 (SLO) 使服务保持较高的性能、速度和用户体验。", - "xpack.apm.slo.callout.dimissButton": "隐藏此项", "xpack.apm.slo.callout.title": "利用 SLO 更快做出响应", "xpack.apm.spanLinks.callout.description": "链接是从当前跨度指向相同或不同跟踪中的另一跨度的指针。例如,这可以用在批处理操作中,其中的单一批处理程序会处理来自不同跟踪的多个请求,或在收到来自不同项目的请求时处理该请求。", "xpack.apm.spanLinks.callout.dimissButton": "关闭", @@ -14292,11 +14289,6 @@ "xpack.customBranding.settings.subscriptionRequiredLink.text": "需要订阅。", "xpack.customBranding.uiSettings.validate.customLogo.badFile": "抱歉,该文件无效。请尝试其他图像文件。", "xpack.customBranding.uiSettings.validate.customLogo.tooLarge": "抱歉,该文件过大。图像文件必须小于 200 千字节。", - "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "选择目标仪表板", - "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "目标仪表板(“{dashboardId}”)已不存在。选择其他仪表板。", - "xpack.dashboard.drilldown.goToDashboard": "前往仪表板", - "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "创建向下钻取", - "xpack.dashboard.panel.openFlyoutEditDrilldown.displayName": "管理向下钻取", "xpack.dataQuality.details.Initializing": "正在初始化“数据集质量”详情页面", "xpack.dataQuality.Initializing": "正在初始化“数据集质量”页面", "xpack.dataQuality.name": "数据集质量", @@ -17878,7 +17870,6 @@ "xpack.fleet.policyDetails.addAgentButton": "添加代理", "xpack.fleet.policyDetails.addFleetServerButton": "添加 Fleet 服务器", "xpack.fleet.policyDetails.addPackagePolicyButtonText": "添加集成", - "xpack.fleet.policyDetails.ErrorGettingFullAgentPolicy": "加载代理策略时出错", "xpack.fleet.policyDetails.packagePoliciesTable.actionsColumnTitle": "操作", "xpack.fleet.policyDetails.packagePoliciesTable.deleteActionTitle": "删除集成", "xpack.fleet.policyDetails.packagePoliciesTable.editActionTitle": "编辑集成", @@ -17912,7 +17903,6 @@ "xpack.fleet.policyDetails.viewAgentListTitle": "查看所有代理策略", "xpack.fleet.policyDetails.yamlDownloadButtonLabel": "下载策略", "xpack.fleet.policyDetails.yamlFlyoutCloseButtonLabel": "关闭", - "xpack.fleet.policyDetails.yamlflyoutTitleWithName": "代理策略“{name}”", "xpack.fleet.policyDetails.yamlflyoutTitleWithoutName": "代理策略", "xpack.fleet.policyDetailsPackagePolicies.createFirstButtonText": "添加集成", "xpack.fleet.policyDetailsPackagePolicies.createFirstMessage": "此策略尚无任何集成。", @@ -24717,7 +24707,6 @@ "xpack.metricsData.metrics.noDataConfig.beatsCard.title": "添加指标集成", "xpack.metricsData.metrics.noDataConfig.promptTitle": "添加指标数据", "xpack.metricsData.metricsTable.container.averageCpuUsagePercentColumnHeader": "CPU 使用率(平均值)", - "xpack.metricsData.metricsTable.container.averageMemoryUsageMegabytesColumnHeader": "内存使用率(平均值)", "xpack.metricsData.metricsTable.container.idColumnHeader": "ID", "xpack.metricsData.metricsTable.container.paginationAriaLabel": "容器指标分页", "xpack.metricsData.metricsTable.container.tableCaption": "容器的基础架构指标", @@ -24737,7 +24726,6 @@ "xpack.metricsData.metricsTable.noResultsIllustrationAlternativeText": "带有惊叹号的放大镜", "xpack.metricsData.metricsTable.numberCell.metricNotAvailableLabel": "不可用", "xpack.metricsData.metricsTable.pod.averageCpuUsagePercentColumnHeader": "CPU 使用率(平均值)", - "xpack.metricsData.metricsTable.pod.averageMemoryUsageMegabytesColumnHeader": "内存使用率(平均值)", "xpack.metricsData.metricsTable.pod.nameColumnHeader": "名称", "xpack.metricsData.metricsTable.pod.paginationAriaLabel": "Pod 指标分页", "xpack.metricsData.metricsTable.pod.tableCaption": "Pod 的基础架构指标", diff --git a/x-pack/platform/plugins/shared/agent_builder/common/http_api/tools.ts b/x-pack/platform/plugins/shared/agent_builder/common/http_api/tools.ts index 230f2210e020f..a49ba2301aaa2 100644 --- a/x-pack/platform/plugins/shared/agent_builder/common/http_api/tools.ts +++ b/x-pack/platform/plugins/shared/agent_builder/common/http_api/tools.ts @@ -25,6 +25,13 @@ export interface DeleteToolResponse { success: boolean; } +export const TOOL_USED_BY_AGENTS_ERROR_CODE = 'TOOL_USED_BY_AGENTS'; + +export interface AgentRef { + id: string; + name: string; +} + export type CreateToolPayload = Omit & Partial>; diff --git a/x-pack/platform/plugins/shared/agent_builder/moon.yml b/x-pack/platform/plugins/shared/agent_builder/moon.yml index 76769986ae3e5..bcabc739957a1 100644 --- a/x-pack/platform/plugins/shared/agent_builder/moon.yml +++ b/x-pack/platform/plugins/shared/agent_builder/moon.yml @@ -110,6 +110,7 @@ dependsOn: - '@kbn/core-elasticsearch-server-mocks' - '@kbn/core-data-streams-server' - '@kbn/task-manager-plugin' + - '@kbn/deeplinks-data-sources' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/icons/robot.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/icons/robot.tsx deleted file mode 100644 index c449cea48e320..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/common/icons/robot.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EuiIconProps } from '@elastic/eui'; -import { EuiIcon } from '@elastic/eui'; -import type { SVGProps } from 'react'; -import React from 'react'; - -// Copied from x-pack/solutions/search/packages/shared-ui/src/v2_icons/robot.tsx - -const iconType: React.FC> = (props) => ( - - - - - - -); - -export const RobotIcon = ({ size = 'm', ...rest }: Omit) => { - return ; -}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx index 9efdc5acc4347..a15447fbda3ab 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_header/more_actions_button.tsx @@ -13,10 +13,13 @@ import { EuiContextMenuPanel, EuiTitle, EuiSpacer, + EuiIcon, useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; +import { DATA_SOURCES_ENABLED_SETTING_ID } from '@kbn/management-settings-ids'; +import { DATA_SOURCES_APP_ID } from '@kbn/deeplinks-data-sources'; import { css } from '@emotion/react'; import { useIsAgentReadOnly } from '../../../hooks/agents/use_is_agent_read_only'; import { useNavigation } from '../../../hooks/use_navigation'; @@ -31,8 +34,6 @@ import { appPaths } from '../../../utils/app_paths'; import { DeleteConversationModal } from '../delete_conversation_modal'; import { useHasConnectorsAllPrivileges } from '../../../hooks/use_has_connectors_all_privileges'; import { useUiPrivileges } from '../../../hooks/use_ui_privileges'; -import { RobotIcon } from '../../common/icons/robot'; - const fullscreenLabels = { actions: i18n.translate('xpack.agentBuilder.conversationActions.actions', { defaultMessage: 'More', @@ -70,6 +71,9 @@ const fullscreenLabels = { tools: i18n.translate('xpack.agentBuilder.conversationActions.tools', { defaultMessage: 'View all tools', }), + sources: i18n.translate('xpack.agentBuilder.conversationActions.sources', { + defaultMessage: 'View all sources', + }), rename: i18n.translate('xpack.agentBuilder.conversationActions.rename', { defaultMessage: 'Rename', }), @@ -125,9 +129,10 @@ export const MoreActionsButton: React.FC = ({ onRenameCo const { manageAgents } = useUiPrivileges(); const { - services: { application }, + services: { application, uiSettings }, } = useKibana(); const hasAccessToGenAiSettings = useHasConnectorsAllPrivileges(); + const isDataSourcesEnabled = uiSettings.get(DATA_SOURCES_ENABLED_SETTING_ID, false); const closePopover = () => { setIsPopoverOpen(false); @@ -204,7 +209,7 @@ export const MoreActionsButton: React.FC = ({ onRenameCo />, } + icon={} onClick={closePopover} href={createAgentBuilderUrl(appPaths.agents.list)} data-test-subj="agentBuilderActionsAgents" @@ -220,6 +225,19 @@ export const MoreActionsButton: React.FC = ({ onRenameCo > {fullscreenLabels.tools} , + ...(isDataSourcesEnabled + ? [ + + {fullscreenLabels.sources} + , + ] + : []), ...(hasAccessToGenAiSettings ? [ void; }> = ({ isPopoverOpen, selectedAgent, onClick }) => { const hasActiveConversation = useHasActiveConversation(); - const iconType = selectedAgent ? () => : RobotIcon; + const iconType = selectedAgent + ? () => + : 'productAgent'; return ( = ({ scrollContainerHeight={scrollContainerHeight} isCurrentRound={isCurrentRound} rawRound={round} + conversationId={conversation?.id} conversationAttachments={conversation?.attachments} /> ); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_layout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_layout.tsx index 94fa0c5a2e841..50d60be766738 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_layout.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_layout.tsx @@ -27,6 +27,7 @@ interface RoundLayoutProps { scrollContainerHeight: number; rawRound: ConversationRound; conversationAttachments?: VersionedAttachment[]; + conversationId?: string; } const labels = { @@ -40,6 +41,7 @@ export const RoundLayout: React.FC = ({ scrollContainerHeight, rawRound, conversationAttachments, + conversationId, }) => { const [roundContainerMinHeight, setRoundContainerMinHeight] = useState(0); const [hasBeenLoading, setHasBeenLoading] = useState(false); @@ -153,6 +155,9 @@ export const RoundLayout: React.FC = ({ steps={steps} isLoading={isLoadingCurrentRound} isLastRound={isCurrentRound} + conversationAttachments={conversationAttachments} + attachmentRefs={input.attachment_refs} + conversationId={conversationId} /> diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx new file mode 100644 index 0000000000000..638f7f1713a64 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.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, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { type ActionButton, ActionButtonType } from '@kbn/agent-builder-browser/attachments'; + +interface AttachmentActionsProps { + buttons: ActionButton[]; +} + +export const AttachmentActions: React.FC = ({ buttons }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const secondaryButtons = buttons.filter((b) => b.type === ActionButtonType.SECONDARY); + const primaryButtons = buttons.filter((b) => b.type === ActionButtonType.PRIMARY); + const overflowButtons = buttons.filter((b) => b.type === ActionButtonType.OVERFLOW); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((prev) => !prev); + }, []); + + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + return ( + + {secondaryButtons.map((button) => ( + + + {button.label} + + + ))} + {primaryButtons.map((button) => ( + + + {button.label} + + + ))} + {overflowButtons.length > 0 && ( + + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downRight" + > + ({ + name: button.label, + icon: button.icon, + onClick: () => { + closePopover(); + button.handler(); + }, + })), + }, + ]} + /> + + + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx new file mode 100644 index 0000000000000..61a7717a0d1f0 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiBadge, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSplitPanel, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { ActionButton } from '@kbn/agent-builder-browser/attachments'; +import { i18n } from '@kbn/i18n'; +import { AttachmentActions } from './attachment_actions'; + +const PREVIEW_ONLY_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.previewOnly', { + defaultMessage: 'Preview Only', +}); + +const CURRENTLY_PREVIEWING_LABEL = i18n.translate( + 'xpack.agentBuilder.attachmentHeader.currentlyPreviewing', + { + defaultMessage: "You're previewing this", + } +); + +const CLOSE_BUTTON_ARIA_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.close', { + defaultMessage: 'Close', +}); + +const HEADER_HEIGHT = 72; + +interface AttachmentHeaderProps { + title: string; + actionButtons?: ActionButton[]; + onClose?: () => void; + showPreviewBadge?: boolean; + showCurrentlyPreviewingBadge?: boolean; +} + +export const AttachmentHeader: React.FC = ({ + title, + actionButtons, + onClose, + showPreviewBadge = false, + showCurrentlyPreviewingBadge = false, +}) => { + const { euiTheme } = useEuiTheme(); + + const textStyles = css` + font-weight: ${euiTheme.font.weight.semiBold}; + `; + + const headerStyles = css` + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: ${euiTheme.border.thin}; + border-color: ${euiTheme.colors.borderBaseSubdued}; + min-height: ${HEADER_HEIGHT}px; + `; + + const badgeStyles = css` + position: absolute; + left: 50%; + bottom: 0; + transform: translate(-50%, 50%); + z-index: ${euiTheme.levels.content}; + `; + + if (!actionButtons || actionButtons.length === 0) { + return null; + } + + return ( + + {showPreviewBadge && ( + + {PREVIEW_ONLY_LABEL} + + )} + + + + {title} + + + {showCurrentlyPreviewingBadge === false && } + {showCurrentlyPreviewingBadge === true && ( + + {CURRENTLY_PREVIEWING_LABEL} + + )} + {onClose && ( + + + + )} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_context.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_context.tsx new file mode 100644 index 0000000000000..db9e2eb044969 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_context.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import type { UnknownAttachment } from '@kbn/agent-builder-common/attachments'; + +interface CanvasState { + attachment: UnknownAttachment; + isSidebar: boolean; +} + +interface CanvasContextValue { + canvasState: CanvasState | null; + openCanvas: (attachment: UnknownAttachment, isSidebar: boolean) => void; + closeCanvas: () => void; +} + +const CanvasContext = createContext(null); + +interface CanvasProviderProps { + children: React.ReactNode; +} + +export const CanvasProvider: React.FC = ({ children }) => { + const [canvasState, setCanvasState] = useState(null); + + const openCanvas = useCallback((attachment: UnknownAttachment, isSidebar: boolean) => { + setCanvasState({ attachment, isSidebar }); + }, []); + + const closeCanvas = useCallback(() => { + setCanvasState(null); + }, []); + + const value = useMemo( + () => ({ canvasState, openCanvas, closeCanvas }), + [canvasState, openCanvas, closeCanvas] + ); + + return {children}; +}; + +export const useCanvasContext = (): CanvasContextValue => { + const context = useContext(CanvasContext); + if (!context) { + throw new Error('useCanvasContext must be used within a CanvasProvider'); + } + return context; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx new file mode 100644 index 0000000000000..d140fc058d43f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.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, { useCallback, useMemo } from 'react'; +import { EuiFlyout, EuiFlyoutBody, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import type { AttachmentsService } from '../../../../../../services/attachments/attachements_service'; +import { AttachmentHeader } from './attachment_header'; +import { useCanvasContext } from './canvas_context'; + +const FLYOUT_ARIA_LABEL = i18n.translate('xpack.agentBuilder.canvasFlyout.ariaLabel', { + defaultMessage: 'Attachment preview', +}); + +interface CanvasFlyoutProps { + attachmentsService: AttachmentsService; +} + +/** + * Flyout component for displaying attachments in canvas mode (expanded view). + * Consumes canvas state from context. In full-screen context, renders at 50% screen width. + * In sidebar context, uses default flyout width. + */ +export const CanvasFlyout: React.FC = ({ attachmentsService }) => { + const { euiTheme } = useEuiTheme(); + const { canvasState, closeCanvas } = useCanvasContext(); + + const updateOrigin = useCallback(async (originId: string) => { + // TODO: Implement updateOrigin + }, []); + + const uiDefinition = canvasState + ? attachmentsService.getAttachmentUiDefinition(canvasState.attachment.type) + : null; + + const canvasHeaderActionButtons = useMemo(() => { + if (!canvasState || !uiDefinition?.getActionButtons) { + return []; + } + return ( + uiDefinition.getActionButtons({ + attachment: canvasState.attachment, + isSidebar: canvasState.isSidebar, + updateOrigin, + isCanvas: true, + }) ?? [] + ); + }, [canvasState, uiDefinition, updateOrigin]); + + if (!canvasState || !uiDefinition?.renderCanvasContent) { + return null; + } + + const { attachment, isSidebar } = canvasState; + const title = attachment.type.toUpperCase(); // TODO: fix this - it won't scale well for all attachment types + + const flyoutStyles = !isSidebar + ? css` + width: 50vw; + ` + : undefined; + + const flyoutBodyStyles = css` + &.euiFlyoutBody { + padding-top: ${euiTheme.size.m}; + } + `; + + return ( + + + + {uiDefinition.renderCanvasContent({ attachment, isSidebar })} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx new file mode 100644 index 0000000000000..fac87d4a79c32 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.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, { useCallback, useMemo } from 'react'; +import type { UnknownAttachment } from '@kbn/agent-builder-common/attachments'; +import { EuiSplitPanel } from '@elastic/eui'; +import type { AttachmentsService } from '../../../../../../services/attachments/attachements_service'; +import { AttachmentHeader } from './attachment_header'; +import { useCanvasContext } from './canvas_context'; + +interface InlineAttachmentWithActionsProps { + attachment: UnknownAttachment; + attachmentsService: AttachmentsService; + isSidebar: boolean; + conversationId: string; +} + +/** + * Component that renders an inline attachment with its action buttons. + */ +export const InlineAttachmentWithActions: React.FC = ({ + attachment, + attachmentsService, + isSidebar, + conversationId, +}) => { + const { openCanvas: openCanvasContext, canvasState } = useCanvasContext(); + + const openCanvas = useCallback(() => { + openCanvasContext(attachment, isSidebar); + }, [openCanvasContext, attachment, isSidebar]); + + const updateOrigin = useCallback(async (originId: string) => { + // TODO: Implement updateOrigin + // attachmentsService.updateOrigin(conversationId, attachment.id, originId); + }, []); + + const uiDefinition = attachmentsService.getAttachmentUiDefinition(attachment.type); + + const inlineActionButtons = useMemo( + () => + uiDefinition?.getActionButtons?.({ + attachment, + isSidebar, + updateOrigin, + openCanvas, + isCanvas: false, + }), + [uiDefinition, attachment, isSidebar, updateOrigin, openCanvas] + ); + + const isViewingAttachmentInCanvas = useMemo(() => { + return canvasState?.attachment.id === attachment.id; + }, [canvasState, attachment]); + + if (!uiDefinition) { + return null; + } + + const title = attachment.type.toUpperCase(); // TODO: fix this - it won't scale well for all attachment types + + return ( + + + + {uiDefinition?.renderInlineContent?.({ attachment, isSidebar })} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx index f7af6becc845b..11cb0514adc05 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx @@ -22,7 +22,14 @@ import { } from '@elastic/eui'; import { type PluggableList } from 'unified'; import type { ConversationRoundStep } from '@kbn/agent-builder-common'; -import { visualizationElement } from '@kbn/agent-builder-common/tools/custom_rendering'; +import type { + VersionedAttachment, + AttachmentVersionRef, +} from '@kbn/agent-builder-common/attachments'; +import { + visualizationElement, + renderAttachmentElement, +} from '@kbn/agent-builder-common/tools/custom_rendering'; import { useAgentBuilderServices } from '../../../../hooks/use_agent_builder_service'; import { Cursor, @@ -30,19 +37,33 @@ import { createVisualizationRenderer, loadingCursorPlugin, visualizationTagParser, + renderAttachmentTagParser, + createRenderAttachmentRenderer, } from './markdown_plugins'; import { useStepsFromPrevRounds } from '../../../../hooks/use_conversation'; +import { useConversationContext } from '../../../../context/conversation/conversation_context'; +import { CanvasProvider } from './attachments/canvas_context'; +import { CanvasFlyout } from './attachments/canvas_flyout'; interface Props { content: string; steps: ConversationRoundStep[]; + conversationAttachments?: VersionedAttachment[]; + attachmentRefs?: AttachmentVersionRef[]; + conversationId?: string; } /** * Component handling markdown support to the assistant's responses. * Also handles "loading" state by appending the blinking cursor. */ -export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props) { +export function ChatMessageText({ + content, + steps: stepsFromCurrentRound, + conversationAttachments, + attachmentRefs, + conversationId, +}: Props) { const { euiTheme } = useEuiTheme(); const containerClassName = css` @@ -58,8 +79,9 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props } `; - const { startDependencies } = useAgentBuilderServices(); + const { attachmentsService, startDependencies } = useAgentBuilderServices(); const stepsFromPrevRounds = useStepsFromPrevRounds(); + const { isEmbeddedContext: isSidebar } = useConversationContext(); const { parsingPluginList, processingPluginList } = useMemo(() => { const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); @@ -125,6 +147,13 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props stepsFromCurrentRound, stepsFromPrevRounds, }), + [renderAttachmentElement.tagName]: createRenderAttachmentRenderer({ + conversationAttachments, + attachmentRefs, + conversationId, + isSidebar, + attachmentsService, + }), }; return { @@ -132,21 +161,34 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props loadingCursorPlugin, esqlLanguagePlugin, visualizationTagParser, + renderAttachmentTagParser, ...parsingPlugins, ], processingPluginList: processingPlugins, }; - }, [startDependencies, stepsFromCurrentRound, stepsFromPrevRounds]); + }, [ + startDependencies, + stepsFromCurrentRound, + stepsFromPrevRounds, + conversationAttachments, + attachmentRefs, + conversationId, + isSidebar, + attachmentsService, + ]); return ( - - - {content} - - + + + + {content} + + + + ); } diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/index.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/index.ts index 6af1102f2ec3b..c87c98c4ff7f5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/index.ts @@ -7,5 +7,9 @@ export { findToolResult, type MutableNode } from './utils'; export { visualizationTagParser, createVisualizationRenderer } from './visualization_plugin'; +export { + renderAttachmentTagParser, + createRenderAttachmentRenderer, +} from './render_attachment_plugin'; export { loadingCursorPlugin, Cursor } from './cursor_plugin'; export { esqlLanguagePlugin } from './esql_plugin'; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/render_attachment_plugin.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/render_attachment_plugin.tsx new file mode 100644 index 0000000000000..f71c3218abf52 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/render_attachment_plugin.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { + VersionedAttachment, + AttachmentVersionRef, +} from '@kbn/agent-builder-common/attachments'; +import { + renderAttachmentElement, + type RenderAttachmentElementAttributes, +} from '@kbn/agent-builder-common/tools/custom_rendering'; +import type { AttachmentsService } from '../../../../../../services'; +import { createTagParser } from './utils'; +import { InlineAttachmentWithActions } from '../attachments/inline_attachment_with_actions'; + +/** + * Parser for tags in markdown. + * Converts HTML/text nodes containing render_attachment tags into structured AST nodes. + */ +export const renderAttachmentTagParser = createTagParser({ + tagName: renderAttachmentElement.tagName, + getAttributes: (value, extractAttr) => ({ + attachmentId: extractAttr(value, renderAttachmentElement.attributes.attachmentId), + version: extractAttr(value, renderAttachmentElement.attributes.version), + }), + assignAttributes: (node, attributes) => { + node.type = renderAttachmentElement.tagName; + node.attachmentId = attributes.attachmentId; + node.attachmentVersion = attributes.version; + delete node.value; + }, + createNode: (attributes, position) => ({ + type: renderAttachmentElement.tagName, + attachmentId: attributes.attachmentId, + attachmentVersion: attributes.version, + position, + }), +}); + +interface RenderAttachmentRendererProps { + attachmentsService: AttachmentsService; + conversationAttachments?: VersionedAttachment[]; + attachmentRefs?: AttachmentVersionRef[]; + conversationId?: string; + isSidebar: boolean; +} +/** + * Creates a renderer for tags. + */ +export const createRenderAttachmentRenderer = ({ + attachmentsService, + conversationAttachments, + attachmentRefs, + conversationId, + isSidebar, +}: RenderAttachmentRendererProps) => { + return (props: RenderAttachmentElementAttributes) => { + const { attachmentId, version: explicitVersion } = props; + + if (!attachmentId || !conversationId) { + return null; + } + + const attachment = conversationAttachments?.find((att) => att.id === attachmentId); + + if (!attachment) { + return null; + } + + // Resolve version: explicit > from refs > current_version + let versionToUse: number; + if (explicitVersion !== undefined) { + versionToUse = + typeof explicitVersion === 'string' ? parseInt(explicitVersion, 10) : explicitVersion; + } else { + const refVersion = attachmentRefs?.find((r) => r.attachment_id === attachmentId)?.version; + versionToUse = refVersion ?? attachment.current_version; + } + + const versionData = attachment.versions.find((v) => v.version === versionToUse); + + if (!versionData) { + return null; + } + + return ( + + ); + }; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/utils.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/utils.ts index 052cd1f6f1aea..1200cb09f6677 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/utils.ts @@ -13,6 +13,8 @@ export type MutableNode = Node & { value?: string; toolResultId?: string; chartType?: string; + attachmentId?: string; + attachmentVersion?: string; }; export const createTagParser = >(config: { @@ -40,8 +42,8 @@ export const createTagParser = >(co visitParent(child as Parent); } - if (child.type !== 'html') { - continue; // terminate iteration if not html node + if (child.type !== 'html' && child.type !== 'text') { + continue; // terminate iteration if not html/text node } const rawValue = child.value; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/round_response.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/round_response.tsx index d23378a537df2..cb452e467dd4e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/round_response.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/round_response.tsx @@ -9,6 +9,10 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import type { AssistantResponse, ConversationRoundStep } from '@kbn/agent-builder-common'; +import type { + VersionedAttachment, + AttachmentVersionRef, +} from '@kbn/agent-builder-common/attachments'; import React from 'react'; import { StreamingText } from './streaming_text'; import { ChatMessageText } from './chat_message_text'; @@ -20,6 +24,9 @@ export interface RoundResponseProps { isLoading: boolean; hasError: boolean; isLastRound: boolean; + conversationAttachments?: VersionedAttachment[]; + attachmentRefs?: AttachmentVersionRef[]; + conversationId?: string; } export const RoundResponse: React.FC = ({ @@ -28,6 +35,9 @@ export const RoundResponse: React.FC = ({ steps, isLoading, isLastRound, + conversationAttachments, + attachmentRefs, + conversationId, }) => ( = ({ > {isLoading ? ( - + ) : ( - + )} {!isLoading && !hasError && ( diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/streaming_text.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/streaming_text.tsx index 5272fd1bc170c..b0199b217e185 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/streaming_text.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/streaming_text.tsx @@ -7,6 +7,10 @@ import React, { useEffect, useRef, useState } from 'react'; import type { ConversationRoundStep } from '@kbn/agent-builder-common'; +import type { + VersionedAttachment, + AttachmentVersionRef, +} from '@kbn/agent-builder-common/attachments'; import { ChatMessageText } from './chat_message_text'; const TOKEN_DELAY = 17; @@ -14,9 +18,19 @@ interface StreamingTextProps { content: string; steps: ConversationRoundStep[]; tokenDelay?: number; // ms between tokens. Defaults to 17ms to ensure 60fps. + conversationAttachments?: VersionedAttachment[]; + attachmentRefs?: AttachmentVersionRef[]; + conversationId?: string; } -export const StreamingText = ({ content, steps, tokenDelay = TOKEN_DELAY }: StreamingTextProps) => { +export const StreamingText = ({ + content, + steps, + tokenDelay = TOKEN_DELAY, + conversationAttachments, + attachmentRefs, + conversationId, +}: StreamingTextProps) => { const [displayedText, setDisplayedText] = useState(''); const tokenQueueRef = useRef([]); const intervalRef = useRef(null); @@ -56,5 +70,13 @@ export const StreamingText = ({ content, steps, tokenDelay = TOKEN_DELAY }: Stre }; }, [content, tokenDelay]); - return ; + return ( + + ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/context/tools_provider.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/context/tools_provider.tsx index 4bb2dcf150649..a76f6fb7114f9 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/context/tools_provider.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/context/tools_provider.tsx @@ -128,6 +128,10 @@ export const ToolsProvider = ({ children }: { children: React.ReactNode }) => { deleteTool, confirmDelete, cancelDelete, + usedByAgents, + isForceConfirmModalOpen, + confirmForceDelete, + cancelForceDelete, } = useDeleteTool(); const { @@ -147,6 +151,10 @@ export const ToolsProvider = ({ children }: { children: React.ReactNode }) => { prefix: 'bulkDeleteEsqlToolsTitle', }); + const deleteToolUsedByAgentsTitleId = useGeneratedHtmlId({ + prefix: 'deleteToolUsedByAgentsTitle', + }); + return ( { {labels.tools.deleteEsqlToolConfirmationText} )} + {isForceConfirmModalOpen && usedByAgents && ( + + +

{labels.tools.deleteToolUsedByAgentsDescription}

+ {usedByAgents.agents.length > 0 && ( +

+ {labels.tools.deleteToolUsedByAgentsAgentListLabel}:{' '} + {labels.tools.deleteToolUsedByAgentsAgentList( + usedByAgents.agents.map((a) => a.name) + )} +

+ )} +
+
+ )} {isBulkDeleteToolsModalOpen && ( ; type DeleteToolsErrorCallback = NonNullable; +function getToolUsedByAgentsFromError(error: unknown, toolId: string): ToolUsedByAgents | null { + const body = (error as { body?: ToolUsedByAgentsErrorBody }).body; + const attrs = body?.attributes; + if (attrs?.code !== TOOL_USED_BY_AGENTS_ERROR_CODE || !Array.isArray(attrs.agents)) { + return null; + } + return { toolId, agents: attrs.agents }; +} + export const useDeleteToolService = ({ onSuccess, onError, @@ -56,7 +80,7 @@ export const useDeleteToolService = ({ Error, DeleteToolMutationVariables >({ - mutationFn: ({ toolId }) => toolsService.delete({ toolId }), + mutationFn: ({ toolId, force }) => toolsService.delete({ toolId, force }), onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.tools.all }), onSuccess, onError, @@ -98,10 +122,12 @@ export const useDeleteTool = ({ } = {}) => { const { addSuccessToast, addErrorToast } = useToasts(); const [deleteToolId, setDeleteToolId] = useState(null); + const [usedByAgents, setUsedByAgents] = useState(null); const onConfirmCallbackRef = useRef<() => void>(); const onCancelCallbackRef = useRef<() => void>(); const isModalOpen = deleteToolId !== null; + const isForceConfirmModalOpen = usedByAgents !== null; const deleteTool = useCallback( ( @@ -109,6 +135,7 @@ export const useDeleteTool = ({ { onConfirm, onCancel }: { onConfirm?: () => void; onCancel?: () => void } = {} ) => { setDeleteToolId(toolId); + setUsedByAgents(null); onConfirmCallbackRef.current = onConfirm; onCancelCallbackRef.current = onCancel; }, @@ -130,9 +157,17 @@ export const useDeleteTool = ({ title: labels.tools.deleteToolSuccessToast(toolId), }); setDeleteToolId(null); + setUsedByAgents(null); }; const handleError: DeleteToolErrorCallback = (error, { toolId }) => { + const payload = getToolUsedByAgentsFromError(error, toolId); + if (payload) { + setUsedByAgents(payload); + setDeleteToolId(null); + return; + } + setUsedByAgents(null); addErrorToast({ title: labels.tools.deleteToolErrorToast(toolId), text: formatAgentBuilderErrorMessage(error), @@ -156,10 +191,24 @@ export const useDeleteTool = ({ const cancelDelete = useCallback(() => { setDeleteToolId(null); + setUsedByAgents(null); onCancelCallbackRef.current?.(); onCancelCallbackRef.current = undefined; }, []); + const confirmForceDelete = useCallback(async () => { + if (!usedByAgents) { + return; + } + await deleteToolMutation({ toolId: usedByAgents.toolId, force: true }, { onSuccess, onError }); + onConfirmCallbackRef.current?.(); + onConfirmCallbackRef.current = undefined; + }, [usedByAgents, deleteToolMutation, onSuccess, onError]); + + const cancelForceDelete = useCallback(() => { + setUsedByAgents(null); + }, []); + return { isOpen: isModalOpen, isLoading, @@ -167,6 +216,10 @@ export const useDeleteTool = ({ deleteTool, confirmDelete, cancelDelete, + usedByAgents, + isForceConfirmModalOpen, + confirmForceDelete, + cancelForceDelete, }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts index 9e6652857746c..a8afa711416f4 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts @@ -290,6 +290,37 @@ export const labels = { } ), + deleteToolUsedByAgentsTitle: (toolId: string) => + i18n.translate('xpack.agentBuilder.tools.deleteToolUsedByAgentsTitle', { + defaultMessage: 'Tool "{toolId}" is used by agents', + values: { toolId }, + }), + deleteToolUsedByAgentsDescription: i18n.translate( + 'xpack.agentBuilder.tools.deleteToolUsedByAgentsDescription', + { + defaultMessage: 'Remove this tool from all agents that use it and delete the tool?', + } + ), + deleteToolUsedByAgentsAgentListLabel: i18n.translate( + 'xpack.agentBuilder.tools.deleteToolUsedByAgentsAgentListLabel', + { + defaultMessage: 'Agents using this tool', + } + ), + deleteToolUsedByAgentsAgentList: (agentNames: string[]) => agentNames.join(', '), + deleteToolUsedByAgentsConfirmButton: i18n.translate( + 'xpack.agentBuilder.tools.deleteToolUsedByAgentsConfirmButton', + { + defaultMessage: 'Yes, remove and delete', + } + ), + deleteToolUsedByAgentsCancelButton: i18n.translate( + 'xpack.agentBuilder.tools.deleteToolUsedByAgentsCancelButton', + { + defaultMessage: 'Cancel', + } + ), + // Bulk delete modal bulkDeleteEsqlToolsTitle: (count: number) => i18n.translate('xpack.agentBuilder.tools.bulkDeleteEsqlToolsTitle', { diff --git a/x-pack/platform/plugins/shared/agent_builder/public/components/nav_control/agent_builder_nav_control.tsx b/x-pack/platform/plugins/shared/agent_builder/public/components/nav_control/agent_builder_nav_control.tsx index c7325ce8995f0..5107b38269db4 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/components/nav_control/agent_builder_nav_control.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/components/nav_control/agent_builder_nav_control.tsx @@ -12,7 +12,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { AIChatExperience } from '@kbn/ai-assistant-common'; import { isMac } from '@kbn/shared-ux-utility'; import type { AgentBuilderPluginStart, AgentBuilderStartDependencies } from '../../types'; -import { RobotIcon } from '../../application/components/common/icons/robot'; import { useUiPrivileges } from '../../application/hooks/use_ui_privileges'; const isSemicolon = (event: KeyboardEvent) => event.code === 'Semicolon' || event.key === ';'; @@ -102,7 +101,7 @@ export function AgentBuilderNavControl() { size="s" fullWidth={false} minWidth={0} - iconType={RobotIcon} + iconType="productAgent" > (`${publicApiPath}/tools/${toolId}`, {}); } - async delete({ toolId }: { toolId: string }) { - return await this.http.delete(`${publicApiPath}/tools/${toolId}`, {}); + async delete({ toolId, force }: { toolId: string; force?: boolean }) { + return await this.http.delete(`${publicApiPath}/tools/${toolId}`, { + query: { force: force ?? false }, + }); } async create(tool: CreateToolPayload) { @@ -78,9 +80,9 @@ export class ToolsService { // internal APIs - async bulkDelete(toolsIds: string[]) { + async bulkDelete(toolIds: string[], options?: { force?: boolean }) { return await this.http.post(`${internalApiPath}/tools/_bulk_delete`, { - body: JSON.stringify({ ids: toolsIds }), + body: JSON.stringify({ ids: toolIds, force: options?.force ?? true }), }); } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts index c1d70d8902345..9e4b03b08b485 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts @@ -53,6 +53,7 @@ export function registerInternalToolsRoutes({ validate: { body: schema.object({ ids: schema.arrayOf(schema.string()), + force: schema.boolean({ defaultValue: true }), }), }, options: { access: 'internal' }, @@ -61,8 +62,39 @@ export function registerInternalToolsRoutes({ }, }, wrapHandler(async (ctx, request, response) => { - const { ids } = request.body; - const { tools: toolService, auditLogService } = getInternalServices(); + const { ids, force } = request.body; + const { tools: toolService, agents: agentsService, auditLogService } = getInternalServices(); + + if (!force) { + const { agents } = await agentsService.getAgentsUsingTools({ + request, + toolIds: ids, + }); + if (agents.length > 0) { + return response.conflict({ + body: { + message: + 'One or more tools are used by agents. Use force=true to remove them from agents and delete.', + attributes: { + code: 'TOOL_USED_BY_AGENTS', + agents, + }, + }, + }); + } + } else { + const { agents } = await agentsService.removeToolRefsFromAgents({ + request, + toolIds: ids, + }); + for (const agent of agents) { + auditLogService.logAgentUpdated(request, { + agentId: agent.id, + agentName: agent.name, + }); + } + } + const registry = await toolService.getRegistry({ request }); const deleteResults = await Promise.allSettled(ids.map((id) => registry.delete(id))); @@ -86,9 +118,7 @@ export function registerInternalToolsRoutes({ auditLogService.logBulkToolDeleteResults(request, { ids, deleteResults }); return response.ok({ - body: { - results, - }, + body: { results }, }); }) ); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/tools.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/tools.ts index 3a2d7fd9e0e7c..2b989f73036ea 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/tools.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/tools.ts @@ -11,6 +11,7 @@ import { editableToolTypes } from '@kbn/agent-builder-common'; import type { RouteDependencies } from './types'; import { getHandlerWrapper } from './wrap_handler'; import { toDescriptor, toDescriptorWithSchema } from '../services/tools/utils/tool_conversion'; +import { TOOL_USED_BY_AGENTS_ERROR_CODE } from '../../common/http_api/tools'; import type { ListToolsResponse, GetToolResponse, @@ -316,6 +317,15 @@ export function registerToolsRoutes({ meta: { description: 'The unique identifier of the tool to delete.' }, }), }), + query: schema.object({ + force: schema.boolean({ + defaultValue: false, + meta: { + description: + 'If true, removes the tool from agents that use it and then deletes it. If false and any agent uses the tool, the request returns 409 Conflict with the list of agents.', + }, + }), + }), }, }, options: { @@ -324,7 +334,37 @@ export function registerToolsRoutes({ }, wrapHandler(async (ctx, request, response) => { const { toolId } = request.params; - const { tools: toolService, auditLogService } = getInternalServices(); + const { force = false } = request.query ?? {}; + const { + tools: toolService, + agents: agentsService, + auditLogService, + } = getInternalServices(); + + if (!force) { + const { agents } = await agentsService.getAgentsUsingTools({ + request, + toolIds: [toolId], + }); + if (agents.length > 0) { + return response.conflict({ + body: { + message: + 'Tool is used by one or more agents. Use force=true to remove it from agents and delete.', + attributes: { + code: TOOL_USED_BY_AGENTS_ERROR_CODE, + agents, + }, + }, + }); + } + } else { + await agentsService.removeToolRefsFromAgents({ + request, + toolIds: [toolId], + }); + } + const registry = await toolService.getRegistry({ request }); try { const success = await registry.delete(toolId); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.test.ts index 069409006958c..fee3af70eca19 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.test.ts @@ -5,16 +5,42 @@ * 2.0. */ +import { + elasticsearchServiceMock, + httpServerMock, + savedObjectsServiceMock, + securityServiceMock, + uiSettingsServiceMock, +} from '@kbn/core/server/mocks'; +import type { KibanaRequest } from '@kbn/core/server'; import { loggerMock } from '@kbn/logging-mocks'; import { isAllowedBuiltinAgent } from '@kbn/agent-builder-server/allow_lists'; import { AgentsService } from './agents_service'; -import { createMockedAgent } from '../../test_utils/agents'; +import type { AgentsServiceStart } from './types'; +import type { AgentsServiceStartDeps } from './agents_service'; +import { createMockedAgent, createToolsServiceStartMock } from '../../test_utils'; +import { createClient } from './persisted/client'; +import { runToolRefCleanup } from './persisted/tool_reference_cleanup'; jest.mock('@kbn/agent-builder-server/allow_lists'); +jest.mock('./persisted/client'); +jest.mock('./persisted/tool_reference_cleanup'); const isAllowedBuiltinAgentMock = isAllowedBuiltinAgent as jest.MockedFunction< typeof isAllowedBuiltinAgent >; +const createClientMock = createClient as jest.MockedFunction; +const runToolRefCleanupMock = runToolRefCleanup as jest.MockedFunction; + +const createStartDeps = (): AgentsServiceStartDeps => ({ + getRunner: () => ({ runAgent: jest.fn() } as any), + security: securityServiceMock.createStart(), + elasticsearch: elasticsearchServiceMock.createStart(), + uiSettings: uiSettingsServiceMock.createStartContract(), + savedObjects: savedObjectsServiceMock.createStartContract(), + spaces: undefined, + toolsService: createToolsServiceStartMock(), +}); describe('AgentsService', () => { let logger: ReturnType; @@ -27,6 +53,8 @@ describe('AgentsService', () => { afterEach(() => { isAllowedBuiltinAgentMock.mockReset(); + createClientMock.mockReset(); + runToolRefCleanupMock.mockReset(); }); describe('#setup', () => { @@ -49,4 +77,110 @@ describe('AgentsService', () => { `); }); }); + + describe('#start', () => { + let started: AgentsServiceStart; + let request: KibanaRequest; + + beforeEach(() => { + isAllowedBuiltinAgentMock.mockReturnValue(true); + service.setup({ logger }); + createClientMock.mockResolvedValue({ + getAgentsUsingTools: (params: { toolIds: string[] }) => + runToolRefCleanupMock({ + storage: {} as any, + spaceId: 'default', + toolIds: params.toolIds, + logger: undefined, + checkOnly: true, + }), + removeToolRefsFromAgents: (params: { toolIds: string[] }) => + runToolRefCleanupMock({ + storage: {} as any, + spaceId: 'default', + toolIds: params.toolIds, + logger: undefined, + }), + } as any); + started = service.start(createStartDeps()); + request = httpServerMock.createKibanaRequest(); + }); + + describe('#getAgentsUsingTools', () => { + it('returns agents that use the given tool IDs', async () => { + const agents = [ + { id: 'agent-1', name: 'Agent One' }, + { id: 'agent-2', name: 'Agent Two' }, + ]; + runToolRefCleanupMock.mockResolvedValue({ agents }); + + const result = await started.getAgentsUsingTools({ request, toolIds: ['tool-1'] }); + + expect(result).toEqual({ agents }); + expect(runToolRefCleanupMock).toHaveBeenCalledTimes(1); + expect(runToolRefCleanupMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolIds: ['tool-1'], + checkOnly: true, + spaceId: 'default', + }) + ); + }); + + it('returns empty agents list when runToolRefCleanup returns no agents', async () => { + runToolRefCleanupMock.mockResolvedValue({ agents: [] }); + + const result = await started.getAgentsUsingTools({ + request, + toolIds: ['tool-1', 'tool-2'], + }); + + expect(result).toEqual({ agents: [] }); + expect(runToolRefCleanupMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolIds: ['tool-1', 'tool-2'], + checkOnly: true, + }) + ); + }); + + it('propagates errors from runToolRefCleanup', async () => { + const error = new Error('Search failed'); + runToolRefCleanupMock.mockRejectedValue(error); + + await expect(started.getAgentsUsingTools({ request, toolIds: ['tool-1'] })).rejects.toThrow( + 'Search failed' + ); + }); + }); + + describe('#removeToolRefsFromAgents', () => { + it('calls runToolRefCleanup without checkOnly and returns updated agents', async () => { + const agents = [{ id: 'agent-1', name: 'Agent 1' }]; + runToolRefCleanupMock.mockResolvedValue({ agents }); + + await expect( + started.removeToolRefsFromAgents({ request, toolIds: ['tool-1', 'tool-2'] }) + ).resolves.toEqual({ agents }); + + expect(runToolRefCleanupMock).toHaveBeenCalledTimes(1); + expect(runToolRefCleanupMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolIds: ['tool-1', 'tool-2'], + spaceId: 'default', + }) + ); + expect(runToolRefCleanupMock.mock.calls[0][0]).not.toHaveProperty('checkOnly'); + }); + + it('propagates errors from runToolRefCleanup', async () => { + const error = new Error('Bulk update failed'); + runToolRefCleanupMock.mockRejectedValue(error); + + await expect( + started.removeToolRefsFromAgents({ request, toolIds: ['tool-1'] }) + ).rejects.toThrow('Bulk update failed'); + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts index 16aaa62f636e8..52ede43c9a823 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts @@ -17,7 +17,8 @@ import { isAllowedBuiltinAgent } from '@kbn/agent-builder-server/allow_lists'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; import type { Runner } from '@kbn/agent-builder-server'; import { getCurrentSpaceId } from '../../utils/spaces'; -import type { AgentsServiceSetup, AgentsServiceStart } from './types'; +import type { AgentsServiceSetup, AgentsServiceStart, ToolRefsParams } from './types'; +import type { AgentsUsingToolsResult } from './persisted/types'; import type { ToolsServiceStart } from '../tools'; import { createBuiltinAgentRegistry, @@ -27,6 +28,7 @@ import { } from './builtin'; import { createPersistedProviderFn } from './persisted'; import { createAgentRegistry } from './agent_registry'; +import { createClient } from './persisted/client'; export interface AgentsServiceSetupDeps { logger: Logger; @@ -84,6 +86,18 @@ export class AgentsService { logger, }); + const getAgentClient = async ({ request }: { request: KibanaRequest }) => { + const space = getCurrentSpaceId({ request, spaces }); + return createClient({ + elasticsearch, + logger, + request, + security, + space, + toolsService, + }); + }; + const getRegistry = async ({ request }: { request: KibanaRequest }) => { const space = getCurrentSpaceId({ request, spaces }); return createAgentRegistry({ @@ -96,11 +110,29 @@ export class AgentsService { }); }; + const removeToolRefsFromAgents = async ({ + request, + toolIds, + }: ToolRefsParams): Promise => { + const client = await getAgentClient({ request }); + return client.removeToolRefsFromAgents({ toolIds }); + }; + + const getAgentsUsingTools = async ({ + request, + toolIds, + }: ToolRefsParams): Promise => { + const client = await getAgentClient({ request }); + return client.getAgentsUsingTools({ toolIds }); + }; + return { getRegistry, execute: async (args) => { return getRunner().runAgent(args); }, + removeToolRefsFromAgents, + getAgentsUsingTools, }; } } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts index 209219bb4307e..59a190e3ef0d4 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts @@ -232,7 +232,7 @@ export const prepareConversation = async ({ } try { - const typeReadonly = definition.isReadonly ?? true; + const typeReadonly = definition.isReadonly ?? false; const isReadonly = typeReadonly || attachment.readonly === true; if (!isReadonly) { return undefined; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts index 25213328765d0..571f33cbe82fb 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts @@ -27,11 +27,12 @@ import type { } from '../../../../../common/agents'; import type { ToolsServiceStart } from '../../../tools'; import { createSpaceDslFilter } from '../../../../utils/spaces'; -import type { PersistedAgentDefinition } from '../types'; +import type { AgentsUsingToolsResult, PersistedAgentDefinition } from '../types'; import type { AgentProfileStorage } from './storage'; import { createStorage } from './storage'; import { createRequestToEs, type Document, fromEs, updateRequestToEs } from './converters'; import { validateToolSelection } from './utils'; +import { runToolRefCleanup } from '../tool_reference_cleanup'; export interface AgentClient { has(agentId: string): Promise; @@ -40,6 +41,8 @@ export interface AgentClient { update(agentId: string, profile: AgentUpdateRequest): Promise; list(options?: AgentListOptions): Promise; delete(options: AgentDeleteRequest): Promise; + getAgentsUsingTools(params: { toolIds: string[] }): Promise; + removeToolRefsFromAgents(params: { toolIds: string[] }): Promise; } export const createClient = async ({ @@ -66,7 +69,7 @@ export const createClient = async ({ const esClient = scopedClient.asInternalUser; const storage = createStorage({ logger, esClient }); - return new AgentClientImpl({ storage, user, request, space, toolsService }); + return new AgentClientImpl({ storage, user, request, space, toolsService, logger }); }; class AgentClientImpl implements AgentClient { @@ -75,6 +78,7 @@ class AgentClientImpl implements AgentClient { private readonly storage: AgentProfileStorage; private readonly toolsService: ToolsServiceStart; private readonly user: UserIdAndName; + private readonly logger: Logger; constructor({ storage, @@ -82,18 +86,40 @@ class AgentClientImpl implements AgentClient { user, request, space, + logger, }: { storage: AgentProfileStorage; toolsService: ToolsServiceStart; user: UserIdAndName; request: KibanaRequest; space: string; + logger: Logger; }) { this.storage = storage; this.toolsService = toolsService; this.request = request; this.user = user; this.space = space; + this.logger = logger; + } + + async getAgentsUsingTools(params: { toolIds: string[] }): Promise { + return runToolRefCleanup({ + storage: this.storage, + spaceId: this.space, + toolIds: params.toolIds, + logger: this.logger, + checkOnly: true, + }); + } + + async removeToolRefsFromAgents(params: { toolIds: string[] }): Promise { + return runToolRefCleanup({ + storage: this.storage, + spaceId: this.space, + toolIds: params.toolIds, + logger: this.logger, + }); } async get(agentId: string): Promise { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/utils.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/utils.ts index 9cac09d410b7f..8acb8cc578351 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/utils.ts @@ -40,3 +40,16 @@ export async function validateToolSelection({ } return errors; } + +export function removeToolIdsFromToolSelection( + tools: ToolSelection[], + toolIdsToRemove: string[] +): ToolSelection[] { + const removeSet = new Set(toolIdsToRemove); + return (tools ?? []) + .map((selection) => ({ + ...selection, + tool_ids: (selection.tool_ids ?? []).filter((id) => !removeSet.has(id)), + })) + .filter((selection) => selection.tool_ids.length > 0); +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/tool_reference_cleanup.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/tool_reference_cleanup.test.ts new file mode 100644 index 0000000000000..0f16835fc24b9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/tool_reference_cleanup.test.ts @@ -0,0 +1,296 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AgentType } from '@kbn/agent-builder-common'; +import type { AgentProperties } from './client/storage'; +import type { AgentProfileStorage } from './client/storage'; +import { runToolRefCleanup } from './tool_reference_cleanup'; + +const SPACE_ID = 'default'; +const CREATED_AT = '2025-01-01T00:00:00.000Z'; +const UPDATED_AT = '2025-01-02T00:00:00.000Z'; + +function createAgentSource(overrides: Partial = {}): AgentProperties { + return { + id: 'agent-1', + name: 'Test Agent', + type: AgentType.chat, + space: SPACE_ID, + description: '', + config: { + instructions: '', + tools: [{ tool_ids: ['tool-a', 'tool-b'] }], + }, + created_at: CREATED_AT, + updated_at: UPDATED_AT, + ...overrides, + }; +} + +function createMockStorage(searchResponse: { + hits: Array<{ _id: string; _source?: AgentProperties }>; +}): jest.Mocked { + const bulk = jest.fn().mockResolvedValue(undefined); + const search = jest.fn().mockResolvedValue({ + hits: { + hits: searchResponse.hits, + }, + }); + + return { + getClient: jest.fn().mockReturnValue({ + search, + bulk, + }), + } as unknown as jest.Mocked; +} + +describe('runToolRefCleanup', () => { + it('completes without error when there are no hits', async () => { + const storage = createMockStorage({ hits: [] }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a'], + }); + expect(result).toEqual({ agents: [] }); + expect(storage.getClient().bulk).not.toHaveBeenCalled(); + }); + + it('skips hits without _source', async () => { + const storage = createMockStorage({ + hits: [{ _id: '1' }, { _id: '2', _source: createAgentSource({ id: 'agent-2' }) }], + }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-x'], + }); + expect(result).toEqual({ agents: [] }); + expect(storage.getClient().bulk).not.toHaveBeenCalled(); + }); + + it('skips agents that do not reference any of the deleted tool ids', async () => { + const storage = createMockStorage({ + hits: [ + { + _id: '1', + _source: createAgentSource({ + config: { instructions: '', tools: [{ tool_ids: ['tool-c', 'tool-d'] }] }, + }), + }, + ], + }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a', 'tool-b'], + }); + expect(result).toEqual({ agents: [] }); + expect(storage.getClient().bulk).not.toHaveBeenCalled(); + }); + + it('updates agents that reference a deleted tool and removes that tool id from selection', async () => { + const source = createAgentSource({ + id: 'agent-1', + config: { + instructions: '', + tools: [{ tool_ids: ['tool-a', 'tool-b'] }, { tool_ids: ['tool-c'] }], + }, + }); + const storage = createMockStorage({ + hits: [{ _id: 'doc-1', _source: source }], + }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a'], + }); + expect(result).toEqual({ agents: [{ id: 'agent-1', name: 'Test Agent' }] }); + expect(storage.getClient().bulk).toHaveBeenCalledTimes(1); + const [bulkCall] = (storage.getClient().bulk as jest.Mock).mock.calls; + const operations = bulkCall[0].operations; + expect(operations).toHaveLength(1); + expect(operations[0].index._id).toBe('doc-1'); + const doc = operations[0].index.document as AgentProperties; + expect(doc.config.tools).toEqual([{ tool_ids: ['tool-b'] }, { tool_ids: ['tool-c'] }]); + }); + + it('updates multiple agents that reference the deleted tool', async () => { + const source1 = createAgentSource({ + id: 'agent-1', + config: { instructions: '', tools: [{ tool_ids: ['tool-a'] }] }, + }); + const source2 = createAgentSource({ + id: 'agent-2', + config: { instructions: '', tools: [{ tool_ids: ['tool-a', 'tool-b'] }] }, + }); + const storage = createMockStorage({ + hits: [ + { _id: 'doc-1', _source: source1 }, + { _id: 'doc-2', _source: source2 }, + ], + }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a'], + }); + expect(result).toEqual({ + agents: [ + { id: 'agent-1', name: 'Test Agent' }, + { id: 'agent-2', name: 'Test Agent' }, + ], + }); + expect(storage.getClient().bulk).toHaveBeenCalledTimes(1); + const [bulkCall] = (storage.getClient().bulk as jest.Mock).mock.calls; + const operations = bulkCall[0].operations; + expect(operations).toHaveLength(2); + expect((operations[0].index.document as AgentProperties).config.tools).toEqual([]); + expect((operations[1].index.document as AgentProperties).config.tools).toEqual([ + { tool_ids: ['tool-b'] }, + ]); + }); + + it('calls search with space filter and correct size', async () => { + const storage = createMockStorage({ hits: [] }); + await runToolRefCleanup({ + storage, + spaceId: 'space-1', + toolIds: ['tool-a'], + }); + expect(storage.getClient().search).toHaveBeenCalledWith( + expect.objectContaining({ + size: 1000, + query: { + bool: { + filter: expect.any(Array), + }, + }, + }) + ); + }); + + it('logs warn when search returns at least SEARCH_SIZE hits', async () => { + const logger = { warn: jest.fn(), error: jest.fn() }; + const manySources = Array.from({ length: 1000 }, (_, i) => + createAgentSource({ id: `agent-${i}` }) + ); + const storage = createMockStorage({ + hits: manySources.map((s, i) => ({ _id: `doc-${i}`, _source: s })), + }); + await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-x'], + logger: logger as unknown as import('@kbn/logging').Logger, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Tool ref cleanup: search limit reached') + ); + }); + + describe('checkOnly: true', () => { + it('returns list of agents that reference the tool without modifying data', async () => { + const source1 = createAgentSource({ + id: 'agent-1', + name: 'Agent One', + config: { instructions: '', tools: [{ tool_ids: ['tool-a'] }] }, + }); + const source2 = createAgentSource({ + id: 'agent-2', + name: 'Agent Two', + config: { instructions: '', tools: [{ tool_ids: ['tool-a', 'tool-b'] }] }, + }); + const storage = createMockStorage({ + hits: [ + { _id: 'doc-1', _source: source1 }, + { _id: 'doc-2', _source: source2 }, + ], + }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a'], + checkOnly: true, + }); + expect(result).toEqual({ + agents: [ + { id: 'agent-1', name: 'Agent One' }, + { id: 'agent-2', name: 'Agent Two' }, + ], + }); + expect(storage.getClient().bulk).not.toHaveBeenCalled(); + }); + + it('returns empty agents list when no agents reference the tool', async () => { + const storage = createMockStorage({ + hits: [ + { + _id: '1', + _source: createAgentSource({ + config: { instructions: '', tools: [{ tool_ids: ['tool-c'] }] }, + }), + }, + ], + }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a'], + checkOnly: true, + }); + expect(result).toEqual({ agents: [] }); + expect(storage.getClient().bulk).not.toHaveBeenCalled(); + }); + + it('returns agent id only when name is missing', async () => { + const source = createAgentSource({ + id: 'agent-1', + name: undefined, + config: { instructions: '', tools: [{ tool_ids: ['tool-a'] }] }, + }); + const storage = createMockStorage({ + hits: [{ _id: 'doc-1', _source: source }], + }); + const result = await runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a'], + checkOnly: true, + }); + expect(result).toEqual({ agents: [{ id: 'agent-1' }] }); + expect(storage.getClient().bulk).not.toHaveBeenCalled(); + }); + }); + + it('logs error and rethrows when bulk fails', async () => { + const logger = { warn: jest.fn(), error: jest.fn() }; + const storage = createMockStorage({ + hits: [ + { + _id: '1', + _source: createAgentSource({ + config: { instructions: '', tools: [{ tool_ids: ['tool-a'] }] }, + }), + }, + ], + }); + (storage.getClient().bulk as jest.Mock).mockRejectedValue(new Error('Bulk failed')); + await expect( + runToolRefCleanup({ + storage, + spaceId: SPACE_ID, + toolIds: ['tool-a'], + logger: logger as unknown as import('@kbn/logging').Logger, + }) + ).rejects.toThrow('Bulk failed'); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Tool ref cleanup: bulk update failed') + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/tool_reference_cleanup.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/tool_reference_cleanup.ts new file mode 100644 index 0000000000000..e8b018f0c9da0 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/tool_reference_cleanup.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/logging'; +import type { ToolSelection } from '@kbn/agent-builder-common'; +import type { AgentProfileStorage, AgentProperties } from './client/storage'; +import type { AgentRef } from '../../../../common/http_api/tools'; +import type { AgentsUsingToolsResult } from './types'; +import { updateRequestToEs } from './client/converters'; +import { removeToolIdsFromToolSelection } from './client/utils'; +import { createSpaceDslFilter } from '../../../utils/spaces'; + +const SEARCH_SIZE = 1000; + +export interface ToolRefCleanupParams { + storage: AgentProfileStorage; + spaceId: string; + toolIds: string[]; + logger?: Logger; + checkOnly?: boolean; +} + +export type ToolRefCleanupRunResult = AgentsUsingToolsResult; + +function getToolsFromSource(source: AgentProperties): ToolSelection[] { + return source.configuration?.tools ?? source.config?.tools ?? []; +} + +function referencesToolIds(tools: ToolSelection[], toolIdSet: Set): boolean { + return tools.some((sel) => (sel.tool_ids ?? []).some((tid) => toolIdSet.has(tid))); +} + +function toAgentRef(source: AgentProperties, fallbackId: string): AgentRef { + const id = String(source.id ?? fallbackId); + const name = source.name; + return { id, name }; +} + +export async function runToolRefCleanup({ + storage, + spaceId, + toolIds, + logger, + checkOnly = false, +}: ToolRefCleanupParams): Promise { + const toolIdSet = new Set(toolIds); + const response = await storage.getClient().search({ + track_total_hits: false, + size: SEARCH_SIZE, + query: { + bool: { + filter: [createSpaceDslFilter(spaceId)], + }, + }, + }); + + const hits = response.hits.hits; + const logPrefix = checkOnly ? 'Get agents using tools' : 'Tool ref cleanup'; + if (hits.length >= SEARCH_SIZE && logger) { + logger.warn(`${logPrefix}: search limit reached (size=${SEARCH_SIZE}, spaceId=${spaceId}).`); + } + + const agents: AgentRef[] = []; + const bulkOperations: Array<{ index: { _id: string; document: AgentProperties } }> = []; + const now = new Date(); + + for (const hit of hits) { + const source = hit._source; + if (!source) continue; + + const currentTools = getToolsFromSource(source); + if (!referencesToolIds(currentTools, toolIdSet)) continue; + + agents.push(toAgentRef(source, String(hit._id))); + + if (!checkOnly) { + const newTools = removeToolIdsFromToolSelection(currentTools, toolIds); + const updated = updateRequestToEs({ + agentId: source.id ?? hit._id, + currentProps: source, + update: { configuration: { tools: newTools } }, + updateDate: now, + }); + bulkOperations.push({ + index: { _id: String(hit._id), document: updated }, + }); + } + } + + if (checkOnly) { + return { agents }; + } + + if (bulkOperations.length > 0) { + try { + await storage.getClient().bulk({ + operations: bulkOperations, + refresh: 'wait_for', + throwOnFail: true, + }); + } catch (err) { + if (logger) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Tool ref cleanup: bulk update failed. ${message}`); + } + throw err; + } + } + + return { agents }; +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/types.ts index 98b8708c98ce9..987a9cc8fe590 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/types.ts @@ -6,5 +6,10 @@ */ import type { AgentDefinition } from '@kbn/agent-builder-common'; +import type { AgentRef } from '../../../../common/http_api/tools'; export type PersistedAgentDefinition = Omit; + +export interface AgentsUsingToolsResult { + agents: AgentRef[]; +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts index fadbe1de99948..81da8ee083e57 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts @@ -9,12 +9,20 @@ import type { KibanaRequest } from '@kbn/core/server'; import type { RunAgentFn } from '@kbn/agent-builder-server'; import type { BuiltInAgentDefinition } from '@kbn/agent-builder-server/agents'; import type { AgentRegistry } from './agent_registry'; +import type { AgentsUsingToolsResult } from './persisted/types'; export interface AgentsServiceSetup { register(agent: BuiltInAgentDefinition): void; } +export interface ToolRefsParams { + request: KibanaRequest; + toolIds: string[]; +} + export interface AgentsServiceStart { execute: RunAgentFn; getRegistry: (opts: { request: KibanaRequest }) => Promise; + removeToolRefsFromAgents: (params: ToolRefsParams) => Promise; + getAgentsUsingTools: (params: ToolRefsParams) => Promise; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/attachment_service.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/attachment_service.ts index 8a705d87b3390..5f7705331f4ab 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/attachment_service.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/attachment_service.ts @@ -42,6 +42,9 @@ export class AttachmentServiceImpl implements AttachmentService { getTypeDefinition: (attachment) => { return this.attachmentTypeRegistry.get(attachment); }, + getRegisteredTypeIds: () => { + return this.attachmentTypeRegistry.list().map((def) => def.id); + }, }; } } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/types.ts index 7d240828afca3..ee17dd3042efa 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/attachments/types.ts @@ -18,4 +18,5 @@ export interface AttachmentServiceStart { attachment: AttachmentInput ): Promise>; getTypeDefinition(type: string): AttachmentTypeDefinition | undefined; + getRegisteredTypeIds(): string[]; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/attachments.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/attachments.ts index d3fd3567c9092..883bf80b7f3dd 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/attachments.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/attachments.ts @@ -44,6 +44,9 @@ export const createAttachmentsService = ({ getTypeDefinition: (type) => { return attachmentsStart.getTypeDefinition(type); }, + getRegisteredTypeIds: () => { + return attachmentsStart.getRegisteredTypeIds(); + }, convertAttachmentTool: toolConverterFn, }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts index ffbb90675618f..3b6a5a597eeea 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts @@ -36,7 +36,23 @@ export const createAttachmentAddTool = ({ tags: ['attachment'], handler: async ({ id, type, data, description }, _context) => { const definition = attachmentsService?.getTypeDefinition(type); - const isReadonly = definition?.isReadonly ?? true; + if (!definition) { + const validTypes = attachmentsService?.getRegisteredTypeIds() ?? []; + return { + results: [ + { + tool_result_id: getToolResultId(), + type: ToolResultType.error, + data: { + message: `Unknown attachment type '${type}'. Valid attachment types are: ${validTypes.join( + ', ' + )}`, + }, + }, + ], + }; + } + const isReadonly = definition.isReadonly ?? false; if (isReadonly) { return { results: [ diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts index 2db1c57d472f1..4d1d47a87ec22 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts @@ -57,7 +57,7 @@ export const createAttachmentReadTool = ({ let formattedData: unknown = versionData.data; if (attachmentsService && formatContext) { const definition = attachmentsService.getTypeDefinition(attachment.type); - const typeReadonly = definition?.isReadonly ?? true; + const typeReadonly = definition?.isReadonly ?? false; if (definition && typeReadonly) { try { const formatted = await definition.format( diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts index 66605d103be94..b116a0773b8fa 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts @@ -111,6 +111,38 @@ describe('attachment tools', () => { expect((result.results[0] as any).data.type).toBe('text'); }); + it('returns error for unknown attachment type with list of valid types', async () => { + const typedAttachmentsService = { + getTypeDefinition: (type: string) => + type === 'text' + ? { + id: 'text', + validate: (input: unknown) => ({ valid: true, data: input }), + format: () => ({ getRepresentation: () => ({ type: 'text', value: '' }) }), + isReadonly: false, + } + : undefined, + getRegisteredTypeIds: () => ['text', 'esql', 'screen_context'], + } as any; + + const tool = createAttachmentTools({ + attachmentManager, + attachmentsService: typedAttachmentsService, + formatContext, + }).find((t) => t.id === 'platform.core.attachment_add')!; + + const result = (await tool.handler( + { type: 'image', data: 'binary data', description: 'A photo' }, + {} as any + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + expect((result.results[0] as any).data.message).toContain("Unknown attachment type 'image'"); + expect((result.results[0] as any).data.message).toContain('text'); + expect((result.results[0] as any).data.message).toContain('esql'); + expect((result.results[0] as any).data.message).toContain('screen_context'); + }); + it('returns error for duplicate ID', async () => { // First, create an attachment with a specific ID await attachmentManager.add({ diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts index 11320533fba66..534e0a952ed93 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts @@ -59,7 +59,7 @@ export const createAttachmentUpdateTool = ({ } const definition = attachmentsService?.getTypeDefinition(existing.type); - const typeReadonly = definition?.isReadonly ?? true; + const typeReadonly = definition?.isReadonly ?? false; const isReadonly = typeReadonly || existing.readonly === true; if (isReadonly) { return { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts index 4b0db93ee1f18..4fe27c22623ab 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts @@ -61,5 +61,7 @@ export const createAgentsServiceStartMock = (): AgentsServiceStartMock => { return { execute: jest.fn(), getRegistry: jest.fn().mockImplementation(() => createMockedAgentRegistry()), + removeToolRefsFromAgents: jest.fn(), + getAgentsUsingTools: jest.fn(), }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts index 925887ef13cda..108364f1b7a83 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts @@ -96,6 +96,7 @@ export const createAttachmentsServiceStartMock = (): AttachmentsServiceStartMock return { validate: jest.fn(), getTypeDefinition: jest.fn(), + getRegisteredTypeIds: jest.fn(), }; }; @@ -103,6 +104,7 @@ export const createAttachmentsService = (): AttachmentsServiceMock => { return { getTypeDefinition: jest.fn(), convertAttachmentTool: jest.fn(), + getRegisteredTypeIds: jest.fn(), }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/tsconfig.json b/x-pack/platform/plugins/shared/agent_builder/tsconfig.json index a9905e5cdc531..2936f1281eb48 100644 --- a/x-pack/platform/plugins/shared/agent_builder/tsconfig.json +++ b/x-pack/platform/plugins/shared/agent_builder/tsconfig.json @@ -106,5 +106,6 @@ "@kbn/core-elasticsearch-server-mocks", "@kbn/core-data-streams-server", "@kbn/task-manager-plugin", + "@kbn/deeplinks-data-sources", ] } diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/screen_context.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/screen_context.ts index 0fe35b316b84e..32e5f93e07ecd 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/screen_context.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/screen_context.ts @@ -36,6 +36,7 @@ export const createScreenContextAttachmentType = (): AttachmentTypeDefinition< }, }; }, + isReadonly: true, getTools: () => [], }; }; diff --git a/x-pack/platform/plugins/shared/alerting/common/saved_objects/rules/mappings.ts b/x-pack/platform/plugins/shared/alerting/common/saved_objects/rules/mappings.ts index 0ded5411b2dc8..b146bd0549053 100644 --- a/x-pack/platform/plugins/shared/alerting/common/saved_objects/rules/mappings.ts +++ b/x-pack/platform/plugins/shared/alerting/common/saved_objects/rules/mappings.ts @@ -116,6 +116,10 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = { updatedAt: { type: 'date', }, + uiamApiKey: { + type: 'binary', + }, + // NO NEED TO BE INDEXED // NEED TO CHECK WITH KIBANA SECURITY // apiKey: { // type: 'binary', diff --git a/x-pack/platform/plugins/shared/alerting/kibana.jsonc b/x-pack/platform/plugins/shared/alerting/kibana.jsonc index 18f933e0dd22d..631a0439d7ac9 100644 --- a/x-pack/platform/plugins/shared/alerting/kibana.jsonc +++ b/x-pack/platform/plugins/shared/alerting/kibana.jsonc @@ -30,7 +30,8 @@ "monitoringCollection", "spaces", "serverless", - "maintenanceWindows" + "maintenanceWindows", + "cps" ], "extraPublicDirs": ["common", "common/parse_duration"] } diff --git a/x-pack/platform/plugins/shared/alerting/moon.yml b/x-pack/platform/plugins/shared/alerting/moon.yml index d970296c35cb4..93f3b53b7119a 100644 --- a/x-pack/platform/plugins/shared/alerting/moon.yml +++ b/x-pack/platform/plugins/shared/alerting/moon.yml @@ -81,6 +81,7 @@ dependsOn: - '@kbn/maintenance-windows-plugin' - '@kbn/config' - '@kbn/expect' + - '@kbn/cps' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts index d571e859b21a1..e0df1d38fb094 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts @@ -37,6 +37,8 @@ import { enabledRuleForBulkOpsWithActions2, returnedRuleForBulkEnableWithActions1, returnedRuleForBulkEnableWithActions2, + enabledRuleForBulkOpsWithActions1WithUiam, + enabledRuleForBulkOpsWithActions2WithUiam, } from '../../../../rules_client/tests/test_helpers'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; @@ -241,10 +243,98 @@ describe('bulkDelete', () => { }); }); + test('invalidates UIAM ApiKeys as well', async () => { + rulesClient = new RulesClient({ ...rulesClientParams, shouldGrantUiam: true }); + + encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest + .fn() + .mockResolvedValueOnce({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { + saved_objects: [ + enabledRuleForBulkOpsWithActions1, + enabledRuleForBulkOpsWithActions1WithUiam, + enabledRuleForBulkOpsWithActions2WithUiam, + ], + }; + }, + }); + + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({ + statuses: [ + { id: 'id1', type: 'alert', success: true }, + { id: 'uiam-1', type: 'alert', success: true }, + { id: 'uiam-2', type: 'alert', success: true }, + ], + }); + + await rulesClient.bulkDeleteRules({ filter: 'fake_filter' }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw==', 'OTc4Onh5eg==', 'MTIzOmVzc3VfYWJj', 'NTc2Onh5eg=='], + }, + expect.anything(), + expect.anything() + ); + }); + + test('does not invalidate API keys created by user', async () => { + rulesClient = new RulesClient({ ...rulesClientParams, shouldGrantUiam: true }); + + encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest + .fn() + .mockResolvedValueOnce({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { + saved_objects: [ + { + ...enabledRuleForBulkOpsWithActions1, + attributes: { + ...enabledRuleForBulkOpsWithActions1.attributes, + apiKeyCreatedByUser: true, + }, + }, + { + ...enabledRuleForBulkOpsWithActions2WithUiam, + attributes: { + ...enabledRuleForBulkOpsWithActions2WithUiam.attributes, + apiKeyCreatedByUser: true, + }, + }, + ], + }; + }, + }); + + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({ + statuses: [ + { id: 'id1', type: 'alert', success: true }, + { id: 'uiam-1', type: 'alert', success: true }, + { id: 'uiam-2', type: 'alert', success: true }, + ], + }); + + await rulesClient.bulkDeleteRules({ filter: 'fake_filter' }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { + apiKeys: [], + }, + expect.anything(), + expect.anything() + ); + }); + test('swallows errors when soft deleting gaps fails', async () => { mockCreatePointInTimeFinderAsInternalUser({ saved_objects: [enabledRuleForBulkOpsWithActions1, enabledRuleForBulkOpsWithActions2], }); + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({ statuses: [ { id: 'id1', type: 'alert', success: true }, diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts index 69b319651651c..f20aae7dc72d8 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts @@ -106,7 +106,9 @@ export const bulkDeleteRules = async ( unsecuredSavedObjectsClient: context.unsecuredSavedObjectsClient, }), bulkMarkApiKeysForInvalidation( - { apiKeys: apiKeysToInvalidate }, + { + apiKeys: apiKeysToInvalidate, + }, context.logger, context.unsecuredSavedObjectsClient ), @@ -169,6 +171,7 @@ const bulkDeleteWithOCC = async ( const rulesToDelete: Array> = []; const apiKeyToRuleIdMapping: Record = {}; + const uiamApiKeyToRuleIdMapping: Record = {}; const taskIdToRuleIdMapping: Record = {}; const ruleNameToRuleIdMapping: Record = {}; @@ -182,8 +185,13 @@ const bulkDeleteWithOCC = async ( skipActionsValidation: true, }); for (const rule of response.saved_objects) { - if (rule.attributes.apiKey && !rule.attributes.apiKeyCreatedByUser) { - apiKeyToRuleIdMapping[rule.id] = rule.attributes.apiKey; + const { apiKey, apiKeyCreatedByUser, uiamApiKey } = rule.attributes; + + if (apiKey && !apiKeyCreatedByUser) { + apiKeyToRuleIdMapping[rule.id] = apiKey; + } + if (uiamApiKey && !apiKeyCreatedByUser) { + uiamApiKeyToRuleIdMapping[rule.id] = uiamApiKey; } const ruleName = rule.attributes.name; if (ruleName) { @@ -241,14 +249,17 @@ const bulkDeleteWithOCC = async ( ); const deletedRuleIds: string[] = []; - const apiKeysToInvalidate: string[] = []; + const apiKeysToInvalidate = new Set(); const taskIdsToDelete: string[] = []; const errors: BulkOperationError[] = []; result.statuses.forEach((status) => { if (status.error === undefined) { if (apiKeyToRuleIdMapping[status.id]) { - apiKeysToInvalidate.push(apiKeyToRuleIdMapping[status.id]); + apiKeysToInvalidate.add(apiKeyToRuleIdMapping[status.id]); + } + if (uiamApiKeyToRuleIdMapping[status.id]) { + apiKeysToInvalidate.add(uiamApiKeyToRuleIdMapping[status.id]); } if (taskIdToRuleIdMapping[status.id]) { taskIdsToDelete.push(taskIdToRuleIdMapping[status.id]); @@ -270,6 +281,6 @@ const bulkDeleteWithOCC = async ( return { errors, rules, - accListSpecificForBulkOperation: [apiKeysToInvalidate, taskIdsToDelete], + accListSpecificForBulkOperation: [Array.from(apiKeysToInvalidate), taskIdsToDelete], }; }; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts index 0faf72fd61280..342b4c9825005 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts @@ -2682,7 +2682,10 @@ describe('bulkEdit()', () => { describe('apiKeys', () => { beforeEach(() => { - createAPIKeyMock.mockResolvedValueOnce({ apiKeysEnabled: true, result: { api_key: '111' } }); + createAPIKeyMock.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '111', api_key: 'abc' }, + }); mockCreatePointInTimeFinderAsInternalUser({ saved_objects: [ { @@ -2769,7 +2772,7 @@ describe('bulkEdit()', () => { expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['dW5kZWZpbmVkOjExMQ=='] }, + { apiKeys: ['MTExOmFiYw=='] }, expect.any(Object), expect.any(Object) ); @@ -2820,7 +2823,7 @@ describe('bulkEdit()', () => { }); expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['dW5kZWZpbmVkOjExMQ=='] }, + { apiKeys: ['MTExOmFiYw=='] }, expect.any(Object), expect.any(Object) ); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts index 6f1596ba3febb..aee2beacfebf3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.test.ts @@ -129,6 +129,15 @@ describe('delete()', () => { }, }; + const existingDecryptedAlertWithUiam = { + ...existingAlert, + attributes: { + ...existingAlert.attributes, + apiKey: Buffer.from('123:abc').toString('base64'), + uiamApiKey: Buffer.from('123:essu_uiam').toString('base64'), + }, + }; + beforeEach(() => { rulesClient = new RulesClient(rulesClientParams); unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); @@ -168,6 +177,29 @@ describe('delete()', () => { expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); }); + test('invalidate UIAM API keys as well', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue( + existingDecryptedAlertWithUiam + ); + + const result = await rulesClient.delete({ id: '1' }); + expect(result).toEqual({ success: true }); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( + RULE_SAVED_OBJECT_TYPE, + '1', + undefined + ); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw==', 'MTIzOmVzc3VfdWlhbQ=='], + }, + expect.any(Object), + expect.any(Object) + ); + }); + test('attempts to soft delete gaps', async () => { await rulesClient.delete({ id: '1' }); expect(softDeleteGapsMock).toHaveBeenCalledWith({ diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts index 7baedaf4d67c2..ebfefa0fb58df 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/delete/delete_rule.ts @@ -40,6 +40,7 @@ export async function deleteRule(context: RulesClientContext, params: DeleteRule async function deleteRuleWithOCC(context: RulesClientContext, { id }: { id: string }) { let taskIdToRemove: string | undefined | null; let apiKeyToInvalidate: string | null = null; + let uiamApiKeyToInvalidate: string | null = null; let apiKeyCreatedByUser: boolean | undefined | null = false; let attributes: RawRule; let rule: SavedObject; @@ -52,7 +53,13 @@ async function deleteRuleWithOCC(context: RulesClientContext, { id }: { id: stri namespace: context.namespace, }, }); - apiKeyToInvalidate = decryptedRule.attributes.apiKey; + + const { uiamApiKey, apiKey } = decryptedRule.attributes; + + apiKeyToInvalidate = apiKey; + if (uiamApiKey) { + uiamApiKeyToInvalidate = uiamApiKey; + } apiKeyCreatedByUser = decryptedRule.attributes.apiKeyCreatedByUser; taskIdToRemove = decryptedRule.attributes.scheduledTaskId; attributes = decryptedRule.attributes; @@ -133,9 +140,14 @@ async function deleteRuleWithOCC(context: RulesClientContext, { id }: { id: stri namespace: context.namespace, unsecuredSavedObjectsClient: context.unsecuredSavedObjectsClient, }), - apiKeyToInvalidate && !apiKeyCreatedByUser + (apiKeyToInvalidate || uiamApiKeyToInvalidate) && !apiKeyCreatedByUser ? bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKeyToInvalidate] }, + { + apiKeys: [ + ...(apiKeyToInvalidate ? [apiKeyToInvalidate] : []), + ...(uiamApiKeyToInvalidate ? [uiamApiKeyToInvalidate] : []), + ], + }, context.logger, context.unsecuredSavedObjectsClient ) diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.test.ts index 341e5cb6c0337..5f7cfb868f5b3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.test.ts @@ -2109,6 +2109,7 @@ describe('update()', () => { rulesClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, result: { id: '234', name: '234', api_key: 'abc' }, + uiamResult: { id: 'uiam-234', name: 'uiam-234', api_key: 'def' }, }); unsecuredSavedObjectsClient.create.mockRejectedValue(new Error('Fail')); await expect( @@ -2142,7 +2143,7 @@ describe('update()', () => { expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1); expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( { - apiKeys: ['MjM0OmFiYw=='], + apiKeys: ['MjM0OmFiYw==', 'dWlhbS0yMzQ6ZGVm'], }, expect.any(Object), expect.any(Object) @@ -4758,4 +4759,96 @@ describe('update()', () => { }); }); }); + + it('creates and invalidates UIAM API key as well', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ + ...existingDecryptedAlert, + attributes: { + ...existingDecryptedAlert.attributes, + uiamApiKey: Buffer.from('001:essu_222').toString('base64'), + }, + }); + rulesClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '123', name: '123', api_key: 'abc' }, + uiamResult: { id: '456', name: '456', api_key: 'essu_def' }, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: 'onThrottleInterval', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + apiKey: Buffer.from('123:abc').toString('base64'), + uiamApiKey: '456:essu_def', + revision: 1, + scheduledTaskId: 'task-123', + }, + updated_at: new Date().toISOString(), + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: '5m', + notifyWhen: 'onThrottleInterval', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }, + }); + + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw==', 'MDAxOmVzc3VfMjIy'], + }, + expect.any(Object), + expect.any(Object) + ); + + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toEqual( + expect.objectContaining({ + apiKey: 'MTIzOmFiYw==', + uiamApiKey: 'NDU2OmVzc3VfZGVm', + }) + ); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts index 12f13b7506993..f4f110b91294c 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts @@ -114,8 +114,16 @@ async function updateWithOCC( systemActions: genSystemActions, }; - const { alertTypeId, consumer, enabled, schedule, name, apiKey, apiKeyCreatedByUser } = - originalRuleSavedObject.attributes; + const { + alertTypeId, + consumer, + enabled, + schedule, + name, + apiKey, + uiamApiKey, + apiKeyCreatedByUser, + } = originalRuleSavedObject.attributes; let validationPayload: ValidateScheduleLimitResult = null; if (enabled && schedule.interval !== data.schedule.interval) { @@ -209,10 +217,20 @@ async function updateWithOCC( ); } + const apiKeysToInvalidate = []; + if (apiKey && !apiKeyCreatedByUser) { + apiKeysToInvalidate.push(apiKey); + } + if (uiamApiKey && !apiKeyCreatedByUser) { + apiKeysToInvalidate.push(uiamApiKey); + } + await Promise.all([ - apiKey && !apiKeyCreatedByUser + apiKeysToInvalidate.length > 0 ? bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKey] }, + { + apiKeys: apiKeysToInvalidate, + }, context.logger, context.unsecuredSavedObjectsClient ) @@ -330,6 +348,7 @@ async function updateRuleAttributes({ let updatedRuleSavedObject: SavedObject; const { id, version } = originalRuleSavedObject; + try { updatedRuleSavedObject = await createRuleSo({ savedObjectsClient: context.unsecuredSavedObjectsClient, @@ -342,14 +361,19 @@ async function updateRuleAttributes({ }, }); } catch (e) { + const { apiKey, apiKeyCreatedByUser, uiamApiKey } = updatedRuleAttributes; + + const apiKeysToInvalidate = []; + if (apiKey && !apiKeyCreatedByUser) { + apiKeysToInvalidate.push(apiKey); + } + if (uiamApiKey && !apiKeyCreatedByUser) { + apiKeysToInvalidate.push(uiamApiKey); + } + // Avoid unused API key await bulkMarkApiKeysForInvalidation( - { - apiKeys: - updatedRuleAttributes.apiKey && !updatedRuleAttributes.apiKeyCreatedByUser - ? [updatedRuleAttributes.apiKey] - : [], - }, + { apiKeys: apiKeysToInvalidate }, context.logger, context.unsecuredSavedObjectsClient ); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.test.ts index a486ae5bbabe4..ecce3e4bc5fca 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.test.ts @@ -176,6 +176,66 @@ describe('updateRuleApiKey()', () => { ); }); + test('updates the UIAM API key for the alert', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ + ...existingEncryptedAlert, + attributes: { + ...existingEncryptedAlert.attributes, + uiamApiKey: 'old-uiam-234:essu_abc', + }, + }); + + rulesClientParams.isAuthenticationTypeAPIKey.mockReturnValueOnce(false); + rulesClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '234', name: '123', api_key: 'abc' }, + uiamResult: { id: 'uiam-234', name: 'uiam-123', api_key: 'essu_abc' }, + }); + await rulesClient.updateRuleApiKey({ id: '1' }); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( + RULE_SAVED_OBJECT_TYPE, + '1', + { + schedule: { interval: '10s' }, + name: ruleName, + alertTypeId: 'myType', + consumer: 'myApp', + enabled: true, + apiKey: Buffer.from('234:abc').toString('base64'), + uiamApiKey: 'dWlhbS0yMzQ6ZXNzdV9hYmM=', + apiKeyOwner: 'elastic', + apiKeyCreatedByUser: false, + revision: 0, + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + meta: { + versionApiKeyLastmodified: kibanaVersion, + }, + }, + { version: '123' } + ); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw==', 'old-uiam-234:essu_abc'], + }, + expect.any(Object), + expect.any(Object) + ); + }); + test('updates the API key for the alert and does not invalidate the old api key if created by a user authenticated using an api key', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ ...existingEncryptedAlert, diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.ts index 7f37583019ada..7ea556900064d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update_api_key/update_rule_api_key.ts @@ -31,6 +31,7 @@ export async function updateRuleApiKey( async function updateApiKeyWithOCC(context: RulesClientContext, { id }: UpdateApiKeyParams) { let oldApiKeyToInvalidate: string | null = null; let oldApiKeyCreatedByUser: boolean | undefined | null = false; + let oldUiamApiKeyToInvalidate: string | undefined | null; let attributes: RawRule; let version: string | undefined; @@ -51,6 +52,7 @@ async function updateApiKeyWithOCC(context: RulesClientContext, { id }: UpdateAp ); oldApiKeyToInvalidate = decryptedAlert.attributes.apiKey; oldApiKeyCreatedByUser = decryptedAlert.attributes.apiKeyCreatedByUser; + oldUiamApiKeyToInvalidate = decryptedAlert.attributes.uiamApiKey; attributes = decryptedAlert.attributes; version = decryptedAlert.version; } catch (e) { @@ -120,23 +122,43 @@ async function updateApiKeyWithOCC(context: RulesClientContext, { id }: UpdateAp version, }); } catch (e) { - // Avoid unused API key - await bulkMarkApiKeysForInvalidation( - { - apiKeys: - updateAttributes.apiKey && !updateAttributes.apiKeyCreatedByUser - ? [updateAttributes.apiKey] - : [], - }, - context.logger, - context.unsecuredSavedObjectsClient - ); + const { apiKey, apiKeyCreatedByUser, uiamApiKey } = updateAttributes; + + const apiKeysToInvalidate = []; + if (apiKey && !apiKeyCreatedByUser) { + apiKeysToInvalidate.push(apiKey); + } + if (uiamApiKey) { + apiKeysToInvalidate.push(uiamApiKey); + } + + if (apiKeysToInvalidate.length > 0) { + // Avoid unused API key + await bulkMarkApiKeysForInvalidation( + { + apiKeys: apiKeysToInvalidate, + }, + context.logger, + context.unsecuredSavedObjectsClient + ); + } + throw e; } + const oldApiKeysToInvalidate = []; if (oldApiKeyToInvalidate && !oldApiKeyCreatedByUser) { + oldApiKeysToInvalidate.push(oldApiKeyToInvalidate); + } + if (oldUiamApiKeyToInvalidate && !oldApiKeyCreatedByUser) { + oldApiKeysToInvalidate.push(oldUiamApiKeyToInvalidate); + } + + if (oldApiKeysToInvalidate.length > 0) { await bulkMarkApiKeysForInvalidation( - { apiKeys: [oldApiKeyToInvalidate] }, + { + apiKeys: oldApiKeysToInvalidate, + }, context.logger, context.unsecuredSavedObjectsClient ); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/schemas/rule_schemas.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/schemas/rule_schemas.ts index 80a7be7e8723d..6eccaa7c6715a 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/schemas/rule_schemas.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/schemas/rule_schemas.ts @@ -173,6 +173,7 @@ export const ruleDomainSchema = schema.object({ apiKey: schema.nullable(schema.string()), apiKeyOwner: schema.nullable(schema.string()), apiKeyCreatedByUser: schema.maybe(schema.nullable(schema.boolean())), + uiamApiKey: schema.maybe(schema.nullable(schema.string())), throttle: schema.maybe(schema.nullable(schema.string())), muteAll: schema.boolean(), notifyWhen: schema.maybe(schema.nullable(notifyWhenSchema)), diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts index 70489f4377cec..abab54bdf8c50 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts @@ -207,6 +207,7 @@ export const transformRuleAttributesToRuleDomain = { updatedBy: 'user', apiKey: MOCK_API_KEY, apiKeyOwner: 'user', + uiamApiKey: 'uiam-api-key', flapping: { lookBackWindow: 20, statusChangeThreshold: 20, diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts index 05f2b1a29facb..30c81e5b099a3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts @@ -47,6 +47,7 @@ export const transformRuleDomainToRuleAttributes = ({ updatedAt: rule.updatedAt.toISOString(), apiKey: rule.apiKey, apiKeyOwner: rule.apiKeyOwner, + ...(rule.uiamApiKey !== undefined ? { uiamApiKey: rule.uiamApiKey } : {}), ...(rule.apiKeyCreatedByUser !== undefined ? { apiKeyCreatedByUser: rule.apiKeyCreatedByUser } : {}), diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/types/rule.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/types/rule.ts index 47131738e6b2d..4020ecf6dd87d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/types/rule.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/types/rule.ts @@ -108,6 +108,7 @@ export interface RuleDomain { apiKey: RuleDomainSchemaType['apiKey']; apiKeyOwner: RuleDomainSchemaType['apiKeyOwner']; apiKeyCreatedByUser?: RuleDomainSchemaType['apiKeyCreatedByUser']; + uiamApiKey?: RuleDomainSchemaType['uiamApiKey']; throttle?: RuleDomainSchemaType['throttle']; muteAll: RuleDomainSchemaType['muteAll']; notifyWhen?: RuleDomainSchemaType['notifyWhen']; diff --git a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts index 5523767521b42..7b2c47e96adde 100644 --- a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts @@ -35,6 +35,42 @@ describe('bulkMarkApiKeysForInvalidation', () => { expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String)); }); + test('should invalidate UIAM API keys when provided', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation( + { + apiKeys: [ + Buffer.from('123').toString('base64'), + Buffer.from('456').toString('base64'), + Buffer.from('111:essu_uiam_key_value_1').toString('base64'), + Buffer.from('222:essu_uiam_key_value_2').toString('base64'), + ], + }, + loggingSystemMock.create().get(), + unsecuredSavedObjectsClient + ); + const bulkCreateCallMock = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]; + const savedObjects = bulkCreateCallMock[0]; + + expect(savedObjects).toHaveLength(4); + expect(savedObjects[0]).toHaveProperty('type', API_KEY_PENDING_INVALIDATION_TYPE); + expect(savedObjects[0]).toHaveProperty('attributes.apiKeyId', '123'); + expect(savedObjects[0]).toHaveProperty('attributes.createdAt', expect.any(String)); + expect(savedObjects[1]).toHaveProperty('type', API_KEY_PENDING_INVALIDATION_TYPE); + expect(savedObjects[1]).toHaveProperty('attributes.apiKeyId', '456'); + expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String)); + expect(savedObjects[2]).toHaveProperty('type', API_KEY_PENDING_INVALIDATION_TYPE); + expect(savedObjects[2]).toHaveProperty('attributes.apiKeyId', '111'); + expect(savedObjects[2]).toHaveProperty('attributes.uiamApiKey', 'essu_uiam_key_value_1'); + expect(savedObjects[2]).toHaveProperty('attributes.createdAt', expect.any(String)); + expect(savedObjects[3]).toHaveProperty('type', API_KEY_PENDING_INVALIDATION_TYPE); + expect(savedObjects[3]).toHaveProperty('attributes.apiKeyId', '222'); + expect(savedObjects[3]).toHaveProperty('attributes.uiamApiKey', 'essu_uiam_key_value_2'); + expect(savedObjects[3]).toHaveProperty('attributes.createdAt', expect.any(String)); + }); + test('should log the proper error when savedObjectsClient create failed', async () => { const e = new Error('Fail'); const logger = loggingSystemMock.create().get(); diff --git a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts index 4e5853951a14a..e7ffafb2b2bdd 100644 --- a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts +++ b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts @@ -7,6 +7,7 @@ import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; import { withSpan } from '@kbn/apm-utils'; +import { isUiamCredential } from '@kbn/core-security-server'; import { API_KEY_PENDING_INVALIDATION_TYPE } from '..'; export const bulkMarkApiKeysForInvalidation = async ( @@ -19,19 +20,31 @@ export const bulkMarkApiKeysForInvalidation = async ( return; } + const apiKeysToInvalidate = apiKeys.map((key) => { + let apiKeyId; + let apiKeyValue; + + const [id, apiKey] = Buffer.from(key, 'base64').toString().split(':'); + + if (apiKey && isUiamCredential(apiKey)) { + apiKeyId = id; + apiKeyValue = apiKey; + } else { + apiKeyId = id; + } + + return { + attributes: { + apiKeyId, + createdAt: new Date().toISOString(), + ...(apiKeyValue ? { uiamApiKey: apiKeyValue } : {}), + }, + type: API_KEY_PENDING_INVALIDATION_TYPE, + }; + }); + try { - const apiKeyIds = apiKeys.map( - (apiKey) => Buffer.from(apiKey, 'base64').toString().split(':')[0] - ); - await savedObjectsClient.bulkCreate( - apiKeyIds.map((apiKeyId) => ({ - attributes: { - apiKeyId, - createdAt: new Date().toISOString(), - }, - type: API_KEY_PENDING_INVALIDATION_TYPE, - })) - ); + await savedObjectsClient.bulkCreate(apiKeysToInvalidate); } catch (e) { logger.error( `Failed to bulk mark list of API keys [${apiKeys diff --git a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts index 228c5794808c4..3c35be1d34332 100644 --- a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts +++ b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts @@ -78,7 +78,8 @@ export function taskRunner( async run() { let totalInvalidated = 0; try { - const [{ savedObjects }, { encryptedSavedObjects, security }] = await coreStartServices; + const [{ savedObjects, security: securityCore }, { encryptedSavedObjects, security }] = + await coreStartServices; const savedObjectsClient = savedObjects.createInternalRepository([ API_KEY_PENDING_INVALIDATION_TYPE, AD_HOC_RUN_SAVED_OBJECT_TYPE, @@ -91,6 +92,7 @@ export function taskRunner( totalInvalidated = await runInvalidate({ encryptedSavedObjectsClient, invalidateApiKeyFn: security?.authc.apiKeys.invalidateAsInternalUser, + invalidateUiamApiKeyFn: securityCore.authc.apiKeys.uiam?.invalidate, logger, removalDelay: config.invalidateApiKeysTask.removalDelay, savedObjectsClient, diff --git a/x-pack/platform/plugins/shared/alerting/server/plugin.ts b/x-pack/platform/plugins/shared/alerting/server/plugin.ts index 39e7a0c00bfae..88fc688bf85f1 100644 --- a/x-pack/platform/plugins/shared/alerting/server/plugin.ts +++ b/x-pack/platform/plugins/shared/alerting/server/plugin.ts @@ -53,6 +53,7 @@ import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import type { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server'; import type { SharePluginStart } from '@kbn/share-plugin/server'; import type { MaintenanceWindowsServerStart } from '@kbn/maintenance-windows-plugin/server'; +import type { CPSServerSetup } from '@kbn/cps/server'; import { ApiKeyType } from './task_runner/types'; import { RuleTypeRegistry } from './rule_type_registry'; @@ -203,6 +204,7 @@ export interface AlertingPluginsSetup { data: DataPluginSetup; features: FeaturesPluginSetup; kql: KQLPluginSetup; + cps?: CPSServerSetup; } export interface AlertingPluginsStart { @@ -249,6 +251,7 @@ export class AlertingPlugin { private readonly disabledRuleTypes: Set; private readonly enabledRuleTypes: Set | null = null; private getRulesClientWithRequest?: (request: KibanaRequest) => Promise; + private cpsSetup?: CPSServerSetup; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); @@ -276,6 +279,7 @@ export class AlertingPlugin { this.kibanaBaseUrl = core.http.basePath.publicBaseUrl; this.licenseState = new LicenseState(plugins.licensing.license$); this.security = plugins.security; + this.cpsSetup = plugins.cps; const elasticsearchAndSOAvailability$ = getElasticsearchAndSOAvailability(core.status.core$); @@ -601,6 +605,8 @@ export class AlertingPlugin { } = this; licenseState?.setNotifyUsage(plugins.licensing.featureUsage.notifyUsage); + const shouldGrantUiam = this.getShouldGrantUiam(core); + const encryptedSavedObjectsClient = plugins.encryptedSavedObjects.getClient({ includedHiddenTypes: [ RULE_SAVED_OBJECT_TYPE, @@ -654,6 +660,7 @@ export class AlertingPlugin { connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: core.uiSettings, securityService: core.security, + shouldGrantUiam, }); rulesSettingsClientFactory.initialize({ @@ -735,6 +742,7 @@ export class AlertingPlugin { getEventLogClient: (request: KibanaRequest) => plugins.eventLog.getClient(request), isServerless: this.isServerless, apiKeyType: (this.config.rules.apiKeyType as ApiKeyType) ?? ApiKeyType.ES, + shouldGrantUiam, }); this.eventLogService!.registerSavedObjectProvider( @@ -778,6 +786,20 @@ export class AlertingPlugin { }; } + private getShouldGrantUiam(core: CoreStart): boolean { + const cpsEnabled = this.cpsSetup?.getCpsEnabled() ?? false; + if (!cpsEnabled) { + return false; + } + if (!core.security.authc.apiKeys.uiam) { + this.logger.error( + 'CPS is enabled but UIAM API key service is not available. UIAM API keys will not be granted.' + ); + return false; + } + return true; + } + private createRouteHandlerContext = ( core: CoreSetup ): IContextProvider => { diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.test.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.test.ts index 495dab4f81487..d527a00903600 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.test.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { apiKeyAsAlertAttributes } from './api_key_as_alert_attributes'; +import { + apiKeyAsAlertAttributes, + apiKeyAsRuleDomainProperties, +} from './api_key_as_alert_attributes'; describe('apiKeyAsAlertAttributes', () => { test('return attributes', () => { @@ -65,4 +68,77 @@ describe('apiKeyAsAlertAttributes', () => { apiKeyCreatedByUser: true, }); }); + + test('returns UIAM API Key as well', () => { + expect( + apiKeyAsRuleDomainProperties( + { + apiKeysEnabled: true, + result: { + id: '123', + name: '123', + api_key: 'abc', + }, + uiamResult: { + id: '456', + name: '456', + api_key: 'def', + }, + }, + 'test', + false + ) + ).toEqual({ + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'test', + apiKeyCreatedByUser: false, + uiamApiKey: 'NDU2OmRlZg==', + }); + }); + + test('returns only UIAM API Key when ES API Key is not provided', () => { + expect( + apiKeyAsRuleDomainProperties( + { + apiKeysEnabled: true, + uiamResult: { + id: '456', + name: '456', + api_key: 'def', + }, + }, + 'test', + true + ) + ).toEqual({ + apiKey: null, + apiKeyOwner: 'test', + apiKeyCreatedByUser: true, + uiamApiKey: 'NDU2OmRlZg==', + }); + }); + + test('does not create both API keys when createdByUser is true', () => { + expect(() => + apiKeyAsRuleDomainProperties( + { + apiKeysEnabled: true, + result: { + id: '123', + name: '123', + api_key: 'abc', + }, + uiamResult: { + id: '456', + name: '456', + api_key: 'def', + }, + }, + 'test', + true + ) + ).toThrow( + 'Both ES and UIAM API keys were created for a rule, but only one should be created when the API key is created by a user. This should never happen.' + ); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.ts index a18ff3c9ba08d..a48e912a5d058 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/api_key_as_alert_attributes.ts @@ -9,6 +9,52 @@ import type { RawRule } from '../../types'; import type { CreateAPIKeyResult } from '../types'; import type { RuleDomain } from '../../application/rule/types'; +interface ApiKeyRuleProperties { + apiKey: string | null; + apiKeyOwner: string | null; + apiKeyCreatedByUser: boolean | null; + uiamApiKey?: string | null; +} + +const encodeApiKey = (id?: string, key?: string): string | null => { + return id && key ? Buffer.from(`${id}:${key}`).toString('base64') : null; +}; + +const getApiKeyRuleProperties = ( + apiKey: CreateAPIKeyResult | null, + username: string | null, + createdByUser: boolean +): ApiKeyRuleProperties => { + if (!apiKey || !apiKey.apiKeysEnabled) { + return { + apiKeyOwner: null, + apiKey: null, + apiKeyCreatedByUser: null, + }; + } + + const esApiKey = apiKey.result?.api_key; + const esApiKeyId = apiKey.result?.id; + const uiamApiKey = apiKey.uiamResult?.api_key; + const uiamApiKeyId = apiKey.uiamResult?.id; + + if (esApiKey && uiamApiKey && createdByUser) { + throw new Error( + 'Both ES and UIAM API keys were created for a rule, but only one should be created when the API key is created by a user. This should never happen.' + ); + } + + const encodedApiKey = encodeApiKey(esApiKeyId, esApiKey); + const encodedUiamApiKey = encodeApiKey(uiamApiKeyId, uiamApiKey); + + return { + apiKeyOwner: username, + apiKey: encodedApiKey, + apiKeyCreatedByUser: createdByUser, + ...(encodedUiamApiKey ? { uiamApiKey: encodedUiamApiKey } : {}), + }; +}; + /** * @deprecated TODO (http-versioning) make sure this is deprecated * once all of the RawRules are phased out @@ -17,34 +63,14 @@ export function apiKeyAsAlertAttributes( apiKey: CreateAPIKeyResult | null, username: string | null, createdByUser: boolean -): Pick { - return apiKey && apiKey.apiKeysEnabled - ? { - apiKeyOwner: username, - apiKey: Buffer.from(`${apiKey.result.id}:${apiKey.result.api_key}`).toString('base64'), - apiKeyCreatedByUser: createdByUser, - } - : { - apiKeyOwner: null, - apiKey: null, - apiKeyCreatedByUser: null, - }; +): Pick { + return getApiKeyRuleProperties(apiKey, username, createdByUser); } export function apiKeyAsRuleDomainProperties( apiKey: CreateAPIKeyResult | null, username: string | null, createdByUser: boolean -): Pick { - return apiKey && apiKey.apiKeysEnabled - ? { - apiKeyOwner: username, - apiKey: Buffer.from(`${apiKey.result.id}:${apiKey.result.api_key}`).toString('base64'), - apiKeyCreatedByUser: createdByUser, - } - : { - apiKeyOwner: null, - apiKey: null, - apiKeyCreatedByUser: null, - }; +): Pick { + return getApiKeyRuleProperties(apiKey, username, createdByUser); } diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.test.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.test.ts index cb73eadde36f2..079386e77567a 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules.test.ts @@ -130,6 +130,7 @@ const existingDecryptedRule: SavedObject = { ...existingRule.attributes, apiKey: MOCK_API_KEY_1, apiKeyCreatedByUser: false, + uiamApiKey: 'uiam-key', }, }; @@ -472,6 +473,66 @@ describe('bulkEditRules', () => { ); }); + test('should call bulkMarkApiKeysForInvalidation with UIAM API keys if there are any', async () => { + await bulkEditRules(rulesClientContext, { + name: `rulesClient.bulkEdit`, + updateFn: jest.fn().mockImplementation(({ apiKeysMap, rules }) => { + rules.push(existingDecryptedRule); + apiKeysMap.set('1', { + oldApiKey: MOCK_API_KEY_1, + oldApiKeyCreatedByUser: false, + oldUiamApiKey: '111:essu_old-uia-key', + }); + }), + shouldInvalidateApiKeys: true, + requiredAuthOperation: ReadOperations.BulkEditParams, + auditAction: RuleAuditAction.BULK_EDIT, + }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { + apiKeys: [MOCK_API_KEY_1, '111:essu_old-uia-key'], + }, + logger, + unsecuredSavedObjectsClient + ); + }); + + test('should invalidate the new keys when creation fails', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Failed to save')); + + try { + await bulkEditRules(rulesClientContext, { + name: `rulesClient.bulkEdit`, + updateFn: jest.fn().mockImplementation(({ apiKeysMap, rules }) => { + rules.push(existingDecryptedRule); + apiKeysMap.set('1', { + oldApiKey: MOCK_API_KEY_1, + oldApiKeyCreatedByUser: false, + oldUiamApiKey: '111:essu_old-uia-key', + newApiKey: 'new-api-key', + newUiamApiKey: '333:essu_new-uia-key', + }); + }), + shouldInvalidateApiKeys: true, + requiredAuthOperation: ReadOperations.BulkEditParams, + auditAction: RuleAuditAction.BULK_EDIT, + }); + } catch (e) { + /* expected */ + } + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { + apiKeys: ['333:essu_new-uia-key', 'new-api-key'], + }, + logger, + unsecuredSavedObjectsClient + ); + }); + test('should return updated rules formatted for the public API', async () => { const result = await bulkEditRules(rulesClientContext, { name: `rulesClient.bulkEdit`, diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts index e29e61c42ca0a..889908356ab5b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/bulk_edit_rules_occ.ts @@ -69,7 +69,6 @@ export async function bulkEditRulesOcc( const prevInterval: string[] = []; for await (const response of rulesFinder.find()) { - context.logger.info(`response.saved_objects ${JSON.stringify(response.saved_objects)}`); if (options.shouldValidateSchedule) { const intervals = response.saved_objects .filter((rule) => rule.attributes.enabled) @@ -111,12 +110,19 @@ export async function bulkEditRulesOcc( }); if (validationPayload) { + const apiKeysToInvalidate = []; + + for (const { newUiamApiKey, newApiKey } of apiKeysMap.values()) { + if (newUiamApiKey) { + apiKeysToInvalidate.push(newUiamApiKey); + } + if (newApiKey) { + apiKeysToInvalidate.push(newApiKey); + } + } + return { - apiKeysToInvalidate: options.shouldInvalidateApiKeys - ? Array.from(apiKeysMap.values()) - .filter((value) => value.newApiKey) - .map((value) => value.newApiKey as string) - : [], + apiKeysToInvalidate: options.shouldInvalidateApiKeys ? apiKeysToInvalidate : [], resultSavedObjects: [], rules: [], errors: rules.map((rule) => ({ @@ -187,13 +193,21 @@ async function saveBulkUpdatedRules({ }); } catch (e) { // avoid unused newly generated API keys + if (apiKeysMap.size > 0) { - const newKeys = Array.from(apiKeysMap.values()) - .filter((value) => value.newApiKey && !value.newApiKeyCreatedByUser) - .map((value) => value.newApiKey as string); - if (newKeys.length > 0) { + const newApiKeysToInvalidate = []; + + for (const { newUiamApiKey, newApiKey, newApiKeyCreatedByUser } of apiKeysMap.values()) { + if (newUiamApiKey && !newApiKeyCreatedByUser) { + newApiKeysToInvalidate.push(newUiamApiKey); + } + if (newApiKey && !newApiKeyCreatedByUser) { + newApiKeysToInvalidate.push(newApiKey); + } + } + if (newApiKeysToInvalidate.length > 0) { await bulkMarkApiKeysForInvalidation( - { apiKeys: newKeys }, + { apiKeys: newApiKeysToInvalidate }, context.logger, context.unsecuredSavedObjectsClient ); @@ -204,10 +218,14 @@ async function saveBulkUpdatedRules({ if (shouldInvalidateApiKeys) { result.saved_objects.map(({ id, error }) => { - const oldApiKey = apiKeysMap.get(id)?.oldApiKey; - const oldApiKeyCreatedByUser = apiKeysMap.get(id)?.oldApiKeyCreatedByUser; - const newApiKey = apiKeysMap.get(id)?.newApiKey; - const newApiKeyCreatedByUser = apiKeysMap.get(id)?.newApiKeyCreatedByUser; + const apiKey = apiKeysMap.get(id); + + const oldApiKey = apiKey?.oldApiKey; + const oldApiKeyCreatedByUser = apiKey?.oldApiKeyCreatedByUser; + const newApiKey = apiKey?.newApiKey; + const newApiKeyCreatedByUser = apiKey?.newApiKeyCreatedByUser; + const oldUiamApiKey = apiKey?.oldUiamApiKey; + const newUiamApiKey = apiKey?.newUiamApiKey; // if SO wasn't saved and has new API key it will be invalidated if (error && newApiKey && !newApiKeyCreatedByUser) { @@ -216,6 +234,12 @@ async function saveBulkUpdatedRules({ } else if (!error && oldApiKey && !oldApiKeyCreatedByUser) { apiKeysToInvalidate.push(oldApiKey); } + + if (error && newUiamApiKey && !newApiKeyCreatedByUser) { + apiKeysToInvalidate.push(newUiamApiKey); + } else if (!error && oldUiamApiKey && !oldApiKeyCreatedByUser) { + apiKeysToInvalidate.push(oldUiamApiKey); + } }); } diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/types.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/types.ts index 31b23bf423668..55a5731ef524c 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/types.ts @@ -28,6 +28,8 @@ export type ApiKeysMap = Map< { oldApiKey?: string; newApiKey?: string; + oldUiamApiKey?: string | null; + newUiamApiKey?: string | null; oldApiKeyCreatedByUser?: boolean | null; newApiKeyCreatedByUser?: boolean | null; } diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/update_rule_in_memory.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/update_rule_in_memory.ts index 1e8ddc9151458..1a860c91dd9af 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/update_rule_in_memory.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/bulk_edit/update_rule_in_memory.ts @@ -35,7 +35,10 @@ import { } from '../../../application/rule/transforms'; import { getMappedParams } from '../mapped_params_utils'; -type ApiKeyAttributes = Pick; +type ApiKeyAttributes = Pick< + RawRule, + 'apiKey' | 'apiKeyOwner' | 'apiKeyCreatedByUser' | 'uiamApiKey' +>; type RuleType = ReturnType; export interface UpdateRuleInMemoryOpts { @@ -73,6 +76,7 @@ export async function updateRuleInMemory( apiKeysMap.set(rule.id, { oldApiKey: rule.attributes.apiKey, oldApiKeyCreatedByUser: rule.attributes.apiKeyCreatedByUser, + oldUiamApiKey: rule.attributes.uiamApiKey, }); } @@ -84,8 +88,6 @@ export async function updateRuleInMemory( rule.references || [] ); - context.logger.info(`ruleActions ${JSON.stringify(ruleActions)}`); - const ruleArtifacts = injectReferencesIntoArtifacts( rule.id, rule.attributes.artifacts, @@ -103,8 +105,6 @@ export async function updateRuleInMemory( context.isSystemAction ); - context.logger.info(`ruleDomain ${JSON.stringify(ruleDomain)}`); - const { rule: updatedRule, ruleActions: updatedRuleActions, @@ -112,8 +112,6 @@ export async function updateRuleInMemory( isAttributesUpdateSkipped, } = await updateAttributesFn({ domainRule: ruleDomain, ruleActions, ruleType }); - context.logger.info(`updatedRule ${JSON.stringify(updatedRule)}`); - const { modifiedParams: ruleParams, isParamsUpdateSkipped } = paramsModifier ? // TODO (http-versioning): Remove the cast when all rule types are fixed await paramsModifier(updatedRule as Rule) @@ -173,8 +171,6 @@ export async function updateRuleInMemory( artifactsWithRefs, }); - context.logger.info(`ruleAttributes ${JSON.stringify(ruleAttributes)}`); - let apiKeyAttributes: ApiKeyAttributes | undefined; if (shouldInvalidateApiKeys) { const { apiKeyAttributes: preparedApiKeyAttributes } = await prepareApiKeys( @@ -198,8 +194,6 @@ export async function updateRuleInMemory( username, }); - context.logger.info(`updatedAttributes ${JSON.stringify(updatedAttributes)}`); - rules.push({ ...rule, references, attributes: updatedAttributes }); } @@ -222,10 +216,13 @@ async function prepareApiKeys( // collect generated API keys if (apiKeyAttributes.apiKey) { + const { apiKey, apiKeyCreatedByUser, uiamApiKey } = apiKeyAttributes; + apiKeysMap.set(rule.id, { ...apiKeysMap.get(rule.id), - newApiKey: apiKeyAttributes.apiKey, - newApiKeyCreatedByUser: apiKeyAttributes.apiKeyCreatedByUser, + newApiKey: apiKey, + newApiKeyCreatedByUser: apiKeyCreatedByUser, + ...(uiamApiKey ? { newUiamApiKey: uiamApiKey } : {}), }); } diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/add_generated_action_values.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/add_generated_action_values.ts index 77051c7330d7a..ad99855f0f112 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/add_generated_action_values.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/add_generated_action_values.ts @@ -17,6 +17,7 @@ import type { RulesClientContext, } from '..'; import { getEsQueryConfig } from '../../lib/get_es_query_config'; +import type { RawRuleAlertsFilter } from '../../types'; export async function addGeneratedActionValues( actions: NormalizedAlertAction[] = [], @@ -55,7 +56,7 @@ export async function addGeneratedActionValues( dsl: generateDSL(alertsFilter.query.kql, alertsFilter.query.filters) ?? '', } : undefined, - }, + } as RawRuleAlertsFilter, } : {}), }; diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_new_api_key_set.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_new_api_key_set.ts index 5ba9144e438a1..6e43c86869bfc 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_new_api_key_set.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_new_api_key_set.ts @@ -25,7 +25,7 @@ export async function createNewAPIKeySet( shouldUpdateApiKey: boolean; errorMessage?: string; } -): Promise> { +): Promise> { let createdAPIKey = null; let isAuthTypeApiKey = false; try { diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_rule_saved_object.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_rule_saved_object.ts index 1f17067fa12e2..419133982ba06 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_rule_saved_object.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/create_rule_saved_object.ts @@ -77,8 +77,22 @@ export async function createRuleSavedObject>( context: RulesClientContext, alertAttributes: T ): T { - if (Object.hasOwn(alertAttributes, 'apiKey') || Object.hasOwn(alertAttributes, 'apiKeyOwner')) { + if ( + Object.hasOwn(alertAttributes, 'apiKey') || + Object.hasOwn(alertAttributes, 'uiamApiKey') || + Object.hasOwn(alertAttributes, 'apiKeyOwner') + ) { return { ...alertAttributes, meta: { diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts index 014fa9cd68756..616dd4beb82ba 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts @@ -180,6 +180,16 @@ export const enabledRuleForBulkOpsWithActions1 = { ], }; +export const enabledRuleForBulkOpsWithActions1WithUiam = { + ...enabledRuleForBulkOpsWithActions1, + id: 'uiam-1', + attributes: { + ...enabledRuleForBulkOpsWithActions1.attributes, + apiKey: Buffer.from('978:xyz').toString('base64'), + uiamApiKey: Buffer.from('123:essu_abc').toString('base64'), + }, +}; + export const enabledRuleForBulkOpsWithActions2 = { ...defaultRuleForBulkDelete, id: 'id2', @@ -210,6 +220,16 @@ export const enabledRuleForBulkOpsWithActions2 = { ], }; +export const enabledRuleForBulkOpsWithActions2WithUiam = { + ...enabledRuleForBulkOpsWithActions2, + id: 'uiam-2', + attributes: { + ...enabledRuleForBulkOpsWithActions2.attributes, + uiamApiKey: Buffer.from('123:essu_abc').toString('base64'), + apiKey: Buffer.from('576:xyz').toString('base64'), + }, +}; + export const disabledRuleForBulkOpsWithActions1 = { ...defaultRuleForBulkDelete, attributes: { diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts index d5318ad921c03..da8235357f458 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts @@ -87,6 +87,7 @@ export interface RulesClientContext { readonly backfillClient: BackfillClient; readonly isSystemAction: (actionId: string) => boolean; readonly uiSettings: UiSettingsServiceStart; + readonly shouldGrantUiam?: boolean; } export type NormalizedAlertAction = DistributiveOmit; @@ -111,7 +112,11 @@ export type NormalizedAlertActionWithGeneratedValues = export type CreateAPIKeyResult = | { apiKeysEnabled: false } - | { apiKeysEnabled: true; result: SecurityPluginGrantAPIKeyResult }; + | { + apiKeysEnabled: true; + result?: SecurityPluginGrantAPIKeyResult; + uiamResult?: SecurityPluginGrantAPIKeyResult; + }; export type InvalidateAPIKeyResult = | { apiKeysEnabled: false } | { apiKeysEnabled: true; result: SecurityPluginInvalidateAPIKeyResult }; diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.test.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.test.ts index 25164b8ee4860..7097512e5f30d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.test.ts @@ -62,402 +62,556 @@ let backfillClient: jest.Mocked; jest.mock('./rules_client'); jest.mock('./authorization/alerting_authorization'); -beforeEach(() => { - jest.clearAllMocks(); - - savedObjectsClient = savedObjectsClientMock.create(); - savedObjectsClient.asScopedToNamespace = jest.fn().mockReturnValue(savedObjectsClient); - savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); - - securityPluginSetup = securityMock.createSetup(); - - securityPluginStart = securityMock.createStart(); - - securityService = securityServiceMock.createStart(); - - alertingAuthorization = alertingAuthorizationMock.create(); - - alertingAuthorizationClientFactory = alertingAuthorizationClientFactoryMock.createFactory(); - - actionsAuthorization = actionsAuthorizationMock.create(); - - const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); - backfillClient = backfillClientMock.create(); - - rulesClientFactoryParams = { - logger: loggingSystemMock.create().get(), - taskManager: taskManagerMock.createStart(), - ruleTypeRegistry: ruleTypeRegistryMock.create(), - getSpaceId: jest.fn(), - spaceIdToNamespace: jest.fn(), - maxScheduledPerMinute: 10000, - minimumScheduleInterval: { value: '1m', enforce: false }, - internalSavedObjectsRepository, - encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), - actions: actionsMock.createStart(), - eventLog: eventLogMock.createStart(), - kibanaVersion: '7.10.0', - authorization: - alertingAuthorizationClientFactory as unknown as AlertingAuthorizationClientFactory, - backfillClient, - connectorAdapterRegistry: new ConnectorAdapterRegistry(), - uiSettings: uiSettingsServiceMock.createStartContract(), - securityService: securityServiceMock.createStart(), - getAlertIndicesAlias: jest.fn(), - alertsService: null, - }; - - rulesClientFactoryParams.actions = actionsMock.createStart(); - ( - rulesClientFactoryParams.actions as jest.Mocked - ).getActionsAuthorizationWithRequest.mockReturnValue(actionsAuthorization); - rulesClientFactoryParams.getSpaceId.mockReturnValue('default'); - rulesClientFactoryParams.spaceIdToNamespace.mockReturnValue('default'); - rulesClientFactoryParams.uiSettings.asScopedToClient = - uiSettingsServiceMock.createStartContract().asScopedToClient; -}); - -test('creates a rules client with proper constructor arguments when security is enabled', async () => { - const factory = new RulesClientFactory(); - factory.initialize({ - securityPluginSetup, - securityPluginStart, - ...rulesClientFactoryParams, - }); - const request = mockRouter.createKibanaRequest(); - - savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); - alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( - alertingAuthorization as unknown as AlertingAuthorization - ); - - await factory.create(request, savedObjectsService); - - expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { - excludedExtensions: [SECURITY_EXTENSION_ID], - includedHiddenTypes: [ - RULE_SAVED_OBJECT_TYPE, - RULE_TEMPLATE_SAVED_OBJECT_TYPE, - API_KEY_PENDING_INVALIDATION_TYPE, - AD_HOC_RUN_SAVED_OBJECT_TYPE, - GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, - ], +describe('RulesClientFactory', () => { + beforeEach(() => { + jest.clearAllMocks(); + + savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.asScopedToNamespace = jest.fn().mockReturnValue(savedObjectsClient); + savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); + + securityPluginSetup = securityMock.createSetup(); + + securityPluginStart = securityMock.createStart(); + + securityService = securityServiceMock.createStart(); + + alertingAuthorization = alertingAuthorizationMock.create(); + + alertingAuthorizationClientFactory = alertingAuthorizationClientFactoryMock.createFactory(); + + actionsAuthorization = actionsAuthorizationMock.create(); + + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + backfillClient = backfillClientMock.create(); + + rulesClientFactoryParams = { + logger: loggingSystemMock.create().get(), + taskManager: taskManagerMock.createStart(), + ruleTypeRegistry: ruleTypeRegistryMock.create(), + getSpaceId: jest.fn(), + spaceIdToNamespace: jest.fn(), + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), + actions: actionsMock.createStart(), + eventLog: eventLogMock.createStart(), + kibanaVersion: '7.10.0', + authorization: + alertingAuthorizationClientFactory as unknown as AlertingAuthorizationClientFactory, + backfillClient, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), + securityService: securityServiceMock.createStart(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + shouldGrantUiam: false, + }; + + rulesClientFactoryParams.actions = actionsMock.createStart(); + ( + rulesClientFactoryParams.actions as jest.Mocked + ).getActionsAuthorizationWithRequest.mockReturnValue(actionsAuthorization); + rulesClientFactoryParams.getSpaceId.mockReturnValue('default'); + rulesClientFactoryParams.spaceIdToNamespace.mockReturnValue('default'); + rulesClientFactoryParams.uiSettings.asScopedToClient = + uiSettingsServiceMock.createStartContract().asScopedToClient; }); - expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( - request, - 'default' - ); - - expect(rulesClientFactoryParams.actions.getActionsAuthorizationWithRequest).toHaveBeenCalledWith( - request - ); - - expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith({ - auditLogger: { - enabled: true, - includeSavedObjectNames: false, - log: expect.any(Function), - }, - unsecuredSavedObjectsClient: savedObjectsClient, - authorization: alertingAuthorization, - actionsAuthorization, - logger: rulesClientFactoryParams.logger, - taskManager: rulesClientFactoryParams.taskManager, - ruleTypeRegistry: rulesClientFactoryParams.ruleTypeRegistry, - spaceId: 'default', - namespace: 'default', - getUserName: expect.any(Function), - getActionsClient: expect.any(Function), - getEventLogClient: expect.any(Function), - createAPIKey: expect.any(Function), - internalSavedObjectsRepository: rulesClientFactoryParams.internalSavedObjectsRepository, - encryptedSavedObjectsClient: rulesClientFactoryParams.encryptedSavedObjectsClient, - kibanaVersion: '7.10.0', - maxScheduledPerMinute: 10000, - minimumScheduleInterval: { value: '1m', enforce: false }, - isAuthenticationTypeAPIKey: expect.any(Function), - getAuthenticationAPIKey: expect.any(Function), - connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), - isSystemAction: expect.any(Function), - getAlertIndicesAlias: expect.any(Function), - alertsService: null, - backfillClient, - uiSettings: rulesClientFactoryParams.uiSettings, + test('creates a rules client with proper constructor arguments when security is enabled', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + securityPluginSetup, + securityPluginStart, + ...rulesClientFactoryParams, + }); + const request = mockRouter.createKibanaRequest(); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( + alertingAuthorization as unknown as AlertingAuthorization + ); + + await factory.create(request, savedObjectsService); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [ + RULE_SAVED_OBJECT_TYPE, + RULE_TEMPLATE_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + ], + }); + + expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( + request, + 'default' + ); + + expect( + rulesClientFactoryParams.actions.getActionsAuthorizationWithRequest + ).toHaveBeenCalledWith(request); + + expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith({ + auditLogger: { + enabled: true, + includeSavedObjectNames: false, + log: expect.any(Function), + }, + unsecuredSavedObjectsClient: savedObjectsClient, + authorization: alertingAuthorization, + actionsAuthorization, + logger: rulesClientFactoryParams.logger, + taskManager: rulesClientFactoryParams.taskManager, + ruleTypeRegistry: rulesClientFactoryParams.ruleTypeRegistry, + spaceId: 'default', + namespace: 'default', + getUserName: expect.any(Function), + getActionsClient: expect.any(Function), + getEventLogClient: expect.any(Function), + createAPIKey: expect.any(Function), + internalSavedObjectsRepository: rulesClientFactoryParams.internalSavedObjectsRepository, + encryptedSavedObjectsClient: rulesClientFactoryParams.encryptedSavedObjectsClient, + kibanaVersion: '7.10.0', + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: expect.any(Function), + getAuthenticationAPIKey: expect.any(Function), + connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), + isSystemAction: expect.any(Function), + getAlertIndicesAlias: expect.any(Function), + alertsService: null, + backfillClient, + uiSettings: rulesClientFactoryParams.uiSettings, + shouldGrantUiam: false, + }); }); -}); -test('creates a rules client with proper constructor arguments', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - const request = mockRouter.createKibanaRequest(); - - savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); - alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( - alertingAuthorization as unknown as AlertingAuthorization - ); - - await factory.create(request, savedObjectsService); - - expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { - excludedExtensions: [SECURITY_EXTENSION_ID], - includedHiddenTypes: [ - RULE_SAVED_OBJECT_TYPE, - RULE_TEMPLATE_SAVED_OBJECT_TYPE, - API_KEY_PENDING_INVALIDATION_TYPE, - AD_HOC_RUN_SAVED_OBJECT_TYPE, - GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, - ], + test('creates a rules client with proper constructor arguments', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + const request = mockRouter.createKibanaRequest(); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( + alertingAuthorization as unknown as AlertingAuthorization + ); + + await factory.create(request, savedObjectsService); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [ + RULE_SAVED_OBJECT_TYPE, + RULE_TEMPLATE_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + ], + }); + + expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( + request, + 'default' + ); + + expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith({ + unsecuredSavedObjectsClient: savedObjectsClient, + authorization: alertingAuthorization, + actionsAuthorization, + logger: rulesClientFactoryParams.logger, + taskManager: rulesClientFactoryParams.taskManager, + ruleTypeRegistry: rulesClientFactoryParams.ruleTypeRegistry, + spaceId: 'default', + namespace: 'default', + getUserName: expect.any(Function), + createAPIKey: expect.any(Function), + internalSavedObjectsRepository: rulesClientFactoryParams.internalSavedObjectsRepository, + encryptedSavedObjectsClient: rulesClientFactoryParams.encryptedSavedObjectsClient, + getActionsClient: expect.any(Function), + getEventLogClient: expect.any(Function), + kibanaVersion: '7.10.0', + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: expect.any(Function), + getAuthenticationAPIKey: expect.any(Function), + connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), + isSystemAction: expect.any(Function), + getAlertIndicesAlias: expect.any(Function), + alertsService: null, + backfillClient, + uiSettings: rulesClientFactoryParams.uiSettings, + shouldGrantUiam: false, + }); }); - expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( - request, - 'default' - ); - - expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith({ - unsecuredSavedObjectsClient: savedObjectsClient, - authorization: alertingAuthorization, - actionsAuthorization, - logger: rulesClientFactoryParams.logger, - taskManager: rulesClientFactoryParams.taskManager, - ruleTypeRegistry: rulesClientFactoryParams.ruleTypeRegistry, - spaceId: 'default', - namespace: 'default', - getUserName: expect.any(Function), - createAPIKey: expect.any(Function), - internalSavedObjectsRepository: rulesClientFactoryParams.internalSavedObjectsRepository, - encryptedSavedObjectsClient: rulesClientFactoryParams.encryptedSavedObjectsClient, - getActionsClient: expect.any(Function), - getEventLogClient: expect.any(Function), - kibanaVersion: '7.10.0', - maxScheduledPerMinute: 10000, - minimumScheduleInterval: { value: '1m', enforce: false }, - isAuthenticationTypeAPIKey: expect.any(Function), - getAuthenticationAPIKey: expect.any(Function), - connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), - isSystemAction: expect.any(Function), - getAlertIndicesAlias: expect.any(Function), - alertsService: null, - backfillClient, - uiSettings: rulesClientFactoryParams.uiSettings, + test('getUserName() returns null when security is disabled', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + const userNameResult = await constructorCall.getUserName(); + expect(userNameResult).toEqual(null); }); -}); -test('getUserName() returns null when security is disabled', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + test('getUserName() returns a name when security is enabled', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + }); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + securityService.authc.getCurrentUser.mockReturnValueOnce({ + username: 'bob', + } as unknown as AuthenticatedUser); + const userNameResult = await constructorCall.getUserName(); + expect(userNameResult).toEqual('bob'); + }); - const userNameResult = await constructorCall.getUserName(); - expect(userNameResult).toEqual(null); -}); + test('getActionsClient() returns ActionsClient', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; -test('getUserName() returns a name when security is enabled', async () => { - const factory = new RulesClientFactory(); - factory.initialize({ - ...rulesClientFactoryParams, - securityService, + const actionsClient = await constructorCall.getActionsClient(); + expect(actionsClient).not.toBe(null); }); - await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - - securityService.authc.getCurrentUser.mockReturnValueOnce({ - username: 'bob', - } as unknown as AuthenticatedUser); - const userNameResult = await constructorCall.getUserName(); - expect(userNameResult).toEqual('bob'); -}); -test('getActionsClient() returns ActionsClient', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - const actionsClient = await constructorCall.getActionsClient(); - expect(actionsClient).not.toBe(null); -}); + const createAPIKeyResult = await constructorCall.createAPIKey(); + expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); + }); -test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + test('createAPIKey() returns { apiKeysEnabled: false } when security is enabled but ES security is disabled', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - const createAPIKeyResult = await constructorCall.createAPIKey(); - expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); -}); - -test('createAPIKey() returns { apiKeysEnabled: false } when security is enabled but ES security is disabled', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + securityPluginStart.authc.apiKeys.grantAsInternalUser.mockResolvedValueOnce(null); + const createAPIKeyResult = await constructorCall.createAPIKey(); + expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); + }); - securityPluginStart.authc.apiKeys.grantAsInternalUser.mockResolvedValueOnce(null); - const createAPIKeyResult = await constructorCall.createAPIKey(); - expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); -}); + test('createAPIKey() invalidates UIAM API key when ES API key creation returns null', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + securityPluginSetup, + securityPluginStart, + shouldGrantUiam: true, + }); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + const uiamApiKeys = { + grant: jest.fn().mockResolvedValueOnce({ + api_key: 'uiam-key', + id: 'uiam-id', + name: 'uiam-name', + }), + invalidate: jest.fn().mockResolvedValueOnce({}), + }; + securityService.authc.apiKeys.uiam = uiamApiKeys as never; + securityService.authc.apiKeys.grantAsInternalUser.mockResolvedValueOnce(null); + + const createAPIKeyResult = await constructorCall.createAPIKey('test'); + + expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); + expect(uiamApiKeys.grant).toHaveBeenCalledWith(expect.any(Object), { + name: 'uiam-test', + }); + expect(uiamApiKeys.invalidate).toHaveBeenCalledWith(expect.any(Object), { + id: 'uiam-id', + }); + }); -test('createAPIKey() returns an API key when security is enabled', async () => { - const factory = new RulesClientFactory(); - factory.initialize({ - ...rulesClientFactoryParams, - securityService, - securityPluginSetup, - securityPluginStart, + test('createAPIKey() returns { apiKeysEnabled: false } when UIAM API key creation fails', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + securityPluginSetup, + securityPluginStart, + shouldGrantUiam: true, + }); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + const uiamApiKeys = { + grant: jest.fn().mockResolvedValueOnce(null), + invalidate: jest.fn(), + }; + securityService.authc.apiKeys.uiam = uiamApiKeys as never; + + const createAPIKeyResult = await constructorCall.createAPIKey('test'); + + expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); + expect(uiamApiKeys.grant).toHaveBeenCalledWith(expect.any(Object), { + name: 'uiam-test', + }); + expect(securityService.authc.apiKeys.grantAsInternalUser).not.toHaveBeenCalled(); + expect(uiamApiKeys.invalidate).not.toHaveBeenCalled(); }); - await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - securityPluginStart.authc.apiKeys.grantAsInternalUser.mockResolvedValueOnce({ - api_key: '123', - id: 'abc', - name: '', + test('createAPIKey() returns an API key when security is enabled', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + securityPluginSetup, + securityPluginStart, + }); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + securityService.authc.apiKeys.grantAsInternalUser.mockResolvedValueOnce({ + api_key: '123', + id: 'abc', + name: '', + }); + const createAPIKeyResult = await constructorCall.createAPIKey('test'); + expect(createAPIKeyResult).toEqual({ + apiKeysEnabled: true, + result: { api_key: '123', id: 'abc', name: '' }, + }); + expect(securityService.authc.apiKeys.grantAsInternalUser).toHaveBeenCalledWith( + expect.any(Object), + { + metadata: { managed: true, kibana: { type: 'alerting_rule' } }, + name: 'test', + role_descriptors: {}, + } + ); }); - const createAPIKeyResult = await constructorCall.createAPIKey('test'); - expect(createAPIKeyResult).toEqual({ - apiKeysEnabled: true, - result: { api_key: '123', id: 'abc', name: '' }, + + test('createAPIKey() throws when security plugin createAPIKey throws an error', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + securityPluginSetup, + securityPluginStart, + }); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + securityService.authc.apiKeys.grantAsInternalUser.mockRejectedValueOnce( + new Error('TLS disabled') + ); + await expect(constructorCall.createAPIKey()).rejects.toThrowErrorMatchingInlineSnapshot( + `"TLS disabled"` + ); }); - expect(securityPluginStart.authc.apiKeys.grantAsInternalUser).toHaveBeenCalledWith( - expect.any(Object), - { - metadata: { managed: true, kibana: { type: 'alerting_rule' } }, - name: 'test', - role_descriptors: {}, - } - ); -}); -test('createAPIKey() throws when security plugin createAPIKey throws an error', async () => { - const factory = new RulesClientFactory(); - factory.initialize({ - ...rulesClientFactoryParams, - securityService, - securityPluginSetup, - securityPluginStart, + test('createAPIKey() invalidates UIAM API key when ES API key creation throws', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + securityPluginSetup, + securityPluginStart, + shouldGrantUiam: true, + }); + await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + const uiamApiKeys = { + grant: jest.fn().mockResolvedValueOnce({ + api_key: 'uiam-key', + id: 'uiam-id', + name: 'uiam-name', + }), + invalidate: jest.fn().mockResolvedValueOnce({ + invalidated_api_keys: [{ id: 'uiam-id', invalidated: true }], + previously_invalidated_api_keys: [], + error_count: 0, + error_details: [], + }), + }; + securityService.authc.apiKeys.uiam = uiamApiKeys as never; + securityService.authc.apiKeys.grantAsInternalUser.mockRejectedValueOnce( + new Error('TLS disabled') + ); + + await expect(constructorCall.createAPIKey('test')).rejects.toThrowErrorMatchingInlineSnapshot( + `"TLS disabled"` + ); + expect(uiamApiKeys.invalidate).toHaveBeenCalledWith(expect.any(Object), { + id: 'uiam-id', + }); }); - await factory.create(mockRouter.createKibanaRequest(), savedObjectsService); - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - - securityPluginStart.authc.apiKeys.grantAsInternalUser.mockRejectedValueOnce( - new Error('TLS disabled') - ); - await expect(constructorCall.createAPIKey()).rejects.toThrowErrorMatchingInlineSnapshot( - `"TLS disabled"` - ); -}); -test('create() calls getSpaceId to derive spaceId from request', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - const request = mockRouter.createKibanaRequest(); + test('create() calls getSpaceId to derive spaceId from request', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + const request = mockRouter.createKibanaRequest(); - savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); - alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( - alertingAuthorization as unknown as AlertingAuthorization - ); + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( + alertingAuthorization as unknown as AlertingAuthorization + ); - await factory.create(request, savedObjectsService); + await factory.create(request, savedObjectsService); - expect(rulesClientFactoryParams.getSpaceId).toHaveBeenCalledWith(request); - expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( - request, - 'default' - ); -}); + expect(rulesClientFactoryParams.getSpaceId).toHaveBeenCalledWith(request); + expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( + request, + 'default' + ); + }); -test('createWithSpaceId() uses the provided spaceId instead of deriving from request', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - const request = mockRouter.createKibanaRequest(); + test('createWithSpaceId() uses the provided spaceId instead of deriving from request', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + const request = mockRouter.createKibanaRequest(); - savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); - alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( - alertingAuthorization as unknown as AlertingAuthorization - ); + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( + alertingAuthorization as unknown as AlertingAuthorization + ); - await factory.createWithSpaceId(request, savedObjectsService, 'custom-space'); + await factory.createWithSpaceId(request, savedObjectsService, 'custom-space'); - // getSpaceId should NOT be called when using createWithSpaceId - expect(rulesClientFactoryParams.getSpaceId).not.toHaveBeenCalled(); + // getSpaceId should NOT be called when using createWithSpaceId + expect(rulesClientFactoryParams.getSpaceId).not.toHaveBeenCalled(); - // createForSpace should be called with the provided spaceId - expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( - request, - 'custom-space' - ); + // createForSpace should be called with the provided spaceId + expect(alertingAuthorizationClientFactory.createForSpace).toHaveBeenCalledWith( + request, + 'custom-space' + ); - // Saved objects client should be scoped to the custom namespace - expect(savedObjectsClient.asScopedToNamespace).toHaveBeenCalledWith('custom-space'); + // Saved objects client should be scoped to the custom namespace + expect(savedObjectsClient.asScopedToNamespace).toHaveBeenCalledWith('custom-space'); - // RulesClient should be created with the custom spaceId - expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith( - expect.objectContaining({ - spaceId: 'custom-space', - }) - ); -}); + // RulesClient should be created with the custom spaceId + expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith( + expect.objectContaining({ + spaceId: 'custom-space', + }) + ); + }); -test('create() uses request-derived client methods for actions and event log', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - const request = mockRouter.createKibanaRequest(); - - savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); - alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( - alertingAuthorization as unknown as AlertingAuthorization - ); - - await factory.create(request, savedObjectsService); - - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - - // Call getActionsClient and verify it uses the request-derived method - await constructorCall.getActionsClient(); - expect(rulesClientFactoryParams.actions.getActionsClientWithRequest).toHaveBeenCalledWith( - request - ); - expect( - rulesClientFactoryParams.actions.getActionsClientWithRequestInSpace - ).not.toHaveBeenCalled(); - - // Call getEventLogClient and verify it uses the request-derived method - await constructorCall.getEventLogClient(); - expect(rulesClientFactoryParams.eventLog.getClient).toHaveBeenCalledWith(request); - expect(rulesClientFactoryParams.eventLog.getClientWithRequestInSpace).not.toHaveBeenCalled(); -}); + test('create() uses request-derived client methods for actions and event log', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + const request = mockRouter.createKibanaRequest(); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( + alertingAuthorization as unknown as AlertingAuthorization + ); + + await factory.create(request, savedObjectsService); + + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + // Call getActionsClient and verify it uses the request-derived method + await constructorCall.getActionsClient(); + expect(rulesClientFactoryParams.actions.getActionsClientWithRequest).toHaveBeenCalledWith( + request + ); + expect( + rulesClientFactoryParams.actions.getActionsClientWithRequestInSpace + ).not.toHaveBeenCalled(); + + // Call getEventLogClient and verify it uses the request-derived method + await constructorCall.getEventLogClient(); + expect(rulesClientFactoryParams.eventLog.getClient).toHaveBeenCalledWith(request); + expect(rulesClientFactoryParams.eventLog.getClientWithRequestInSpace).not.toHaveBeenCalled(); + }); + + test('createWithSpaceId() uses space-scoped client methods for actions and event log', async () => { + const factory = new RulesClientFactory(); + factory.initialize(rulesClientFactoryParams); + const request = mockRouter.createKibanaRequest(); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( + alertingAuthorization as unknown as AlertingAuthorization + ); + + await factory.createWithSpaceId(request, savedObjectsService, 'custom-space'); + + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + // Call getActionsClient and verify it uses the space-scoped method + await constructorCall.getActionsClient(); + expect( + rulesClientFactoryParams.actions.getActionsClientWithRequestInSpace + ).toHaveBeenCalledWith(request, 'custom-space'); + expect(rulesClientFactoryParams.actions.getActionsClientWithRequest).not.toHaveBeenCalled(); + + // Call getEventLogClient and verify it uses the space-scoped method + await constructorCall.getEventLogClient(); + expect(rulesClientFactoryParams.eventLog.getClientWithRequestInSpace).toHaveBeenCalledWith( + request, + 'custom-space' + ); + expect(rulesClientFactoryParams.eventLog.getClient).not.toHaveBeenCalled(); + }); -test('createWithSpaceId() uses space-scoped client methods for actions and event log', async () => { - const factory = new RulesClientFactory(); - factory.initialize(rulesClientFactoryParams); - const request = mockRouter.createKibanaRequest(); - - savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); - alertingAuthorizationClientFactory.createForSpace.mockResolvedValue( - alertingAuthorization as unknown as AlertingAuthorization - ); - - await factory.createWithSpaceId(request, savedObjectsService, 'custom-space'); - - const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; - - // Call getActionsClient and verify it uses the space-scoped method - await constructorCall.getActionsClient(); - expect(rulesClientFactoryParams.actions.getActionsClientWithRequestInSpace).toHaveBeenCalledWith( - request, - 'custom-space' - ); - expect(rulesClientFactoryParams.actions.getActionsClientWithRequest).not.toHaveBeenCalled(); - - // Call getEventLogClient and verify it uses the space-scoped method - await constructorCall.getEventLogClient(); - expect(rulesClientFactoryParams.eventLog.getClientWithRequestInSpace).toHaveBeenCalledWith( - request, - 'custom-space' - ); - expect(rulesClientFactoryParams.eventLog.getClient).not.toHaveBeenCalled(); + test('getAuthenticationAPIKey() throws when a UIAM API key is used in a non-serverless environment', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + securityPluginSetup, + securityPluginStart, + shouldGrantUiam: false, + }); + + const request = mockRouter.createKibanaRequest({ + headers: { + authorization: `ApiKey ${Buffer.from('id:essu_uiam_api_key').toString('base64')}`, + }, + }); + + await factory.create(request, savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + expect(() => constructorCall.getAuthenticationAPIKey()).toThrowErrorMatchingInlineSnapshot( + `"UIAM API keys should only be used in serverless environments"` + ); + }); + + test('getAuthenticationAPIKey() throws when API Key is invalid', async () => { + const factory = new RulesClientFactory(); + factory.initialize({ + ...rulesClientFactoryParams, + securityService, + securityPluginSetup, + securityPluginStart, + shouldGrantUiam: false, + }); + + const request = mockRouter.createKibanaRequest({ + headers: { + authorization: `ApiKey a-broken-api-key`, + }, + }); + + await factory.create(request, savedObjectsService); + const constructorCall = jest.requireMock('./rules_client').RulesClient.mock.calls[0][0]; + + expect(() => + constructorCall.getAuthenticationAPIKey('test') + ).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse API key credentials from authorization header for alerting rule : test"` + ); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts index 9a8df2b3e90bf..7ae8a62ec17d5 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts @@ -14,12 +14,16 @@ import type { CoreStart, } from '@kbn/core/server'; import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; -import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; +import type { + GrantAPIKeyResult, + SecurityPluginSetup, + SecurityPluginStart, +} from '@kbn/security-plugin/server'; import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import type { IEventLogClientService, IEventLogger } from '@kbn/event-log-plugin/server'; import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; -import { HTTPAuthorizationHeader } from '@kbn/core-security-server'; +import { HTTPAuthorizationHeader, isUiamCredential } from '@kbn/core-security-server'; import type { RuleTypeRegistry, SpaceIdToNamespaceFunction } from './types'; import { RulesClient } from './rules_client'; import type { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; @@ -58,6 +62,7 @@ export interface RulesClientFactoryOpts { connectorAdapterRegistry: ConnectorAdapterRegistry; uiSettings: CoreStart['uiSettings']; securityService: CoreStart['security']; + shouldGrantUiam: boolean; } export class RulesClientFactory { @@ -84,6 +89,7 @@ export class RulesClientFactory { private connectorAdapterRegistry!: ConnectorAdapterRegistry; private uiSettings!: CoreStart['uiSettings']; private securityService!: CoreStart['security']; + private shouldGrantUiam: boolean = false; public initialize(options: RulesClientFactoryOpts) { if (this.isInitialized) { @@ -112,6 +118,7 @@ export class RulesClientFactory { this.connectorAdapterRegistry = options.connectorAdapterRegistry; this.uiSettings = options.uiSettings; this.securityService = options.securityService; + this.shouldGrantUiam = options.shouldGrantUiam; } /** @@ -198,6 +205,7 @@ export class RulesClientFactory { backfillClient: this.backfillClient, connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: this.uiSettings, + shouldGrantUiam: this.shouldGrantUiam, async getUserName() { const user = securityService.authc.getCurrentUser(request); @@ -210,21 +218,60 @@ export class RulesClientFactory { // Create an API key using the new grant API - in this case the Kibana system user is creating the // API key for the user, instead of having the user create it themselves, which requires api_key // privileges - const createAPIKeyResult = await securityPluginStart.authc.apiKeys.grantAsInternalUser( - request, - { + let createUiamApiKeyResult: GrantAPIKeyResult | null | undefined; + const shouldCreateUiamApiKey = this.shouldGrantUiam; + + const invalidateUiamApiKey = async (id?: string) => { + if (!id) return; + const invalidateUiamApiKeyResult = await securityService.authc.apiKeys.uiam?.invalidate( + request, + { id } + ); + if (invalidateUiamApiKeyResult && invalidateUiamApiKeyResult.error_count > 0) { + this.logger.error( + `Failed to invalidate UIAM API key for alerting rule : ${name}: ${invalidateUiamApiKeyResult.error_details + ?.map((error) => error.reason) + .join(', ')} ` + ); + } + }; + + if (shouldCreateUiamApiKey) { + // if this throws we return bad request where this function is called from + createUiamApiKeyResult = await securityService.authc.apiKeys.uiam?.grant(request, { + name: `uiam-${name}`, + }); + + if (!createUiamApiKeyResult) { + this.logger.error(`Failed to create UIAM API key for alerting rule : ${name}`); + return { apiKeysEnabled: false }; + } + } + + let createEsAPIKeyResult; + try { + createEsAPIKeyResult = await securityService.authc.apiKeys.grantAsInternalUser(request, { name, role_descriptors: {}, metadata: { managed: true, kibana: { type: 'alerting_rule' } }, - } - ); - if (!createAPIKeyResult) { + }); + } catch (err) { + // if the ES API key creation failed, we need to invalidate the UIAM API key + await invalidateUiamApiKey(createUiamApiKeyResult?.id); + // rethrow the error to be handled by the caller + throw err; + } + + // if we created a UIAM API key but the ES API key creation failed, we need to invalidate the UIAM API key + if (!createEsAPIKeyResult) { + await invalidateUiamApiKey(createUiamApiKeyResult?.id); return { apiKeysEnabled: false }; } return { apiKeysEnabled: true, - result: createAPIKeyResult, + result: createEsAPIKeyResult, + ...(createUiamApiKeyResult ? { uiamResult: createUiamApiKeyResult } : {}), }; }, async getActionsClient() { @@ -250,15 +297,37 @@ export class RulesClientFactory { getAuthenticationAPIKey(name: string) { const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); if (authorizationHeader && authorizationHeader.credentials) { - const apiKey = Buffer.from(authorizationHeader.credentials, 'base64') + const [apiKeyId, apiKey] = Buffer.from(authorizationHeader.credentials, 'base64') .toString() .split(':'); + + if (!apiKeyId || !apiKey) { + throw new Error( + `Failed to parse API key credentials from authorization header for alerting rule : ${name}` + ); + } + + if (isUiamCredential(apiKey) && !this.shouldGrantUiam) { + throw new Error('UIAM API keys should only be used in serverless environments'); + } + + if (isUiamCredential(apiKey)) { + return { + apiKeysEnabled: true, + uiamResult: { + name: `uiam-${name}`, + id: apiKeyId, + api_key: apiKey, + }, + }; + } + return { apiKeysEnabled: true, result: { name, - id: apiKey[0], - api_key: apiKey[1], + id: apiKeyId, + api_key: apiKey, }, }; } diff --git a/x-pack/platform/plugins/shared/alerting/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/index.ts index 453494ac121e2..aff6b1b2d08a5 100644 --- a/x-pack/platform/plugins/shared/alerting/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/index.ts @@ -45,7 +45,7 @@ export const AD_HOC_RUN_SAVED_OBJECT_TYPE = 'ad_hoc_run_params'; export const API_KEY_PENDING_INVALIDATION_TYPE = 'api_key_pending_invalidation'; export const GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE = 'gap_auto_fill_scheduler'; -export const RuleAttributesToEncrypt = ['apiKey']; +export const RuleAttributesToEncrypt = ['apiKey', 'uiamApiKey']; // Use caution when removing items from this array! These fields // are used to construct decryption AAD and must be remain in @@ -157,6 +157,9 @@ export function setupSavedObjects( createdAt: { type: 'date', }, + uiamApiKey: { + type: 'binary', + }, }, }, modelVersions: apiKeyPendingInvalidationModelVersions, @@ -280,7 +283,7 @@ export function setupSavedObjects( // Encrypted attributes encryptedSavedObjects.registerType({ type: API_KEY_PENDING_INVALIDATION_TYPE, - attributesToEncrypt: new Set(['apiKeyId']), + attributesToEncrypt: new Set(['apiKeyId', 'uiamApiKey']), attributesToIncludeInAAD: new Set(['createdAt']), }); diff --git a/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts index e08fe9c16e384..cb6bb8881009e 100644 --- a/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts @@ -7,6 +7,7 @@ import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; import { rawApiKeyPendingInvalidationSchemaV1 } from '../schemas/raw_api_key_pending_invalidation'; +import { rawApiKeyPendingInvalidationSchemaV2 } from '../schemas/raw_api_key_pending_invalidation'; export const apiKeyPendingInvalidationModelVersions: SavedObjectsModelVersionMap = { '1': { @@ -19,4 +20,23 @@ export const apiKeyPendingInvalidationModelVersions: SavedObjectsModelVersionMap create: rawApiKeyPendingInvalidationSchemaV1, }, }, + '2': { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + uiamApiKey: { + type: 'binary', + }, + }, + }, + ], + schemas: { + forwardCompatibility: rawApiKeyPendingInvalidationSchemaV2.extends( + {}, + { unknowns: 'ignore' } + ), + create: rawApiKeyPendingInvalidationSchemaV2, + }, + }, }; diff --git a/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/rule_model_versions.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/rule_model_versions.ts index 1ef75cb9bf654..e8f13546d9572 100644 --- a/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/rule_model_versions.ts +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/model_versions/rule_model_versions.ts @@ -15,6 +15,7 @@ import { rawRuleSchemaV6, rawRuleSchemaV7, rawRuleSchemaV8, + rawRuleSchemaV9, } from '../schemas/raw_rule'; export const ruleModelVersions: SavedObjectsModelVersionMap = { @@ -104,4 +105,20 @@ export const ruleModelVersions: SavedObjectsModelVersionMap = { create: rawRuleSchemaV8, }, }, + '9': { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + uiamApiKey: { + type: 'binary', + }, + }, + }, + ], + schemas: { + forwardCompatibility: rawRuleSchemaV9.extends({}, { unknowns: 'ignore' }), + create: rawRuleSchemaV9, + }, + }, }; diff --git a/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts index 585c0601eb2a3..478ff3be028b4 100644 --- a/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts @@ -6,3 +6,4 @@ */ export { rawApiKeyPendingInvalidationSchema as rawApiKeyPendingInvalidationSchemaV1 } from './v1'; +export { rawApiKeyPendingInvalidationSchema as rawApiKeyPendingInvalidationSchemaV2 } from './v2'; diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/i18n.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/v2.ts similarity index 54% rename from x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/i18n.ts rename to x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/v2.ts index 774084b28db63..8eba548e98ca9 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/i18n.ts +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/v2.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; -export const txtUrlDrilldownDisplayName = i18n.translate('xpack.urlDrilldown.DisplayName', { - defaultMessage: 'Go to URL', +export const rawApiKeyPendingInvalidationSchema = schema.object({ + apiKeyId: schema.string(), + createdAt: schema.string(), + uiamApiKey: schema.maybe(schema.string()), }); diff --git a/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/index.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/index.ts index 2621c68728164..af471a0bd8ea2 100644 --- a/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/index.ts @@ -15,3 +15,4 @@ export { rawRuleSchema as rawRuleSchemaV5 } from './v5'; export { rawRuleSchema as rawRuleSchemaV6 } from './v6'; export { rawRuleSchema as rawRuleSchemaV7 } from './v7'; export { rawRuleSchema as rawRuleSchemaV8 } from './v8'; +export { rawRuleSchema as rawRuleSchemaV9 } from './v9'; diff --git a/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/latest.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/latest.ts index 98433615e713f..b917b1403277b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/latest.ts +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/latest.ts @@ -14,7 +14,7 @@ import type { } from './v3'; import type { rawRuleMonitoringSchema } from './v4'; -import type { rawRuleSchema } from './v8'; +import type { rawRuleSchema } from './v9'; type Mutable = { -readonly [P in keyof T]: T[P] extends object ? Mutable : T[P] }; diff --git a/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/v9.ts b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/v9.ts new file mode 100644 index 0000000000000..91d409457fe0d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/saved_objects/schemas/raw_rule/v9.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { rawRuleSchema as rawRuleSchemaV8 } from './v8'; + +export const rawRuleSchema = rawRuleSchemaV8.extends({ + uiamApiKey: schema.maybe(schema.nullable(schema.string())), +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts index 2b03812e5f6a3..7a15c68fc4217 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts @@ -189,6 +189,7 @@ export class AdHocTaskRunner implements CancellableTask { ruleRunMetricsStore, spaceId: adHocRunData.spaceId, isServerless: this.context.isServerless, + shouldGrantUiam: this.context.shouldGrantUiam, }; const alertsClient = await initializeAlertsClient< RuleTypeParams, diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts index 0b642ac17c761..437f279b7883e 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/get_executor_services.ts @@ -26,7 +26,7 @@ import type { PublicRuleResultService, } from '../types'; import { withAlertingSpan } from './lib'; -import type { AsyncSearchClient, TaskRunnerContext } from './types'; +import { type AsyncSearchClient, type TaskRunnerContext } from './types'; import { wrapAsyncSearchClient } from '../lib/wrap_async_search_client'; interface GetExecutorServicesOpts { @@ -38,6 +38,7 @@ interface GetExecutorServicesOpts { ruleResultService: RuleResultService; ruleData: { name: string; alertTypeId: string; id: string; spaceId: string }; ruleTaskTimeout?: string; + uiamApiKey?: string | null; } export interface ExecutorServices { diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.test.ts index d9430b42d9c5a..6b37ce9dda6d0 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.test.ts @@ -17,7 +17,7 @@ import { getFakeKibanaRequest, validateRuleAndCreateFakeRequest, } from './rule_loader'; -import type { TaskRunnerContext } from './types'; +import { ApiKeyType, type TaskRunnerContext } from './types'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import type { Rule } from '../types'; import { MONITORING_HISTORY_LIMIT, RuleExecutionStatusErrorReasons } from '../../common'; @@ -288,6 +288,18 @@ describe('rule_loader', () => { expect(fakeRequest.url.toString()).toEqual('https://fake-request/url'); expect(fakeRequest.uuid).toEqual(expect.any(String)); }); + + test('returns UIAM API key when config is set to uiam', async () => { + const fakeRequest = getFakeKibanaRequest( + { ...context, shouldGrantUiam: true, apiKeyType: ApiKeyType.UIAM }, + 'default', + null, + Buffer.from('456:essu_uiam_api_key').toString('base64') + ); + expect(fakeRequest.headers).toEqual({ + authorization: `ApiKey essu_uiam_api_key`, + }); + }); }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.ts index d959c693ae652..2489ce9c21f78 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/rule_loader.ts @@ -12,7 +12,7 @@ import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server'; import type { Logger } from '@kbn/logging'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; -import type { RunRuleParams, TaskRunnerContext } from './types'; +import { ApiKeyType, type RunRuleParams, type TaskRunnerContext } from './types'; import { ErrorWithReason, validateRuleTypeParams } from '../lib'; import type { RawRule, RuleTypeRegistry, RuleTypeParamsValidator } from '../types'; import { RuleExecutionStatusErrorReasons } from '../types'; @@ -56,7 +56,7 @@ export function validateRuleAndCreateFakeRequest( spaceId, } = params; - const { enabled, apiKey, alertTypeId: ruleTypeId } = rawRule; + const { enabled, apiKey, uiamApiKey, alertTypeId: ruleTypeId } = rawRule; if (!enabled) { throw createTaskRunError( @@ -68,7 +68,7 @@ export function validateRuleAndCreateFakeRequest( ); } - const fakeRequest = getFakeKibanaRequest(context, spaceId, apiKey); + const fakeRequest = getFakeKibanaRequest(context, spaceId, apiKey, uiamApiKey); const rule = getAlertFromRaw({ id: ruleId, includeLegacyId: false, @@ -109,6 +109,7 @@ export function validateRuleAndCreateFakeRequest( return { apiKey, + uiamApiKey, fakeRequest, rule, validatedParams, @@ -152,11 +153,24 @@ export async function getDecryptedRule( export function getFakeKibanaRequest( context: TaskRunnerContext, spaceId: string, - apiKey: RawRule['apiKey'] + apiKey: RawRule['apiKey'], + uiamApiKey?: RawRule['uiamApiKey'] ) { const requestHeaders: Headers = {}; - if (apiKey) { + const shouldUseUiamApiKey = context.shouldGrantUiam && context.apiKeyType === ApiKeyType.UIAM; + + if (shouldUseUiamApiKey) { + if (!uiamApiKey) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + context.logger.warn( + 'UIAM API key is not provided to create a fake request, falling back to regular API key.' + ); + } else { + const [_, uiamApiKeyValue] = Buffer.from(uiamApiKey, 'base64').toString().split(':'); + requestHeaders.authorization = `ApiKey ${uiamApiKeyValue}`; + } + } else if (apiKey) { requestHeaders.authorization = `ApiKey ${apiKey}`; } diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.test.ts index f6ec624c154f4..84d427b0b58ff 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.test.ts @@ -205,6 +205,7 @@ describe('Task Runner', () => { isServerless: false, getEventLogClient: jest.fn().mockReturnValue(eventLogClientMock.create()), apiKeyType: ApiKeyType.ES, + shouldGrantUiam: false, }; beforeEach(() => { diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts index 0f526c3487e3d..79d98bf6a5284 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts @@ -272,6 +272,7 @@ export class TaskRunner< fakeRequest, rule, apiKey, + uiamApiKey, validatedParams: params, }: RunRuleParams): Promise { if (apm.currentTransaction) { @@ -317,6 +318,7 @@ export class TaskRunner< ruleRunMetricsStore, spaceId, isServerless: this.context.isServerless, + shouldGrantUiam: this.context.shouldGrantUiam, }; const alertsClient = await withAlertingSpan('alerting:initialize-alerts-client', () => initializeAlertsClient< @@ -362,6 +364,7 @@ export class TaskRunner< spaceId, }, ruleTaskTimeout: this.ruleType.ruleTaskTimeout, + uiamApiKey, }); const actionsClient = await this.context.actionsPlugin.getActionsClientWithRequest(fakeRequest); diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts index 90d62210ca955..668f1cbd6d17e 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts @@ -81,6 +81,7 @@ export interface RunRuleResult { export interface RunRuleParams { apiKey: RawRule['apiKey']; + uiamApiKey?: RawRule['uiamApiKey']; fakeRequest: KibanaRequest; rule: SanitizedRule; validatedParams: Params; @@ -158,6 +159,7 @@ export interface RuleTypeRunnerContext { ruleRunMetricsStore: RuleRunMetricsStore; spaceId: string; isServerless: boolean; + shouldGrantUiam?: boolean; } export interface RuleRunnerErrorStackTraceLog { @@ -198,6 +200,7 @@ export interface TaskRunnerContext { usageCounter?: UsageCounter; getEventLogClient: (request: KibanaRequest) => IEventLogClient; isServerless: boolean; + shouldGrantUiam?: boolean; } export interface AsyncSearchClient { diff --git a/x-pack/platform/plugins/shared/alerting/server/usage/alerting_usage_collector.ts b/x-pack/platform/plugins/shared/alerting/server/usage/alerting_usage_collector.ts index d557d2dac9dbe..93c34b5391ba5 100644 --- a/x-pack/platform/plugins/shared/alerting/server/usage/alerting_usage_collector.ts +++ b/x-pack/platform/plugins/shared/alerting/server/usage/alerting_usage_collector.ts @@ -261,6 +261,54 @@ const byStatusPerDaySchema: MakeSchemaFrom['count_rules_by_execut unknown: { type: 'long' }, }; +const gapAutoFillSchedulerRunStatusSchema: MakeSchemaFrom['gap_auto_fill_scheduler_runs_by_status_per_day'] = + { + // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) + DYNAMIC_KEY: { + type: 'long', + _meta: { + description: 'The number of gap auto-fill scheduler runs by dynamic status key per day', + }, + }, + success: { + type: 'long', + _meta: { description: 'The number of successful gap auto-fill scheduler runs per day' }, + }, + error: { + type: 'long', + _meta: { description: 'The number of errored gap auto-fill scheduler runs per day' }, + }, + skipped: { + type: 'long', + _meta: { description: 'The number of skipped gap auto-fill scheduler runs per day' }, + }, + no_gaps: { + type: 'long', + _meta: { + description: 'The number of gap auto-fill scheduler runs with no gaps found per day', + }, + }, + }; + +const gapAutoFillSchedulerResultStatusSchema: MakeSchemaFrom['gap_auto_fill_scheduler_results_by_status_per_day'] = + { + // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) + DYNAMIC_KEY: { + type: 'long', + _meta: { + description: 'The number of gap auto-fill scheduler results by dynamic status key per day', + }, + }, + success: { + type: 'long', + _meta: { description: 'The number of successful gap auto-fill scheduler results per day' }, + }, + error: { + type: 'long', + _meta: { description: 'The number of errored gap auto-fill scheduler results per day' }, + }, + }; + const byNotifyWhenSchema: MakeSchemaFrom['count_rules_by_notify_when'] = { on_action_group_change: { type: 'long' }, on_active_alert: { type: 'long' }, @@ -390,6 +438,17 @@ export function createAlertingUsageCollector( count_gaps: 0, total_unfilled_gap_duration_ms: 0, total_filled_gap_duration_ms: 0, + gap_auto_fill_scheduler_runs_per_day: 0, + gap_auto_fill_scheduler_runs_by_status_per_day: {}, + gap_auto_fill_scheduler_duration_ms_per_day: { + min: 0, + max: 0, + avg: 0, + sum: 0, + }, + gap_auto_fill_scheduler_unique_rule_count_per_day: 0, + gap_auto_fill_scheduler_processed_gaps_total_per_day: 0, + gap_auto_fill_scheduler_results_by_status_per_day: {}, }; } }, @@ -487,6 +546,55 @@ export function createAlertingUsageCollector( count_gaps: { type: 'long' }, total_unfilled_gap_duration_ms: { type: 'long' }, total_filled_gap_duration_ms: { type: 'long' }, + gap_auto_fill_scheduler_runs_per_day: { + type: 'long', + _meta: { description: 'The total number of gap auto-fill scheduler runs per day' }, + }, + gap_auto_fill_scheduler_runs_by_status_per_day: gapAutoFillSchedulerRunStatusSchema, + gap_auto_fill_scheduler_duration_ms_per_day: { + min: { + type: 'long', + _meta: { + description: + 'The minimum duration in milliseconds of gap auto-fill scheduler runs per day', + }, + }, + max: { + type: 'long', + _meta: { + description: + 'The maximum duration in milliseconds of gap auto-fill scheduler runs per day', + }, + }, + avg: { + type: 'float', + _meta: { + description: + 'The average duration in milliseconds of gap auto-fill scheduler runs per day', + }, + }, + sum: { + type: 'float', + _meta: { + description: + 'The total duration in milliseconds of gap auto-fill scheduler runs per day', + }, + }, + }, + gap_auto_fill_scheduler_unique_rule_count_per_day: { + type: 'long', + _meta: { + description: + 'The number of unique rules processed by the gap auto-fill scheduler per day', + }, + }, + gap_auto_fill_scheduler_processed_gaps_total_per_day: { + type: 'long', + _meta: { + description: 'The total number of gaps processed by the gap auto-fill scheduler per day', + }, + }, + gap_auto_fill_scheduler_results_by_status_per_day: gapAutoFillSchedulerResultStatusSchema, }, }); } diff --git a/x-pack/platform/plugins/shared/alerting/server/usage/lib/get_gap_auto_fill_scheduler_telemetry.test.ts b/x-pack/platform/plugins/shared/alerting/server/usage/lib/get_gap_auto_fill_scheduler_telemetry.test.ts new file mode 100644 index 0000000000000..a06e5a594a23d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/usage/lib/get_gap_auto_fill_scheduler_telemetry.test.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 type { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { getGapAutoFillSchedulerTelemetryPerDay } from './get_gap_auto_fill_scheduler_telemetry'; + +describe('getGapAutoFillSchedulerTelemetryPerDay', () => { + it('parses aggregations including nested processed gaps sum', async () => { + const esClient = { + search: jest.fn().mockResolvedValue({ + hits: { total: { value: 3 } }, + aggregations: { + by_status: { + buckets: [ + { key: 'success', doc_count: 2 }, + { key: 'no_gaps', doc_count: 1 }, + ], + }, + duration_ms: { min: 10, max: 50, avg: 30, sum: 90 }, + unique_rule_count: { value: 5 }, + results_nested: { + doc_count: 2, + processed_gaps_total: { value: 11 }, + by_result_status: { + buckets: [ + { key: 'success', doc_count: 2 }, + { key: 'error', doc_count: 0 }, + ], + }, + }, + }, + }), + } as unknown as ElasticsearchClient; + + const res = await getGapAutoFillSchedulerTelemetryPerDay({ + esClient, + eventLogIndex: '.kibana-event-log-*', + logger: { + warn: jest.fn(), + debug: jest.fn(), + isLevelEnabled: jest.fn().mockReturnValue(false), + } as unknown as Logger, + }); + + expect(res.hasErrors).toBe(false); + expect(res.runsTotal).toBe(3); + expect(res.runsByStatus).toEqual({ success: 2, no_gaps: 1 }); + expect(res.durationMs).toEqual({ min: 10, max: 50, avg: 30, sum: 90 }); + expect(res.uniqueRuleCount).toBe(5); + expect(res.processedGapsTotal).toBe(11); + expect(res.resultsByStatus).toEqual({ success: 2, error: 0 }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/usage/lib/get_gap_auto_fill_scheduler_telemetry.ts b/x-pack/platform/plugins/shared/alerting/server/usage/lib/get_gap_auto_fill_scheduler_telemetry.ts new file mode 100644 index 0000000000000..ea8b81367d714 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/usage/lib/get_gap_auto_fill_scheduler_telemetry.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + AggregationsSingleMetricAggregateBase, + AggregationsStatsAggregate, + AggregationsTermsAggregateBase, + AggregationsStringTermsBucketKeys, +} from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { getProviderAndActionFilterForTimeRange } from './get_telemetry_from_event_log'; +import { parseSimpleRuleTypeBucket } from './parse_simple_rule_type_bucket'; +import { parseAndLogError } from './parse_and_log_error'; + +interface Opts { + esClient: ElasticsearchClient; + eventLogIndex: string; + logger: Logger; +} + +export interface GapAutoFillSchedulerTelemetry { + hasErrors: boolean; + errorMessage?: string; + runsTotal: number; + runsByStatus: Record; + durationMs: { + min: number; + max: number; + avg: number; + sum: number; + }; + uniqueRuleCount: number; + processedGapsTotal: number; + resultsByStatus: Record; +} + +export async function getGapAutoFillSchedulerTelemetryPerDay({ + esClient, + eventLogIndex, + logger, +}: Opts): Promise { + try { + const query = { + index: eventLogIndex, + size: 0, + track_total_hits: true, + query: getProviderAndActionFilterForTimeRange('gap-auto-fill-schedule'), + aggs: { + by_status: { + terms: { + field: 'kibana.gap_auto_fill.execution.status', + }, + }, + duration_ms: { + stats: { + field: 'kibana.gap_auto_fill.execution.duration_ms', + }, + }, + unique_rule_count: { + cardinality: { + field: 'kibana.gap_auto_fill.execution.rule_ids', + }, + }, + results_nested: { + nested: { path: 'kibana.gap_auto_fill.execution.results' }, + aggs: { + processed_gaps_total: { + sum: { field: 'kibana.gap_auto_fill.execution.results.processed_gaps' }, + }, + by_result_status: { + terms: { field: 'kibana.gap_auto_fill.execution.results.status' }, + }, + }, + }, + }, + } as const; + + const results = await esClient.search(query); + + const totalRuns = + typeof results.hits.total === 'number' ? results.hits.total : results.hits.total?.value; + + const aggregations = results.aggregations as { + by_status: AggregationsTermsAggregateBase; + duration_ms: AggregationsStatsAggregate; + unique_rule_count: AggregationsSingleMetricAggregateBase; + results_nested: { + doc_count: number; + processed_gaps_total: AggregationsSingleMetricAggregateBase; + by_result_status: AggregationsTermsAggregateBase; + }; + }; + + const runsByStatus = parseSimpleRuleTypeBucket(aggregations.by_status.buckets); + const resultsByStatus = parseSimpleRuleTypeBucket( + aggregations.results_nested.by_result_status.buckets + ); + + return { + hasErrors: false, + runsTotal: totalRuns ?? 0, + runsByStatus, + durationMs: { + min: aggregations.duration_ms.min ?? 0, + max: aggregations.duration_ms.max ?? 0, + avg: aggregations.duration_ms.avg ?? 0, + sum: aggregations.duration_ms.sum ?? 0, + }, + uniqueRuleCount: aggregations.unique_rule_count.value ?? 0, + processedGapsTotal: aggregations.results_nested.processed_gaps_total.value ?? 0, + resultsByStatus, + }; + } catch (err) { + const errorMessage = parseAndLogError(err, `getGapAutoFillSchedulerTelemetryPerDay`, logger); + + return { + hasErrors: true, + errorMessage, + runsTotal: 0, + runsByStatus: {}, + durationMs: { min: 0, max: 0, avg: 0, sum: 0 }, + uniqueRuleCount: 0, + processedGapsTotal: 0, + resultsByStatus: {}, + }; + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/usage/task.ts b/x-pack/platform/plugins/shared/alerting/server/usage/task.ts index bd9f01a2a3e13..a05ae1ccf82db 100644 --- a/x-pack/platform/plugins/shared/alerting/server/usage/task.ts +++ b/x-pack/platform/plugins/shared/alerting/server/usage/task.ts @@ -25,6 +25,7 @@ import { getExecutionTimeoutsPerDayCount, } from './lib/get_telemetry_from_event_log'; import { getBackfillTelemetryPerDay } from './lib/get_backfill_telemetry'; +import { getGapAutoFillSchedulerTelemetryPerDay } from './lib/get_gap_auto_fill_scheduler_telemetry'; import { stateSchemaByVersion, emptyState, type LatestTaskStateSchema } from './task_state'; import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; @@ -121,6 +122,7 @@ export function telemetryTaskRunner( getMWTelemetry({ logger, savedObjectsClient }), getTotalAlertsCountAggregations({ esClient, logger }), getBackfillTelemetryPerDay({ esClient, eventLogIndex, logger }), + getGapAutoFillSchedulerTelemetryPerDay({ esClient, eventLogIndex, logger }), ]) .then( ([ @@ -132,6 +134,7 @@ export function telemetryTaskRunner( MWTelemetry, totalAlertsCountAggregations, dailyBackfillCounts, + dailyGapAutoFillSchedulerCounts, ]) => { const hasErrors = totalCountAggregations.hasErrors || @@ -141,7 +144,8 @@ export function telemetryTaskRunner( dailyFailedAndUnrecognizedTasks.hasErrors || MWTelemetry.hasErrors || totalAlertsCountAggregations.hasErrors || - dailyBackfillCounts.hasErrors; + dailyBackfillCounts.hasErrors || + dailyGapAutoFillSchedulerCounts.hasErrors; const errorMessages = [ totalCountAggregations.errorMessage, @@ -152,6 +156,7 @@ export function telemetryTaskRunner( MWTelemetry.errorMessage, totalAlertsCountAggregations.errorMessage, dailyBackfillCounts.errorMessage, + dailyGapAutoFillSchedulerCounts.errorMessage, ].filter((message) => message !== undefined); const updatedState: LatestTaskStateSchema = { @@ -237,6 +242,17 @@ export function telemetryTaskRunner( count_gaps: dailyBackfillCounts.countGaps, total_unfilled_gap_duration_ms: dailyBackfillCounts.totalUnfilledGapDurationMs, total_filled_gap_duration_ms: dailyBackfillCounts.totalFilledGapDurationMs, + gap_auto_fill_scheduler_runs_per_day: dailyGapAutoFillSchedulerCounts.runsTotal, + gap_auto_fill_scheduler_runs_by_status_per_day: + dailyGapAutoFillSchedulerCounts.runsByStatus, + gap_auto_fill_scheduler_duration_ms_per_day: + dailyGapAutoFillSchedulerCounts.durationMs, + gap_auto_fill_scheduler_unique_rule_count_per_day: + dailyGapAutoFillSchedulerCounts.uniqueRuleCount, + gap_auto_fill_scheduler_processed_gaps_total_per_day: + dailyGapAutoFillSchedulerCounts.processedGapsTotal, + gap_auto_fill_scheduler_results_by_status_per_day: + dailyGapAutoFillSchedulerCounts.resultsByStatus, count_ignored_fields_by_rule_type: totalAlertsCountAggregations.count_ignored_fields_by_rule_type, }; diff --git a/x-pack/platform/plugins/shared/alerting/server/usage/task_state.test.ts b/x-pack/platform/plugins/shared/alerting/server/usage/task_state.test.ts index 6c0f56303d3b4..98a8fc2bc2a9f 100644 --- a/x-pack/platform/plugins/shared/alerting/server/usage/task_state.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/usage/task_state.test.ts @@ -1070,4 +1070,40 @@ describe('telemetry task state', () => { expect(result).not.toHaveProperty('foo'); }); }); + + describe('v9', () => { + const v9 = stateSchemaByVersion[9]; + + it('should set defaults for gap auto fill scheduler telemetry fields', () => { + const result = v9.up({}); + expect(result).toEqual( + expect.objectContaining({ + gap_auto_fill_scheduler_runs_per_day: 0, + gap_auto_fill_scheduler_runs_by_status_per_day: {}, + gap_auto_fill_scheduler_duration_ms_per_day: { + min: 0, + max: 0, + avg: 0, + sum: 0, + }, + gap_auto_fill_scheduler_unique_rule_count_per_day: 0, + gap_auto_fill_scheduler_processed_gaps_total_per_day: 0, + gap_auto_fill_scheduler_results_by_status_per_day: {}, + }) + ); + }); + + it(`shouldn't overwrite properties when running the up migration`, () => { + const state = { + gap_auto_fill_scheduler_runs_per_day: 1, + gap_auto_fill_scheduler_runs_by_status_per_day: { success: 2 }, + gap_auto_fill_scheduler_duration_ms_per_day: { min: 1, max: 2, avg: 3, sum: 4 }, + gap_auto_fill_scheduler_unique_rule_count_per_day: 5, + gap_auto_fill_scheduler_processed_gaps_total_per_day: 6, + gap_auto_fill_scheduler_results_by_status_per_day: { success: 7, error: 8 }, + }; + const result = v9.up(state); + expect(result).toEqual(expect.objectContaining(state)); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/usage/task_state.ts b/x-pack/platform/plugins/shared/alerting/server/usage/task_state.ts index 2bc7e00b75de9..5ac17de86d61b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/usage/task_state.ts +++ b/x-pack/platform/plugins/shared/alerting/server/usage/task_state.ts @@ -152,6 +152,23 @@ const stateSchemaV8 = stateSchemaV7.extends({ count_rules_with_elasticagent_tag_by_type: schema.recordOf(schema.string(), schema.number()), }); +const stateSchemaV9 = stateSchemaV8.extends({ + gap_auto_fill_scheduler_runs_per_day: schema.number(), + gap_auto_fill_scheduler_runs_by_status_per_day: schema.recordOf(schema.string(), schema.number()), + gap_auto_fill_scheduler_duration_ms_per_day: schema.object({ + min: schema.number(), + max: schema.number(), + avg: schema.number(), + sum: schema.number(), + }), + gap_auto_fill_scheduler_unique_rule_count_per_day: schema.number(), + gap_auto_fill_scheduler_processed_gaps_total_per_day: schema.number(), + gap_auto_fill_scheduler_results_by_status_per_day: schema.recordOf( + schema.string(), + schema.number() + ), +}); + export const stateSchemaByVersion = { 1: { // A task that was created < 8.10 will go through this "up" migration @@ -301,9 +318,31 @@ export const stateSchemaByVersion = { }), schema: stateSchemaV8, }, + 9: { + up: (state: Record) => ({ + ...stateSchemaByVersion[8].up(state), + gap_auto_fill_scheduler_runs_per_day: state.gap_auto_fill_scheduler_runs_per_day || 0, + gap_auto_fill_scheduler_runs_by_status_per_day: + state.gap_auto_fill_scheduler_runs_by_status_per_day || {}, + gap_auto_fill_scheduler_duration_ms_per_day: + state.gap_auto_fill_scheduler_duration_ms_per_day || { + min: 0, + max: 0, + avg: 0, + sum: 0, + }, + gap_auto_fill_scheduler_unique_rule_count_per_day: + state.gap_auto_fill_scheduler_unique_rule_count_per_day || 0, + gap_auto_fill_scheduler_processed_gaps_total_per_day: + state.gap_auto_fill_scheduler_processed_gaps_total_per_day || 0, + gap_auto_fill_scheduler_results_by_status_per_day: + state.gap_auto_fill_scheduler_results_by_status_per_day || {}, + }), + schema: stateSchemaV9, + }, }; -const latestTaskStateSchema = stateSchemaByVersion[8].schema; +const latestTaskStateSchema = stateSchemaByVersion[9].schema; export type LatestTaskStateSchema = TypeOf; export const emptyState: LatestTaskStateSchema = { @@ -395,4 +434,15 @@ export const emptyState: LatestTaskStateSchema = { count_rules_with_linked_dashboards: 0, count_rules_with_investigation_guide: 0, count_rules_with_api_key_created_by_user: 0, + gap_auto_fill_scheduler_runs_per_day: 0, + gap_auto_fill_scheduler_runs_by_status_per_day: {}, + gap_auto_fill_scheduler_duration_ms_per_day: { + min: 0, + max: 0, + avg: 0, + sum: 0, + }, + gap_auto_fill_scheduler_unique_rule_count_per_day: 0, + gap_auto_fill_scheduler_processed_gaps_total_per_day: 0, + gap_auto_fill_scheduler_results_by_status_per_day: {}, }; diff --git a/x-pack/platform/plugins/shared/alerting/server/usage/types.ts b/x-pack/platform/plugins/shared/alerting/server/usage/types.ts index b31ae3cb50b95..c941f2c790bbb 100644 --- a/x-pack/platform/plugins/shared/alerting/server/usage/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/usage/types.ts @@ -112,4 +112,15 @@ export interface AlertingUsage { count_gaps: number; total_unfilled_gap_duration_ms: number; total_filled_gap_duration_ms: number; + gap_auto_fill_scheduler_runs_per_day: number; + gap_auto_fill_scheduler_runs_by_status_per_day: Record; + gap_auto_fill_scheduler_duration_ms_per_day: { + min: number; + max: number; + avg: number; + sum: number; + }; + gap_auto_fill_scheduler_unique_rule_count_per_day: number; + gap_auto_fill_scheduler_processed_gaps_total_per_day: number; + gap_auto_fill_scheduler_results_by_status_per_day: Record; } diff --git a/x-pack/platform/plugins/shared/alerting/tsconfig.json b/x-pack/platform/plugins/shared/alerting/tsconfig.json index 7e562fa96c3fc..bbcbafd15eef2 100644 --- a/x-pack/platform/plugins/shared/alerting/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting/tsconfig.json @@ -74,6 +74,7 @@ "@kbn/maintenance-windows-plugin", "@kbn/config", "@kbn/expect", + "@kbn/cps", ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/README.asciidoc b/x-pack/platform/plugins/shared/dashboard_enhanced/README.asciidoc deleted file mode 100644 index 2abeeb6a74e0c..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/README.asciidoc +++ /dev/null @@ -1,5 +0,0 @@ - -[[dashboard-enhanced-plugin]] -== Dashboard app enhancements plugin - -Adds drilldown capabilities to dashboard. Owned by the Kibana App team. diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/jest.config.js b/x-pack/platform/plugins/shared/dashboard_enhanced/jest.config.js deleted file mode 100644 index 00627bd90221e..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/jest.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../../../../..', - roots: ['/x-pack/platform/plugins/shared/dashboard_enhanced'], - coverageDirectory: - '/target/kibana-coverage/jest/x-pack/platform/plugins/shared/dashboard_enhanced', - coverageReporters: ['text', 'html'], - collectCoverageFrom: [ - '/x-pack/platform/plugins/shared/dashboard_enhanced/{common,public,server}/**/*.{ts,tsx}', - ], -}; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/kibana.jsonc b/x-pack/platform/plugins/shared/dashboard_enhanced/kibana.jsonc deleted file mode 100644 index e3a1566d9ae96..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/kibana.jsonc +++ /dev/null @@ -1,32 +0,0 @@ -{ - "type": "plugin", - "id": "@kbn/dashboard-enhanced-plugin", - "owner": [ - "@elastic/kibana-presentation" - ], - "group": "platform", - "visibility": "shared", - "plugin": { - "id": "dashboardEnhanced", - "browser": true, - "server": false, - "configPath": [ - "xpack", - "dashboardEnhanced" - ], - "requiredPlugins": [ - "dashboard", - "data", - "embeddable", - "share", - "uiActionsEnhanced", - "unifiedSearch" - ], - "requiredBundles": [ - "dashboard", - "embeddableEnhanced", - "kibanaUtils", - "uiActions" - ] - } -} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/moon.yml b/x-pack/platform/plugins/shared/dashboard_enhanced/moon.yml deleted file mode 100644 index 310db218ccf76..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/moon.yml +++ /dev/null @@ -1,60 +0,0 @@ -# This file is generated by the @kbn/moon package. Any manual edits will be erased! -# To extend this, write your extensions/overrides to 'moon.extend.yml' -# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/dashboard-enhanced-plugin' - -$schema: https://moonrepo.dev/schemas/project.json -id: '@kbn/dashboard-enhanced-plugin' -type: unknown -owners: - defaultOwner: '@elastic/kibana-presentation' -toolchain: - default: node -language: typescript -project: - name: '@kbn/dashboard-enhanced-plugin' - description: Moon project for @kbn/dashboard-enhanced-plugin - channel: '' - owner: '@elastic/kibana-presentation' - metadata: - sourceRoot: x-pack/platform/plugins/shared/dashboard_enhanced -dependsOn: - - '@kbn/kibana-utils-plugin' - - '@kbn/dashboard-plugin' - - '@kbn/share-plugin' - - '@kbn/data-plugin' - - '@kbn/embeddable-plugin' - - '@kbn/ui-actions-enhanced-plugin' - - '@kbn/embeddable-enhanced-plugin' - - '@kbn/core' - - '@kbn/i18n' - - '@kbn/es-query' - - '@kbn/unified-search-plugin' - - '@kbn/ui-actions-plugin' - - '@kbn/presentation-publishing' - - '@kbn/deeplinks-analytics' - - '@kbn/presentation-util' -tags: - - plugin - - prod - - group-platform - - shared - - jest-unit-tests -fileGroups: - src: - - common/**/* - - public/**/* - - server/**/* - - '!target/**/*' -tasks: - jest: - args: - - '--config' - - $projectRoot/jest.config.js - inputs: - - '@group(src)' - jestCI: - args: - - '--config' - - $projectRoot/jest.config.js - inputs: - - '@group(src)' diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/index.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/index.ts deleted file mode 100644 index 5f5e0aa57bc97..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { PluginInitializerContext } from '@kbn/core/public'; -import { DashboardEnhancedPlugin } from './plugin'; - -export type { - SetupContract as DashboardEnhancedSetupContract, - SetupDependencies as DashboardEnhancedSetupDependencies, - StartContract as DashboardEnhancedStartContract, - StartDependencies as DashboardEnhancedStartDependencies, -} from './plugin'; - -export type { DashboardDrilldownConfig } from './services/drilldowns/abstract_dashboard_drilldown'; -export { AbstractDashboardDrilldown as DashboardEnhancedAbstractDashboardDrilldown } from './services/drilldowns/abstract_dashboard_drilldown'; - -export function plugin(context: PluginInitializerContext) { - return new DashboardEnhancedPlugin(context); -} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/mocks.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/mocks.ts deleted file mode 100644 index 6b4dde5dfcf0e..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/mocks.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; - -export type Setup = jest.Mocked; -export type Start = jest.Mocked; - -const createSetupContract = (): Setup => { - const setupContract: Setup = {}; - - return setupContract; -}; - -const createStartContract = (): Start => { - const startContract: Start = {}; - - return startContract; -}; - -export const dashboardEnhancedPluginMock = { - createSetupContract, - createStartContract, -}; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/plugin.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/plugin.ts deleted file mode 100644 index 227fe15619cc7..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/plugin.ts +++ /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 type { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public'; -import type { SharePluginStart, SharePluginSetup } from '@kbn/share-plugin/public'; -import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { CONTEXT_MENU_TRIGGER } from '@kbn/ui-actions-plugin/common/trigger_ids'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { DashboardStart } from '@kbn/dashboard-plugin/public'; -import type { - AdvancedUiActionsSetup, - AdvancedUiActionsStart, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import { setKibanaServices } from './services/kibana_services'; -import { EmbeddableToDashboardDrilldown } from './services/drilldowns/embeddable_to_dashboard_drilldown'; - -export interface SetupDependencies { - uiActionsEnhanced: AdvancedUiActionsSetup; - embeddable: EmbeddableSetup; - share: SharePluginSetup; -} - -export interface StartDependencies { - uiActionsEnhanced: AdvancedUiActionsStart; - dashboard: DashboardStart; - data: DataPublicPluginStart; - embeddable: EmbeddableStart; - share: SharePluginStart; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SetupContract {} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface StartContract {} - -export class DashboardEnhancedPlugin - implements Plugin -{ - constructor(protected readonly context: PluginInitializerContext) {} - - public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { - const start = createStartServicesGetter(core.getStartServices); - - const dashboardToDashboardDrilldown = new EmbeddableToDashboardDrilldown({ start }); - plugins.uiActionsEnhanced.registerDrilldown(dashboardToDashboardDrilldown); - - return {}; - } - - public start(core: CoreStart, plugins: StartDependencies): StartContract { - setKibanaServices(core, plugins); - - plugins.uiActionsEnhanced.addTriggerActionAsync( - CONTEXT_MENU_TRIGGER, - 'OPEN_FLYOUT_ADD_DRILLDOWN', - async () => { - const { flyoutCreateDrilldownAction } = await import( - './services/drilldowns/actions/context_menu_actions_module' - ); - return flyoutCreateDrilldownAction; - } - ); - - plugins.uiActionsEnhanced.addTriggerActionAsync( - CONTEXT_MENU_TRIGGER, - 'OPEN_FLYOUT_EDIT_DRILLDOWN', - async () => { - const { flyoutEditDrilldownAction } = await import( - './services/drilldowns/actions/context_menu_actions_module' - ); - return flyoutEditDrilldownAction; - } - ); - - return {}; - } - - public stop() {} -} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx deleted file mode 100644 index 1a87dde61fd0e..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { KibanaLocation, SharePluginStart } from '@kbn/share-plugin/public'; -import React from 'react'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { - AdvancedUiActionsStart, - UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, - UiActionsEnhancedDrilldownDefinition as Drilldown, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import type { CollectConfigProps, StartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import { DEFAULT_DASHBOARD_NAVIGATION_OPTIONS } from '@kbn/dashboard-plugin/public'; -import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; - -import type { DashboardStart } from '@kbn/dashboard-plugin/public'; -import { CollectConfigContainer } from './components'; -import { txtGoToDashboard } from './i18n'; -import type { DashboardDrilldownConfig } from './types'; -export interface Params { - start: StartServicesGetter<{ - uiActionsEnhanced: AdvancedUiActionsStart; - data: DataPublicPluginStart; - share: SharePluginStart; - dashboard: DashboardStart; - }>; -} - -export abstract class AbstractDashboardDrilldown - implements Drilldown -{ - constructor(protected readonly params: Params) { - this.ReactCollectConfig = (props) => ; - this.CollectConfig = this.ReactCollectConfig; - } - - public abstract readonly id: string; - - public abstract readonly supportedTriggers: () => string[]; - - protected abstract getLocation( - config: DashboardDrilldownConfig, - context: Context, - useUrlForState: boolean - ): Promise; - - public readonly order = 100; - - public readonly getDisplayName = () => txtGoToDashboard; - - public readonly euiIcon = 'dashboardApp'; - - private readonly ReactCollectConfig: React.FC< - CollectConfigProps - >; - - public readonly CollectConfig: React.FC< - CollectConfigProps - >; - - public readonly createConfig = () => ({ - dashboardId: '', - ...DEFAULT_DASHBOARD_NAVIGATION_OPTIONS, - }); - - public readonly isConfigValid = ( - config: DashboardDrilldownConfig - ): config is DashboardDrilldownConfig => { - if (!config.dashboardId) return false; - return true; - }; - - public readonly getHref = async ( - config: DashboardDrilldownConfig, - context: Context - ): Promise => { - const { app, path } = await this.getLocation(config, context, true); - const url = await this.params.start().core.application.getUrlForApp(app, { - path, - absolute: true, - }); - return url; - }; - - public readonly execute = async (config: DashboardDrilldownConfig, context: Context) => { - if (config.open_in_new_tab) { - window.open(await this.getHref(config, context), '_blank'); - } else { - const { app, path, state } = await this.getLocation(config, context, false); - await this.params.start().core.application.navigateToApp(app, { path, state }); - } - }; - - protected get locator() { - const locator = this.params.start().plugins.share.url.locators.get(DASHBOARD_APP_LOCATOR); - if (!locator) throw new Error('Dashboard locator is required for dashboard drilldown.'); - return locator; - } -} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx deleted file mode 100644 index 8181fe2d328eb..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { debounce, findIndex } from 'lodash'; -import type { CollectConfigProps } from '@kbn/kibana-utils-plugin/public'; -import { DashboardDrilldownEditor } from './dashboard_drilldown_editor'; -import { txtDestinationDashboardNotFound } from './i18n'; -import type { DashboardDrilldownConfig } from '../types'; -import type { Params } from '../abstract_dashboard_drilldown'; - -const mergeDashboards = ( - dashboards: Array>, - selectedDashboard?: EuiComboBoxOptionOption -) => { - // if we have a selected dashboard and its not in the list, append it - if (selectedDashboard && findIndex(dashboards, { value: selectedDashboard.value }) === -1) { - return [selectedDashboard, ...dashboards]; - } - return dashboards; -}; - -const dashboardToMenuItem = (dashboardId: string, title: string) => ({ - value: dashboardId, - label: title, -}); - -export interface DashboardDrilldownCollectConfigProps - extends CollectConfigProps { - params: Params; -} - -interface CollectConfigContainerState { - dashboards: Array>; - searchString?: string; - isLoading: boolean; - selectedDashboard?: EuiComboBoxOptionOption; - error?: string; -} - -export class CollectConfigContainer extends React.Component< - DashboardDrilldownCollectConfigProps, - CollectConfigContainerState -> { - private isMounted = true; - state = { - dashboards: [], - isLoading: false, - searchString: undefined, - selectedDashboard: undefined, - error: undefined, - }; - - constructor(props: DashboardDrilldownCollectConfigProps) { - super(props); - this.debouncedLoadDashboards = debounce(this.loadDashboards.bind(this), 500); - } - - componentDidMount() { - this.loadSelectedDashboard(); - this.loadDashboards(); - } - - componentWillUnmount() { - this.isMounted = false; - } - - render() { - const { config, onConfig } = this.props; - const { dashboards, selectedDashboard, isLoading, error } = this.state; - - return ( - { - onConfig({ ...config, dashboardId }); - if (this.state.error) { - this.setState({ error: undefined }); - } - }} - onSearchChange={this.debouncedLoadDashboards} - config={config} - onConfigChange={(changes: Partial) => { - onConfig({ ...config, ...changes }); - }} - /> - ); - } - - private async loadSelectedDashboard() { - const { - config, - params: { start }, - } = this.props; - if (!config.dashboardId) return; - const { dashboard } = await start().plugins; - const findDashboardsService = await dashboard.findDashboardsService(); - const dashboardResponse = await findDashboardsService.findById(config.dashboardId); - - if (!this.isMounted) return; - - // handle case when destination dashboard no longer exists - if (dashboardResponse.status === 'error' && dashboardResponse.notFound) { - this.setState({ - error: txtDestinationDashboardNotFound(config.dashboardId), - }); - this.props.onConfig({ ...config, dashboardId: undefined }); - return; - } - - if (dashboardResponse.status === 'error') { - this.setState({ - error: dashboardResponse.error.message, - }); - this.props.onConfig({ ...config, dashboardId: undefined }); - return; - } - - this.setState({ - selectedDashboard: dashboardToMenuItem( - config.dashboardId, - dashboardResponse.attributes.title - ), - }); - } - - private readonly debouncedLoadDashboards: (searchString?: string) => void; - private async loadDashboards(searchString?: string) { - this.setState({ searchString, isLoading: true }); - const { dashboard } = this.props.params.start().plugins; - const findDashboardsService = await dashboard.findDashboardsService(); - const results = await findDashboardsService.search({ - search: searchString ?? '', - per_page: 100, - }); - - // bail out if this response is no longer needed - if (!this.isMounted) return; - if (searchString !== this.state.searchString) return; - - const dashboardList = results.dashboards.map(({ id, data }) => - dashboardToMenuItem(id, data.title) - ); - - this.setState({ dashboards: dashboardList, isLoading: false }); - } -} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_editor.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_editor.tsx deleted file mode 100644 index 4f0a11927e5fd..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_editor.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiFormRow, EuiComboBox } from '@elastic/eui'; -import { DashboardNavigationOptionsEditor } from '@kbn/dashboard-plugin/public'; - -import { i18n } from '@kbn/i18n'; -import type { DashboardDrilldownConfig } from '../types'; - -export interface DashboardDrilldownEditorProps { - dashboards: Array>; - onDashboardSelect: (dashboardId: string) => void; - onSearchChange: (searchString: string) => void; - isLoading: boolean; - error?: string; - config: DashboardDrilldownConfig; - onConfigChange: (changes: Partial) => void; -} - -export const DashboardDrilldownEditor: React.FC = ({ - dashboards, - onDashboardSelect, - onSearchChange, - isLoading, - error, - config, - onConfigChange, -}: DashboardDrilldownEditorProps) => { - const selectedTitle = dashboards.find((item) => item.value === config.dashboardId)?.label || ''; - - return ( - <> - - - async - selectedOptions={ - config.dashboardId ? [{ label: selectedTitle, value: config.dashboardId }] : [] - } - options={dashboards} - onChange={([{ value = '' } = { value: '' }]) => onDashboardSelect(value)} - onSearchChange={onSearchChange} - isLoading={isLoading} - singleSelection={{ asPlainText: true }} - fullWidth - data-test-subj={'dashboardDrilldownSelectDashboard'} - isInvalid={!!error} - /> - - - - ); -}; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/i18n.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/i18n.ts deleted file mode 100644 index bbcb7f5b2c6b3..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/i18n.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const txtDestinationDashboardNotFound = (dashboardId?: string) => - i18n.translate('xpack.dashboard.drilldown.errorDestinationDashboardIsMissing', { - defaultMessage: - "Destination dashboard (''{dashboardId}'') no longer exists. Choose another dashboard.", - values: { - dashboardId, - }, - }); diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/index.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/index.ts deleted file mode 100644 index cfd1caff820cd..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export type { Params as AbstractDashboardDrilldownParams } from './abstract_dashboard_drilldown'; -export { AbstractDashboardDrilldown } from './abstract_dashboard_drilldown'; -export type { DashboardDrilldownConfig } from './types'; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts deleted file mode 100644 index 8f17ac826aee6..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { UiActionsEnhancedBaseActionFactoryContext } from '@kbn/ui-actions-enhanced-plugin/public'; -import type { DashboardNavigationOptions } from '@kbn/dashboard-plugin/server'; - -export type DashboardDrilldownConfig = { - dashboardId?: string; -} & DashboardNavigationOptions; - -export type FactoryContext = UiActionsEnhancedBaseActionFactoryContext; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts deleted file mode 100644 index ec1c5c438721f..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts +++ /dev/null @@ -1,80 +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 { - apiIsPresentationContainer, - type PresentationContainer, - getTitle, - type PublishesTitle, - type HasUniqueId, - type HasParentApi, -} from '@kbn/presentation-publishing'; -import { apiHasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public'; -import type { UiActionsEnhancedDrilldownTemplate as DrilldownTemplate } from '@kbn/ui-actions-enhanced-plugin/public'; -import { - APPLY_FILTER_TRIGGER, - VALUE_CLICK_TRIGGER, - SELECT_RANGE_TRIGGER, -} from '@kbn/ui-actions-plugin/common/trigger_ids'; - -/** - * We know that VALUE_CLICK_TRIGGER and SELECT_RANGE_TRIGGER are also triggering APPLY_FILTER_TRIGGER. - * This function appends APPLY_FILTER_TRIGGER to the list of triggers if either VALUE_CLICK_TRIGGER - * or SELECT_RANGE_TRIGGER was executed. - * - * TODO: this probably should be part of uiActions infrastructure, - * but dynamic implementation of nested trigger doesn't allow to statically express such relations - * - * @param triggers - */ -export function ensureNestedTriggers(triggers: string[]): string[] { - if ( - !triggers.includes(APPLY_FILTER_TRIGGER) && - (triggers.includes(VALUE_CLICK_TRIGGER) || triggers.includes(SELECT_RANGE_TRIGGER)) - ) { - return [...triggers, APPLY_FILTER_TRIGGER]; - } - - return triggers; -} - -/** - * Given a dashboard panel embeddable, it will find the parent (dashboard - * container embeddable), then iterate through all the dashboard panels and - * generate DrilldownTemplate for each existing drilldown. - */ -export const createDrilldownTemplatesFromSiblings = ( - embeddable: Partial & HasParentApi> -): DrilldownTemplate[] => { - const parentApi = embeddable.parentApi; - if (!apiIsPresentationContainer(parentApi)) return []; - - const templates: DrilldownTemplate[] = []; - for (const childId of Object.keys(parentApi.children$.value)) { - const child = parentApi.children$.value[childId] as Partial; - if (childId === embeddable.uuid) continue; - if (!apiHasDynamicActions(child)) continue; - const events = child.enhancements.dynamicActions.state.get().events; - - for (const event of events) { - const template: DrilldownTemplate = { - id: event.eventId, - name: event.action.name, - icon: 'dashboardApp', - description: getTitle(child) ?? child.uuid ?? '', - config: event.action.config, - factoryId: event.action.factoryId, - triggers: event.triggers, - }; - templates.push(template); - } - } - - return templates; -}; - -export const DRILLDOWN_ACTION_GROUP = { id: 'drilldown', order: 3 } as const; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx deleted file mode 100644 index 5bc800b76cddf..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx +++ /dev/null @@ -1,181 +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 { BehaviorSubject } from 'rxjs'; -import type { ActionDefinitionContext } from '@kbn/ui-actions-plugin/public/actions'; -import { - UiActionsEnhancedMemoryActionStorage as MemoryActionStorage, - UiActionsEnhancedDynamicActionManager as DynamicActionManager, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import type { EmbeddableApiContext, ViewMode } from '@kbn/presentation-publishing'; -import { flyoutCreateDrilldownAction } from './flyout_create_drilldown'; -import { uiActionsEnhancedPluginMock } from '@kbn/ui-actions-enhanced-plugin/public/mocks'; -import type { UiActionsEnhancedActionFactory } from '@kbn/ui-actions-enhanced-plugin/public'; -import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; - -jest.mock('../../../kibana_services', () => { - return { - coreServices: { - overlays: { - openFlyout: jest.fn(), - }, - application: { - currentAppId$: { pipe: () => ({ subscribe: () => {} }) }, - }, - } as any, - uiActionsEnhancedServices: { - getActionFactories: jest.fn(() => [ - { - supportedTriggers: () => ['VALUE_CLICK_TRIGGER'], - isCompatibleLicense: () => true, - } as unknown as UiActionsEnhancedActionFactory, - ]), - }, - }; -}); -import { coreServices, uiActionsEnhancedServices } from '../../../kibana_services'; - -const dynamicActionsState$ = new BehaviorSubject({ - dynamicActions: { events: [] }, -}); - -const compatibleEmbeddableApi = { - enhancements: { - dynamicActions: new DynamicActionManager({ - storage: new MemoryActionStorage(), - isCompatible: async () => true, - uiActions: uiActionsEnhancedPluginMock.createStartContract(), - }), - }, - setDynamicActions: (newDynamicActions: DynamicActionsSerializedState['enhancements']) => { - dynamicActionsState$.next(newDynamicActions); - }, - dynamicActionsState$, - parentApi: { - type: 'dashboard', - }, - supportedTriggers: () => { - return ['VALUE_CLICK_TRIGGER']; - }, - viewMode$: new BehaviorSubject('edit'), -}; - -const context = {} as unknown as ActionDefinitionContext; - -test('title is a string', () => { - expect( - flyoutCreateDrilldownAction.getDisplayName && - typeof flyoutCreateDrilldownAction.getDisplayName(context) === 'string' - ).toBe(true); -}); - -test('icon exists', () => { - expect( - flyoutCreateDrilldownAction.getIconType && - typeof flyoutCreateDrilldownAction.getIconType(context) === 'string' - ).toBe(true); -}); - -describe('isCompatible', () => { - test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { - expect( - flyoutCreateDrilldownAction.isCompatible && - (await flyoutCreateDrilldownAction.isCompatible({ embeddable: compatibleEmbeddableApi })) - ).toBe(true); - }); - - test('not compatible if embeddable is not enhanced', async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - enhancements: undefined, - }; - expect( - flyoutCreateDrilldownAction.isCompatible && - (await flyoutCreateDrilldownAction.isCompatible({ embeddable: embeddableApi })) - ).toBe(false); - }); - - test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - supportedTriggers: () => { - return []; - }, - }; - expect( - flyoutCreateDrilldownAction.isCompatible && - (await flyoutCreateDrilldownAction.isCompatible({ embeddable: embeddableApi })) - ).toBe(false); - }); - - test('not compatible if in view mode', async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - viewMode$: new BehaviorSubject('view'), - }; - expect( - flyoutCreateDrilldownAction.isCompatible && - (await flyoutCreateDrilldownAction.isCompatible({ embeddable: embeddableApi })) - ).toBe(false); - }); - - test('not compatible if parent embeddable is not "dashboard"', async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - parentApi: { - type: 'visualization', - }, - }; - expect( - flyoutCreateDrilldownAction.isCompatible && - (await flyoutCreateDrilldownAction.isCompatible({ embeddable: embeddableApi })) - ).toBe(false); - }); - test('not compatible if no triggers intersect', async () => { - // Mock getActionFactories to return a factory that only supports SELECT_RANGE_TRIGGER - (uiActionsEnhancedServices.getActionFactories as jest.Mock).mockImplementation(() => [ - { - supportedTriggers: () => ['SELECT_RANGE_TRIGGER'], - isCompatibleLicense: () => true, - } as unknown as UiActionsEnhancedActionFactory, - ]); - - expect( - flyoutCreateDrilldownAction.isCompatible && - (await flyoutCreateDrilldownAction.isCompatible({ embeddable: compatibleEmbeddableApi })) - ).toBe(false); - (uiActionsEnhancedServices.getActionFactories as jest.Mock).mockImplementation(() => [ - { - supportedTriggers: () => [], - isCompatibleLicense: () => true, - } as unknown as UiActionsEnhancedActionFactory, - ]); - expect( - flyoutCreateDrilldownAction.isCompatible && - (await flyoutCreateDrilldownAction.isCompatible({ embeddable: compatibleEmbeddableApi })) - ).toBe(false); - }); -}); - -describe('execute', () => { - test('throws if no dynamicUiActions', async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - enhancements: undefined, - }; - await expect( - flyoutCreateDrilldownAction.execute({ embeddable: embeddableApi }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action is incompatible"`); - }); - - test('should open flyout', async () => { - await flyoutCreateDrilldownAction.execute({ - embeddable: compatibleEmbeddableApi, - }); - expect(coreServices.overlays.openFlyout).toBeCalled(); - }); -}); diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index 88d47dcbef155..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,124 +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 { - apiHasDynamicActions, - type HasDynamicActions, -} from '@kbn/embeddable-enhanced-plugin/public'; -import { CONTEXT_MENU_TRIGGER } from '@kbn/ui-actions-plugin/common/trigger_ids'; -import { i18n } from '@kbn/i18n'; -import type { StartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import { openLazyFlyout } from '@kbn/presentation-util'; -import { - apiCanAccessViewMode, - apiHasParentApi, - apiHasSupportedTriggers, - apiIsOfType, - getInheritedViewMode, - type CanAccessViewMode, - type EmbeddableApiContext, - type HasUniqueId, - type HasParentApi, - type HasSupportedTriggers, - type HasType, - type PresentationContainer, - apiHasUniqueId, -} from '@kbn/presentation-publishing'; -import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import React from 'react'; -import type { ActionDefinition } from '@kbn/ui-actions-plugin/public/actions'; -import type { StartDependencies } from '../../../../plugin'; -import { - createDrilldownTemplatesFromSiblings, - DRILLDOWN_ACTION_GROUP, - ensureNestedTriggers, -} from '../drilldown_shared'; -import { coreServices, uiActionsEnhancedServices } from '../../../kibana_services'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface OpenFlyoutAddDrilldownParams { - start: StartServicesGetter>; -} - -export type FlyoutCreateDrilldownActionApi = CanAccessViewMode & - Required & - HasParentApi> & - HasSupportedTriggers & - Partial; - -const isApiCompatible = (api: unknown | null): api is FlyoutCreateDrilldownActionApi => - apiHasDynamicActions(api) && - apiHasParentApi(api) && - apiCanAccessViewMode(api) && - apiHasSupportedTriggers(api); - -export const flyoutCreateDrilldownAction: ActionDefinition = { - id: OPEN_FLYOUT_ADD_DRILLDOWN, - type: OPEN_FLYOUT_ADD_DRILLDOWN, - order: 12, - getIconType: () => 'plusInCircle', - grouping: [DRILLDOWN_ACTION_GROUP], - getDisplayName: () => - i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }), - isCompatible: async ({ embeddable }) => { - if (!isApiCompatible(embeddable)) return false; - if ( - getInheritedViewMode(embeddable) !== 'edit' || - !apiIsOfType(embeddable.parentApi, 'dashboard') - ) - return false; - - const supportedTriggers = [CONTEXT_MENU_TRIGGER, ...embeddable.supportedTriggers()]; - - /** - * Check if there is an intersection between all registered drilldowns possible triggers that they could be attached to - * and triggers that current embeddable supports - */ - const allPossibleTriggers = uiActionsEnhancedServices - .getActionFactories() - .map((factory) => (factory.isCompatibleLicense() ? factory.supportedTriggers() : [])) - .reduce((res, next) => res.concat(next), []); - - return ensureNestedTriggers(supportedTriggers).some((trigger) => - allPossibleTriggers.includes(trigger) - ); - }, - execute: async ({ embeddable }) => { - if (!isApiCompatible(embeddable)) throw new IncompatibleActionError(); - - openLazyFlyout({ - core: coreServices, - parentApi: embeddable.parentApi, - loadContent: async ({ closeFlyout }) => { - const templates = createDrilldownTemplatesFromSiblings(embeddable); - const triggers = [ - ...ensureNestedTriggers(embeddable.supportedTriggers()), - CONTEXT_MENU_TRIGGER, - ]; - return ( - - ); - }, - flyoutProps: { - 'data-test-subj': 'createDrilldownFlyout', - 'aria-labelledby': 'drilldownFlyoutTitleAriaId', - focusedPanelId: apiHasUniqueId(embeddable) ? embeddable.uuid : undefined, - }, - }); - }, -}; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts deleted file mode 100644 index 0431d9ab3c86f..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.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. - */ - -export type { OpenFlyoutAddDrilldownParams } from './flyout_create_drilldown'; -export { flyoutCreateDrilldownAction, OPEN_FLYOUT_ADD_DRILLDOWN } from './flyout_create_drilldown'; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx deleted file mode 100644 index 1ca01300ad9e7..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx +++ /dev/null @@ -1,158 +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 { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; -import type { EmbeddableApiContext, ViewMode } from '@kbn/presentation-publishing'; -import type { SerializedEvent } from '@kbn/ui-actions-enhanced-plugin/common'; -import { - UiActionsEnhancedDynamicActionManager as DynamicActionManager, - UiActionsEnhancedMemoryActionStorage as MemoryActionStorage, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import { uiActionsEnhancedPluginMock } from '@kbn/ui-actions-enhanced-plugin/public/mocks'; -import { BehaviorSubject } from 'rxjs'; -import { flyoutEditDrilldownAction } from './flyout_edit_drilldown'; -import type { ActionDefinitionContext } from '@kbn/ui-actions-plugin/public/actions'; - -jest.mock('../../../kibana_services', () => { - return { - coreServices: { - overlays: { - openFlyout: jest.fn(), - }, - application: { - currentAppId$: { pipe: () => ({ subscribe: () => {} }) }, - }, - } as any, - uiActionsEnhancedServices: {}, - }; -}); -import { coreServices } from '../../../kibana_services'; - -const dynamicActionsState$ = new BehaviorSubject({ - dynamicActions: { events: [{} as SerializedEvent] }, -}); - -const compatibleEmbeddableApi = { - enhancements: { - dynamicActions: new DynamicActionManager({ - storage: new MemoryActionStorage(), - isCompatible: async () => true, - uiActions: uiActionsEnhancedPluginMock.createStartContract(), - }), - }, - setDynamicActions: (newDynamicActions: DynamicActionsSerializedState['enhancements']) => { - dynamicActionsState$.next(newDynamicActions); - }, - dynamicActionsState$, - supportedTriggers: () => { - return ['VALUE_CLICK_TRIGGER']; - }, - viewMode$: new BehaviorSubject('edit'), -}; - -beforeAll(async () => { - await compatibleEmbeddableApi.enhancements.dynamicActions.createEvent( - { - config: {}, - factoryId: 'foo', - name: '', - }, - ['VALUE_CLICK_TRIGGER'] - ); -}); -const context = {} as unknown as ActionDefinitionContext; - -test('title is a string', () => { - expect( - flyoutEditDrilldownAction.getDisplayName && - typeof flyoutEditDrilldownAction.getDisplayName(context) === 'string' - ).toBeTruthy(); -}); - -test('icon exists', () => { - expect( - flyoutEditDrilldownAction.getIconType && - typeof flyoutEditDrilldownAction.getIconType(context) === 'string' - ).toBe(true); -}); - -test('MenuItem exists', () => { - expect(flyoutEditDrilldownAction.MenuItem).toBeDefined(); -}); - -describe('isCompatible', () => { - test("compatible if dynamicUiActions enabled (with event), 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { - expect( - flyoutEditDrilldownAction.isCompatible && - (await flyoutEditDrilldownAction.isCompatible({ embeddable: compatibleEmbeddableApi })) - ).toBe(true); - }); - - test('not compatible if no drilldowns', async () => { - const newDynamicActionsState$ = new BehaviorSubject< - DynamicActionsSerializedState['enhancements'] - >({ - dynamicActions: { events: [] }, - }); - - const embeddableApi = { - ...compatibleEmbeddableApi, - dynamicActionsState$: newDynamicActionsState$, - setDynamicActions: (newDynamicActions: DynamicActionsSerializedState['enhancements']) => { - newDynamicActionsState$.next(newDynamicActions); - }, - }; - expect( - flyoutEditDrilldownAction.isCompatible && - (await flyoutEditDrilldownAction.isCompatible({ embeddable: embeddableApi })) - ).toBe(false); - }); - - test('not compatible if embeddable is not enhanced', async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - enhancements: undefined, - }; - expect( - flyoutEditDrilldownAction.isCompatible && - (await flyoutEditDrilldownAction.isCompatible({ embeddable: embeddableApi })) - ).toBe(false); - }); - - test('not compatible in view mode', async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - viewMode$: new BehaviorSubject('view'), - }; - expect( - flyoutEditDrilldownAction.isCompatible && - (await flyoutEditDrilldownAction.isCompatible({ embeddable: embeddableApi })) - ).toBe(false); - }); -}); - -describe('execute', () => { - test('throws error if no dynamicUiActions', async () => { - const embeddableApi = { - ...compatibleEmbeddableApi, - enhancements: undefined, - }; - await expect( - flyoutEditDrilldownAction.execute({ - embeddable: embeddableApi, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action is incompatible"`); - }); - - test('should open flyout', async () => { - await flyoutEditDrilldownAction.execute({ - embeddable: compatibleEmbeddableApi, - }); - - expect(coreServices.overlays.openFlyout).toBeCalled(); - }); -}); diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx deleted file mode 100644 index d1e906853da05..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ /dev/null @@ -1,95 +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 { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import { openLazyFlyout } from '@kbn/presentation-util'; -import { - apiCanAccessViewMode, - apiHasSupportedTriggers, - getInheritedViewMode, - type CanAccessViewMode, - type EmbeddableApiContext, - type HasUniqueId, - type HasParentApi, - type HasSupportedTriggers, - type PresentationContainer, - apiHasUniqueId, -} from '@kbn/presentation-publishing'; -import { CONTEXT_MENU_TRIGGER } from '@kbn/ui-actions-plugin/common/trigger_ids'; -import { - apiHasDynamicActions, - type HasDynamicActions, -} from '@kbn/embeddable-enhanced-plugin/public'; -import type { StartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import type { ActionDefinition } from '@kbn/ui-actions-plugin/public/actions'; -import { txtDisplayName } from './i18n'; -import { MenuItem } from './menu_item'; -import type { StartDependencies } from '../../../../plugin'; -import { - createDrilldownTemplatesFromSiblings, - DRILLDOWN_ACTION_GROUP, - ensureNestedTriggers, -} from '../drilldown_shared'; -import { coreServices, uiActionsEnhancedServices } from '../../../kibana_services'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownParams { - start: StartServicesGetter>; -} - -export type FlyoutEditDrilldownActionApi = CanAccessViewMode & - Required & - HasParentApi> & - HasSupportedTriggers & - Partial; - -const isApiCompatible = (api: unknown | null): api is FlyoutEditDrilldownActionApi => - apiHasDynamicActions(api) && apiCanAccessViewMode(api) && apiHasSupportedTriggers(api); - -export const flyoutEditDrilldownAction: ActionDefinition = { - id: OPEN_FLYOUT_EDIT_DRILLDOWN, - type: OPEN_FLYOUT_EDIT_DRILLDOWN, - order: 10, - getIconType: () => 'list', - grouping: [DRILLDOWN_ACTION_GROUP], - getDisplayName: () => txtDisplayName, - MenuItem: MenuItem as any, - isCompatible: async ({ embeddable }) => { - if (!isApiCompatible(embeddable) || getInheritedViewMode(embeddable) !== 'edit') return false; - return (embeddable.dynamicActionsState$.getValue()?.dynamicActions.events ?? []).length > 0; - }, - execute: async ({ embeddable }) => { - if (!isApiCompatible(embeddable)) throw new IncompatibleActionError(); - openLazyFlyout({ - core: coreServices, - parentApi: embeddable.parentApi, - loadContent: async ({ closeFlyout }) => { - const templates = createDrilldownTemplatesFromSiblings(embeddable); - return ( - - ); - }, - flyoutProps: { - 'data-test-subj': 'editDrilldownFlyout', - 'aria-labelledby': 'drilldownFlyoutTitleAriaId', - focusedPanelId: apiHasUniqueId(embeddable) ? embeddable.uuid : undefined, - }, - }); - }, -}; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts deleted file mode 100644 index 7054c641c1960..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const txtDisplayName = i18n.translate( - 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', - { - defaultMessage: 'Manage drilldowns', - } -); diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index 11811d1996474..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx +++ /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. - */ - -export type { FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; -export { flyoutEditDrilldownAction, OPEN_FLYOUT_EDIT_DRILLDOWN } from './flyout_edit_drilldown'; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx deleted file mode 100644 index 29f42b076ad1a..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; -import { createStateContainer } from '@kbn/kibana-utils-plugin/public'; -import type { SerializedEvent } from '@kbn/ui-actions-enhanced-plugin/common'; -import type { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '@kbn/ui-actions-enhanced-plugin/public'; -import { act, render } from '@testing-library/react'; -import React from 'react'; -import { BehaviorSubject } from 'rxjs'; -import type { FlyoutEditDrilldownActionApi } from './flyout_edit_drilldown'; -import { MenuItem } from './menu_item'; - -test('', () => { - const dynamicActionsState$ = new BehaviorSubject({ - dynamicActions: { events: [] }, - }); - - const state = createStateContainer<{ events: SerializedEvent[] }>({ events: [] }); - const context = { - embeddable: { - enhancements: { - dynamicActions: { state } as unknown as DynamicActionManager, - }, - dynamicActionsState$, - } as unknown as FlyoutEditDrilldownActionApi, - trigger: {}, - }; - const { getByText, queryByText } = render(); - - expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); - expect(queryByText('0')).not.toBeInTheDocument(); - - act(() => { - dynamicActionsState$.next({ - dynamicActions: { events: [{} as SerializedEvent] }, - }); - }); - - expect(queryByText('1')).toBeInTheDocument(); -}); diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx deleted file mode 100644 index 9ae9af66f3a97..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx +++ /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 { EuiFlexGroup, EuiFlexItem, EuiNotificationBadge } from '@elastic/eui'; -import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; -import React, { useMemo } from 'react'; -import type { FlyoutEditDrilldownActionApi } from './flyout_edit_drilldown'; -import { txtDisplayName } from './i18n'; - -export const MenuItem = ({ - context: { embeddable }, -}: { - context: { embeddable: FlyoutEditDrilldownActionApi }; -}) => { - const dynamicActionsState = useStateFromPublishingSubject(embeddable.dynamicActionsState$); - - const count = useMemo(() => { - return (dynamicActionsState?.dynamicActions?.events ?? []).length; - }, [dynamicActionsState]); - - return ( - - {txtDisplayName} - {count > 0 && ( - - {count} - - )} - - ); -}; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/constants.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/constants.ts deleted file mode 100644 index 68f80f364a744..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * NOTE: DO NOT CHANGE THIS STRING WITHOUT CAREFUL CONSIDERATOIN, BECAUSE IT IS - * STORED IN SAVED OBJECTS. - * - * Also temporary dashboard drilldown migration code inside embeddable plugin relies on it - * x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts - */ -export const EMBEDDABLE_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx deleted file mode 100644 index f1113048cd6c3..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx +++ /dev/null @@ -1,352 +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 { Filter, RangeFilter, Query, TimeRange } from '@kbn/es-query'; -import { FilterStateStore } from '@kbn/es-query'; -import { type Context, EmbeddableToDashboardDrilldown } from './embeddable_to_dashboard_drilldown'; -import type { DashboardDrilldownConfig } from '../abstract_dashboard_drilldown'; -import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks'; -import type { StartDependencies } from '../../../plugin'; -import type { StartServicesGetter } from '@kbn/kibana-utils-plugin/public/core'; -import type { DashboardLocatorParams } from '@kbn/dashboard-plugin/common'; -import { DashboardAppLocatorDefinition } from '@kbn/dashboard-plugin/public'; -import { BehaviorSubject } from 'rxjs'; - -describe('.isConfigValid()', () => { - const drilldown = new EmbeddableToDashboardDrilldown({} as any); - - test('returns false for invalid config with missing dashboard id', () => { - expect( - drilldown.isConfigValid({ - dashboardId: '', - use_time_range: false, - use_filters: false, - open_in_new_tab: false, - }) - ).toBe(false); - }); - - test('returns true for valid config', () => { - expect( - drilldown.isConfigValid({ - dashboardId: 'id', - use_time_range: false, - use_filters: false, - open_in_new_tab: false, - }) - ).toBe(true); - }); -}); - -test('config component exist', () => { - const drilldown = new EmbeddableToDashboardDrilldown({} as any); - expect(drilldown.CollectConfig).toEqual(expect.any(Function)); -}); - -test('initial config: switches are ON', () => { - const drilldown = new EmbeddableToDashboardDrilldown({} as any); - const { use_time_range, use_filters } = drilldown.createConfig(); - expect(use_time_range).toBe(true); - expect(use_filters).toBe(true); -}); - -test('getHref is defined', () => { - const drilldown = new EmbeddableToDashboardDrilldown({} as any); - expect(drilldown.getHref).toBeDefined(); -}); - -describe('.execute() & getHref', () => { - async function setupTestBed( - config: Partial, - embeddableInput: { filters?: Filter[]; timeRange?: TimeRange; query?: Query }, - filtersFromEvent: Filter[], - timeFieldName?: string - ) { - const navigateToApp = jest.fn(); - const getUrlForApp = jest.fn((app, opt) => `${app}/${opt.path}`); - const definition = new DashboardAppLocatorDefinition({ - useHashedUrl: false, - getDashboardFilterFields: async () => [], - }); - const getLocationSpy = jest.spyOn(definition, 'getLocation'); - const drilldown = new EmbeddableToDashboardDrilldown({ - start: (() => ({ - core: { - application: { - navigateToApp, - getUrlForApp, - }, - }, - plugins: { - dashboard: dashboardPluginMock.createStartContract(), - uiActionsEnhanced: {}, - share: { - url: { - locators: { - get: () => ({ - getLocation: async (params: DashboardLocatorParams) => { - return await definition.getLocation(params); - }, - }), - }, - }, - }, - }, - self: {}, - })) as unknown as StartServicesGetter< - Pick - >, - }); - - const completeConfig: DashboardDrilldownConfig = { - dashboardId: 'id', - use_filters: false, - use_time_range: false, - open_in_new_tab: false, - ...config, - }; - - const context = { - filters: filtersFromEvent, - embeddable: { - parentApi: { - filters$: new BehaviorSubject(embeddableInput.filters ? embeddableInput.filters : []), - query$: new BehaviorSubject( - embeddableInput.query ? embeddableInput.query : { query: 'test', language: 'kuery' } - ), - timeRange$: new BehaviorSubject( - embeddableInput.timeRange ? embeddableInput.timeRange : { from: 'now-15m', to: 'now' } - ), - }, - }, - timeFieldName, - } as Context; - - await drilldown.execute(completeConfig, context); - - expect(navigateToApp).toBeCalledTimes(1); - expect(navigateToApp.mock.calls[0][0]).toBe('dashboards'); - - const executeNavigatedPath = navigateToApp.mock.calls[0][1]?.path; - const href = await drilldown.getHref(completeConfig, context); - - expect(href.includes(executeNavigatedPath)).toBe(true); - - return { - href, - getLocationSpy, - }; - } - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('navigates to correct dashboard', async () => { - const testDashboardId = 'dashboardId'; - const { href } = await setupTestBed( - { - dashboardId: testDashboardId, - }, - {}, - [] - ); - - expect(href).toEqual(expect.stringContaining(`view/${testDashboardId}`)); - }); - - test('query is removed if filters are disabled', async () => { - const queryString = 'querystring'; - const queryLanguage = 'kuery'; - const { href } = await setupTestBed( - { - use_filters: false, - }, - { - query: { query: queryString, language: queryLanguage }, - }, - [] - ); - - expect(href).toEqual(expect.not.stringContaining(queryString)); - expect(href).toEqual(expect.not.stringContaining(queryLanguage)); - }); - - test('navigates with query if filters are enabled', async () => { - const queryString = 'querystring'; - const queryLanguage = 'kuery'; - const { getLocationSpy } = await setupTestBed( - { - use_filters: true, - }, - { - query: { query: queryString, language: queryLanguage }, - }, - [] - ); - - const { - state: { query }, - } = await getLocationSpy.mock.results[0].value; - - expect(query.query).toBe(queryString); - expect(query.language).toBe(queryLanguage); - }); - - test('when user chooses to keep current filters, current filters are set on destination dashboard', async () => { - const existingAppFilterKey = 'appExistingFilter'; - const existingGlobalFilterKey = 'existingGlobalFilter'; - const newAppliedFilterKey = 'newAppliedFilter'; - - const { getLocationSpy } = await setupTestBed( - { - use_filters: true, - }, - { - filters: [getFilter(false, existingAppFilterKey), getFilter(true, existingGlobalFilterKey)], - }, - [getFilter(false, newAppliedFilterKey)] - ); - - const { - state: { filters }, - } = await getLocationSpy.mock.results[0].value; - - expect(filters.length).toBe(3); - - const filtersString = JSON.stringify(filters); - expect(filtersString).toEqual(expect.stringContaining(existingAppFilterKey)); - expect(filtersString).toEqual(expect.stringContaining(existingGlobalFilterKey)); - expect(filtersString).toEqual(expect.stringContaining(newAppliedFilterKey)); - }); - - test('when user chooses to remove current filters, current app filters are remove on destination dashboard', async () => { - const existingAppFilterKey = 'appExistingFilter'; - const existingGlobalFilterKey = 'existingGlobalFilter'; - const newAppliedFilterKey = 'newAppliedFilter'; - - const { getLocationSpy } = await setupTestBed( - { - use_filters: false, - }, - { - filters: [getFilter(false, existingAppFilterKey), getFilter(true, existingGlobalFilterKey)], - }, - [getFilter(false, newAppliedFilterKey)] - ); - - const { - state: { filters }, - } = await getLocationSpy.mock.results[0].value; - - expect(filters.length).toBe(2); - - const filtersString = JSON.stringify(filters); - expect(filtersString).not.toEqual(expect.stringContaining(existingAppFilterKey)); - expect(filtersString).toEqual(expect.stringContaining(existingGlobalFilterKey)); - expect(filtersString).toEqual(expect.stringContaining(newAppliedFilterKey)); - }); - - test('when user chooses to keep current time range, current time range is passed in url', async () => { - const { href } = await setupTestBed( - { - use_time_range: true, - }, - { - timeRange: { - from: 'now-300m', - to: 'now', - }, - }, - [] - ); - - expect(href).toEqual(expect.stringContaining('now-300m')); - }); - - test('when user chooses to not keep current time range, no current time range is passed in url', async () => { - const { href } = await setupTestBed( - { - use_time_range: false, - }, - { - timeRange: { - from: 'now-300m', - to: 'now', - }, - }, - [] - ); - - expect(href).not.toEqual(expect.stringContaining('now-300m')); - }); - - test('if range filter contains date, then it is passed as time', async () => { - const { href } = await setupTestBed( - { - use_time_range: true, - }, - { - timeRange: { - from: 'now-300m', - to: 'now', - }, - }, - [getMockTimeRangeFilter()], - getMockTimeRangeFilter().meta.key - ); - - expect(href).not.toEqual(expect.stringContaining('now-300m')); - expect(href).toEqual(expect.stringContaining('2020-03-23')); - }); -}); - -function getFilter(isPinned: boolean, queryKey: string): Filter { - return { - $state: { - store: isPinned ? FilterStateStore.GLOBAL_STATE : FilterStateStore.APP_STATE, - }, - meta: { - index: 'logstash-*', - disabled: false, - negate: false, - alias: null, - }, - query: { - match: { - [queryKey]: 'any', - }, - }, - }; -} - -function getMockTimeRangeFilter(): RangeFilter { - return { - meta: { - index: 'logstash-*', - params: { - gte: '2020-03-23T13:10:29.665Z', - lt: '2020-03-23T13:10:36.736Z', - format: 'strict_date_optional_time', - }, - type: 'range', - key: 'order_date', - disabled: false, - negate: false, - alias: null, - }, - query: { - range: { - order_date: { - gte: '2020-03-23T13:10:29.665Z', - lt: '2020-03-23T13:10:36.736Z', - format: 'strict_date_optional_time', - }, - }, - }, - }; -} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx deleted file mode 100644 index 0f4c128b5cd57..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { extractTimeRange, isFilterPinned } from '@kbn/es-query'; -import type { HasParentApi, PublishesUnifiedSearch } from '@kbn/presentation-publishing'; -import type { KibanaLocation } from '@kbn/share-plugin/public'; -import { - cleanEmptyKeys, - getDashboardLocatorParamsFromEmbeddable, -} from '@kbn/dashboard-plugin/public'; -import type { DashboardLocatorParams } from '@kbn/dashboard-plugin/common'; -import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; -import type { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; -import { - APPLY_FILTER_TRIGGER, - IMAGE_CLICK_TRIGGER, -} from '@kbn/ui-actions-plugin/common/trigger_ids'; -import type { AbstractDashboardDrilldownParams } from '../abstract_dashboard_drilldown'; -import { AbstractDashboardDrilldown } from '../abstract_dashboard_drilldown'; -import { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; -import type { DashboardDrilldownConfig } from '../abstract_dashboard_drilldown'; - -export type Context = ApplyGlobalFilterActionContext & { - embeddable: Partial>>; -}; -export type Params = AbstractDashboardDrilldownParams; - -/** - * This drilldown is the "Go to Dashboard" you can find in Dashboard app panles. - * This drilldown can be used on any embeddable and it is tied to embeddables - * in two ways: (1) it works with APPLY_FILTER_TRIGGER, which is usually executed - * by embeddables (but not necessarily); (2) its `getURL` method depends on - * `embeddable` field being present in `context`. - */ -export class EmbeddableToDashboardDrilldown extends AbstractDashboardDrilldown { - public readonly id = EMBEDDABLE_TO_DASHBOARD_DRILLDOWN; - - public readonly supportedTriggers = () => [APPLY_FILTER_TRIGGER, IMAGE_CLICK_TRIGGER]; - - protected async getLocation( - config: DashboardDrilldownConfig, - context: Context, - useUrlForState: boolean - ): Promise { - let params: DashboardLocatorParams = { dashboardId: config.dashboardId }; - - if (context.embeddable) { - params = { - ...params, - ...getDashboardLocatorParamsFromEmbeddable(context.embeddable, config), - }; - } - - /** Get event params */ - const { restOfFilters: filtersFromEvent, timeRange: timeRangeFromEvent } = extractTimeRange( - context.filters, - context.timeFieldName - ); - - if (filtersFromEvent) { - params.filters = [...(params.filters ?? []), ...filtersFromEvent]; - } - - if (timeRangeFromEvent) { - params.time_range = timeRangeFromEvent; - } - - const location = await this.locator.getLocation(params); - if (useUrlForState) { - this.useUrlForState(location); - } - - return location; - } - - private useUrlForState(location: KibanaLocation) { - const state = location.state; - location.path = setStateToKbnUrl( - '_a', - cleanEmptyKeys({ - query: state.query, - filters: state.filters?.filter((f) => !isFilterPinned(f)), - }), - { useHash: false, storeInHashQuery: true }, - location.path - ); - } -} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/index.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/index.ts deleted file mode 100644 index 3a34de6299fc4..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; -export type { Params as EmbeddableToDashboardDrilldownParams } from './embeddable_to_dashboard_drilldown'; -export { EmbeddableToDashboardDrilldown } from './embeddable_to_dashboard_drilldown'; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/kibana_services.ts b/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/kibana_services.ts deleted file mode 100644 index ea9ee0f2f59ad..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/kibana_services.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 { BehaviorSubject } from 'rxjs'; -import type { CoreStart } from '@kbn/core/public'; -import type { StartDependencies } from '../plugin'; - -export let coreServices: CoreStart; -export let uiActionsEnhancedServices: StartDependencies['uiActionsEnhanced']; - -const servicesReady$ = new BehaviorSubject(false); - -export const untilPluginStartServicesReady = () => { - if (servicesReady$.value) return Promise.resolve(); - return new Promise((resolve) => { - const subscription = servicesReady$.subscribe((isInitialized) => { - if (isInitialized) { - subscription.unsubscribe(); - resolve(); - } - }); - }); -}; - -export const setKibanaServices = (kibanaCore: CoreStart, plugins: StartDependencies) => { - coreServices = kibanaCore; - uiActionsEnhancedServices = plugins.uiActionsEnhanced; - - servicesReady$.next(true); -}; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/tsconfig.json b/x-pack/platform/plugins/shared/dashboard_enhanced/tsconfig.json deleted file mode 100644 index 0155ad2b93415..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "extends": "@kbn/tsconfig-base/tsconfig.json", - "compilerOptions": { - "outDir": "target/types" - }, - "include": ["common/**/*", "public/**/*", "server/**/*"], - "kbn_references": [ - "@kbn/kibana-utils-plugin", - "@kbn/dashboard-plugin", - "@kbn/share-plugin", - "@kbn/data-plugin", - "@kbn/embeddable-plugin", - "@kbn/ui-actions-enhanced-plugin", - "@kbn/embeddable-enhanced-plugin", - "@kbn/core", - "@kbn/i18n", - "@kbn/es-query", - "@kbn/unified-search-plugin", - "@kbn/ui-actions-plugin", - "@kbn/presentation-publishing", - "@kbn/deeplinks-analytics", - "@kbn/presentation-util" - ], - "exclude": ["target/**/*"] -} diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts b/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts index c34d3b709171b..e35648a6839be 100644 --- a/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts @@ -9,6 +9,7 @@ import { notionDataSource } from './notion'; import { githubDataSource } from './github'; import { googleDriveDataSource } from './google_drive'; import { sharepointOnlineDataSource } from './sharepoint_online'; +import { slackDataSource } from './slack'; import { jiraDataSource } from './jira-cloud'; export function registerDataSources(dataCatalog: DataCatalogPluginSetup) { @@ -16,5 +17,6 @@ export function registerDataSources(dataCatalog: DataCatalogPluginSetup) { dataCatalog.register(githubDataSource); dataCatalog.register(googleDriveDataSource); dataCatalog.register(sharepointOnlineDataSource); + dataCatalog.register(slackDataSource); dataCatalog.register(jiraDataSource); } diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/slack/data_type.ts b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/data_type.ts new file mode 100644 index 0000000000000..5295aca427379 --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/data_type.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 { i18n } from '@kbn/i18n'; +import type { DataSource } from '@kbn/data-catalog-plugin'; + +export const slackDataSource: DataSource = { + id: 'slack', + name: 'Slack', + description: i18n.translate('xpack.dataSources.slack.description', { + defaultMessage: + 'Connect to Slack to list public channels, fetch message history, and send messages.', + }), + + // Must map to an icon registered in @kbn/connector-specs ConnectorIconsMap + iconType: '.slack2', + + // Slack data source uses a Stack Connector for execution. + // We expect users to create/configure the stack connector via the connector flyout, + // then the data source will reference that connector id. + stackConnectors: [ + { + type: '.slack2', + config: {}, + }, + ], + + workflows: { + directory: __dirname + '/workflows', + }, +}; diff --git a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/index.ts b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/index.ts similarity index 83% rename from x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/index.ts rename to x-pack/platform/plugins/shared/data_sources/server/sources/slack/index.ts index 5bad461cf3030..ab5b2e2172da7 100644 --- a/x-pack/platform/plugins/private/drilldowns/url_drilldown/public/lib/index.ts +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/index.ts @@ -4,5 +4,4 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -export { UrlDrilldown } from './url_drilldown'; +export { slackDataSource } from './data_type'; diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/slack/workflows/search_messages.yaml b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/workflows/search_messages.yaml new file mode 100644 index 0000000000000..6491bbf0737bd --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/workflows/search_messages.yaml @@ -0,0 +1,68 @@ +version: '1' +name: 'sources.slack.search_messages' +description: 'Search Slack messages and return compact results suitable for LLM summaries and follow-up navigation. Uses Slack Real-time Search API (assistant.search.context). Actions and inputs: searchMessages (required query, optional in_channel, optional from_user, optional after, optional before, optional sort, optional sort_dir, optional count, optional cursor, optional raw). Output (compact default): matches with text, channel {id,name}, sender, mentions, and permalink.' +tags: ['agent-builder-tool'] +enabled: true +triggers: + - type: 'manual' +inputs: + - name: query + type: string + required: true + description: 'Search query. You can include Slack search operators directly (e.g. in:#general from:@user after:2026-02-10).' + - name: in_channel + type: string + required: false + description: 'Optional constraint. Appends `in:` to the query (e.g. general).' + - name: from_user + type: string + required: false + description: 'Optional constraint. Appends `in:` to the query. This should be the display name of the user.' + - name: after + type: string + required: false + description: 'Optional constraint. Appends `after:` to the query (e.g. 2026-02-10).' + - name: before + type: string + required: false + description: 'Optional constraint. Appends `before:` to the query (e.g. 2026-02-10).' + - name: sort + type: string + required: false + default: "score" + description: 'Sort order. Valid values: [score, timestamp].' + - name: sort_dir + type: string + required: false + default: "desc" + description: 'Sort direction. Valid values: [asc, desc].' + - name: count + type: number + required: false + default: 20 + description: 'Number of results to return (1-20). Slack returns up to 20 results per page.' + - name: cursor + type: string + required: false + description: 'Pagination cursor from a previous response (response_metadata.next_cursor).' + - name: raw + type: boolean + required: false + default: false + description: 'If true, return the full raw Slack API response (very verbose). If false, return compact results.' +steps: + - name: search-messages + type: slack2.searchMessages + connector-id: <%= slack2-stack-connector-id %> + with: + query: "${{inputs.query}}" + inChannel: "${{inputs.in_channel}}" + fromUser: "${{inputs.from_user}}" + after: "${{inputs.after}}" + before: "${{inputs.before}}" + sort: "${{inputs.sort}}" + sortDir: "${{inputs.sort_dir}}" + count: ${{inputs.count}} + cursor: "${{inputs.cursor}}" + raw: ${{inputs.raw}} + diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/slack/workflows/send_message.yaml b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/workflows/send_message.yaml new file mode 100644 index 0000000000000..9249b9a8cd5b0 --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/slack/workflows/send_message.yaml @@ -0,0 +1,37 @@ +version: '1' +name: 'sources.slack.send_message' +description: 'Send a message to a Slack public channel by channel conversation ID (C...). Actions and inputs: sendMessage (channel, text, optional thread_ts, optional unfurl_links, optional unfurl_media). Use sources.slack.find first to resolve a channel name (e.g. "#general") into a channel id (C...). Requires chat:write and access to the channel.' +tags: ['agent-builder-tool'] +enabled: true +triggers: + - type: 'manual' +inputs: + - name: channel + type: string + description: 'Public channel name or conversation ID to send the message to (e.g. #general or C123...).' + - name: text + type: string + description: 'Message text.' + - name: thread_ts + type: string + required: false + description: 'Optional thread timestamp to reply in a thread.' + - name: unfurl_links + type: boolean + required: false + description: 'If true, enable unfurling of primarily text-based content.' + - name: unfurl_media + type: boolean + required: false + description: 'If true, enable unfurling of media content.' +steps: + - name: send-message + type: slack2.sendMessage + connector-id: <%= slack2-stack-connector-id %> + with: + channel: "${{inputs.channel}}" + text: "${{inputs.text}}" + threadTs: "${{inputs.thread_ts}}" + unfurlLinks: ${{inputs.unfurl_links}} + unfurlMedia: ${{inputs.unfurl_media}} + diff --git a/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx b/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx index 44ac84330a203..1f7f98b08d2e5 100644 --- a/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx +++ b/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx @@ -44,6 +44,7 @@ describe('getEmbeddableAlertsTableFactory', () => { {} as EmbeddableAlertsTablePublicStartDependencies ); const embeddableParams: Parameters[0] = { + initializeDrilldownsManager: jest.fn(), initialState: { timeRange: { from: '2025-01-01T00:00:00.000Z', diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/README.asciidoc b/x-pack/platform/plugins/shared/embeddable_enhanced/README.asciidoc deleted file mode 100644 index 9a7fe9c2669d9..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/README.asciidoc +++ /dev/null @@ -1,6 +0,0 @@ -[[enhanced-embeddables-plugin]] -== Enhanced embeddables plugin - -Enhances Embeddables by registering a custom factory provider. The enhanced factory provider -adds dynamic actions to every embeddables state, in order to support drilldowns. - diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/jest.config.js b/x-pack/platform/plugins/shared/embeddable_enhanced/jest.config.js deleted file mode 100644 index b36f4f6f57ccd..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/jest.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../../../../..', - roots: ['/x-pack/platform/plugins/shared/embeddable_enhanced'], - coverageDirectory: - '/target/kibana-coverage/jest/x-pack/platform/plugins/shared/embeddable_enhanced', - coverageReporters: ['text', 'html'], - collectCoverageFrom: [ - '/x-pack/platform/plugins/shared/embeddable_enhanced/public/**/*.{ts,tsx}', - ], -}; diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/kibana.jsonc b/x-pack/platform/plugins/shared/embeddable_enhanced/kibana.jsonc deleted file mode 100644 index 3b9632d4bf36c..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/kibana.jsonc +++ /dev/null @@ -1,21 +0,0 @@ -{ - "type": "plugin", - "id": "@kbn/embeddable-enhanced-plugin", - "owner": [ - "@elastic/kibana-presentation" - ], - "group": "platform", - "visibility": "shared", - "description": "Extends embeddable plugin with more functionality", - "plugin": { - "id": "embeddableEnhanced", - "browser": true, - "server": false, - "requiredPlugins": [ - "embeddable", - "kibanaReact", - "uiActions", - "uiActionsEnhanced" - ] - } -} \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/moon.yml b/x-pack/platform/plugins/shared/embeddable_enhanced/moon.yml deleted file mode 100644 index ecaee47dc3025..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/moon.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This file is generated by the @kbn/moon package. Any manual edits will be erased! -# To extend this, write your extensions/overrides to 'moon.extend.yml' -# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/embeddable-enhanced-plugin' - -$schema: https://moonrepo.dev/schemas/project.json -id: '@kbn/embeddable-enhanced-plugin' -type: unknown -owners: - defaultOwner: '@elastic/kibana-presentation' -toolchain: - default: node -language: typescript -project: - name: '@kbn/embeddable-enhanced-plugin' - description: Moon project for @kbn/embeddable-enhanced-plugin - channel: '' - owner: '@elastic/kibana-presentation' - metadata: - sourceRoot: x-pack/platform/plugins/shared/embeddable_enhanced -dependsOn: - - '@kbn/core' - - '@kbn/embeddable-plugin' - - '@kbn/ui-actions-enhanced-plugin' - - '@kbn/kibana-utils-plugin' - - '@kbn/presentation-publishing' - - '@kbn/ui-actions-plugin' -tags: - - plugin - - prod - - group-platform - - shared - - jest-unit-tests -fileGroups: - src: - - public/**/* - - '!target/**/*' -tasks: - jest: - args: - - '--config' - - $projectRoot/jest.config.js - inputs: - - '@group(src)' - jestCI: - args: - - '--config' - - $projectRoot/jest.config.js - inputs: - - '@group(src)' diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/bwc.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/bwc.ts deleted file mode 100644 index f39c0c6027975..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/bwc.ts +++ /dev/null @@ -1,144 +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 { v4 as uuidv4 } from 'uuid'; -import type { DynamicActionsSerializedState } from './types'; - -// -// Temporary work around - REST APIs use DrilldownState but client still uses EnhancementsState -// Remove when client code supports DrilldownState -// - -export function extractEnhancements(state: DynamicActionsSerializedState) { - if (!state.drilldowns || !state.drilldowns.length) { - return {}; - } - - return { - dynamicActions: { - events: state.drilldowns - .map((drilldown) => { - if (drilldown.type === 'dashboard_drilldown') { - const { dashboard_id, open_in_new_tab, use_filters, use_time_range } = - drilldown as unknown as { - dashboard_id: string; - open_in_new_tab?: boolean; - use_filters?: boolean; - use_time_range?: boolean; - }; - return { - action: { - config: { - dashboardId: dashboard_id, - open_in_new_tab: open_in_new_tab ?? false, - use_time_range: use_time_range ?? true, - use_filters: use_filters ?? true, - }, - factoryId: 'DASHBOARD_TO_DASHBOARD_DRILLDOWN', - name: drilldown.label ?? '', - }, - eventId: uuidv4(), - triggers: [drilldown.trigger], - }; - } - - if (drilldown.type === 'discover_drilldown') { - const { open_in_new_tab } = drilldown as unknown as { - open_in_new_tab?: boolean; - }; - return { - action: { - config: { - openInNewTab: open_in_new_tab ?? false, - }, - factoryId: 'OPEN_IN_DISCOVER_DRILLDOWN', - name: drilldown.label ?? '', - }, - eventId: uuidv4(), - triggers: [drilldown.trigger], - }; - } - - if (drilldown.type === 'url_drilldown') { - const { encode_url, open_in_new_tab, url } = drilldown as unknown as { - encode_url?: boolean; - open_in_new_tab?: boolean; - url: string; - }; - return { - action: { - config: { - encodeUrl: encode_url ?? true, - openInNewTab: open_in_new_tab ?? false, - url: { - template: url, - }, - }, - factoryId: 'URL_DRILLDOWN', - name: drilldown.label ?? '', - }, - eventId: uuidv4(), - triggers: [drilldown.trigger], - }; - } - }) - .filter((event) => event !== undefined), - }, - }; -} - -export function serializeEnhancements(enhancements: DynamicActionsSerializedState['enhancements']) { - if (!enhancements?.dynamicActions?.events.length) { - return {}; - } - - const drilldowns = enhancements.dynamicActions.events - .map((event) => { - if (event.action.factoryId === 'DASHBOARD_TO_DASHBOARD_DRILLDOWN') { - const { dashboardId, openInNewTab, useCurrentDateRange, useCurrentFilters } = - event.action.config; - return { - dashboard_id: dashboardId, - label: event.action.name, - open_in_new_tab: openInNewTab ?? false, - trigger: event.triggers[0] ?? 'unknown', - type: 'dashboard_drilldown', - use_time_range: useCurrentDateRange ?? true, - use_filters: useCurrentFilters ?? true, - }; - } - - if (event.action.factoryId === 'OPEN_IN_DISCOVER_DRILLDOWN') { - const { openInNewTab } = event.action.config; - return { - label: event.action.name, - open_in_new_tab: openInNewTab ?? false, - trigger: event.triggers[0] ?? 'unknown', - type: 'discover_drilldown', - }; - } - - if (event.action.factoryId === 'URL_DRILLDOWN') { - const { encodeUrl, openInNewTab, url } = event.action.config as { - encodeUrl?: boolean; - openInNewTab?: boolean; - url?: { template?: string }; - }; - return { - label: event.action.name, - encode_url: encodeUrl ?? true, - open_in_new_tab: openInNewTab ?? true, - trigger: event.triggers[0] ?? 'unknown', - type: 'url_drilldown', - url: url?.template ?? '', - }; - } - }) - .filter((drilldown) => drilldown !== undefined); - - return drilldowns.length ? { drilldowns } : {}; -} diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_action_storage.test.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_action_storage.test.ts deleted file mode 100644 index abd903dbdf504..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_action_storage.test.ts +++ /dev/null @@ -1,565 +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 { of } from '@kbn/kibana-utils-plugin/public'; -import type { UiActionsEnhancedSerializedEvent } from '@kbn/ui-actions-enhanced-plugin/public'; -import { BehaviorSubject } from 'rxjs'; -import type { DynamicActionStorageApi } from './dynamic_action_storage'; -import { DynamicActionStorage } from './dynamic_action_storage'; -// use real const to make test fail in case someone accidentally changes it -import type { DynamicActionsSerializedState } from './types'; -import type { SerializedAction } from '@kbn/ui-actions-enhanced-plugin/common/types'; -import { - VALUE_CLICK_TRIGGER, - SELECT_RANGE_TRIGGER, - APPLY_FILTER_TRIGGER, -} from '@kbn/ui-actions-plugin/common/trigger_ids'; - -const getApi = (): DynamicActionStorageApi => { - const dynamicActionsState$ = new BehaviorSubject({ - dynamicActions: { events: [] }, - }); - return { - setDynamicActions: (newDynamicActions) => { - dynamicActionsState$.next(newDynamicActions); - }, - dynamicActionsState$, - }; -}; - -describe('EmbeddableActionStorage', () => { - describe('.create()', () => { - test('method exists', () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - expect(typeof storage.create).toBe('function'); - }); - - test('can add event to embeddable', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - const event: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - const events1 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events1).toEqual([]); - - const spy = jest.spyOn(api, 'setDynamicActions'); - await storage.create(event); - expect(spy).toBeCalledWith({ - dynamicActions: { - events: [ - { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }, - ], - }, - }); - - const events2 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events2).toEqual([event]); - }); - - test('can create multiple events', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - - const event1: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - const event2: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - const event3: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - const events1 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events1).toEqual([]); - - await storage.create(event1); - - const events2 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events2).toEqual([event1]); - - await storage.create(event2); - await storage.create(event3); - - const events3 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events3).toEqual([event1, event2, event3]); - }); - - test('throws when creating an event with the same ID', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const event: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - await storage.create(event); - const [, error] = await of(storage.create(event)); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[EEXIST]: Event with [eventId = EVENT_ID] already exists on [embeddable.id = testId, embeddable.title = testTitle]."` - ); - }); - }); - - describe('.update()', () => { - test('method exists', () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - expect(typeof storage.update).toBe('function'); - }); - - test('can update an existing event', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - - const event1: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: { - name: 'foo', - } as SerializedAction, - }; - const event2: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: { - name: 'bar', - } as SerializedAction, - }; - - await storage.create(event1); - await storage.update(event2); - - const events = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events).toEqual([event2]); - }); - - test('updates event in place of the old event', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - - const event1: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], - action: { - name: 'foo', - } as SerializedAction, - }; - const event2: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: { - name: 'bar', - } as SerializedAction, - }; - const event22: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: { - name: 'baz', - } as SerializedAction, - }; - const event3: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], - action: { - name: 'qux', - } as SerializedAction, - }; - - await storage.create(event1); - await storage.create(event2); - await storage.create(event3); - - const events1 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events1).toEqual([event1, event2, event3]); - - await storage.update(event22); - - const events2 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events2).toEqual([event1, event22, event3]); - - await storage.update(event2); - - const events3 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events3).toEqual([event1, event2, event3]); - }); - - test('throws when updating event, but storage is empty', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const event: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - const [, error] = await of(storage.update(event)); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID] could not be updated as it does not exist in [embeddable.id = testId, embeddable.title = testTitle]."` - ); - }); - - test('throws when updating event with ID that is not stored', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - - const event1: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - const event2: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - await storage.create(event1); - const [, error] = await of(storage.update(event2)); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID2] could not be updated as it does not exist in [embeddable.id = testId, embeddable.title = testTitle]."` - ); - }); - }); - - describe('.remove()', () => { - test('method exists', () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - expect(typeof storage.remove).toBe('function'); - }); - - test('can remove existing event', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - - const event: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - await storage.create(event); - await storage.remove(event.eventId); - - const events = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events).toEqual([]); - }); - - test('removes correct events in a list of events', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - - const event1: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], - action: { - name: 'foo', - } as SerializedAction, - }; - const event2: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: { - name: 'bar', - } as SerializedAction, - }; - const event3: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], - action: { - name: 'qux', - } as SerializedAction, - }; - - await storage.create(event1); - await storage.create(event2); - await storage.create(event3); - - const events1 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events1).toEqual([event1, event2, event3]); - - await storage.remove(event2.eventId); - - const events2 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events2).toEqual([event1, event3]); - - await storage.remove(event3.eventId); - - const events3 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events3).toEqual([event1]); - - await storage.remove(event1.eventId); - - const events4 = api.dynamicActionsState$.getValue()?.dynamicActions.events ?? []; - expect(events4).toEqual([]); - }); - - test('throws when removing an event from an empty storage', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const [, error] = await of(storage.remove('EVENT_ID')); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID] could not be removed as it does not exist in [embeddable.id = testId, embeddable.title = testTitle]."` - ); - }); - - test('throws when removing with ID that does not exist in storage', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const event: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - await storage.create(event); - const [, error] = await of(storage.remove('WRONG_ID')); - await storage.remove(event.eventId); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = WRONG_ID] could not be removed as it does not exist in [embeddable.id = testId, embeddable.title = testTitle]."` - ); - }); - }); - - describe('.read()', () => { - test('method exists', () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - expect(typeof storage.read).toBe('function'); - }); - - test('can read an existing event out of storage', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const event: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - await storage.create(event); - const event2 = await storage.read(event.eventId); - - expect(event2).toEqual(event); - }); - - test('throws when reading from empty storage', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const [, error] = await of(storage.read('EVENT_ID')); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID] could not be found in [embeddable.id = testId, embeddable.title = testTitle]."` - ); - }); - - test('throws when reading event with ID not existing in storage', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const event: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - await storage.create(event); - const [, error] = await of(storage.read('WRONG_ID')); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = WRONG_ID] could not be found in [embeddable.id = testId, embeddable.title = testTitle]."` - ); - }); - - test('returns correct event when multiple events are stored', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const event1: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - const event2: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - const event3: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - await storage.create(event1); - await storage.create(event2); - await storage.create(event3); - - const event12 = await storage.read(event1.eventId); - const event22 = await storage.read(event2.eventId); - const event32 = await storage.read(event3.eventId); - - expect(event12).toEqual(event1); - expect(event22).toEqual(event2); - expect(event32).toEqual(event3); - - expect(event12).not.toEqual(event2); - }); - }); - - describe('.count()', () => { - test('method exists', () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - expect(typeof storage.count).toBe('function'); - }); - - test('returns 0 when storage is empty', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - const count = await storage.count(); - expect(count).toBe(0); - }); - - test('returns correct number of events in storage', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - expect(await storage.count()).toBe(0); - - await storage.create({ - eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }); - expect(await storage.count()).toBe(1); - - await storage.create({ - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }); - expect(await storage.count()).toBe(2); - - await storage.remove('EVENT_ID1'); - expect(await storage.count()).toBe(1); - - await storage.remove('EVENT_ID2'); - expect(await storage.count()).toBe(0); - }); - }); - - describe('.list()', () => { - test('method exists', () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - expect(typeof storage.list).toBe('function'); - }); - - test('returns empty array when storage is empty', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - const list = await storage.list(); - expect(list).toEqual([]); - }); - - test('returns correct list of events in storage', async () => { - const storage = new DynamicActionStorage('testId', () => 'testTitle', getApi()); - - const event1: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - const event2: UiActionsEnhancedSerializedEvent = { - eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], - action: {} as SerializedAction, - }; - - expect(await storage.list()).toEqual([]); - - await storage.create(event1); - expect(await storage.list()).toEqual([event1]); - - await storage.create(event2); - expect(await storage.list()).toEqual([event1, event2]); - - await storage.remove('EVENT_ID1'); - expect(await storage.list()).toEqual([event2]); - - await storage.remove('EVENT_ID2'); - expect(await storage.list()).toEqual([]); - }); - }); - - describe('migrate', () => { - test('DASHBOARD_TO_DASHBOARD_DRILLDOWN triggers migration', async () => { - const api = getApi(); - const storage = new DynamicActionStorage('testId', () => 'testTitle', api); - - const OTHER_TRIGGER = 'OTHER_TRIGGER'; - api.setDynamicActions({ - dynamicActions: { - events: [ - { - eventId: '1', - triggers: [OTHER_TRIGGER, VALUE_CLICK_TRIGGER], - action: { - factoryId: 'DASHBOARD_TO_DASHBOARD_DRILLDOWN', - name: '', - config: {}, - }, - }, - { - eventId: '3', - triggers: [OTHER_TRIGGER, SELECT_RANGE_TRIGGER], - action: { - factoryId: 'DASHBOARD_TO_DASHBOARD_DRILLDOWN', - name: '', - config: {}, - }, - }, - { - eventId: '3', - triggers: [OTHER_TRIGGER], - action: { - factoryId: 'SOME_OTHER', - name: '', - config: {}, - }, - }, - ], - }, - }); - - const [event1, event2, event3] = await storage.list(); - expect(event1.triggers).toEqual([OTHER_TRIGGER, APPLY_FILTER_TRIGGER]); - expect(event2.triggers).toEqual([OTHER_TRIGGER, APPLY_FILTER_TRIGGER]); - expect(event3.triggers).toEqual([OTHER_TRIGGER]); - }); - }); -}); diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_action_storage.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_action_storage.ts deleted file mode 100644 index 043510a749dd5..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_action_storage.ts +++ /dev/null @@ -1,140 +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 { - DynamicActionsState, - UiActionsEnhancedSerializedEvent as SerializedEvent, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import { UiActionsEnhancedAbstractActionStorage as AbstractActionStorage } from '@kbn/ui-actions-enhanced-plugin/public'; -import { - VALUE_CLICK_TRIGGER, - SELECT_RANGE_TRIGGER, -} from '@kbn/ui-actions-plugin/common/trigger_ids'; -import type { HasDynamicActions } from './interfaces/has_dynamic_actions'; - -export type DynamicActionStorageApi = Pick< - Required, - 'setDynamicActions' | 'dynamicActionsState$' ->; -export class DynamicActionStorage extends AbstractActionStorage { - constructor( - private id: string, - private getPanelTitle: () => string | undefined, - private readonly api: DynamicActionStorageApi - ) { - super(); - } - - private put(dynamicActionsState: DynamicActionsState) { - this.api.setDynamicActions({ dynamicActions: dynamicActionsState }); - } - - public async create(event: SerializedEvent) { - const events = this.getEvents(); - const exists = !!events.find(({ eventId }) => eventId === event.eventId); - - if (exists) { - throw new Error( - `[EEXIST]: Event with [eventId = ${event.eventId}] already exists on ` + - `[embeddable.id = ${this.id}, embeddable.title = ${this.getPanelTitle()}].` - ); - } - - this.put({ - events: [...events, event], - }); - } - - public async update(event: SerializedEvent) { - const dynamicActionsState = this.api.dynamicActionsState$.getValue(); - const events = this.getEvents(); - const index = events.findIndex(({ eventId }) => eventId === event.eventId); - - if (index === -1) { - throw new Error( - `[ENOENT]: Event with [eventId = ${event.eventId}] could not be ` + - `updated as it does not exist in ` + - `[embeddable.id = ${this.id}, embeddable.title = ${this.getPanelTitle()}].` - ); - } - - this.put({ - ...dynamicActionsState, - events: [...events.slice(0, index), event, ...events.slice(index + 1)], - }); - } - - public async remove(eventId: string) { - const dynamicActionsState = this.api.dynamicActionsState$.getValue(); - - const events = this.getEvents(); - const index = events.findIndex((event) => eventId === event.eventId); - - if (index === -1) { - throw new Error( - `[ENOENT]: Event with [eventId = ${eventId}] could not be ` + - `removed as it does not exist in ` + - `[embeddable.id = ${this.id}, embeddable.title = ${this.getPanelTitle()}].` - ); - } - - this.put({ - ...dynamicActionsState, - events: [...events.slice(0, index), ...events.slice(index + 1)], - }); - } - - public async read(eventId: string): Promise { - const events = this.getEvents(); - const event = events.find((ev) => eventId === ev.eventId); - - if (!event) { - throw new Error( - `[ENOENT]: Event with [eventId = ${eventId}] could not be found in ` + - `[embeddable.id = ${this.id}, embeddable.title = ${this.getPanelTitle()}].` - ); - } - - return event; - } - - public async list(): Promise { - return this.getEvents(); - } - - private getEvents() { - const dynamicActionsState = this.api.dynamicActionsState$.getValue(); - const events = dynamicActionsState?.dynamicActions?.events ?? []; - return this.migrate(events); - } - - // TODO: https://github.com/elastic/kibana/issues/148005 - // Migration implementation should use registry - // Action factories implementations should register own migrations - private migrate(events: SerializedEvent[]): SerializedEvent[] { - return events.map((event) => { - // Initially dashboard drilldown relied on VALUE_CLICK & RANGE_SELECT - if (event.action.factoryId === 'DASHBOARD_TO_DASHBOARD_DRILLDOWN') { - const migratedTriggers = event.triggers.filter( - (t) => t !== VALUE_CLICK_TRIGGER && t !== SELECT_RANGE_TRIGGER - ); - if ( - migratedTriggers.length !== event.triggers.length && - !migratedTriggers.includes(`FILTER_TRIGGER`) - ) { - migratedTriggers.push(`FILTER_TRIGGER`); - } - - return { - ...event, - triggers: migratedTriggers, - }; - } - return event; - }); - } -} diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_actions_manager.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_actions_manager.ts deleted file mode 100644 index 8dabd7a924e52..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/dynamic_actions_manager.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EmbeddableApiContext, StateComparators } from '@kbn/presentation-publishing'; -import { apiHasUniqueId } from '@kbn/presentation-publishing'; -import { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '@kbn/ui-actions-enhanced-plugin/public'; -import deepEqual from 'react-fast-compare'; -import { BehaviorSubject, map } from 'rxjs'; -import { DynamicActionStorage, type DynamicActionStorageApi } from './dynamic_action_storage'; -import { getDynamicActionsState } from './get_dynamic_actions_state'; -import type { DynamicActionsSerializedState, EmbeddableDynamicActionsManager } from './types'; -import type { StartDependencies } from '../plugin'; -import { extractEnhancements, serializeEnhancements } from './bwc'; - -export function initializeDynamicActionsManager( - uuid: string, - getTitle: () => string | undefined, - state: DynamicActionsSerializedState, - services: StartDependencies -): EmbeddableDynamicActionsManager { - const dynamicActionsState$ = new BehaviorSubject( - getDynamicActionsState(extractEnhancements(state)) - ); - const api: DynamicActionStorageApi = { - dynamicActionsState$, - setDynamicActions: (enhancements) => { - dynamicActionsState$.next(getDynamicActionsState(enhancements)); - storage.reload$.next(); - }, - }; - const storage = new DynamicActionStorage(uuid, getTitle, api); - const dynamicActions = new DynamicActionManager({ - isCompatible: async (context: EmbeddableApiContext) => { - const { embeddable } = context; - return apiHasUniqueId(embeddable) && embeddable.uuid === uuid; - }, - storage, - uiActions: services.uiActionsEnhanced, - }); - - function getLatestState() { - return serializeEnhancements(dynamicActionsState$.getValue()); - } - - return { - api: { ...api, enhancements: { dynamicActions } }, - comparators: { - enhancements: 'skip', - drilldowns: (a, b) => deepEqual(a, b), - } as StateComparators, - anyStateChange$: dynamicActionsState$.pipe(map(() => undefined)), - getLatestState, - serializeState: () => getLatestState(), - reinitializeState: (lastState: DynamicActionsSerializedState) => { - api.setDynamicActions(getDynamicActionsState(extractEnhancements(lastState))); - }, - startDynamicActions: () => { - dynamicActions.start().catch((error) => { - /* eslint-disable no-console */ - console.log('Failed to start embeddable dynamic actions', dynamicActions); - console.error(error); - /* eslint-enable */ - }); - - return { - stopDynamicActions: () => { - dynamicActions.stop().catch((error) => { - /* eslint-disable no-console */ - console.log('Failed to stop embeddable dynamic actions', dynamicActions); - console.error(error); - /* eslint-enable */ - }); - }, - }; - }, - }; -} diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/get_dynamic_actions_state.test.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/get_dynamic_actions_state.test.ts deleted file mode 100644 index 29bfc22b98aa3..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/get_dynamic_actions_state.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SerializedEvent } from '@kbn/ui-actions-enhanced-plugin/common/types'; -import { getDynamicActionsState } from './get_dynamic_actions_state'; - -describe('getDynamicActionsState', () => { - test('should return empty state when enhancements is undefined', () => { - expect(getDynamicActionsState()).toEqual({ dynamicActions: { events: [] } }); - }); - - test('should return empty state when enhancements is empty object', () => { - expect(getDynamicActionsState({})).toEqual({ dynamicActions: { events: [] } }); - }); - - test('should return empty state when enhancements.dynamicActions is undefined', () => { - expect(getDynamicActionsState({ dynamicActions: undefined })).toEqual({ - dynamicActions: { events: [] }, - }); - }); - - test('should return empty state when enhancements.dynamicActions is empty object', () => { - expect(getDynamicActionsState({ dynamicActions: {} })).toEqual({ - dynamicActions: { events: [] }, - }); - }); - - test('should return state when enhancements.dynamicActions is provided', () => { - expect( - getDynamicActionsState({ dynamicActions: { events: [{} as unknown as SerializedEvent] } }) - ).toEqual({ dynamicActions: { events: [{}] } }); - }); -}); diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/get_dynamic_actions_state.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/get_dynamic_actions_state.ts deleted file mode 100644 index 463a45f915709..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/get_dynamic_actions_state.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { DynamicActionsState } from '@kbn/ui-actions-enhanced-plugin/public'; - -export function getDynamicActionsState(enhancementState?: { - dynamicActions?: Partial; -}) { - return { - dynamicActions: { - events: [], - ...(enhancementState?.dynamicActions ?? {}), - }, - }; -} diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/interfaces/has_dynamic_actions.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/interfaces/has_dynamic_actions.ts deleted file mode 100644 index ff2246ae0bd2b..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/interfaces/has_dynamic_actions.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { PublishingSubject } from '@kbn/presentation-publishing'; -import type { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '@kbn/ui-actions-enhanced-plugin/public'; -import type { DynamicActionsSerializedState } from '../types'; - -export type HasDynamicActions = Partial<{ - enhancements: { dynamicActions: DynamicActionManager }; - setDynamicActions: (newState: DynamicActionsSerializedState['enhancements']) => void; - dynamicActionsState$: PublishingSubject; -}>; - -export const apiHasDynamicActions = (api: unknown): api is Required => { - const apiMaybeHasDynamicActions = api as Required; - return Boolean( - apiMaybeHasDynamicActions && - apiMaybeHasDynamicActions.enhancements && - typeof apiMaybeHasDynamicActions.enhancements.dynamicActions === 'object' && - typeof apiMaybeHasDynamicActions.setDynamicActions === 'function' && - apiMaybeHasDynamicActions.dynamicActionsState$ - ); -}; diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/types.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/types.ts deleted file mode 100644 index 32d3860539102..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/embeddables/types.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 type { StateComparators } from '@kbn/presentation-publishing'; -import type { DynamicActionsState } from '@kbn/ui-actions-enhanced-plugin/public'; -import type { Observable } from 'rxjs'; -import type { DrilldownsState } from '@kbn/embeddable-plugin/server'; -import type { HasDynamicActions } from './interfaces/has_dynamic_actions'; - -export interface EmbeddableDynamicActionsManager { - api: HasDynamicActions; - comparators: StateComparators; - anyStateChange$: Observable; - getLatestState: () => DynamicActionsSerializedState; - serializeState: () => DynamicActionsSerializedState; - reinitializeState: (lastState: DynamicActionsSerializedState) => void; - startDynamicActions: () => { stopDynamicActions: () => void }; -} - -export type DynamicActionsSerializedState = DrilldownsState & { - enhancements?: { dynamicActions: DynamicActionsState }; -}; diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/index.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/index.ts deleted file mode 100644 index d90315677bec6..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { PluginInitializerContext } from '@kbn/core/public'; -import { EmbeddableEnhancedPlugin } from './plugin'; - -export type { - SetupContract as EmbeddableEnhancedSetupContract, - SetupDependencies as EmbeddableEnhancedSetupDependencies, - StartContract as EmbeddableEnhancedPluginStart, - StartDependencies as EmbeddableEnhancedStartDependencies, -} from './plugin'; - -export type { - DynamicActionsSerializedState, - EmbeddableDynamicActionsManager, -} from './embeddables/types'; - -export function plugin(context: PluginInitializerContext) { - return new EmbeddableEnhancedPlugin(context); -} - -export { - type HasDynamicActions, - apiHasDynamicActions, -} from './embeddables/interfaces/has_dynamic_actions'; diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/mocks.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/mocks.ts deleted file mode 100644 index bb0adc62b9158..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/mocks.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EmbeddableEnhancedSetupContract, EmbeddableEnhancedPluginStart } from '.'; - -export type Setup = jest.Mocked; -export type Start = jest.Mocked; - -const createSetupContract = (): Setup => { - const setupContract: Setup = {}; - - return setupContract; -}; - -const createStartContract = (): Start => { - const startContract: Start = { - initializeEmbeddableDynamicActions: jest.fn(), - }; - - return startContract; -}; - -export const embeddableEnhancedPluginMock = { - createSetupContract, - createStartContract, -}; diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/public/plugin.ts b/x-pack/platform/plugins/shared/embeddable_enhanced/public/plugin.ts deleted file mode 100644 index 6f79b4ae2a1e4..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/public/plugin.ts +++ /dev/null @@ -1,65 +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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; -import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import type { - AdvancedUiActionsSetup, - AdvancedUiActionsStart, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import type { - DynamicActionsSerializedState, - EmbeddableDynamicActionsManager, -} from './embeddables/types'; - -export interface SetupDependencies { - embeddable: EmbeddableSetup; - uiActionsEnhanced: AdvancedUiActionsSetup; -} - -export interface StartDependencies { - embeddable: EmbeddableStart; - uiActionsEnhanced: AdvancedUiActionsStart; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SetupContract {} - -export interface StartContract { - initializeEmbeddableDynamicActions: ( - uuid: string, - getTitle: () => string | undefined, - state: DynamicActionsSerializedState - ) => Promise; -} - -export class EmbeddableEnhancedPlugin - implements Plugin -{ - constructor(protected readonly context: PluginInitializerContext) {} - - public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { - return {}; - } - - public start(core: CoreStart, plugins: StartDependencies): StartContract { - return { - initializeEmbeddableDynamicActions: async ( - uuid: string, - getTitle: () => string | undefined, - state: DynamicActionsSerializedState - ) => { - const { initializeDynamicActionsManager } = await import( - './embeddables/dynamic_actions_manager' - ); - return initializeDynamicActionsManager(uuid, getTitle, state, plugins); - }, - }; - } - - public stop() {} -} diff --git a/x-pack/platform/plugins/shared/embeddable_enhanced/tsconfig.json b/x-pack/platform/plugins/shared/embeddable_enhanced/tsconfig.json deleted file mode 100644 index b128597323f2f..0000000000000 --- a/x-pack/platform/plugins/shared/embeddable_enhanced/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "@kbn/tsconfig-base/tsconfig.json", - "compilerOptions": { - "outDir": "target/types", - }, - "include": [ - "public/**/*", - ], - "kbn_references": [ - "@kbn/core", - "@kbn/embeddable-plugin", - "@kbn/ui-actions-enhanced-plugin", - "@kbn/kibana-utils-plugin", - "@kbn/presentation-publishing", - "@kbn/ui-actions-plugin", - ], - "exclude": [ - "target/**/*", - ] -} diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts index 7efea25e26d0e..6aae441ef2a96 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts @@ -67,8 +67,8 @@ describe('checking changes on all registered encrypted SO types', () => { "action": "4e9f7946dfcbee267e685618638f76f3d55e65c949bd259487dc4bf004018478", "action_task_params": "06aa563283bdcd5c07ec433a7d0b8425019ad11d75595ee1431691667ecd2cec", "ad_hoc_run_params": "6539367aa4ae8340c62f123c3457c6b8d7873c92de68651c70d41028dfe7ed32", - "alert": "d961ff113e2b7995a49483b8937fcbdccfe425ac82b59a050931cd620b043ed1", - "api_key_pending_invalidation": "ce3641d95c31bcc2880a294f0123060dcc5026f0a493befdda74924a7ea5c4a0", + "alert": "878a3b83179bbf2ad9d3862fcba539b7066429869b14c120a1dc7a8d39f4a7fa", + "api_key_pending_invalidation": "4dafadadaaca2f2f3f6038ee8363b71b2d101371ca98c34d2b6aa2a96f7e71c5", "cloud-connect-api-key": "8c0ae7a780c411145ae4aaf7a70235672c9ccfb56d011c322da3c4eeb258f32d", "connector_token": "16ca2154c13c5ee3d3a45b55d4ea6cd33aeaceaef3dc229b002d25470bfc9b3b", "entity-discovery-api-key": "cd3b5230a513d2d3503583223e48362fbbbc7812aa4710579a62acfa5bbc30e6", @@ -115,6 +115,7 @@ describe('checking changes on all registered encrypted SO types', () => { "ad_hoc_run_params|3", "ad_hoc_run_params|2", "ad_hoc_run_params|1", + "alert|9", "alert|8", "alert|7", "alert|6", @@ -123,6 +124,7 @@ describe('checking changes on all registered encrypted SO types', () => { "alert|3", "alert|2", "alert|1", + "api_key_pending_invalidation|2", "api_key_pending_invalidation|1", "cloud-connect-api-key|1", "connector_token|1", diff --git a/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.test.ts index 0ecaf170de715..576ac4a1e8e06 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { Agent, AgentUpgradeDetails } from '../types/models/agent'; +import type { Agent, AgentUpgradeDetails, AgentUpgradeStateType } from '../types/models/agent'; import { getRecentUpgradeInfoForAgent, @@ -180,24 +180,27 @@ describe('Fleet - isAgentUpgradeable', () => { ) ).toBe(false); }); - it('returns true if agent reports upgradeable and has a failed upgrade status', () => { - expect( - isAgentUpgradeable( - getAgent({ - version: '7.9.0', - upgradeable: true, - upgradeDetails: { - target_version: '8.0.0', - action_id: 'XXX', - state: 'UPG_FAILED', - metadata: { - error_msg: 'Upgrade timed out', + it.each(['UPG_FAILED', 'UPG_ROLLBACK'])( + 'returns true if agent reports upgradeable and has a %s upgrade status', + (state: AgentUpgradeStateType) => { + expect( + isAgentUpgradeable( + getAgent({ + version: '7.9.0', + upgradeable: true, + upgradeDetails: { + target_version: '8.0.0', + action_id: 'XXX', + state, + metadata: { + error_msg: 'Upgrade timed out', + }, }, - }, - }) - ) - ).toBe(true); - }); + }) + ) + ).toBe(true); + } + ); it('returns false if the agent reports upgradeable but was upgraded less than 10 minutes ago', () => { expect( isAgentUpgradeable(getAgent({ version: '7.9.0', upgradeable: true, minutesSinceUpgrade: 9 })) @@ -484,7 +487,7 @@ describe('hasAgentBeenUpgradedRecently', () => { }); describe('isAgentUpgrading', () => { - it('returns true if the agent has an upgrade status other than failed', () => { + it('returns true if the agent has an upgrade status other than failed, or rollback', () => { expect( isAgentUpgrading( getAgent({ @@ -499,23 +502,26 @@ describe('isAgentUpgrading', () => { ).toBe(true); }); - it('returns false if the agent has a failed upgrade status', () => { - expect( - isAgentUpgrading( - getAgent({ - version: '7.9.0', - upgradeDetails: { - target_version: '8.0.0', - action_id: 'XXX', - state: 'UPG_FAILED', - metadata: { - error_msg: 'Upgrade timed out', + it.each(['UPG_FAILED', 'UPG_ROLLBACK'])( + 'returns false if the agent has a %s upgrade status', + (state: AgentUpgradeStateType) => { + expect( + isAgentUpgrading( + getAgent({ + version: '7.9.0', + upgradeDetails: { + target_version: '8.0.0', + action_id: 'XXX', + state, + metadata: { + error_msg: 'Upgrade timed out', + }, }, - }, - }) - ) - ).toBe(false); - }); + }) + ) + ).toBe(false); + } + ); it('returns true if the agent is upgrading but has no upgrade details', () => { expect( diff --git a/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.ts b/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.ts index 79b85249e1515..bb9fdc8522f04 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/is_agent_upgradeable.ts @@ -194,7 +194,7 @@ export function isAgentInWatchingState(agent: Agent) { export function isAgentUpgrading(agent: Agent) { if (agent.upgrade_details) { - return agent.upgrade_details.state !== 'UPG_FAILED'; + return !['UPG_FAILED', 'UPG_ROLLBACK'].includes(agent.upgrade_details.state); } return agent.upgrade_started_at && !agent.upgraded_at; } diff --git a/x-pack/platform/plugins/shared/fleet/common/services/policy_template.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/policy_template.test.ts index 770436dc50cd4..850244991dc97 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/policy_template.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/policy_template.test.ts @@ -351,6 +351,31 @@ describe('getNormalizedDataStreams', () => { }, ]); }); + + it('should add use_apm var with default true for otel traces input', () => { + const result = getNormalizedDataStreams({ + ...integrationPkg, + type: 'input', + policy_templates: [ + { + input: 'otelcol', + type: 'traces', + name: 'otel-traces', + template_path: 'some/path.hbl', + title: 'OTel Traces', + description: 'OTel Traces', + vars: [], + }, + ], + }); + expect(result).toHaveLength(1); + expect(result[0].streams).toHaveLength(1); + const vars = result[0].streams![0].vars; + const useApmVar = vars?.find((v) => v.name === 'use_apm'); + expect(useApmVar).toBeDefined(); + expect(useApmVar?.default).toEqual(true); + expect(useApmVar?.title).toEqual('Use Elastic APM'); + }); }); describe('filterPolicyTemplatesTiles', () => { diff --git a/x-pack/platform/plugins/shared/fleet/common/services/policy_template.ts b/x-pack/platform/plugins/shared/fleet/common/services/policy_template.ts index df32fac648702..1705568d0d7cd 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/policy_template.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/policy_template.ts @@ -36,11 +36,12 @@ const DATA_STREAM_DATASET_VAR: RegistryVarsEntry = { const DATA_STREAM_USE_APM_VAR: RegistryVarsEntry = { name: USE_APM_VAR_NAME, type: 'bool', - title: 'Use APM Server', + title: 'Use Elastic APM', description: 'enables the apm collector and processor.', multi: false, required: false, show_user: true, + default: true, }; export function packageHasNoPolicyTemplates(packageInfo: PackageInfo): boolean { diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts index 505cfebede014..37fc6e927a51e 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts @@ -105,6 +105,7 @@ export interface AgentPolicy extends Omit { agents?: number; unprivileged_agents?: number; fips_agents?: number; + agents_per_version?: Array<{ version: string; count: number }>; is_protected: boolean; version?: string; } diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/components/agent_policy_yaml_flyout.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/components/agent_policy_yaml_flyout.tsx new file mode 100644 index 0000000000000..a5f2497070130 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/components/agent_policy_yaml_flyout.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, { memo, useCallback } from 'react'; +import styled from '@emotion/styled'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { dump } from 'js-yaml'; +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButtonEmpty, + EuiButton, + EuiCallOut, + EuiIconTip, + EuiSpacer, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import { MAX_FLYOUT_WIDTH } from '../constants'; +import { useGetOneAgentPolicyFull, useGetOneAgentPolicy, useStartServices } from '../hooks'; + +import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../services'; +import { API_VERSIONS } from '../../../../common/constants'; +import { splitVersionSuffixFromPolicyId } from '../../../../common/services/version_specific_policies_utils'; + +import { Loading } from '.'; + +const FlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflowContent { + padding: 0; + } +`; + +export const AgentPolicyYamlFlyout = memo<{ + policyId: string; + revision?: number | null; + onClose: () => void; +}>(({ policyId, revision, onClose }) => { + const flyoutTitleId = useGeneratedHtmlId(); + const { version: agentVersion } = splitVersionSuffixFromPolicyId(policyId); + + const core = useStartServices(); + const { + isLoading: isLoadingYaml, + data: yamlData, + error, + } = useGetOneAgentPolicyFull(policyId, revision ? { revision } : undefined); + const { data: agentPolicyData } = useGetOneAgentPolicy(policyId); + const packagePoliciesContainSecrets = agentPolicyData?.item?.package_policies?.some( + (packagePolicy) => packagePolicy?.secret_references?.length + ); + + const body = isLoadingYaml ? ( + + ) : error ? ( + + } + color="danger" + iconType="warning" + > + {error.message} + + ) : ( + + {fullAgentPolicyToYaml(yamlData!.item, dump)} + + ); + + const revisionQueryParam = revision ? `&revision=${revision}` : ''; + const downloadLink = + core.http.basePath.prepend(agentPolicyRouteService.getInfoFullDownloadPath(policyId)) + + `?apiVersion=${API_VERSIONS.public.v1}${revisionQueryParam}`; + + const downloadYaml = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (!yamlData?.item) { + return; + } + const yaml = fullAgentPolicyToYaml(yamlData.item, dump); + const link = document.createElement('a'); + link.href = `data:text/x-yaml;charset=utf-8,${encodeURIComponent(yaml)}`; + link.download = 'elastic-agent.yml'; + link.click(); + }, + [yamlData] + ); + + return ( + + + +

+ {agentPolicyData?.item ? ( + <> + + {agentVersion && ( + <> + {' '} + + } + /> + + )} + + ) : ( + + )} +

+
+ {packagePoliciesContainSecrets && ( + <> + + + } + size="m" + color="primary" + iconType="info" + > + {'${SECRET_0}'}, + }} + /> + + + )} +
+ {body} + + + + + + + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + + +
+ ); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/components/index.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/components/index.ts index e09be7a2d1927..15e29b5822f75 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/components/index.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/components/index.ts @@ -10,3 +10,4 @@ export * from '../../../components'; export * from './search_bar'; export * from './fleet_server_instructions'; export * from './generate_service_token'; +export { AgentPolicyYamlFlyout } from './agent_policy_yaml_flyout'; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.test.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.test.ts new file mode 100644 index 0000000000000..c193015843b02 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AgentPolicy } from '../types'; +import type { PackageInfo } from '../../../../common/types'; + +import { getIncompatibleAgentVersionStatus } from './use_incompatible_agent_version_status'; + +const createAgentPolicy = ( + versions: Array<{ version: string; count: number }> | undefined +): AgentPolicy => + ({ + agents_per_version: versions, + } as unknown as AgentPolicy); + +const createPackageInfo = (agentVersionCondition?: string): PackageInfo => + ({ + conditions: agentVersionCondition ? { agent: { version: agentVersionCondition } } : undefined, + } as unknown as PackageInfo); + +describe('getIncompatibleAgentVersionStatus', () => { + it('returns { status: NONE } when packageInfo is undefined', () => { + const result = getIncompatibleAgentVersionStatus(undefined, []); + expect(result).toEqual({ status: 'NONE' }); + }); + + it('returns { status: NONE } when no agent version condition is set', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo(), []); + expect(result).toEqual({ status: 'NONE' }); + }); + + it('returns { status: NONE } when agentPolicies is undefined', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), undefined); + expect(result).toEqual({ status: 'NONE' }); + }); + + it('returns { status: NONE } when agentPolicies is empty', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), []); + expect(result).toEqual({ status: 'NONE' }); + }); + + it('returns { status: NONE } when agent policy has no agents_per_version', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ + createAgentPolicy(undefined), + ]); + expect(result).toEqual({ status: 'NONE' }); + }); + + it('returns { status: NONE } when all agents satisfy the version condition', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ + createAgentPolicy([ + { version: '8.1.0', count: 3 }, + { version: '8.2.0', count: 5 }, + ]), + ]); + expect(result).toEqual({ status: 'NONE' }); + }); + + it('returns { status: SOME } with versionCondition when some agents are incompatible', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ + createAgentPolicy([ + { version: '7.17.0', count: 2 }, + { version: '8.1.0', count: 5 }, + ]), + ]); + expect(result).toEqual({ status: 'SOME', versionCondition: '>=8.0.0' }); + }); + + it('returns { status: ALL } with versionCondition when all agents are incompatible', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ + createAgentPolicy([ + { version: '7.16.0', count: 2 }, + { version: '7.17.0', count: 3 }, + ]), + ]); + expect(result).toEqual({ status: 'ALL', versionCondition: '>=8.0.0' }); + }); + + it('returns { status: SOME } across multiple agent policies with mixed compatibility', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ + createAgentPolicy([{ version: '8.1.0', count: 5 }]), + createAgentPolicy([ + { version: '7.17.0', count: 2 }, + { version: '8.1.0', count: 3 }, + ]), + ]); + expect(result).toEqual({ status: 'SOME', versionCondition: '>=8.0.0' }); + }); + + it('returns { status: ALL } when all agents across all policies are incompatible', () => { + const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ + createAgentPolicy([{ version: '7.16.0', count: 2 }]), + createAgentPolicy([{ version: '7.17.0', count: 3 }]), + ]); + expect(result).toEqual({ status: 'ALL', versionCondition: '>=8.0.0' }); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.ts new file mode 100644 index 0000000000000..1678e53fb2f27 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +import satisfies from 'semver/functions/satisfies'; + +import type { AgentPolicy } from '../types'; +import type { PackageInfo } from '../../../../common/types'; + +export type IncompatibleAgentVersionResult = + | { status: 'NONE' } + | { status: 'SOME' | 'ALL'; versionCondition: string }; + +export const useIncompatibleAgentVersionStatus = ( + packageInfo: PackageInfo | undefined, + agentPolicies: AgentPolicy[] | undefined +): IncompatibleAgentVersionResult => { + return useMemo(() => { + return getIncompatibleAgentVersionStatus(packageInfo, agentPolicies); + }, [packageInfo, agentPolicies]); +}; + +export const getIncompatibleAgentVersionStatus = ( + packageInfo: PackageInfo | undefined, + agentPolicies: AgentPolicy[] | undefined +): IncompatibleAgentVersionResult => { + const versionCondition = packageInfo?.conditions?.agent?.version; + if (!versionCondition) { + return { status: 'NONE' }; + } + const status = (agentPolicies ?? []).reduce<'NONE' | 'SOME' | 'ALL'>((acc, agentPolicy) => { + if (acc === 'SOME') return acc; + const { agents_per_version: agentPerVersion } = agentPolicy; + if (!agentPerVersion) { + return acc; + } + const hasAllIncompatible = agentPerVersion.every( + (entry) => !satisfies(entry.version, versionCondition) + ); + const hasSomeIncompatible = agentPerVersion.some( + (entry) => !satisfies(entry.version, versionCondition) + ); + return hasAllIncompatible ? 'ALL' : hasSomeIncompatible ? 'SOME' : acc; + }, 'NONE'); + + if (status === 'NONE') { + return { status: 'NONE' }; + } + return { status, versionCondition }; +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx index 3e5809368ad81..15ff0c053951f 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx @@ -26,7 +26,8 @@ import { ManageAutoUpgradeAgentsModal } from '../../agents/components/manage_aut import { useCanEnableAutomaticAgentUpgrades } from '../../../../../hooks/use_can_enable_auto_upgrades'; -import { AgentPolicyYamlFlyout } from './agent_policy_yaml_flyout'; +import { AgentPolicyYamlFlyout } from '../../../components'; + import { AgentPolicyCopyProvider } from './agent_policy_copy_provider'; import { AgentPolicyDeleteProvider } from './agent_policy_delete_provider'; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx deleted file mode 100644 index 04a0582d1fe2c..0000000000000 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import styled from 'styled-components'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { dump } from 'js-yaml'; -import { - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiButtonEmpty, - EuiButton, - EuiCallOut, - EuiSpacer, - useGeneratedHtmlId, -} from '@elastic/eui'; - -import { MAX_FLYOUT_WIDTH } from '../../../constants'; -import { useGetOneAgentPolicyFull, useGetOneAgentPolicy, useStartServices } from '../../../hooks'; -import { Loading } from '../../../components'; -import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../../../services'; -import { API_VERSIONS } from '../../../../../../common/constants'; - -const FlyoutBody = styled(EuiFlyoutBody)` - .euiFlyoutBody__overflowContent { - padding: 0; - } -`; - -export const AgentPolicyYamlFlyout = memo<{ policyId: string; onClose: () => void }>( - ({ policyId, onClose }) => { - const flyoutTitleId = useGeneratedHtmlId(); - - const core = useStartServices(); - const { isLoading: isLoadingYaml, data: yamlData, error } = useGetOneAgentPolicyFull(policyId); - const { data: agentPolicyData } = useGetOneAgentPolicy(policyId); - const packagePoliciesContainSecrets = agentPolicyData?.item?.package_policies?.some( - (packagePolicy) => packagePolicy?.secret_references?.length - ); - const body = isLoadingYaml ? ( - - ) : error ? ( - - } - color="danger" - iconType="warning" - > - {error.message} - - ) : ( - <> - - {fullAgentPolicyToYaml(yamlData!.item, dump)} - - - ); - - const downloadLink = - core.http.basePath.prepend(agentPolicyRouteService.getInfoFullDownloadPath(policyId)) + - `?apiVersion=${API_VERSIONS.public.v1}`; - - return ( - - - -

- {agentPolicyData?.item ? ( - - ) : ( - - )} -

-
- {packagePoliciesContainSecrets && ( - <> - - - } - size="m" - color="primary" - iconType="info" - > - {'${SECRET_0}'}, - }} - /> - - - )} -
- {body} - - - - - - - - - - - - - - -
- ); - } -); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/incompatible_agent_version_callout.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/incompatible_agent_version_callout.tsx new file mode 100644 index 0000000000000..7f2d92e5b2f4b --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/incompatible_agent_version_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 { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +export const IncompatibleAgentVersionCallout: React.FC<{ + incompatibility: 'SOME' | 'ALL'; + versionCondition?: string; +}> = ({ incompatibility, versionCondition }) => { + return ( + <> + + } + color="warning" + > + {incompatibility === 'SOME' ? ( + + ) : ( + + )} + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/index.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/index.ts index f57714f7e76f5..24ef552d912c3 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/index.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/components/index.ts @@ -11,9 +11,9 @@ export { LinkedAgentCount } from '../../../components'; export { AgentPolicyForm } from './agent_policy_form'; export { AgentPolicyCopyProvider } from './agent_policy_copy_provider'; export { AgentPolicyDeleteProvider } from './agent_policy_delete_provider'; -export { AgentPolicyYamlFlyout } from './agent_policy_yaml_flyout'; export { ConfirmDeployAgentPolicyModal } from './confirm_deploy_modal'; export { AgentPolicyActionMenu } from './actions_menu'; export { AgentPolicyIntegrationForm } from './agent_policy_integration'; export { agentPolicyFormValidation } from './agent_policy_validation'; export { AgentPolicyCreateInlineForm } from './agent_policy_create_inline'; +export { IncompatibleAgentVersionCallout } from './incompatible_agent_version_callout'; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx index eb5eb9a34b448..a59727ae5143b 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx @@ -24,7 +24,7 @@ import { Error } from '../../../../../components'; import type { AgentPolicy, PackageInfo } from '../../../../../types'; import { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from '../../../../../services'; import { useFleetStatus, sendBulkGetAgentPolicies } from '../../../../../hooks'; - +import { useIncompatibleAgentVersionStatus } from '../../../../../hooks/use_incompatible_agent_version_status'; import { useMultipleAgentPolicies } from '../../../../../hooks'; import { AgentPolicyMultiSelect } from './components/agent_policy_multi_select'; @@ -168,6 +168,16 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ [] ); + const newlySelectedAgentPolicies = selectedAgentPolicies.filter( + (policy) => !initialSelectedAgentPolicyIds.find((id) => policy.id === id) + ); + + const incompatibleAgentVersion = useIncompatibleAgentVersionStatus( + packageInfo, + newlySelectedAgentPolicies + ); + const someNewAgentPoliciesHaveAllAgentIncompatible = incompatibleAgentVersion.status === 'ALL'; + // Display agent policies list error if there is one if (agentPoliciesError) { return ( @@ -185,11 +195,9 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ const someNewAgentPoliciesHaveLimitedPackage = !packageInfo || - selectedAgentPolicies - .filter((policy) => !initialSelectedAgentPolicyIds.find((id) => policy.id === id)) - .some((selectedAgentPolicy) => - doesAgentPolicyHaveLimitedPackage(selectedAgentPolicy, packageInfo) - ); + newlySelectedAgentPolicies.some((selectedAgentPolicy) => + doesAgentPolicyHaveLimitedPackage(selectedAgentPolicy, packageInfo) + ); return ( <> @@ -243,13 +251,24 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ /> ) : null } - isInvalid={Boolean(someNewAgentPoliciesHaveLimitedPackage)} + isInvalid={Boolean( + someNewAgentPoliciesHaveLimitedPackage || + someNewAgentPoliciesHaveAllAgentIncompatible + )} error={ someNewAgentPoliciesHaveLimitedPackage ? ( + ) : someNewAgentPoliciesHaveAllAgentIncompatible ? ( + ) : null } > diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index f5e8a81f93053..5c9a0e2623f50 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -52,6 +52,7 @@ import { useAuthz, useBulkGetAgentPoliciesQuery, } from '../../../../hooks'; +import { useIncompatibleAgentVersionStatus } from '../../../../hooks/use_incompatible_agent_version_status'; import { DevtoolsRequestFlyoutButton, Error as ErrorComponent, @@ -59,7 +60,11 @@ import { Loading, } from '../../../../components'; -import { agentPolicyFormValidation, ConfirmDeployAgentPolicyModal } from '../../components'; +import { + agentPolicyFormValidation, + ConfirmDeployAgentPolicyModal, + IncompatibleAgentVersionCallout, +} from '../../components'; import { pkgKeyFromPackageInfo } from '../../../../services'; import type { CreatePackagePolicyParams } from '../types'; @@ -318,6 +323,11 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ return agentPolicyData.items.reduce((acc, item) => (acc += item.fips_agents || 0), 0); }, [agentPolicyData?.items]); + const incompatibleAgentVersion = useIncompatibleAgentVersionStatus( + packageInfo, + agentPolicyData?.items + ); + useEffect(() => { if ( (integration || integrationToEnable) && @@ -673,6 +683,14 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ )} + + {incompatibleAgentVersion.status !== 'NONE' && ( + + )} + {showSecretsDisabledCallout && ( <> { const { @@ -212,6 +213,15 @@ export const EditPackagePolicyForm = memo<{ [agentPoliciesToRemove] ); + const selectedExistingPolicies = useMemo(() => { + return existingAgentPolicies.filter((existingPolicy) => + agentPolicies.find((policy) => policy.id === existingPolicy.id) + ); + }, [agentPolicies, existingAgentPolicies]); + const incompatibleAgentVersion = useIncompatibleAgentVersionStatus( + packageInfo, + selectedExistingPolicies + ); // Retrieve agent count const [agentCount, setAgentCount] = useState(0); const [impactedAgentCount, setImpactedAgentCount] = useState(0); @@ -592,6 +602,15 @@ export const EditPackagePolicyForm = memo<{ ) : null} + {incompatibleAgentVersion.status !== 'NONE' && ( + <> + + + + )} {isUpgrade && upgradeDryRunData && ( <> diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx index 3b3295cd99b20..887afa5122356 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx @@ -27,6 +27,7 @@ import { } from '../../agent_list_page/components'; import { UninstallCommandFlyout } from '../../../../components'; import { AgentRollbackModal } from '../../components/agent_rollback_modal'; +import { AgentPolicyYamlFlyout } from '../../../../components'; import { AgentDetailsJsonFlyout } from './agent_details_json_flyout'; @@ -52,6 +53,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] = useState(false); const [isAgentDetailsJsonFlyoutOpen, setIsAgentDetailsJsonFlyoutOpen] = useState(false); + const [isAgentPolicyYamlFlyoutOpen, setIsAgentPolicyYamlFlyoutOpen] = useState(false); const [isAgentMigrateFlyoutOpen, setIsAgentMigrateFlyoutOpen] = useState(false); const [isChangePrivilegeLevelFlyoutOpen, setIsChangePrivilegeLevelFlyoutOpen] = useState(false); const [isUninstallCommandFlyoutOpen, setIsUninstallCommandFlyoutOpen] = useState(false); @@ -81,6 +83,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ onReassignClick: () => setIsReassignFlyoutOpen(true), onUpgradeClick: () => setIsUpgradeModalOpen(true), onViewAgentJsonClick: () => setIsAgentDetailsJsonFlyoutOpen(true), + onViewAgentPolicyClick: () => setIsAgentPolicyYamlFlyoutOpen(true), onMigrateAgentClick: () => setIsAgentMigrateFlyoutOpen(true), onRequestDiagnosticsClick: () => setIsRequestDiagnosticsModalOpen(true), onChangeAgentPrivilegeLevelClick: () => setIsChangePrivilegeLevelFlyoutOpen(true), @@ -150,6 +153,15 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ /> )} + {isAgentPolicyYamlFlyoutOpen && agent.policy_id && ( + + setIsAgentPolicyYamlFlyoutOpen(false)} + /> + + )} {isAgentMigrateFlyoutOpen && ( { - const tooltip = - typeof description === 'string' && description.length > 20 ? description : ''; - return ( - - - {title} - - - - - {description} - - - - - ); - })} - - - - {[ - { - title: i18n.translate('xpack.fleet.agentDetails.outputForMonitoringLabel', { - defaultMessage: 'Output for integrations', - }), - description: outputs ? : '-', - }, - { - title: i18n.translate('xpack.fleet.agentDetails.outputForMonitoringLabel', { - defaultMessage: 'Output for monitoring', - }), - description: outputs ? ( - - ) : ( - '-' - ), - }, - { - title: i18n.translate('xpack.fleet.agentDetails.logLevel', { - defaultMessage: 'Logging level', - }), - description: - typeof agent.local_metadata?.elastic?.agent?.log_level === 'string' - ? agent.local_metadata.elastic.agent.log_level - : '-', - }, - { - title: i18n.translate('xpack.fleet.agentDetails.privilegeModeLabel', { - defaultMessage: 'Privilege mode', - }), - description: - agent.local_metadata.elastic.agent.unprivileged === true ? ( - - ) : ( - - ), - }, - { - title: i18n.translate('xpack.fleet.agentDetails.releaseLabel', { - defaultMessage: 'Agent release', - }), - description: - typeof agent.local_metadata?.elastic?.agent?.snapshot === 'boolean' - ? agent.local_metadata.elastic.agent.snapshot === true - ? 'snapshot' - : 'stable' - : '-', - }, - { - title: i18n.translate('xpack.fleet.agentDetails.platformLabel', { - defaultMessage: 'Platform', - }), - description: - typeof agent.local_metadata?.os?.platform === 'string' - ? agent.local_metadata.os.platform - : '-', - }, - { - title: i18n.translate('xpack.fleet.agentDetails.monitorLogsLabel', { - defaultMessage: 'Monitor logs', - }), - description: Array.isArray(agentPolicy?.monitoring_enabled) ? ( - agentPolicy?.monitoring_enabled?.includes('logs') ? ( - - ) : ( - - ) - ) : ( - - ), - }, - { - title: i18n.translate('xpack.fleet.agentDetails.monitorMetricsLabel', { - defaultMessage: 'Monitor metrics', - }), - description: Array.isArray(agentPolicy?.monitoring_enabled) ? ( - agentPolicy?.monitoring_enabled?.includes('metrics') ? ( - - ) : ( - - ) - ) : ( - - ), - }, - { - title: i18n.translate('xpack.fleet.agentDetails.tagsLabel', { - defaultMessage: 'Tags', - }), - description: - (agent.tags ?? []).length > 0 ? : '-', - }, - { - title: i18n.translate('xpack.fleet.agentDetails.platformLabel', { - defaultMessage: 'FIPS mode', - }), - description: - agent.local_metadata.elastic.agent.fips === true ? ( - - ) : ( - - ), - }, - ].map(({ title, description }) => { - const tooltip = - typeof description === 'string' && description.length > 20 ? description : ''; - return ( - - - {title} - - - - - {description} - - - - - ); - })} - - - {agent.capabilities && ( - + { + title: i18n.translate('xpack.fleet.agentDetails.outputForMonitoringLabel', { + defaultMessage: 'Output for integrations', + }), + description: outputs ? : '-', + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.outputForMonitoringLabel', { + defaultMessage: 'Output for monitoring', + }), + description: outputs ? ( + + ) : ( + '-' + ), + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.logLevel', { + defaultMessage: 'Logging level', + }), + description: + typeof agent.local_metadata?.elastic?.agent?.log_level === 'string' + ? agent.local_metadata.elastic.agent.log_level + : '-', + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.privilegeModeLabel', { + defaultMessage: 'Privilege mode', + }), + description: + agent.local_metadata.elastic.agent.unprivileged === true ? ( + + ) : ( + + ), + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.releaseLabel', { + defaultMessage: 'Agent release', + }), + description: + typeof agent.local_metadata?.elastic?.agent?.snapshot === 'boolean' + ? agent.local_metadata.elastic.agent.snapshot === true + ? 'snapshot' + : 'stable' + : '-', + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.platformLabel', { + defaultMessage: 'Platform', + }), + description: + typeof agent.local_metadata?.os?.platform === 'string' + ? agent.local_metadata.os.platform + : '-', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.monitorLogsLabel', { + defaultMessage: 'Monitor logs', + }), + description: Array.isArray(agentPolicy?.monitoring_enabled) ? ( + agentPolicy?.monitoring_enabled?.includes('logs') ? ( + + ) : ( + + ) + ) : ( + + ), + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.monitorMetricsLabel', { + defaultMessage: 'Monitor metrics', + }), + description: Array.isArray(agentPolicy?.monitoring_enabled) ? ( + agentPolicy?.monitoring_enabled?.includes('metrics') ? ( + + ) : ( + + ) + ) : ( + + ), + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.tagsLabel', { + defaultMessage: 'Tags', + }), + description: (agent.tags ?? []).length > 0 ? : '-', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.platformLabel', { + defaultMessage: 'FIPS mode', + }), + description: + agent.local_metadata.elastic.agent.fips === true ? ( + + ) : ( + + ), + hidden: agent.type === 'OPAMP', + }, + { + title: i18n.translate('xpack.fleet.agentDetails.capabilitiesLabel', { + defaultMessage: 'Collector capabilities', + }), + description: ( - - - - - - {agent.capabilities.sort().map((capability) => ( + {agent.capabilities?.sort().map((capability) => ( ))} - - )} - + ), + hidden: !Array.isArray(agent.capabilities), + }, + ] + .filter(({ hidden }) => !hidden) + .map(({ title, description }) => { + const tooltip = + typeof description === 'string' && description.length > 20 ? description : ''; + return ( + + + {title} + + + + + {description} + + + + + ); + })}
{effectiveConfigFlyoutOpen && ( diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_upgrade_status.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_upgrade_status.tsx index 8e7c1fdc97006..2eb2ef2051499 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_upgrade_status.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_upgrade_status.tsx @@ -235,7 +235,7 @@ function getStatusComponents(agentUpgradeDetails?: AgentUpgradeDetails) { case 'UPG_ROLLBACK': return { Badge: ( - + ); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx index 079b3020f7553..ec9c997a2a728 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx @@ -25,6 +25,7 @@ export const TableRowActions: React.FunctionComponent<{ onMigrateAgentClick: () => void; onChangeAgentPrivilegeLevelClick: () => void; onViewAgentJsonClick: () => void; + onViewAgentPolicyClick: () => void; onRollbackClick: () => void; }> = ({ agent, @@ -38,6 +39,7 @@ export const TableRowActions: React.FunctionComponent<{ onMigrateAgentClick, onChangeAgentPrivilegeLevelClick, onViewAgentJsonClick, + onViewAgentPolicyClick, onRollbackClick, }) => { const { getHref } = useLink(); @@ -53,6 +55,7 @@ export const TableRowActions: React.FunctionComponent<{ onReassignClick, onUpgradeClick, onViewAgentJsonClick, + onViewAgentPolicyClick, onMigrateAgentClick, onRequestDiagnosticsClick, onChangeAgentPrivilegeLevelClick, @@ -67,6 +70,7 @@ export const TableRowActions: React.FunctionComponent<{ onReassignClick, onUpgradeClick, onViewAgentJsonClick, + onViewAgentPolicyClick, onMigrateAgentClick, onRequestDiagnosticsClick, onChangeAgentPrivilegeLevelClick, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 8adce9902c21d..5bcc3170477ff 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -40,6 +40,7 @@ import { AgentRequestDiagnosticsModal } from '../components/agent_request_diagno import { ManageAutoUpgradeAgentsModal } from '../components/manage_auto_upgrade_agents_modal'; import { AgentDetailsJsonFlyout } from '../agent_details_page/components/agent_details_json_flyout'; import { AgentRollbackModal } from '../components/agent_rollback_modal'; +import { AgentPolicyYamlFlyout } from '../../../components'; import type { SelectionMode } from './components/types'; @@ -95,6 +96,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { undefined ); const [agentToViewJson, setAgentToViewJson] = useState(undefined); + const [agentToViewPolicy, setAgentToViewPolicy] = useState(undefined); const [agentToRollback, setAgentToRollback] = useState(undefined); const [showAgentActivityTour, setShowAgentActivityTour] = useState(false); @@ -225,6 +227,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { onMigrateAgentClick={() => setAgentToMigrate(agent)} onChangeAgentPrivilegeLevelClick={() => setAgentToChangePrivilege(agent)} onViewAgentJsonClick={() => setAgentToViewJson(agent)} + onViewAgentPolicyClick={() => setAgentToViewPolicy(agent)} onRollbackClick={() => setAgentToRollback(agent)} /> ); @@ -483,6 +486,15 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { /> )} + {agentToViewPolicy && agentToViewPolicy.policy_id && ( + + setAgentToViewPolicy(undefined)} + /> + + )} {showUnhealthyCallout && ( <> {cloud?.deploymentUrl ? ( diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.test.tsx index f005675f6f0bd..2bf5ffe8ceb0d 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.test.tsx @@ -35,6 +35,7 @@ const mockCallbacks: SingleAgentMenuCallbacks = { onUnenrollClick: jest.fn(), onUninstallClick: jest.fn(), onRollbackClick: jest.fn(), + onViewAgentPolicyClick: jest.fn(), }; function createMockAgent(overrides: Partial = {}): Agent { @@ -235,6 +236,61 @@ describe('useSingleAgentMenuItems', () => { expect(upgradeManagement?.children).toBeDefined(); }); + it('should disable rollback when agent is upgrading', () => { + mockedExperimentalFeaturesService.get.mockReturnValue({ + enableAgentPrivilegeLevelChange: true, + enableAgentRollback: true, + } as any); + + const validUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const { result } = renderer.renderHook(() => + useSingleAgentMenuItems({ + agent: createMockAgent({ + status: 'updating', + upgrade_started_at: new Date().toISOString(), + upgrade_details: { + state: 'UPG_DOWNLOADING', + target_version: '8.9.0', + action_id: 'action-1', + }, + upgrade: { rollbacks: [{ valid_until: validUntil, version: '8.7.0' }] }, + local_metadata: { elastic: { agent: { version: '8.8.0', upgradeable: true } } }, + }), + agentPolicy: createMockAgentPolicy(), + callbacks: mockCallbacks, + }) + ); + + const upgradeManagement = result.current.find((item) => item.id === 'upgrade-management'); + const rollbackItem = upgradeManagement?.children?.find((item) => item.id === 'rollback'); + expect(rollbackItem).toBeDefined(); + expect(rollbackItem?.disabled).toBe(true); + }); + + it('should enable rollback when agent is not upgrading and has valid rollback', () => { + mockedExperimentalFeaturesService.get.mockReturnValue({ + enableAgentPrivilegeLevelChange: true, + enableAgentRollback: true, + } as any); + + const validUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const { result } = renderer.renderHook(() => + useSingleAgentMenuItems({ + agent: createMockAgent({ + upgrade: { rollbacks: [{ valid_until: validUntil, version: '8.7.0' }] }, + local_metadata: { elastic: { agent: { version: '8.8.0', upgradeable: true } } }, + }), + agentPolicy: createMockAgentPolicy(), + callbacks: mockCallbacks, + }) + ); + + const upgradeManagement = result.current.find((item) => item.id === 'upgrade-management'); + const rollbackItem = upgradeManagement?.children?.find((item) => item.id === 'rollback'); + expect(rollbackItem).toBeDefined(); + expect(rollbackItem?.disabled).toBe(false); + }); + it('should include Maintenance and diagnostics submenu', () => { const { result } = renderer.renderHook(() => useSingleAgentMenuItems({ diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.tsx index ae5c10c9f817e..4aede93e7cdfd 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/hooks/use_single_agent_menu_items.tsx @@ -37,6 +37,7 @@ export interface SingleAgentMenuCallbacks { onReassignClick: () => void; onUpgradeClick: () => void; onViewAgentJsonClick: () => void; + onViewAgentPolicyClick: () => void; onMigrateAgentClick: () => void; onRequestDiagnosticsClick: () => void; onChangeAgentPrivilegeLevelClick: () => void; @@ -205,6 +206,7 @@ export function useSingleAgentMenuItems({ icon: 'clockCounter', disabled: !agentHasValidRollback || + isAgentUpgrading(agent) || !licenseService.hasAtLeast(LICENSE_FOR_AGENT_ROLLBACK), onClick: () => { callbacks.onRollbackClick(); @@ -228,7 +230,22 @@ export function useSingleAgentMenuItems({ ), panelTitle: 'Maintenance and diagnostics', children: [ - // View agent JSON - always available + // View agent policy - available when agent has a policy + { + id: 'view-agent-policy', + name: ( + + ), + icon: 'inspect', + disabled: !authz.fleet.readAgentPolicies || !agent.policy_id, + onClick: () => { + callbacks.onViewAgentPolicyClick(); + }, + 'data-test-subj': 'viewAgentPolicyBtn', + }, viewAgentJsonMenuItem, ], }; @@ -372,6 +389,7 @@ export function useSingleAgentMenuItems({ agentHasValidRollback, licenseService, isUnenrolling, + authz.fleet.readAgentPolicies, ]); return menuItems; diff --git a/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts index 9810e398725fe..33e427ca16f42 100644 --- a/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts @@ -125,11 +125,16 @@ export const useGetOneAgentPolicy = (agentPolicyId: string | undefined) => { } as SendConditionalRequestConfig); }; -export const useGetOneAgentPolicyFull = (agentPolicyId: string) => { - return useRequest({ - path: agentPolicyRouteService.getInfoFullPath(agentPolicyId), - method: 'get', - version: API_VERSIONS.public.v1, +export const useGetOneAgentPolicyFull = (agentPolicyId: string, query?: { revision?: number }) => { + return useQuery({ + queryKey: ['agentPolicyFull', agentPolicyId, query], + queryFn: () => + sendRequestForRq({ + path: agentPolicyRouteService.getInfoFullPath(agentPolicyId), + method: 'get', + version: API_VERSIONS.public.v1, + query, + }), }); }; diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/agent_policy/handlers.ts b/x-pack/platform/plugins/shared/fleet/server/routes/agent_policy/handlers.ts index 11c2fe8c2d225..80235d4eba791 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/agent_policy/handlers.ts @@ -70,6 +70,7 @@ import type { GetAgentPolicyOutputsResponse, GetListAgentPolicyOutputsResponse, CreatePackagePolicyRequest, + FullAgentPolicy, } from '../../../common/types'; import { AgentPolicyNotFoundError, FleetUnauthorizedError, FleetError } from '../../errors'; import { createAgentPolicyWithPackages } from '../../services/agent_policy_create'; @@ -124,7 +125,32 @@ export async function populateAssignedAgentsCount( kuery: `${policyKuery} and ${FIPS_AGENT_KUERY}`, }) .then(({ total }) => (agentPolicy.fips_agents = total)); - return Promise.all([totalAgents, unprivilegedAgents, fipsAgents]); + + const perVersionAgents = agentClient + .listAgents({ + showInactive: true, + perPage: 0, + page: 1, + aggregations: { + versions: { + terms: { + field: 'agent.version', + size: 1000, + }, + }, + }, + kuery: policyKuery, + }) + .then(({ aggregations }) => { + const versions = (aggregations?.versions as any)?.buckets ?? []; + agentPolicy.agents_per_version = versions.map( + (version: { key: string; doc_count: number }) => ({ + version: version.key, + count: version.doc_count, + }) + ); + }); + return Promise.all([totalAgents, unprivilegedAgents, fipsAgents, perVersionAgents]); }, { concurrency: MAX_CONCURRENT_AGENT_POLICIES_OPERATIONS_10 } ); @@ -723,13 +749,41 @@ export const getFullAgentPolicy: FleetRequestHandler< > = async (context, request, response) => { const fleetContext = await context.fleet; const soClient = fleetContext.internalSoClient; + const { agentPolicyId } = request.params; + + if (request.query.revision && (request.query.kubernetes || request.query.standalone)) { + return response.customError({ + statusCode: 400, + body: { message: 'revision cannot be used with kubernetes or standalone flags' }, + }); + } + + if (request.query.revision) { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + const fleetServerPolicy = await agentPolicyService.getFleetServerPolicy( + esClient, + agentPolicyId, + request.query.revision + ); + if (!fleetServerPolicy) { + return response.customError({ + statusCode: 404, + body: { message: 'Agent policy not found' }, + }); + } + const body: GetFullAgentPolicyResponse = { + item: fleetServerPolicy.data as unknown as FullAgentPolicy, + }; + return response.ok({ body }); + } if (request.query.kubernetes === true) { const agentVersion = await fleetContext.agentClient.asInternalUser.getLatestAgentAvailableDockerImageVersion(); const fullAgentConfigMap = await agentPolicyService.getFullAgentConfigMap( soClient, - request.params.agentPolicyId, + agentPolicyId, agentVersion, { standalone: request.query.standalone === true } ); @@ -747,13 +801,9 @@ export const getFullAgentPolicy: FleetRequestHandler< }); } } else { - const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy( - soClient, - request.params.agentPolicyId, - { - standalone: request.query.standalone === true, - } - ); + const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId, { + standalone: request.query.standalone === true, + }); if (fullAgentPolicy) { const body: GetFullAgentPolicyResponse = { item: fullAgentPolicy, @@ -780,51 +830,79 @@ export const downloadFullAgentPolicy: FleetRequestHandler< params: { agentPolicyId }, } = request; + if (request.query.revision && (request.query.kubernetes || request.query.standalone)) { + return response.customError({ + statusCode: 400, + body: { message: 'revision cannot be used with kubernetes or standalone flags' }, + }); + } + + if (request.query.revision) { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + const fleetServerPolicy = await agentPolicyService.getFleetServerPolicy( + esClient, + agentPolicyId, + request.query.revision + ); + if (!fleetServerPolicy) { + return response.customError({ + statusCode: 404, + body: { message: 'Agent policy not found' }, + }); + } + const fullAgentPolicy = fleetServerPolicy.data as unknown as FullAgentPolicy; + const body = fullAgentPolicyToYaml(fullAgentPolicy, dump); + const headers: ResponseHeaders = { + 'content-type': 'text/x-yaml', + 'content-disposition': `attachment; filename="elastic-agent.yml"`, + }; + return response.ok({ body, headers }); + } + if (request.query.kubernetes === true) { const agentVersion = await fleetContext.agentClient.asInternalUser.getLatestAgentAvailableDockerImageVersion(); const fullAgentConfigMap = await agentPolicyService.getFullAgentConfigMap( soClient, - request.params.agentPolicyId, + agentPolicyId, agentVersion, { standalone: request.query.standalone === true } ); - if (fullAgentConfigMap) { - const body = fullAgentConfigMap; - const headers: ResponseHeaders = { - 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent-standalone-kubernetes.yml"`, - }; - return response.ok({ - body, - headers, - }); - } else { + if (!fullAgentConfigMap) { return response.customError({ statusCode: 404, body: { message: 'Agent config map not found' }, }); } + const body = fullAgentConfigMap; + const headers: ResponseHeaders = { + 'content-type': 'text/x-yaml', + 'content-disposition': `attachment; filename="elastic-agent-standalone-kubernetes.yml"`, + }; + return response.ok({ + body, + headers, + }); } else { const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId, { standalone: request.query.standalone === true, }); - if (fullAgentPolicy) { - const body = fullAgentPolicyToYaml(fullAgentPolicy, dump); - const headers: ResponseHeaders = { - 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent.yml"`, - }; - return response.ok({ - body, - headers, - }); - } else { + if (!fullAgentPolicy) { return response.customError({ statusCode: 404, body: { message: 'Agent policy not found' }, }); } + const body = fullAgentPolicyToYaml(fullAgentPolicy, dump); + const headers: ResponseHeaders = { + 'content-type': 'text/x-yaml', + 'content-disposition': `attachment; filename="elastic-agent.yml"`, + }; + return response.ok({ + body, + headers, + }); } }; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.test.ts index 7c6f5f21c024c..17ae3d2ac0b40 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.test.ts @@ -705,6 +705,9 @@ describe('generateOtelcolConfig', () => { 'traces/otlp': { receivers: ['otlp'], }, + 'profiles/otlp': { + receivers: ['otlp'], + }, }, }, }, @@ -754,6 +757,9 @@ describe('generateOtelcolConfig', () => { traces: { receivers: ['otlp'], }, + profiles: { + receivers: ['otlp'], + }, }, }, }, @@ -823,6 +829,16 @@ describe('generateOtelcolConfig', () => { ], }, ], + profile_statements: [ + { + context: 'profile', + statements: [ + 'set(attributes["data_stream.type"], "profiles")', + 'set(attributes["data_stream.dataset"], "multidataset")', + 'set(attributes["data_stream.namespace"], "default")', + ], + }, + ], }); }); @@ -868,6 +884,16 @@ describe('generateOtelcolConfig', () => { ], }, ], + profile_statements: [ + { + context: 'profile', + statements: [ + 'set(attributes["data_stream.type"], "profiles")', + 'set(attributes["data_stream.dataset"], "multidataset")', + 'set(attributes["data_stream.namespace"], "default")', + ], + }, + ], }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.ts b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.ts index ac3818d1089be..c386dd8d25945 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/otel_collector.ts @@ -171,6 +171,19 @@ function generateOtelTypeTransforms( }, ], }; + case 'profiles': + return { + profile_statements: [ + { + context: 'profile', + statements: [ + `set(attributes["data_stream.type"], "profiles")`, + `set(attributes["data_stream.dataset"], "${dataset}")`, + `set(attributes["data_stream.namespace"], "${namespace}")`, + ], + }, + ], + }; default: throw new FleetError(`unexpected data stream type ${type}`); } @@ -182,7 +195,7 @@ export function extractSignalTypesFromPipelines( const signalTypes = new Set(); Object.keys(pipelines).forEach((pipelineId) => { const signalType = getSignalType(pipelineId); - if (signalType && ['logs', 'metrics', 'traces'].includes(signalType)) { + if (signalType && ['logs', 'metrics', 'traces', 'profiles'].includes(signalType)) { signalTypes.add(signalType); } }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/server/services/agent_policy.ts index d12a07e838e4b..eb4a6d1317eba 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agent_policy.ts @@ -1903,6 +1903,30 @@ class AgentPolicyService { return res.hits.hits[0]._source; } + public async getFleetServerPolicy( + esClient: ElasticsearchClient, + agentPolicyId: string, + revision: number + ) { + const res = await esClient.search({ + index: AGENT_POLICY_INDEX, + ignore_unavailable: true, + rest_total_hits_as_int: true, + query: { + bool: { + filter: [{ term: { policy_id: agentPolicyId } }, { term: { revision_idx: revision } }], + }, + }, + size: 1, + }); + + if ((res.hits.total as number) === 0) { + return null; + } + + return res.hits.hits[0]._source; + } + public async getFullAgentConfigMap( soClient: SavedObjectsClientContract, id: string, diff --git a/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts index 0cdc549e5653b..5e5c77026c8cd 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts @@ -328,6 +328,15 @@ export const AgentPolicyResponseSchema = AgentPolicySchema.extends({ agents: schema.maybe(schema.number()), unprivileged_agents: schema.maybe(schema.number()), fips_agents: schema.maybe(schema.number()), + agents_per_version: schema.maybe( + schema.arrayOf( + schema.object({ + version: schema.string(), + count: schema.number(), + }), + { maxSize: 1000 } + ) + ), is_protected: schema.boolean({ meta: { description: diff --git a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/agent_policy.ts index 542c2bae2d631..613922bc5f311 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/agent_policy.ts @@ -166,9 +166,29 @@ export const GetFullAgentPolicyRequestSchema = { agentPolicyId: schema.string(), }), query: schema.object({ - download: schema.maybe(schema.boolean()), - standalone: schema.maybe(schema.boolean()), - kubernetes: schema.maybe(schema.boolean()), + download: schema.maybe( + schema.boolean({ + meta: { description: 'If true, returns the policy as a downloadable file' }, + }) + ), + standalone: schema.maybe( + schema.boolean({ + meta: { description: 'If true, returns the policy formatted for standalone agents' }, + }) + ), + kubernetes: schema.maybe( + schema.boolean({ + meta: { description: 'If true, returns the policy formatted for Kubernetes deployment' }, + }) + ), + revision: schema.maybe( + schema.number({ + meta: { + description: + 'If provided, returns the policy at the specified revision. Cannot be used with standalone or kubernetes flags.', + }, + }) + ), }), }; diff --git a/x-pack/platform/plugins/shared/fleet/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/shared/fleet/test/scout/.meta/ui/standard.json index 7098d73d9d2e3..2a225919305dd 100644 --- a/x-pack/platform/plugins/shared/fleet/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/plugins/shared/fleet/test/scout/.meta/ui/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-09T18:23:02.003Z", - "sha1": "7981c2762e9d2285b5eb6d07193edb20ca0a294e", + "sha1": "8825cfbf28dd674e252bbabde6c5205007bb49cf", "tests": [ { "id": "0ab9aa6583f6769-b18346ef7c3b1d3", @@ -49,12 +48,11 @@ "title": "Copy integration can copy nginx package policy", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic" + "@local-stateful-classic" ], "location": { "file": "x-pack/platform/plugins/shared/fleet/test/scout/ui/tests/copy_integration.spec.ts", - "line": 121, + "line": 120, "column": 7 } }, @@ -105,8 +103,7 @@ "title": "When the user has Fleet Agents Read built-in role is accessible but user cannot perform any write actions on agent tabs", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic" + "@local-stateful-classic" ], "location": { "file": "x-pack/platform/plugins/shared/fleet/test/scout/ui/tests/privileges_fleet_agents_read_integrations_none.spec.ts", @@ -119,8 +116,7 @@ "title": "When the user has Fleet Agents Read built-in role is accessible and user only see agents tab", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic" + "@local-stateful-classic" ], "location": { "file": "x-pack/platform/plugins/shared/fleet/test/scout/ui/tests/privileges_fleet_agents_read_integrations_none.spec.ts", diff --git a/x-pack/platform/plugins/shared/global_search/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/shared/global_search/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..25fafe13862f6 --- /dev/null +++ b/x-pack/platform/plugins/shared/global_search/test/scout/.meta/ui/standard.json @@ -0,0 +1,327 @@ +{ + "sha1": "332591b9388a3598288edf72e3a0d6b81a6bf41f", + "tests": [ + { + "id": "d421c5332514126-a4adeccca38aa8c", + "title": "GlobalSearchBar shows the popover on focus", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 31, + "column": 7 + } + }, + { + "id": "d421c5332514126-67e28311fef9fab", + "title": "GlobalSearchBar redirects to the correct page", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 39, + "column": 7 + } + }, + { + "id": "d421c5332514126-c2823df2262e7c1", + "title": "GlobalSearchBar shows a suggestion when searching for a term matching a type", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 47, + "column": 7 + } + }, + { + "id": "d421c5332514126-21d2ca760d238e0", + "title": "GlobalSearchBar shows a suggestion when searching for a term matching a tag name", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 66, + "column": 7 + } + }, + { + "id": "d421c5332514126-8e61059251e958c", + "title": "GlobalSearchBar allows to filter by type", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 86, + "column": 7 + } + }, + { + "id": "d421c5332514126-f9706f331195def", + "title": "GlobalSearchBar allows to filter by multiple types", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 98, + "column": 7 + } + }, + { + "id": "d421c5332514126-6ac245c5cb099ea", + "title": "GlobalSearchBar allows to filter by tag", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 114, + "column": 7 + } + }, + { + "id": "d421c5332514126-3c87a86facd499e", + "title": "GlobalSearchBar allows to filter by multiple tags", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 124, + "column": 7 + } + }, + { + "id": "d421c5332514126-7ef54974bae4e0c", + "title": "GlobalSearchBar allows to filter by type and tag", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 135, + "column": 7 + } + }, + { + "id": "d421c5332514126-2b779af829fe3be", + "title": "GlobalSearchBar allows to filter by multiple types and tags", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 145, + "column": 7 + } + }, + { + "id": "d421c5332514126-dbfd2a827c9d9f8", + "title": "GlobalSearchBar allows to filter by term and type", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 158, + "column": 7 + } + }, + { + "id": "d421c5332514126-79c6f18f466fe89", + "title": "GlobalSearchBar allows to filter by term and tag", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 164, + "column": 7 + } + }, + { + "id": "d421c5332514126-41766dbec95bec9", + "title": "GlobalSearchBar allows to filter by tags containing special characters", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 170, + "column": 7 + } + }, + { + "id": "d421c5332514126-870de8c49fe9065", + "title": "GlobalSearchBar returns no results when searching for an unknown tag", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 178, + "column": 7 + } + }, + { + "id": "d421c5332514126-dfcad4f1223a8c2", + "title": "GlobalSearchBar returns no results when searching for an unknown type", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_bar.spec.ts", + "line": 184, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-1717e257a702a3d", + "title": "GlobalSearch providers SavedObject provider - can search for data views", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 31, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-fd0823150785898", + "title": "GlobalSearch providers SavedObject provider - can search for visualizations", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 38, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-ef4a57e44be8500", + "title": "GlobalSearch providers SavedObject provider - can search for maps", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 45, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-a5e295be266d507", + "title": "GlobalSearch providers SavedObject provider - can search for dashboards", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 52, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-4497b09216793f6", + "title": "GlobalSearch providers SavedObject provider - returns all objects matching the search", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 59, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-1a7099510baedcc", + "title": "GlobalSearch providers SavedObject provider - can search by prefix", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 70, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-d4914a9adde7697", + "title": "GlobalSearch providers Applications provider - can search for root-level applications", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 77, + "column": 7 + } + }, + { + "id": "27dffa2763fb1ba-94c8b8590358376", + "title": "GlobalSearch providers Applications provider - can search for application deep links", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/global_search/test/scout/ui/tests/global_search_providers.spec.ts", + "line": 86, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/helpers/http_requests.ts index bb0925741fa90..d2b465a39a63c 100644 --- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -241,13 +241,47 @@ const registerHttpRequestMockHelpers = ( const setInferenceModels = (response?: HttpResponse, error?: ResponseError) => mockResponse('GET', `${API_BASE_PATH}/inference/all`, response, error); + const setUserStartPrivilegesResponse = ( indexName: string, response?: HttpResponse, error?: ResponseError ) => { - mockResponse('GET', `${API_BASE_PATH}/start_privileges/${indexName}`, response, error); + mockResponse( + 'GET', + `${API_BASE_PATH}/start_privileges/${encodeURIComponent(indexName)}`, + response, + error + ); }; + + const setLoadIndexDocumentsSampleResponse = ( + indexName: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse( + 'GET', + `${INTERNAL_API_BASE_PATH}/indices/${encodeURIComponent(indexName)}/sample`, + response, + error + ); + + const setDeleteDocumentResponse = ( + indexName: string, + id: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse( + 'DELETE', + `${INTERNAL_API_BASE_PATH}/indices/${encodeURIComponent( + indexName + )}/documents/${encodeURIComponent(id)}`, + response, + error + ); + return { setLoadTemplatesResponse, setLoadIndicesStatsResponse, @@ -281,6 +315,8 @@ const registerHttpRequestMockHelpers = ( setInferenceModels, setGetMatchingDataStreams, setUserStartPrivilegesResponse, + setLoadIndexDocumentsSampleResponse, + setDeleteDocumentResponse, }; }; diff --git a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/mocks.ts b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/mocks.ts index 5a6d0b4f93387..e457c5c396bfc 100644 --- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/mocks.ts +++ b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/mocks.ts @@ -123,3 +123,12 @@ export const testUserStartPrivilegesResponse = { canManageIndex: true, }, }; + +export const testIndexDocumentsSampleResponse = { + results: [ + { + _id: '1', + _source: { field: 'value' }, + }, + ], +}; diff --git a/x-pack/platform/plugins/shared/index_management/moon.yml b/x-pack/platform/plugins/shared/index_management/moon.yml index 5fc906a07908b..6de4278f890a2 100644 --- a/x-pack/platform/plugins/shared/index_management/moon.yml +++ b/x-pack/platform/plugins/shared/index_management/moon.yml @@ -77,6 +77,7 @@ dependsOn: - '@kbn/test-eui-helpers' - '@kbn/core-elasticsearch-server' - '@kbn/cloud' + - '@kbn/search-index-documents' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/index_management/public/application/services/api.ts b/x-pack/platform/plugins/shared/index_management/public/application/services/api.ts index 09fdc05d7bb36..f4572b3a85701 100644 --- a/x-pack/platform/plugins/shared/index_management/public/application/services/api.ts +++ b/x-pack/platform/plugins/shared/index_management/public/application/services/api.ts @@ -7,7 +7,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; import type { SerializedEnrichPolicy } from '@kbn/index-management-shared-types'; -import type { IndicesStatsResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { IndicesStatsResponse, SearchHit } from '@elastic/elasticsearch/lib/api/types'; import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; import type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; import type { ReindexService } from '@kbn/reindex-service-plugin/public'; @@ -560,6 +560,22 @@ export function createIndex(indexName: string, indexMode: string) { }); } +export function useLoadIndexDocumentsSample(indexName: string) { + return useRequest<{ results: SearchHit[] }>({ + path: `${INTERNAL_API_BASE_PATH}/indices/${encodeURIComponent(indexName)}/sample`, + method: 'get', + }); +} + +export async function deleteDocuments(indexName: string, id: string) { + return sendRequest({ + path: `${INTERNAL_API_BASE_PATH}/indices/${encodeURIComponent( + indexName + )}/documents/${encodeURIComponent(id)}`, + method: 'delete', + }); +} + export function updateIndexMappings(indexName: string, newFields: Fields) { return sendRequest({ path: `${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`, diff --git a/x-pack/platform/plugins/shared/index_management/server/lib/fetch_indices_status.ts b/x-pack/platform/plugins/shared/index_management/server/lib/fetch_indices_status.ts index 65919e4421ea5..541499020b274 100644 --- a/x-pack/platform/plugins/shared/index_management/server/lib/fetch_indices_status.ts +++ b/x-pack/platform/plugins/shared/index_management/server/lib/fetch_indices_status.ts @@ -17,7 +17,7 @@ export async function fetchUserStartPrivileges( index: [ { names: [indexName], - privileges: ['manage'], + privileges: ['manage', 'delete'], }, ], }); diff --git a/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_delete_document_route.test.ts b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_delete_document_route.test.ts new file mode 100644 index 0000000000000..366ea55889f93 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_delete_document_route.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 { registerDeleteDocumentRoute } from './register_delete_document_route'; +import { addInternalBasePath } from '..'; +import type { RequestMock } from '../../../test/helpers'; +import { RouterMock, routeDependencies, withStubbedHandleEsError } from '../../../test/helpers'; + +const router = new RouterMock(); +const deleteMock = router.getMockESApiFn('delete'); + +const mockRequest: RequestMock = { + method: 'delete', + path: addInternalBasePath('/indices/{indexName}/documents/{id}'), + params: { indexName: 'my-index', id: 'doc-1' }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + registerDeleteDocumentRoute({ + ...routeDependencies, + router, + }); +}); + +describe('Delete document API', () => { + describe('DELETE /internal/index_management/indices/{indexName}/documents/{id}', () => { + it('should call the ES delete API with the correct parameters', async () => { + deleteMock.mockResolvedValue({}); + + await router.runRequest(mockRequest); + + expect(deleteMock).toHaveBeenCalledWith({ index: 'my-index', id: 'doc-1' }); + }); + + it('should return ok on success', async () => { + deleteMock.mockResolvedValue({}); + + const res = await router.runRequest(mockRequest); + + expect(res).toEqual({ status: 200, options: {} }); + }); + + it('should handle errors via handleEsError', async () => { + const restore = withStubbedHandleEsError(routeDependencies); + registerDeleteDocumentRoute({ ...routeDependencies, router }); + + deleteMock.mockRejectedValue(new Error('Internal Server Error')); + + const res = await router.runRequest(mockRequest); + expect(res).toEqual({ status: 500, options: {} }); + + restore(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_delete_document_route.ts b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_delete_document_route.ts new file mode 100644 index 0000000000000..a62f0a28c2eff --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_delete_document_route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import type { RouteDependencies } from '../../../types'; +import { addInternalBasePath } from '..'; + +export function registerDeleteDocumentRoute({ router, lib: { handleEsError } }: RouteDependencies) { + router.delete( + { + path: addInternalBasePath('/indices/{indexName}/documents/{id}'), + security: { + authz: { + enabled: false, + reason: 'Relies on es client for authorization', + }, + }, + validate: { + params: schema.object({ + indexName: schema.string(), + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = (await context.core).elasticsearch.client.asCurrentUser; + const { indexName, id } = request.params; + + try { + await client.delete({ index: indexName, id }); + return response.ok(); + } catch (error) { + return handleEsError({ error, response }); + } + } + ); +} diff --git a/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_documents_sample_route.test.ts b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_documents_sample_route.test.ts new file mode 100644 index 0000000000000..7f4729eb3a3e8 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_documents_sample_route.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { registerDocumentsSampleRoute } from './register_documents_sample_route'; +import { addInternalBasePath } from '..'; +import type { RequestMock } from '../../../test/helpers'; +import { RouterMock, routeDependencies, withStubbedHandleEsError } from '../../../test/helpers'; +import { DEFAULT_DOCS_PER_PAGE } from '@kbn/search-index-documents'; + +const router = new RouterMock(); +const searchMock = router.getMockESApiFn('search'); + +const mockRequest: RequestMock = { + method: 'get', + path: addInternalBasePath('/indices/{indexName}/sample'), + params: { indexName: 'my-index' }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + registerDocumentsSampleRoute({ + ...routeDependencies, + router, + }); +}); + +describe('Documents sample API', () => { + describe('GET /internal/index_management/indices/{indexName}/sample', () => { + it('should return search results', async () => { + searchMock.mockResolvedValue({ + hits: { + hits: [{ _id: '1', _source: { field: 'value' } }], + }, + }); + + const res = await router.runRequest(mockRequest); + + expect(searchMock).toHaveBeenCalledWith({ + index: 'my-index', + size: DEFAULT_DOCS_PER_PAGE, + }); + + expect(res).toEqual({ + body: { + results: [{ _id: '1', _source: { field: 'value' } }], + }, + }); + }); + + it('should handle errors via handleEsError', async () => { + const restore = withStubbedHandleEsError(routeDependencies); + registerDocumentsSampleRoute({ ...routeDependencies, router }); + + searchMock.mockRejectedValue(new Error('Internal Server Error')); + + const res = await router.runRequest(mockRequest); + expect(res).toEqual({ status: 500, options: {} }); + + restore(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_documents_sample_route.ts b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_documents_sample_route.ts new file mode 100644 index 0000000000000..6c3667f487c58 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_documents_sample_route.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { DEFAULT_DOCS_PER_PAGE } from '@kbn/search-index-documents'; + +import type { RouteDependencies } from '../../../types'; +import { addInternalBasePath } from '..'; + +export function registerDocumentsSampleRoute({ + router, + lib: { handleEsError }, +}: RouteDependencies) { + router.get( + { + path: addInternalBasePath('/indices/{indexName}/sample'), + security: { + authz: { + enabled: false, + reason: 'Relies on es client for authorization', + }, + }, + validate: { + params: schema.object({ + indexName: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = (await context.core).elasticsearch.client.asCurrentUser; + const { indexName } = request.params; + + try { + const searchResults = await client.search({ + index: indexName, + size: DEFAULT_DOCS_PER_PAGE, + }); + + return response.ok({ + body: { + results: searchResults.hits.hits, + }, + }); + } catch (error) { + return handleEsError({ error, response }); + } + } + ); +} diff --git a/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_indices_routes.ts b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_indices_routes.ts index 8a37fcf3ec851..dc78b6258fde5 100644 --- a/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_indices_routes.ts +++ b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_indices_routes.ts @@ -19,9 +19,12 @@ import { registerDeleteRoute } from './register_delete_route'; import { registerGetRoute } from './register_get_route'; import { registerCreateRoute } from './register_create_route'; import { registerPostIndexDocCountRoute } from './register_post_index_doc_count'; +import { registerDocumentsSampleRoute } from './register_documents_sample_route'; +import { registerDeleteDocumentRoute } from './register_delete_document_route'; import { registerIndicesGet } from './indices_get'; import { registerIndicesStats } from './indices_stats'; +import { registerUserStatusPrivilegeRoutes } from './register_user_status_route'; export function registerIndicesRoutes(dependencies: RouteDependencies) { registerClearCacheRoute(dependencies); @@ -38,4 +41,7 @@ export function registerIndicesRoutes(dependencies: RouteDependencies) { registerPostIndexDocCountRoute(dependencies); registerIndicesGet(dependencies); registerIndicesStats(dependencies); + registerDocumentsSampleRoute(dependencies); + registerDeleteDocumentRoute(dependencies); + registerUserStatusPrivilegeRoutes(dependencies); } diff --git a/x-pack/platform/plugins/shared/index_management/server/routes/api/mapping/register_user_status_route.ts b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_user_status_route.ts similarity index 94% rename from x-pack/platform/plugins/shared/index_management/server/routes/api/mapping/register_user_status_route.ts rename to x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_user_status_route.ts index 500dc6fd94536..2e2e2fe7ce76f 100644 --- a/x-pack/platform/plugins/shared/index_management/server/routes/api/mapping/register_user_status_route.ts +++ b/x-pack/platform/plugins/shared/index_management/server/routes/api/indices/register_user_status_route.ts @@ -42,6 +42,7 @@ export function registerUserStatusPrivilegeRoutes({ body: { privileges: { canManageIndex: securityCheck?.index?.[indexName]?.manage ?? false, + canDeleteDocuments: securityCheck?.index?.[indexName]?.delete ?? false, }, }, headers: { 'content-type': 'application/json' }, diff --git a/x-pack/platform/plugins/shared/index_management/server/routes/api/mapping/register_index_mapping_route.ts b/x-pack/platform/plugins/shared/index_management/server/routes/api/mapping/register_index_mapping_route.ts index d25f2c893330b..9345595ecbf55 100644 --- a/x-pack/platform/plugins/shared/index_management/server/routes/api/mapping/register_index_mapping_route.ts +++ b/x-pack/platform/plugins/shared/index_management/server/routes/api/mapping/register_index_mapping_route.ts @@ -8,10 +8,8 @@ import type { RouteDependencies } from '../../../types'; import { registerGetMappingRoute } from './register_mapping_route'; import { registerUpdateMappingRoute } from './register_update_mapping_route'; -import { registerUserStatusPrivilegeRoutes } from './register_user_status_route'; export function registerIndexMappingRoutes(dependencies: RouteDependencies) { registerGetMappingRoute(dependencies); registerUpdateMappingRoute(dependencies); - registerUserStatusPrivilegeRoutes(dependencies); } diff --git a/x-pack/platform/plugins/shared/index_management/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/shared/index_management/test/scout/.meta/ui/standard.json index 2825c01f9368c..a12d22135150b 100644 --- a/x-pack/platform/plugins/shared/index_management/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/plugins/shared/index_management/test/scout/.meta/ui/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-06T14:35:12.875Z", "sha1": "1284fc5f1ff1408ac248272036442119308302bb", "tests": [ { diff --git a/x-pack/platform/plugins/shared/index_management/tsconfig.json b/x-pack/platform/plugins/shared/index_management/tsconfig.json index f3b6940b20927..8556523fb1d0a 100644 --- a/x-pack/platform/plugins/shared/index_management/tsconfig.json +++ b/x-pack/platform/plugins/shared/index_management/tsconfig.json @@ -71,7 +71,8 @@ "@kbn/deeplinks-management", "@kbn/test-eui-helpers", "@kbn/core-elasticsearch-server", - "@kbn/cloud" + "@kbn/cloud", + "@kbn/search-index-documents" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/gemini/utils.test.ts b/x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/gemini/utils.test.ts new file mode 100644 index 0000000000000..37e3ef815d723 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/gemini/utils.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mustUseThoughtSignature } from './utils'; + +describe('mustUseThoughtSignature', () => { + it('returns false when modelName is undefined', () => { + expect(mustUseThoughtSignature(undefined)).toBe(false); + }); + + it('returns false for non-gemini-3 models', () => { + expect(mustUseThoughtSignature('gemini-2.0-flash')).toBe(false); + expect(mustUseThoughtSignature('gemini-1.5-pro')).toBe(false); + expect(mustUseThoughtSignature('some-other-model')).toBe(false); + }); + + it('returns true for gemini-3-flash', () => { + expect(mustUseThoughtSignature('gemini-3-flash')).toBe(true); + }); + + it('returns true for gemini-3-pro', () => { + expect(mustUseThoughtSignature('gemini-3-pro')).toBe(true); + }); + + it('returns true for gemini-3.1-pro-preview', () => { + expect(mustUseThoughtSignature('gemini-3.1-pro-preview')).toBe(true); + }); + + it('returns true for future gemini-3.x variants', () => { + expect(mustUseThoughtSignature('gemini-3.2-flash')).toBe(true); + expect(mustUseThoughtSignature('gemini-3.5-pro')).toBe(true); + }); + + it('is case insensitive', () => { + expect(mustUseThoughtSignature('Gemini-3-Flash')).toBe(true); + expect(mustUseThoughtSignature('GEMINI-3.1-PRO-PREVIEW')).toBe(true); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/gemini/utils.ts b/x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/gemini/utils.ts index 78b245b40b023..3775fae8123ec 100644 --- a/x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/gemini/utils.ts +++ b/x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/gemini/utils.ts @@ -16,5 +16,5 @@ export const mustUseThoughtSignature = (modelName: string | undefined): boolean return isGemini3(modelName); }; -// gemini-3-flash or gemini-3-pro -const isGemini3 = (modelName: string) => modelName.toLowerCase().includes('gemini-3-'); +// matches gemini-3-flash, gemini-3-pro, gemini-3.1-pro-preview, etc. +const isGemini3 = (modelName: string) => /gemini-3[\.\-]/i.test(modelName); diff --git a/x-pack/platform/plugins/shared/lens/kibana.jsonc b/x-pack/platform/plugins/shared/lens/kibana.jsonc index 9e66c83428cf7..0d52c6d266d57 100644 --- a/x-pack/platform/plugins/shared/lens/kibana.jsonc +++ b/x-pack/platform/plugins/shared/lens/kibana.jsonc @@ -21,7 +21,6 @@ "urlForwarding", "visualizations", "uiActions", - "uiActionsEnhanced", "embeddable", "share", "presentationUtil", @@ -41,7 +40,6 @@ "expressionLegacyMetricVis", "expressionPartitionVis", "usageCollection", - "embeddableEnhanced", "taskManager", "globalSearch", "savedObjectsTagging", diff --git a/x-pack/platform/plugins/shared/lens/moon.yml b/x-pack/platform/plugins/shared/lens/moon.yml index 61b2d3063b5c6..afbee114622e2 100644 --- a/x-pack/platform/plugins/shared/lens/moon.yml +++ b/x-pack/platform/plugins/shared/lens/moon.yml @@ -32,7 +32,6 @@ dependsOn: - '@kbn/url-forwarding-plugin' - '@kbn/visualizations-plugin' - '@kbn/ui-actions-plugin' - - '@kbn/ui-actions-enhanced-plugin' - '@kbn/share-plugin' - '@kbn/usage-collection-plugin' - '@kbn/saved-objects-plugin' @@ -119,7 +118,6 @@ dependsOn: - '@kbn/licensing-plugin' - '@kbn/react-kibana-context-render' - '@kbn/react-kibana-mount' - - '@kbn/embeddable-enhanced-plugin' - '@kbn/es-types' - '@kbn/esql-datagrid' - '@kbn/transpose-utils' diff --git a/x-pack/platform/plugins/shared/lens/public/async_services.ts b/x-pack/platform/plugins/shared/lens/public/async_services.ts index 31a0a23f84022..da5bfe91011eb 100644 --- a/x-pack/platform/plugins/shared/lens/public/async_services.ts +++ b/x-pack/platform/plugins/shared/lens/public/async_services.ts @@ -52,7 +52,6 @@ export * from './app_plugin/save_modal_container'; export * from './chart_info_api'; export * from './utils'; -export * from './trigger_actions/open_in_discover_helpers'; export * from './trigger_actions/open_lens_config/in_app_embeddable_edit/in_app_embeddable_edit_action_helpers'; export { EditLensEmbeddableAction } from './trigger_actions/open_lens_config/in_app_embeddable_edit/in_app_embeddable_edit_action'; export { getAddLensPanelAction } from './trigger_actions/add_lens_panel_action'; @@ -63,3 +62,5 @@ export { visualizeFieldAction } from './trigger_actions/visualize_field_actions' export { deserializeState } from './react_embeddable/helper'; export * from './react_embeddable/lens_embeddable'; +export { createOpenInDiscoverAction } from './trigger_actions/open_in_discover_action'; +export { getDiscoverDrilldown } from './drilldowns/get_discover_drilldown'; diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.test.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.test.ts index 5961b8b0f9eec..2517574132686 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.test.ts @@ -215,98 +215,6 @@ describe('generateEsqlQuery', () => { ); }); - it('should return failure with non_utc_timezone reason if timezone is not UTC', () => { - uiSettings.get.mockImplementation((key: string) => { - if (key === 'dateFormat:tz') return 'America/Chicago'; - return defaultUiSettingsGet(key); - }); - - const result = generateEsqlQuery( - [ - [ - '1', - { - operationType: 'date_histogram', - sourceField: 'order_date', - label: 'Date histogram', - dataType: 'date', - isBucketed: true, - interval: 'auto', - }, - ], - [ - '2', - { - operationType: 'count', - sourceField: 'records', - label: 'Count', - dataType: 'number', - isBucketed: false, - }, - ], - ], - mockLayer, - mockIndexPattern, - uiSettings, - mockDateRange, - new Date() - ); - - expect(result).toEqual({ - success: false, - reason: 'non_utc_timezone', - }); - }); - - it('should work with iana timezones that fall under UTC+0', () => { - uiSettings.get.mockImplementation((key: string) => { - // There are only few countries that falls under UTC all year round, others just fall into that configuration half hear when not in DST - if (key === 'dateFormat:tz') return 'Atlantic/Reykjavik'; - return defaultUiSettingsGet(key); - }); - - const result = generateEsqlQuery( - [ - [ - '1', - { - operationType: 'date_histogram', - sourceField: 'order_date', - label: 'Date histogram', - dataType: 'date', - isBucketed: true, - interval: 'auto', - }, - ], - [ - '2', - { - operationType: 'count', - sourceField: 'records', - label: 'Count', - dataType: 'number', - isBucketed: false, - }, - ], - ], - mockLayer, - mockIndexPattern, - uiSettings, - mockDateRange, - new Date() - ); - - expect(result).toEqual( - expect.objectContaining({ - success: true, - esql: `FROM myIndexPattern - | WHERE order_date >= ?_tstart AND order_date <= ?_tend - | STATS bucket_0_0 = COUNT(*) - BY order_date = BUCKET(order_date, 30 minutes)`, - }) - ); - }); - it('should preserve user-configured format (e.g., currency) in esAggsIdMap', () => { uiSettings.get.mockImplementation((key: string) => { return defaultUiSettingsGet(key); diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.ts index 24ab7f8efe4b6..9d73b4de1e623 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/generate_esql_query.ts @@ -8,7 +8,7 @@ import { esql } from '@kbn/esql-language'; import type { IUiSettingsClient } from '@kbn/core/public'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; -import { getCalculateAutoTimeExpression, getUserTimeZone } from '@kbn/data-plugin/common'; +import { getCalculateAutoTimeExpression } from '@kbn/data-plugin/common'; import { convertIntervalToEsInterval } from '@kbn/data-plugin/public'; import moment from 'moment'; import { partition } from 'lodash'; @@ -105,12 +105,6 @@ export function generateEsqlQuery( // esql mode variables const partialRows = true; - const timeZone = getUserTimeZone((key) => uiSettings.get(key), true); - const utcOffset = moment.tz(timeZone).utcOffset() / 60; - if (utcOffset !== 0) { - return getEsqlQueryFailedResult('non_utc_timezone'); - } - // Check for unsupported column features in layer.columns for (const col of Object.values(layer.columns)) { if (col.operationType === 'formula') { diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql_failure_reasons.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql_failure_reasons.ts index 0db2665bfb6a2..08de632bd1ffc 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql_failure_reasons.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql_failure_reasons.ts @@ -14,7 +14,6 @@ import { i18n } from '@kbn/i18n'; export type EsqlConversionFailureReason = | 'multi_layer_not_supported' | 'trend_line_not_supported' - | 'non_utc_timezone' | 'formula_not_supported' | 'time_shift_not_supported' | 'runtime_field_not_supported' @@ -41,10 +40,6 @@ export const esqlConversionFailureReasonMessages: Record +> = (props) => { + return ( + + + props.onChange({ ...props.state, open_in_new_tab: event.target.checked }) + } + data-test-subj="openInDiscoverDrilldownOpenInNewTab" + /> + + ); +}; diff --git a/x-pack/platform/plugins/shared/lens/public/drilldowns/get_discover_drilldown.ts b/x-pack/platform/plugins/shared/lens/public/drilldowns/get_discover_drilldown.ts new file mode 100644 index 0000000000000..d2f6c2629fab2 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/drilldowns/get_discover_drilldown.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ApplicationStart } from '@kbn/core/public'; +import type { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; +import { i18n } from '@kbn/i18n'; +import type { DataViewsService } from '@kbn/data-views-plugin/public'; +import type { LensApi } from '@kbn/lens-common-2'; +import type { DrilldownDefinition } from '@kbn/embeddable-plugin/public'; +import { apiIsOfType, type EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { DISCOVER_DRILLDOWN_SUPPORTED_TRIGGERS, DOC_TYPE } from '../../common/constants'; +import { DiscoverDrilldownEditor } from './editor'; +import type { DiscoverDrilldownState } from '../../server'; +import type { DiscoverAppLocator } from '../trigger_actions/open_in_discover_helpers'; +import { getHref, getLocation, isCompatible } from '../trigger_actions/open_in_discover_helpers'; + +export type ExecutionContext = ApplyGlobalFilterActionContext & { + embeddable: LensApi; +}; + +export type SetupContext = EmbeddableApiContext; + +export function getDiscoverDrilldown(deps: { + locator: () => DiscoverAppLocator | undefined; + dataViews: () => Pick; + hasDiscoverAccess: () => boolean; + application: () => ApplicationStart; +}): DrilldownDefinition { + return { + displayName: i18n.translate('xpack.lens.app.exploreDataInDiscoverDrilldown', { + defaultMessage: 'Open in Discover', + }), + euiIcon: 'discoverApp', + supportedTriggers: DISCOVER_DRILLDOWN_SUPPORTED_TRIGGERS, + action: { + execute: async (drilldownState: DiscoverDrilldownState, context: ExecutionContext) => { + if (drilldownState.open_in_new_tab) { + window.open( + await getHref({ + locator: deps.locator(), + dataViews: deps.dataViews(), + hasDiscoverAccess: deps.hasDiscoverAccess(), + ...context, + embeddable: context.embeddable, + }), + '_blank' + ); + } else { + const { app, path, state } = await getLocation({ + locator: deps.locator(), + dataViews: deps.dataViews(), + hasDiscoverAccess: deps.hasDiscoverAccess(), + ...context, + embeddable: context.embeddable, + }); + await deps.application().navigateToApp(app, { path, state }); + } + }, + isCompatible: async ( + DrilldownState: DiscoverDrilldownState, + { embeddable }: ExecutionContext + ) => { + return isCompatible({ + hasDiscoverAccess: deps.hasDiscoverAccess(), + locator: deps.locator(), + dataViews: deps.dataViews(), + embeddable, + }); + }, + getHref: (drilldownState: DiscoverDrilldownState, context: ExecutionContext) => + getHref({ + locator: deps.locator(), + dataViews: deps.dataViews(), + hasDiscoverAccess: deps.hasDiscoverAccess(), + ...context, + embeddable: context.embeddable, + }), + }, + setup: { + Editor: DiscoverDrilldownEditor, + getInitialState: () => ({ + open_in_new_tab: true, + }), + isCompatible: (context: SetupContext) => + deps.hasDiscoverAccess() && apiIsOfType(context.embeddable, DOC_TYPE), + isStateValid: () => true, + order: 8, + }, + }; +} diff --git a/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch/chart_switch.test.tsx b/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch/chart_switch.test.tsx index 00e0056cf792f..a56cea5582a86 100644 --- a/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch/chart_switch.test.tsx +++ b/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch/chart_switch.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { screen, within, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMockVisualization, @@ -258,8 +258,13 @@ describe('chart_switch', () => { } ); + const user = userEvent.setup(); + const openChartSwitch = async () => { - await userEvent.click(screen.getByTestId('lnsChartSwitchPopover')); + await user.click(screen.getByTestId('lnsChartSwitchPopover')); + await waitFor(() => { + expect(screen.getByTestId('lnsChartSwitchList')).toBeInTheDocument(); + }); }; const queryWarningNode = (subType: string) => @@ -269,8 +274,9 @@ describe('chart_switch', () => { return screen.getByTestId(`lnsChartSwitchPopover_${subType}`); }; - const switchToVis = (subType: string) => { - fireEvent.click(getMenuItem(subType)); + const switchToVis = async (subType: string) => { + await user.click(getMenuItem(subType)); + await waitForChartSwitchClosed(); }; const waitForChartSwitchClosed = async () => { @@ -405,14 +411,14 @@ describe('chart_switch', () => { it('should initialize other visualization on switch', async () => { const { openChartSwitch, switchToVis } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(visualizationMap.testVis2.initialize).toHaveBeenCalled(); }); it('should use suggested state if there is a suggestion from the target visualization', async () => { const { store, openChartSwitch, switchToVis } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', @@ -444,7 +450,7 @@ describe('chart_switch', () => { }, ]); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(visualizationMap.testVis2.getSuggestions).toHaveBeenCalled(); expect(visualizationMap.testVis2.initialize).toHaveBeenCalledWith( expect.anything(), @@ -457,7 +463,7 @@ describe('chart_switch', () => { visualizationMap.testVis2.getSuggestions.mockReturnValueOnce([]); const { openChartSwitch, switchToVis, waitForChartSwitchClosed } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(visualizationMap.testVis2.getSuggestions).toHaveBeenCalled(); expect(visualizationMap.testVis2.initialize).toHaveBeenCalledWith( @@ -473,7 +479,7 @@ describe('chart_switch', () => { (frame.datasourceLayers.a?.getTableSpec as jest.Mock).mockReturnValue([]); const { store, switchToVis, openChartSwitch } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(datasourceMap.formBased.removeLayer).toHaveBeenCalledWith({}, 'a'); // from preloaded state expect(store.dispatch).toHaveBeenCalledWith({ @@ -504,7 +510,7 @@ describe('chart_switch', () => { const { openChartSwitch, switchToVis } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(visualizationMap.testVis.getMainPalette).toHaveBeenCalledWith('state from a'); @@ -522,7 +528,7 @@ describe('chart_switch', () => { ); const { openChartSwitch, switchToVis, store } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', @@ -549,7 +555,7 @@ describe('chart_switch', () => { }, }); await openChartSwitch(); - switchToVis('subvisC1'); + await switchToVis('subvisC1'); expect(visualizationMap.testVis3.switchVisualizationType).toHaveBeenCalledWith( 'subvisC1', { @@ -578,7 +584,7 @@ describe('chart_switch', () => { const { store, openChartSwitch, switchToVis } = renderChartSwitch({ layerId: 'b' }); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', @@ -610,7 +616,7 @@ describe('chart_switch', () => { }); await openChartSwitch(); - switchToVis('subvisC3'); + await switchToVis('subvisC3'); expect(visualizationMap.testVis3.switchVisualizationType).toHaveBeenCalledWith( 'subvisC3', { @@ -651,7 +657,7 @@ describe('chart_switch', () => { }, }); await openChartSwitch(); - switchToVis('subvisC3'); + await switchToVis('subvisC3'); expect(visualizationMap.testVis3.switchVisualizationType).toHaveBeenCalledWith( 'subvisC3', @@ -672,7 +678,7 @@ describe('chart_switch', () => { const { openChartSwitch, switchToVis, store } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', @@ -728,7 +734,7 @@ describe('chart_switch', () => { const { openChartSwitch, switchToVis, store } = renderChartSwitch(); await openChartSwitch(); - switchToVis('testVis2'); + await switchToVis('testVis2'); expect(datasourceMap.formBased.removeLayer).toHaveBeenCalledWith({}, 'a'); expect(datasourceMap.formBased.removeLayer).toHaveBeenCalledWith({}, 'b'); diff --git a/x-pack/platform/plugins/shared/lens/public/plugin.ts b/x-pack/platform/plugins/shared/lens/public/plugin.ts index 47d9169e85c3d..652379ecbbc13 100644 --- a/x-pack/platform/plugins/shared/lens/public/plugin.ts +++ b/x-pack/platform/plugins/shared/lens/public/plugin.ts @@ -33,7 +33,6 @@ import { EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public'; import type { UiActionsStart, VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; import { ACTION_VISUALIZE_FIELD, ACTION_VISUALIZE_LENS_FIELD } from '@kbn/ui-actions-plugin/public'; import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import type { AdvancedUiActionsSetup } from '@kbn/ui-actions-enhanced-plugin/public'; import type { SharePluginSetup, ExportShare, SharePluginStart } from '@kbn/share-plugin/public'; import type { ContentManagementPublicSetup, @@ -56,7 +55,6 @@ import type { import type { Start as InspectorStartContract } from '@kbn/inspector-plugin/public'; import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { EventAnnotationServiceType } from '@kbn/event-annotation-components'; import type { EventAnnotationPluginStart } from '@kbn/event-annotation-plugin/public'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; @@ -114,6 +112,7 @@ import type { TagcloudVisualization as TagcloudVisualizationType } from './visua import { APP_ID, + DISCOVER_DRILLDOWN_TYPE, getEditPath, LENS_EMBEDDABLE_TYPE, LENS_ICON, @@ -126,7 +125,6 @@ import { getSaveModalComponent } from './app_plugin/shared/saved_modal_lazy'; import type { SaveModalContainerProps } from './app_plugin/save_modal_container'; import { setupExpressions } from './expressions'; -import { OpenInDiscoverDrilldown } from './trigger_actions/open_in_discover_drilldown'; import type { ChartInfoApi } from './chart_info_api'; import { LensAppLocatorDefinition } from '../common/locator/locator'; @@ -156,7 +154,6 @@ export interface LensPluginSetupDependencies { charts: ChartsPluginSetup; globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; - uiActionsEnhanced: AdvancedUiActionsSetup; share?: SharePluginSetup; contentManagement: ContentManagementPublicSetup; } @@ -187,7 +184,6 @@ export interface LensPluginStartDependencies { contentManagement: ContentManagementPublicStart; serverless?: ServerlessPluginStart; licensing?: LicensingPluginStart; - embeddableEnhanced?: EmbeddableEnhancedPluginStart; fieldsMetadata?: FieldsMetadataPublicStart; cps?: CPSPluginStart; } @@ -334,7 +330,6 @@ export class LensPlugin { charts, globalSearch, usageCollection, - uiActionsEnhanced, share, contentManagement, }: LensPluginSetupDependencies @@ -438,6 +433,16 @@ export class LensPlugin { }), getIconForSavedObject: () => LENS_ICON, }); + + embeddable.registerDrilldown(DISCOVER_DRILLDOWN_TYPE, async () => { + const { getDiscoverDrilldown } = await import('./async_services'); + return getDiscoverDrilldown({ + dataViews: () => this.dataViewsService!, + locator: () => share?.url.locators.get('DISCOVER_APP_LOCATOR'), + hasDiscoverAccess: () => this.hasDiscoverAccess, + application: () => startServices().core.application, + }); + }); } if (share) { @@ -463,15 +468,6 @@ export class LensPlugin { visualizations.registerAlias(lensVisTypeAlias); - uiActionsEnhanced.registerDrilldown( - new OpenInDiscoverDrilldown({ - dataViews: () => this.dataViewsService!, - locator: () => share?.url.locators.get('DISCOVER_APP_LOCATOR'), - hasDiscoverAccess: () => this.hasDiscoverAccess, - application: () => startServices().core.application, - }) - ); - contentManagement.registry.register({ id: LENS_CONTENT_TYPE, version: { @@ -742,9 +738,7 @@ export class LensPlugin { CONTEXT_MENU_TRIGGER, 'ACTION_OPEN_IN_DISCOVER', async () => { - const { createOpenInDiscoverAction } = await import( - './trigger_actions/open_in_discover_action' - ); + const { createOpenInDiscoverAction } = await import('./async_services'); return createOpenInDiscoverAction( discoverLocator, startDependencies.dataViews, diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.test.ts index 97b72a20379da..1e8de182936e2 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.test.ts @@ -190,22 +190,17 @@ describe('Data Loader', () => { }); }); - it('should re-render when dashboard view/edit mode changes if dynamic actions are set', async () => { + it('should re-render when dashboard view/edit mode changes if drilldowns are set', async () => { await expectRerenderOnDataLoader(async ({ api, getState }) => { getState.mockReturnValue({ attributes: getLensAttributesMock(), - enhancements: { - dynamicActions: { - events: [ - // make sure there's at least one event - { - eventId: 'test', - triggers: [], - action: { factoryId: 'test', name: 'testAction', config: {} }, - }, - ], + drilldowns: [ + { + label: 'Go to', + type: 'test', + trigger: 'on_click', }, - }, + ], }); // trigger a change by changing the title in the attributes (api.viewMode$ as BehaviorSubject).next('view'); @@ -214,16 +209,11 @@ describe('Data Loader', () => { }); }); - it('should not re-render when dashboard view/edit mode changes if dynamic actions are not set', async () => { + it('should not re-render when dashboard view/edit mode changes if there are no drilldowns', async () => { await expectRerenderOnDataLoader(async ({ api, getState }) => { getState.mockReturnValue({ attributes: getLensAttributesMock(), - enhancements: { - dynamicActions: { - // empty list should not trigger - events: [], - }, - }, + drilldowns: [], }); // trigger a change by changing the title in the attributes (api.viewMode$ as BehaviorSubject).next('view'); diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts index 831843a3e6679..fa37c485b97a7 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts @@ -43,7 +43,7 @@ import { getLogError } from './expressions/telemetry'; import { getUsedDataViews } from './expressions/update_data_views'; import { getParentContext, getRenderMode } from './helper'; import { addLog } from './logger'; -import { apiHasLensComponentCallbacks } from './type_guards'; +import { apiHasLensComponentCallbacks, apiHasUserMessages } from './type_guards'; import type { LensEmbeddableStartServices } from './types'; import { buildUserMessagesHelpers } from './user_messages/api'; @@ -105,6 +105,9 @@ export function loadEmbeddableData( ? parentApi : ({} as LensPublicCallbacks); + const getConsumerMessages = () => + apiHasUserMessages(parentApi) ? parentApi.userMessages ?? [] : []; + // Some convenience api for the user messaging const { getUserMessages, @@ -114,7 +117,14 @@ export function loadEmbeddableData( updateWarnings, resetMessages, updateMessages, - } = buildUserMessagesHelpers(api, internalApi, services, onBeforeBadgesRender, metaInfo); + } = buildUserMessagesHelpers( + api, + internalApi, + services, + onBeforeBadgesRender, + metaInfo, + getConsumerMessages + ); const dispatchBlockingErrorIfAny = () => { const blockingErrors = getUserMessages(blockingMessageDisplayLocations, { @@ -313,7 +323,7 @@ export function loadEmbeddableData( // make sure to reload on viewMode change api.viewMode$.subscribe(() => { // only reload if drilldowns are set - if (getState().enhancements?.dynamicActions?.events.length) { + if (getState().drilldowns?.length) { reload('viewMode'); } }), diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.test.ts index 12f225c3ed520..122102e6d7abe 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.test.ts @@ -15,9 +15,9 @@ import { getLensRuntimeStateMock, createUnifiedSearchApi, getLensInternalApiMock, - mockDynamicActionsManager, } from '../mocks'; import { createEmptyLensState } from '../helper'; +import { mockDrilldownsManager } from '@kbn/embeddable-plugin/public/mocks'; const DATAVIEW_ID = 'myDataView'; jest.mock('../../app_plugin/show_underlying_data', () => { @@ -67,7 +67,7 @@ function setupActionsApi( nowProvider: { ...services.data.nowProvider, get: jest.fn(() => new Date()) }, }, }, - mockDynamicActionsManager() + mockDrilldownsManager() ); return api; } @@ -76,7 +76,7 @@ describe('Dashboard actions', () => { describe('Drilldowns', () => { it('should expose drilldowns for DSL based visualization', async () => { const api = setupActionsApi(); - expect(api.enhancements).toBeDefined(); + expect(api.setDrilldowns).toBeDefined(); }); it('should not expose drilldowns for ES|QL chart types', async () => { @@ -85,7 +85,7 @@ describe('Dashboard actions', () => { esql: 'FROM index', }) ); - expect(api.enhancements).toBeUndefined(); + expect(api.setDrilldowns).toBeUndefined(); }); }); diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.tsx b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.tsx index 1cbfa6603ac0d..0c10df3c7dd8c 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.tsx +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_actions.tsx @@ -14,10 +14,6 @@ import { apiPublishesProjectRouting, apiPublishesUnifiedSearch, } from '@kbn/presentation-publishing'; -import type { - EmbeddableDynamicActionsManager, - HasDynamicActions, -} from '@kbn/embeddable-enhanced-plugin/public'; import { partition } from 'lodash'; import type { Observable } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; @@ -33,6 +29,7 @@ import type { ViewUnderlyingDataArgs, } from '@kbn/lens-common'; import type { LensSerializedAPIConfig } from '@kbn/lens-common-2'; +import type { DrilldownsManager, HasDrilldowns } from '@kbn/embeddable-plugin/public'; import { combineQueryAndFilters, findDataViewByIndexPatternId, @@ -251,20 +248,18 @@ export function initializeActionApi( searchContextApi: { timeRange$: PublishingSubject }, internalApi: LensInternalApi, services: LensEmbeddableStartServices, - dynamicActionsManager?: EmbeddableDynamicActionsManager + drilldownsManager: DrilldownsManager ): { - api: ViewInDiscoverCallbacks & HasDynamicActions; + api: ViewInDiscoverCallbacks & Partial; anyStateChange$: Observable; - getComparators: () => EmbeddableDynamicActionsManager['comparators']; - getLatestState: () => ReturnType; + getComparators: () => DrilldownsManager['comparators']; + getLatestState: () => ReturnType; cleanup: () => void; reinitializeState: (lastSaved?: LensSerializedAPIConfig) => void; } { - const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); - return { api: { - ...(isTextBasedLanguage(initialState) ? {} : dynamicActionsManager?.api ?? {}), + ...(isTextBasedLanguage(initialState) ? {} : drilldownsManager.api), ...createViewUnderlyingDataApis( getLatestState, internalApi, @@ -273,19 +268,16 @@ export function initializeActionApi( services ), }, - anyStateChange$: dynamicActionsManager?.anyStateChange$ ?? new BehaviorSubject(undefined), + anyStateChange$: drilldownsManager.anyStateChange$, getComparators: () => ({ - ...(dynamicActionsManager?.comparators ?? { - drilldowns: 'skip', - enhancements: 'skip', - }), + ...drilldownsManager.comparators, }), - getLatestState: () => dynamicActionsManager?.getLatestState() ?? {}, + getLatestState: drilldownsManager.getLatestState, cleanup: () => { - maybeStopDynamicActions?.stopDynamicActions(); + drilldownsManager.cleanup(); }, reinitializeState: (lastSaved?: LensSerializedAPIConfig) => { - dynamicActionsManager?.reinitializeState(lastSaved ?? {}); + drilldownsManager.reinitializeState(lastSaved ?? {}); }, }; } diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/lens_embeddable.tsx b/x-pack/platform/plugins/shared/lens/public/react_embeddable/lens_embeddable.tsx index 1ba537594a6d7..3d9642ae1564a 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/lens_embeddable.tsx +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/lens_embeddable.tsx @@ -54,15 +54,16 @@ export const createLensEmbeddableFactory = ( * from the Lens component container to the Lens embeddable. * @returns an object with the Lens API and the React component to render in the Embeddable */ - buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + buildEmbeddable: async ({ + initializeDrilldownsManager, + initialState, + finalizeApi, + parentApi, + uuid, + }) => { const titleManager = initializeTitleManager(initialState); - const dynamicActionsManager = - await services.embeddableEnhanced?.initializeEmbeddableDynamicActions( - uuid, - () => titleManager.api.title$.getValue(), - initialState - ); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); const initialRuntimeState = await deserializeState(services, initialState); @@ -123,7 +124,7 @@ export const createLensEmbeddableFactory = ( searchContextConfig.api, internalApi, services, - dynamicActionsManager + drilldownsManager ); /** diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx b/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx index d221dd00f1eff..b7bc4e1688757 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { BehaviorSubject, Subject, of } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import deepMerge from 'deepmerge'; import React from 'react'; import { faker } from '@faker-js/faker'; @@ -22,7 +22,6 @@ import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import type { ReactExpressionRendererProps } from '@kbn/expressions-plugin/public'; import { fieldsMetadataPluginPublicMock } from '@kbn/fields-metadata-plugin/public/mocks'; import type { ESQLControlVariable } from '@kbn/esql-types'; -import type { EmbeddableDynamicActionsManager } from '@kbn/embeddable-enhanced-plugin/public'; import type { Datasource, DatasourceMap, @@ -199,32 +198,10 @@ export function makeEmbeddableServices( ...services.uiActions, getTrigger: jest.fn().mockImplementation(() => ({ exec: jest.fn() })), }, - embeddableEnhanced: { - initializeEmbeddableDynamicActions: jest.fn().mockResolvedValue(mockDynamicActionsManager), - }, fieldsMetadata: fieldsMetadataPluginPublicMock.createStartContract(), }; } -export function mockDynamicActionsManager() { - return { - api: { - enhancements: { dynamicActions: {} }, - setDynamicActions: jest.fn(), - dynamicActionsState$: {}, - } as unknown as EmbeddableDynamicActionsManager['api'], - anyStateChange$: of(undefined), - comparators: { - drilldown: jest.fn(), - enhancements: jest.fn(), - } as unknown as EmbeddableDynamicActionsManager['comparators'], - getLatestState: jest.fn(), - serializeState: jest.fn(), - reinitializeState: jest.fn(), - startDynamicActions: jest.fn(), - } as EmbeddableDynamicActionsManager; -} - export const mockVisualizationMap = ( type: string | undefined = undefined, overrides: Partial = {} diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.test.ts new file mode 100644 index 0000000000000..c560c5cb57533 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { apiHasUserMessages } from './type_guards'; + +describe('apiHasUserMessages', () => { + const message = { + uniqueId: 'msg-1', + severity: 'info', + shortMessage: 'Test message', + longMessage: () => 'Test message', + fixableInEditor: false, + displayLocations: [{ id: 'embeddableBadge' }], + }; + + const validCases = [ + { + input: { userMessages: [message] }, + label: 'array with one message', + }, + { input: { userMessages: [message, message] }, label: 'array with multiple messages' }, + { input: { userMessages: [] }, label: 'empty array' }, + ]; + + const invalidCases = [ + { input: null, label: 'null' }, + { input: undefined, label: 'undefined' }, + { + input: { onLoad: jest.fn(), onBeforeBadgesRender: jest.fn() }, + label: 'object without userMessages property', + }, + { input: 0, label: 'number primitive' }, + { input: '', label: 'string primitive' }, + ]; + + it.each(validCases)('returns true for $label', ({ input }) => { + expect(apiHasUserMessages(input)).toBe(true); + }); + + it.each(invalidCases)('returns false for $label', ({ input }) => { + expect(apiHasUserMessages(input)).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts index 74a21071fa402..1251d9a6ec399 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts @@ -15,6 +15,7 @@ import type { LensApiCallbacks, LensPublicCallbacks, LensComponentForwardedProps, + UserMessage, } from '@kbn/lens-common'; import type { LensApi } from '@kbn/lens-common-2'; @@ -48,6 +49,10 @@ export function apiHasLensComponentCallbacks(api: unknown): api is LensPublicCal ); } +export function apiHasUserMessages(api: unknown): api is { userMessages?: UserMessage[] } { + return isObject(api) && Object.hasOwn(api, 'userMessages'); +} + export function apiHasLensComponentProps(api: unknown): api is LensComponentForwardedProps { return ( isObject(api) && diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts index 90f365b57edbb..c8b5322c18165 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts @@ -36,10 +36,11 @@ const ALL_LOCATIONS: UserMessagesDisplayLocationId[] = [ function createUserMessage( locations: Array> = ['embeddableBadge'], - severity: UserMessage['severity'] = 'error' + severity: UserMessage['severity'] = 'error', + id?: string ): UserMessage { return { - uniqueId: faker.string.uuid(), + uniqueId: id ?? faker.string.uuid(), severity: severity || 'error', shortMessage: faker.lorem.word(), longMessage: () => faker.lorem.sentence(), @@ -53,9 +54,11 @@ function buildUserMessagesApi( { visOverrides, dataOverrides, + getConsumerMessages, }: { visOverrides?: { id: string } & Partial; dataOverrides?: { id: string } & Partial; + getConsumerMessages?: () => UserMessage[]; } = { visOverrides: { id: 'lnsXY' }, dataOverrides: { id: 'formBased' }, @@ -86,7 +89,8 @@ function buildUserMessagesApi( internalApi, services, onBeforeBadgesRender, - metaInfo + metaInfo, + getConsumerMessages ); return { api, internalApi, userMessagesApi, onBeforeBadgesRender }; } @@ -293,6 +297,87 @@ describe('User Messages API', () => { userMessagesApi.getUserMessages('embeddableBadge'); expect(onBeforeBadgesRender).toHaveBeenCalled(); }); + + it('should not add consumer messages when getConsumerMessages returns empty array', () => { + const getConsumerMessages = jest.fn(() => []); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(0); + expect(getConsumerMessages).toHaveBeenCalled(); + }); + + it('should filter consumer and internal messages based on severity', () => { + const consumerMessage = createUserMessage(['embeddableBadge'], 'info'); + const getConsumerMessages = jest.fn(() => [consumerMessage]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + const internalMessage = createUserMessage(['embeddableBadge'], 'error'); + userMessagesApi.addUserMessages([internalMessage]); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(2); + expect(getConsumerMessages).toHaveBeenCalled(); + + expect(result[0]).toEqual(expect.objectContaining({ uniqueId: internalMessage.uniqueId })); + expect(result[1]).toEqual(expect.objectContaining({ uniqueId: consumerMessage.uniqueId })); + }); + + it('should return only consumer messages when no internal messages', () => { + const consumerMessage = createUserMessage(['embeddableBadge'], 'error'); + const getConsumerMessages = jest.fn(() => [consumerMessage]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result[0]).toEqual(expect.objectContaining({ uniqueId: consumerMessage.uniqueId })); + }); + + it('when consumer and internal share the same uniqueId, both appear in the result (no dedupe)', () => { + const sharedId = 'shared-message-id'; + const consumerMessage = createUserMessage(['embeddableBadge'], 'error', sharedId); + const getConsumerMessages = jest.fn(() => [consumerMessage]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + const internalMessage = createUserMessage(['embeddableBadge'], 'warning', sharedId); + userMessagesApi.addUserMessages([internalMessage]); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + expect(result).toHaveLength(2); + }); + + it('should return multiple consumer messages', () => { + const msg1 = createUserMessage(['embeddableBadge'], 'info'); + const msg2 = createUserMessage(['embeddableBadge'], 'warning'); + const getConsumerMessages = jest.fn(() => [msg1, msg2]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual(expect.objectContaining({ uniqueId: msg2.uniqueId })); + expect(result[1]).toEqual(expect.objectContaining({ uniqueId: msg1.uniqueId })); + }); }); describe('addUserMessages', () => { diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts index 4233220f9ba0b..22d812d7446be 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts @@ -97,7 +97,8 @@ export function buildUserMessagesHelpers( internalApi: LensInternalApi, { coreStart, data, visualizationMap, datasourceMap, spaces }: LensEmbeddableStartServices, onBeforeBadgesRender: LensPublicCallbacks['onBeforeBadgesRender'], - metaInfo?: SharingSavedObjectProps + metaInfo?: SharingSavedObjectProps, + getConsumerMessages?: () => UserMessage[] ): { getUserMessages: UserMessagesGetter; addUserMessages: (messages: UserMessage[]) => void; @@ -204,6 +205,11 @@ export function buildUserMessagesHelpers( }) ?? []) ); + const consumerMessages = getConsumerMessages?.() ?? []; + + // When an internal error occurs (block chart rendering), the consumer message is not displayed. + userMessages.push(...consumerMessages); + return handleMessageOverwriteFromConsumer( filterAndSortUserMessages( userMessages.concat(Object.values(runtimeUserMessages)), diff --git a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_action.ts b/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_action.ts index a4c0155f52a86..61fcac583a189 100644 --- a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_action.ts +++ b/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_action.ts @@ -11,12 +11,15 @@ import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; import type { DataViewsService } from '@kbn/data-views-plugin/public'; import { map } from 'rxjs'; import type { LensApi } from '@kbn/lens-common-2'; -import type { DiscoverAppLocator } from './open_in_discover_helpers'; +import { + execute, + getHref, + isCompatible, + type DiscoverAppLocator, +} from './open_in_discover_helpers'; const ACTION_OPEN_IN_DISCOVER = 'ACTION_OPEN_IN_DISCOVER'; -export const getDiscoverHelpersAsync = async () => await import('../async_services'); - export const createOpenInDiscoverAction = ( locator: DiscoverAppLocator, dataViews: Pick, @@ -32,7 +35,6 @@ export const createOpenInDiscoverAction = ( defaultMessage: 'Explore in Discover', }), getHref: async (context: EmbeddableApiContext) => { - const { getHref } = await getDiscoverHelpersAsync(); return getHref({ locator, dataViews, @@ -41,7 +43,6 @@ export const createOpenInDiscoverAction = ( }); }, isCompatible: async (context: EmbeddableApiContext) => { - const { isCompatible } = await getDiscoverHelpersAsync(); return isCompatible({ hasDiscoverAccess, locator, @@ -59,7 +60,6 @@ export const createOpenInDiscoverAction = ( return (embeddable as LensApi).canViewUnderlyingData$.pipe(map(() => undefined)); }, execute: async (context: EmbeddableApiContext) => { - const { execute } = await getDiscoverHelpersAsync(); return execute({ ...context, locator, dataViews, hasDiscoverAccess }); }, }; diff --git a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx b/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx deleted file mode 100644 index 1536fd5023a26..0000000000000 --- a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import type { ApplicationStart } from '@kbn/core/public'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { DiscoverAppLocator } from './open_in_discover_helpers'; -import { getHref, isCompatible } from './open_in_discover_helpers'; -import type { Filter } from '@kbn/es-query'; -import type { ActionFactoryContext, CollectConfigProps } from './open_in_discover_drilldown'; -import { OpenInDiscoverDrilldown } from './open_in_discover_drilldown'; -import type { DataViewsService } from '@kbn/data-views-plugin/public'; -import { getLensApiMock } from '../react_embeddable/mocks'; - -jest.mock('./open_in_discover_helpers', () => ({ - isCompatible: jest.fn().mockReturnValue(true), - getHref: jest.fn(), -})); - -describe('open in discover drilldown', () => { - let drilldown: OpenInDiscoverDrilldown; - const originalOpen = window.open; - - // Prevent the JSDOM error about missing "window.open" - beforeAll(() => { - window.open = jest.fn(); - }); - - beforeEach(() => { - drilldown = new OpenInDiscoverDrilldown({ - locator: () => ({} as DiscoverAppLocator), - dataViews: () => ({} as DataViewsService), - hasDiscoverAccess: () => true, - application: () => ({} as ApplicationStart), - }); - }); - - afterAll(() => { - window.open = originalOpen; - }); - - it('provides UI to edit config', async () => { - const Component = (drilldown as unknown as { ReactCollectConfig: React.FC }) - .ReactCollectConfig; - const setConfig = jest.fn(); - render( - - ); - await userEvent.click(screen.getByRole('switch')); - expect(setConfig).toHaveBeenCalledWith({ openInNewTab: true }); - }); - - it('calls through to isCompatible helper', async () => { - const filters: Filter[] = [{ meta: { disabled: false } }]; - await drilldown.isCompatible({ openInNewTab: true }, { embeddable: getLensApiMock(), filters }); - expect(isCompatible).toHaveBeenCalledWith(expect.objectContaining({ filters })); - }); - - it('calls through to getHref helper', async () => { - const filters: Filter[] = [{ meta: { disabled: false } }]; - await drilldown.execute({ openInNewTab: true }, { embeddable: getLensApiMock(), filters }); - expect(getHref).toHaveBeenCalledWith(expect.objectContaining({ filters })); - }); -}); diff --git a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_drilldown.tsx b/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_drilldown.tsx deleted file mode 100644 index 3e138730d296c..0000000000000 --- a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_in_discover_drilldown.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import type { ApplicationStart } from '@kbn/core/public'; -import type { SerializableRecord } from '@kbn/utility-types'; -import type { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public'; -import type { - UiActionsEnhancedDrilldownDefinition as Drilldown, - UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, -} from '@kbn/ui-actions-enhanced-plugin/public'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import type { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; -import { i18n } from '@kbn/i18n'; -import type { DataViewsService } from '@kbn/data-views-plugin/public'; -import { apiIsOfType } from '@kbn/presentation-publishing'; -import type { LensApi } from '@kbn/lens-common-2'; -import type { APPLY_FILTER_TRIGGER } from '@kbn/ui-actions-plugin/common/trigger_ids'; -import { DISCOVER_DRILLDOWN_SUPPORTED_TRIGGERS, DOC_TYPE } from '../../common/constants'; -import type { DiscoverAppLocator } from './open_in_discover_helpers'; - -export const getDiscoverHelpersAsync = async () => await import('../async_services'); - -interface UrlDrilldownDeps { - locator: () => DiscoverAppLocator | undefined; - dataViews: () => Pick; - hasDiscoverAccess: () => boolean; - application: () => ApplicationStart; -} - -export type ActionContext = ApplyGlobalFilterActionContext & { - embeddable: LensApi; -}; - -export interface Config extends SerializableRecord { - openInNewTab: boolean; -} - -export type OpenInDiscoverTrigger = typeof APPLY_FILTER_TRIGGER; - -export type ActionFactoryContext = BaseActionFactoryContext & ActionContext; -export type CollectConfigProps = CollectConfigPropsBase; - -export class OpenInDiscoverDrilldown - implements Drilldown -{ - public readonly id = 'OPEN_IN_DISCOVER_DRILLDOWN'; - - constructor(private readonly deps: UrlDrilldownDeps) {} - - public readonly order = 8; - - public readonly getDisplayName = () => - i18n.translate('xpack.lens.app.exploreDataInDiscoverDrilldown', { - defaultMessage: 'Open in Discover', - }); - - public readonly euiIcon = 'discoverApp'; - - supportedTriggers(): OpenInDiscoverTrigger[] { - return DISCOVER_DRILLDOWN_SUPPORTED_TRIGGERS as OpenInDiscoverTrigger[]; - } - - private readonly ReactCollectConfig: React.FC = ({ config, onConfig }) => { - return ( - - onConfig({ ...config, openInNewTab: !config.openInNewTab })} - data-test-subj="openInDiscoverDrilldownOpenInNewTab" - /> - - ); - }; - - public readonly CollectConfig = this.ReactCollectConfig; - - public readonly createConfig = () => ({ - openInNewTab: true, - }); - - public readonly isConfigValid = (config: Config): config is Config => { - return true; - }; - - public readonly isCompatible = async (config: Config, context: ActionContext) => { - const { isCompatible } = await getDiscoverHelpersAsync(); - - return isCompatible({ - locator: this.deps.locator(), - dataViews: this.deps.dataViews(), - hasDiscoverAccess: this.deps.hasDiscoverAccess(), - ...context, - embeddable: context.embeddable, - ...config, - }); - }; - - public readonly isConfigurable = (context: ActionFactoryContext) => - this.deps.hasDiscoverAccess() && apiIsOfType(context.embeddable, DOC_TYPE); - - public readonly getHref = async (config: Config, context: ActionContext) => { - const { getHref } = await getDiscoverHelpersAsync(); - - return getHref({ - locator: this.deps.locator(), - dataViews: this.deps.dataViews(), - hasDiscoverAccess: this.deps.hasDiscoverAccess(), - ...context, - embeddable: context.embeddable, - }); - }; - - public readonly execute = async (config: Config, context: ActionContext) => { - if (config.openInNewTab) { - window.open(await this.getHref(config, context), '_blank'); - } else { - const { getLocation } = await getDiscoverHelpersAsync(); - - const { app, path, state } = await getLocation({ - locator: this.deps.locator(), - dataViews: this.deps.dataViews(), - hasDiscoverAccess: this.deps.hasDiscoverAccess(), - ...context, - embeddable: context.embeddable, - }); - await this.deps.application().navigateToApp(app, { path, state }); - } - }; -} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx index e82b471e79894..481b40c71e617 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx @@ -454,7 +454,7 @@ export const getDatatableVisualization = ({ supportsMoreColumns: true, filterOperations: (op) => !op.isBucketed, isMetricDimension: true, - requiredMinDimensionCount: 1, + requiredMinDimensionCount: isTextBasedLanguage ? 0 : 1, dataTestSubj: 'lnsDatatable_metrics', enableDimensionEditor: true, }, diff --git a/x-pack/platform/plugins/shared/lens/server/drilldowns/types.ts b/x-pack/platform/plugins/shared/lens/server/drilldowns/types.ts new file mode 100644 index 0000000000000..ed596ec96a189 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/drilldowns/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 type { DrilldownState } from '@kbn/embeddable-plugin/server'; +import type { TypeOf } from '@kbn/config-schema'; +import type { discoverDrilldownSchema } from './register_discover_drilldown'; + +export type DiscoverDrilldownState = DrilldownState & TypeOf; diff --git a/x-pack/platform/plugins/shared/lens/server/index.ts b/x-pack/platform/plugins/shared/lens/server/index.ts index 8f1bd4db15eb0..8fb9bb79fd5a1 100644 --- a/x-pack/platform/plugins/shared/lens/server/index.ts +++ b/x-pack/platform/plugins/shared/lens/server/index.ts @@ -42,3 +42,5 @@ export type { RegisterAPIRoutesArgs, RegisterAPIRouteFn, } from './types'; + +export type { DiscoverDrilldownState } from './drilldowns/types'; diff --git a/x-pack/platform/plugins/shared/lens/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/shared/lens/test/scout/.meta/ui/standard.json index 4efceb111a19e..4521dd1f29103 100644 --- a/x-pack/platform/plugins/shared/lens/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/plugins/shared/lens/test/scout/.meta/ui/standard.json @@ -1,18 +1,16 @@ { - "lastModified": "2026-02-09T13:21:47.840Z", - "sha1": "27126cc55b631209972a5c07f114ae006f0060aa", + "sha1": "0fa6c8f7f58b18b7eb756577aacf45633a7b8ecc", "tests": [ { "id": "76ddacbf8381e78-00e065404a881ec", "title": "Lens ES|QL should display ES|QL conversion modal", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic" + "@local-stateful-classic" ], "location": { "file": "x-pack/platform/plugins/shared/lens/test/scout/ui/tests/esql_conversion.spec.ts", - "line": 53, + "line": 52, "column": 7 } } diff --git a/x-pack/platform/plugins/shared/lens/tsconfig.json b/x-pack/platform/plugins/shared/lens/tsconfig.json index 5437d1276756e..d0de26cc1b618 100644 --- a/x-pack/platform/plugins/shared/lens/tsconfig.json +++ b/x-pack/platform/plugins/shared/lens/tsconfig.json @@ -26,7 +26,6 @@ "@kbn/url-forwarding-plugin", "@kbn/visualizations-plugin", "@kbn/ui-actions-plugin", - "@kbn/ui-actions-enhanced-plugin", "@kbn/share-plugin", "@kbn/usage-collection-plugin", "@kbn/saved-objects-plugin", @@ -113,7 +112,6 @@ "@kbn/licensing-plugin", "@kbn/react-kibana-context-render", "@kbn/react-kibana-mount", - "@kbn/embeddable-enhanced-plugin", "@kbn/es-types", "@kbn/esql-datagrid", "@kbn/transpose-utils", diff --git a/x-pack/platform/plugins/shared/maps/common/embeddable/types.ts b/x-pack/platform/plugins/shared/maps/common/embeddable/types.ts index e93b10aed55ab..1ad9a211a82e2 100644 --- a/x-pack/platform/plugins/shared/maps/common/embeddable/types.ts +++ b/x-pack/platform/plugins/shared/maps/common/embeddable/types.ts @@ -5,14 +5,14 @@ * 2.0. */ -import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; import type { SerializedTimeRange, SerializedTitles } from '@kbn/presentation-publishing'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { MapCenterAndZoom, MapExtent, MapSettings } from '../descriptor_types'; import type { MapAttributes } from '../../server'; export type MapEmbeddableBaseState = SerializedTimeRange & SerializedTitles & - Partial & { + SerializedDrilldowns & { isLayerTOCOpen?: boolean; openTOCDetails?: string[]; mapCenter?: MapCenterAndZoom; diff --git a/x-pack/platform/plugins/shared/maps/kibana.jsonc b/x-pack/platform/plugins/shared/maps/kibana.jsonc index 0e6a74ccb56eb..b81834ece1c9d 100644 --- a/x-pack/platform/plugins/shared/maps/kibana.jsonc +++ b/x-pack/platform/plugins/shared/maps/kibana.jsonc @@ -40,7 +40,6 @@ "cloud", "cps", "customIntegrations", - "embeddableEnhanced", "home", "savedObjectsTagging", "charts", diff --git a/x-pack/platform/plugins/shared/maps/moon.yml b/x-pack/platform/plugins/shared/maps/moon.yml index 28b0673a7774b..975411e8b65e2 100644 --- a/x-pack/platform/plugins/shared/maps/moon.yml +++ b/x-pack/platform/plugins/shared/maps/moon.yml @@ -92,7 +92,6 @@ dependsOn: - '@kbn/apm-data-view' - '@kbn/shared-ux-utility' - '@kbn/react-kibana-context-render' - - '@kbn/embeddable-enhanced-plugin' - '@kbn/field-utils' - '@kbn/react-hooks' - '@kbn/scout' diff --git a/x-pack/platform/plugins/shared/maps/public/kibana_services.ts b/x-pack/platform/plugins/shared/maps/public/kibana_services.ts index dd99926b6d589..f7ca681a4d575 100644 --- a/x-pack/platform/plugins/shared/maps/public/kibana_services.ts +++ b/x-pack/platform/plugins/shared/maps/public/kibana_services.ts @@ -102,7 +102,6 @@ export const isScreenshotMode = () => { return pluginsStart.screenshotMode ? pluginsStart.screenshotMode.isScreenshotMode() : false; }; export const getServerless = () => pluginsStart.serverless; -export const getEmbeddableEnhanced = () => pluginsStart.embeddableEnhanced; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/platform/plugins/shared/maps/public/plugin.ts b/x-pack/platform/plugins/shared/maps/public/plugin.ts index b854b1537dc4a..c8d4f2eaaee6e 100644 --- a/x-pack/platform/plugins/shared/maps/public/plugin.ts +++ b/x-pack/platform/plugins/shared/maps/public/plugin.ts @@ -24,7 +24,6 @@ import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { VisualizationsSetup, VisualizationsStart } from '@kbn/visualizations-plugin/public'; import type { Plugin as ExpressionsPublicPlugin } from '@kbn/expressions-plugin/public'; import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import type { MapsEmsPluginPublicStart } from '@kbn/maps-ems-plugin/public'; import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -107,7 +106,6 @@ export interface MapsPluginStartDependencies { unifiedSearch: UnifiedSearchPublicPluginStart; kql: KqlPluginStart; embeddable: EmbeddableStart; - embeddableEnhanced?: EmbeddableEnhancedPluginStart; fieldFormats: FieldFormatsStart; fileUpload: FileUploadPluginStart; inspector: InspectorStartContract; diff --git a/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx b/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx index cc309b5019739..aad9b68ad5771 100644 --- a/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx +++ b/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx @@ -33,7 +33,7 @@ import { getByValueState, initializeLibraryTransforms, } from './library_transforms'; -import { getEmbeddableEnhanced, getSpacesApi } from '../kibana_services'; +import { getSpacesApi } from '../kibana_services'; import { initializeActionHandlers } from './initialize_action_handlers'; import { MapContainer } from '../connected_components/map_container'; import { waitUntilTimeLayersLoad$ } from '../routes/map_page/map_app/wait_until_time_layers_load'; @@ -54,7 +54,13 @@ export function getControlledBy(id: string) { export const mapEmbeddableFactory: EmbeddableFactory = { type: MAP_SAVED_OBJECT_TYPE, - buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + buildEmbeddable: async ({ + initializeDrilldownsManager, + initialState, + finalizeApi, + parentApi, + uuid, + }) => { const state = initialState; const savedMap = new SavedMap({ mapEmbeddableState: state }); await savedMap.whenReady(); @@ -68,12 +74,7 @@ export const mapEmbeddableFactory: EmbeddableFactory const controlledBy = getControlledBy(uuid); const titleManager = initializeTitleManager(state); const timeRangeManager = initializeTimeRangeManager(state); - const dynamicActionsManager = await getEmbeddableEnhanced()?.initializeEmbeddableDynamicActions( - uuid, - () => titleManager.api.title$.getValue(), - initialState - ); - const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); const defaultTitle$ = new BehaviorSubject(savedMap.getAttributes().title); const defaultDescription$ = new BehaviorSubject( @@ -101,7 +102,7 @@ export const mapEmbeddableFactory: EmbeddableFactory ...state, ...timeRangeManager.getLatestState(), ...titleManager.getLatestState(), - ...(dynamicActionsManager?.getLatestState() ?? {}), + ...drilldownsManager.getLatestState(), ...crossPanelActions.getLatestState(), ...reduxSync.getLatestState(), }; @@ -125,7 +126,7 @@ export const mapEmbeddableFactory: EmbeddableFactory parentApi, serializeState, anyStateChange$: merge( - ...(dynamicActionsManager ? [dynamicActionsManager.anyStateChange$] : []), + drilldownsManager.anyStateChange$, crossPanelActions.anyStateChange$, reduxSync.anyStateChange$, titleManager.anyStateChange$, @@ -134,7 +135,7 @@ export const mapEmbeddableFactory: EmbeddableFactory getComparators: () => { return { ...crossPanelActionsComparators, - ...(dynamicActionsManager?.comparators ?? { drilldowns: 'skip', enhancements: 'skip' }), + ...drilldownsManager.comparators, ...reduxSyncComparators, ...titleComparators, ...timeRangeComparators, @@ -144,7 +145,7 @@ export const mapEmbeddableFactory: EmbeddableFactory }; }, onReset: async (lastSaved) => { - dynamicActionsManager?.reinitializeState(lastSaved ?? {}); + drilldownsManager.reinitializeState(lastSaved ?? {}); timeRangeManager.reinitializeState(lastSaved); titleManager.reinitializeState(lastSaved); @@ -159,7 +160,7 @@ export const mapEmbeddableFactory: EmbeddableFactory defaultDescription$, ...unsavedChangesApi, ...timeRangeManager.api, - ...(dynamicActionsManager?.api ?? {}), + ...drilldownsManager.api, ...titleManager.api, ...reduxSync.api, ...initializeEditApi( @@ -208,10 +209,10 @@ export const mapEmbeddableFactory: EmbeddableFactory useEffect(() => { return () => { crossPanelActions.cleanup(); + drilldownsManager.cleanup(); reduxSync.cleanup(); unsubscribeFromFetch(); projectRoutingManager.cleanup(); - maybeStopDynamicActions?.stopDynamicActions(); }; }, []); diff --git a/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts b/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts index 064162ac76f90..5def037a4e8c9 100644 --- a/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts +++ b/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import type { DefaultEmbeddableApi, HasDrilldowns } from '@kbn/embeddable-plugin/public'; import type { HasInspectorAdapters } from '@kbn/inspector-plugin/public'; import type { HasEditCapabilities, @@ -17,7 +17,6 @@ import type { PublishesProjectRoutingOverrides, PublishesUnifiedSearch, } from '@kbn/presentation-publishing'; -import type { HasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public'; import type { Observable } from 'rxjs'; import type { LayerDescriptor } from '../../common/descriptor_types'; import type { ILayer } from '../classes/layers/layer'; @@ -29,7 +28,7 @@ import type { } from '../../common/embeddable/types'; export type MapApi = DefaultEmbeddableApi & - HasDynamicActions & + HasDrilldowns & Partial & HasInspectorAdapters & HasSupportedTriggers & diff --git a/x-pack/platform/plugins/shared/maps/public/trigger_actions/trigger_utils.ts b/x-pack/platform/plugins/shared/maps/public/trigger_actions/trigger_utils.ts index 0fb453a03b008..1da538e0d998a 100644 --- a/x-pack/platform/plugins/shared/maps/public/trigger_actions/trigger_utils.ts +++ b/x-pack/platform/plugins/shared/maps/public/trigger_actions/trigger_utils.ts @@ -10,7 +10,7 @@ import type { DatatableColumnType } from '@kbn/expressions-plugin/common'; import type { RawValue } from '../../common/constants'; export function isUrlDrilldown(action: Action) { - return action.type === 'URL_DRILLDOWN'; + return action.type === 'url_drilldown'; } // VALUE_CLICK_TRIGGER is coupled with expressions and Datatable type diff --git a/x-pack/platform/plugins/shared/maps/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/shared/maps/test/scout/.meta/ui/standard.json index 5310de8e82c72..71b58c66a0d6e 100644 --- a/x-pack/platform/plugins/shared/maps/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/plugins/shared/maps/test/scout/.meta/ui/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T18:40:16.785Z", "sha1": "ace4fa767fb89ecdbb6f261ff7497e3f66125693", "tests": [ { diff --git a/x-pack/platform/plugins/shared/maps/tsconfig.json b/x-pack/platform/plugins/shared/maps/tsconfig.json index 243e5b41dd7a5..806d923a2b6c2 100644 --- a/x-pack/platform/plugins/shared/maps/tsconfig.json +++ b/x-pack/platform/plugins/shared/maps/tsconfig.json @@ -88,7 +88,6 @@ "@kbn/apm-data-view", "@kbn/shared-ux-utility", "@kbn/react-kibana-context-render", - "@kbn/embeddable-enhanced-plugin", "@kbn/field-utils", "@kbn/react-hooks", "@kbn/scout", diff --git a/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/empty_prompt/analysis_monitors.png b/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/empty_prompt/analysis_monitors.png index bf7bd23b4812d..51f99f9bf0aeb 100644 Binary files a/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/empty_prompt/analysis_monitors.png and b/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/empty_prompt/analysis_monitors.png differ diff --git a/x-pack/platform/plugins/shared/ml/public/application/jobs/jobs_list/components/anomaly_detection_empty_state/machine_learning_cog.png b/x-pack/platform/plugins/shared/ml/public/application/jobs/jobs_list/components/anomaly_detection_empty_state/machine_learning_cog.png index 2c52eaa343598..477e68a27a248 100644 Binary files a/x-pack/platform/plugins/shared/ml/public/application/jobs/jobs_list/components/anomaly_detection_empty_state/machine_learning_cog.png and b/x-pack/platform/plugins/shared/ml/public/application/jobs/jobs_list/components/anomaly_detection_empty_state/machine_learning_cog.png differ diff --git a/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx b/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx index 9298454635108..03f2c5b1142c5 100644 --- a/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx +++ b/x-pack/platform/plugins/shared/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx @@ -87,6 +87,7 @@ describe('getAnomalySwimLaneEmbeddableFactory', () => { }, }; const { api, Component } = await factory.buildEmbeddable({ + initializeDrilldownsManager: jest.fn(), initialState: { swimlaneType: 'viewBy', jobIds: ['my-job'], diff --git a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts index aedad5f3b8877..053182259be24 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts +++ b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts @@ -16,6 +16,20 @@ import { z } from '@kbn/zod'; +export type FindLiveQueryRequestQuery = z.infer; +export const FindLiveQueryRequestQuery = z.object({ + kuery: z.string().optional(), + page: z.number().int().optional(), + pageSize: z.number().int().optional(), + sort: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), + /** + * When true, the response includes result_counts on each item with aggregated result statistics from the action responses index. + + */ + withResultCounts: z.boolean().optional(), +}); + export type FindLiveQueryResponse = z.infer; export const FindLiveQueryResponse = z.object({}); diff --git a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml index 0cbe29aa92fdc..c37bba71e4249 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml @@ -5,6 +5,25 @@ info: paths: { } components: schemas: + FindLiveQueryRequestQuery: + type: object + properties: + kuery: + type: string + page: + type: integer + pageSize: + type: integer + sort: + type: string + sortOrder: + type: string + enum: [asc, desc] + withResultCounts: + type: boolean + description: > + When true, the response includes result_counts on each item with + aggregated result statistics from the action responses index. FindLiveQueryResponse: example: data: @@ -24,6 +43,11 @@ components: host.uptime: field: "total_seconds" agents: [ "16d7caf5-efd2-4212-9b62-73dafc91fa13" ] + result_counts: + total_rows: 42 + responded_agents: 1 + successful_agents: 1 + error_agents: 0 type: object properties: { } FindLiveQueryDetailsResponse: diff --git a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query_route.ts b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query_route.ts index be0787b85fd87..7ed04996ae3f3 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query_route.ts +++ b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query_route.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { toNumberRt } from '@kbn/io-ts-utils'; +import { toBooleanRt, toNumberRt } from '@kbn/io-ts-utils'; export const findLiveQueryRequestQuerySchema = t.type({ kuery: t.union([t.string, t.undefined]), @@ -14,6 +14,7 @@ export const findLiveQueryRequestQuerySchema = t.type({ pageSize: t.union([toNumberRt, t.undefined]), sort: t.union([t.string, t.undefined]), sortOrder: t.union([t.union([t.literal('asc'), t.literal('desc')]), t.undefined]), + withResultCounts: t.union([toBooleanRt, t.undefined]), }); export type FindLiveQueryRequestQuerySchema = t.OutputOf; diff --git a/x-pack/platform/plugins/shared/osquery/common/search_strategy/osquery/actions/index.ts b/x-pack/platform/plugins/shared/osquery/common/search_strategy/osquery/actions/index.ts index 6255b52b5c515..8998d225930d7 100644 --- a/x-pack/platform/plugins/shared/osquery/common/search_strategy/osquery/actions/index.ts +++ b/x-pack/platform/plugins/shared/osquery/common/search_strategy/osquery/actions/index.ts @@ -19,6 +19,23 @@ export interface ActionsStrategyResponse extends IEsSearchResponse { inspect?: Maybe; } +export interface SingleQueryResultCounts { + total_rows: number; + responded_agents: number; + successful_agents: number; + error_agents: number; +} + +export interface PackResultCounts { + total_rows: number; + queries_with_results: number; + queries_total: number; + successful_agents: number; + error_agents: number; +} + +export type ResultCounts = SingleQueryResultCounts | PackResultCounts; + export interface ActionDetails { action_id: string; expiration: string; @@ -29,11 +46,13 @@ export interface ActionDetails { agent_policy_ids: string[]; agents: string[]; user_id?: string; + user_profile_uid?: string; pack_id?: string; pack_name?: string; space_id?: string; pack_prebuilt?: boolean; status?: string; + result_counts?: ResultCounts; queries?: Array<{ action_id: string; id: string; diff --git a/x-pack/platform/plugins/shared/osquery/cypress/e2e/roles/reader.cy.ts b/x-pack/platform/plugins/shared/osquery/cypress/e2e/roles/reader.cy.ts index 3f3ffa067b61f..00ae3d3a20421 100644 --- a/x-pack/platform/plugins/shared/osquery/cypress/e2e/roles/reader.cy.ts +++ b/x-pack/platform/plugins/shared/osquery/cypress/e2e/roles/reader.cy.ts @@ -55,7 +55,7 @@ describe('Reader - only READ', { tags: ['@ess'] }, () => { navigateTo('/app/osquery/saved_queries'); cy.contains(savedQueryName); cy.contains('Add saved query').should('be.disabled'); - cy.get(customActionRunSavedQuerySelector(savedQueryName)).should('not.exist'); + cy.get(customActionRunSavedQuerySelector(savedQueryName)).should('be.disabled'); cy.get(customActionEditSavedQuerySelector(savedQueryName)).click(); cy.get(formFieldInputSelector('id')).should('be.disabled'); cy.get(formFieldInputSelector('description')).should('be.disabled'); diff --git a/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml b/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml index 8307483f55b24..c32f9c762728e 100644 --- a/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml @@ -689,6 +689,11 @@ components: id: 6724a474-cbba-41ef-a1aa-66aebf0879e2 query: select * from uptime; saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 user_id: elastic type: object properties: {} diff --git a/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml b/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml index 30718029551de..07f6ae9997e5f 100644 --- a/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml @@ -689,6 +689,11 @@ components: id: 6724a474-cbba-41ef-a1aa-66aebf0879e2 query: select * from uptime; saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 user_id: elastic type: object properties: {} diff --git a/x-pack/platform/plugins/shared/osquery/moon.yml b/x-pack/platform/plugins/shared/osquery/moon.yml index f230764691281..5c0ce66372280 100644 --- a/x-pack/platform/plugins/shared/osquery/moon.yml +++ b/x-pack/platform/plugins/shared/osquery/moon.yml @@ -79,6 +79,7 @@ dependsOn: - '@kbn/react-query' - '@kbn/react-kibana-mount' - '@kbn/tooling-log' + - '@kbn/user-profile-components' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/osquery/public/actions/actions_table.tsx b/x-pack/platform/plugins/shared/osquery/public/actions/actions_table.tsx index 5276cd66c41e1..5be7cc654bd2d 100644 --- a/x-pack/platform/plugins/shared/osquery/public/actions/actions_table.tsx +++ b/x-pack/platform/plugins/shared/osquery/public/actions/actions_table.tsx @@ -15,6 +15,7 @@ import { EuiIcon, EuiFlexItem, EuiFlexGroup, + EuiTextColor, EuiSkeletonText, EuiToolTip, } from '@elastic/eui'; @@ -24,10 +25,13 @@ import { useHistory } from 'react-router-dom'; import { QUERY_TIMEOUT } from '../../common/constants'; import { removeMultilines } from '../../common/utils/build_query/remove_multilines'; import { useAllLiveQueries } from './use_all_live_queries'; -import type { SearchHit } from '../../common/search_strategy'; +import { useBulkGetUserProfiles } from './use_user_profiles'; +import type { ActionDetails, SearchHit } from '../../common/search_strategy'; +import type { PackResultCounts } from '../../common/search_strategy/osquery/actions'; import { useRouterNavigate, useKibana } from '../common/lib/kibana'; -import { useIsExperimentalFeatureEnabled } from '../common/experimental_features_context'; import { usePacks } from '../packs/use_packs'; +import { RunByColumn } from './components/run_by_column'; +import { useIsExperimentalFeatureEnabled } from '../common/experimental_features_context'; const EMPTY_ARRAY: SearchHit[] = []; @@ -77,8 +81,18 @@ const ActionsTableComponent = () => { activePage: pageIndex, limit: pageSize, kuery: 'user_id: *', + withResultCounts: isHistoryEnabled, }); + const actionItems = useMemo( + () => actionsData?.data?.items ?? EMPTY_ARRAY, + [actionsData?.data?.items] + ); + + const { profilesMap, isLoading: isLoadingProfiles } = useBulkGetUserProfiles( + isHistoryEnabled ? actionItems : EMPTY_ARRAY + ); + const onTableChange = useCallback(({ page = {} }: any) => { const { index, size } = page; @@ -114,16 +128,77 @@ const ActionsTableComponent = () => { [] ); + const renderHistoryAgentsColumn = useCallback((_: unknown, item: SearchHit) => { + const action = item._source as ActionDetails | undefined; + const counts = action?.result_counts; + + if (!counts || counts.successful_agents == null) { + return <>{item.fields?.agents?.length ?? action?.agents?.length ?? 0}; + } + + return ( + + + + + + {counts.successful_agents} + + + + + + {counts.error_agents ?? 0} + + + ); + }, []); + const renderCreatedByColumn = useCallback( (userId: any) => (isArray(userId) ? userId[0] : '-'), [] ); + const renderRunByColumn = useCallback( + (_: unknown, item: SearchHit) => { + const userId = (item.fields?.user_id as string[] | undefined)?.[0]; + const userProfileUid = (item.fields?.user_profile_uid as string[] | undefined)?.[0]; + + return ( + + ); + }, + [profilesMap, isLoadingProfiles] + ); + const renderTimestampColumn = useCallback( (_: any, item: any) => <>{formatDate(item.fields['@timestamp'][0])}, [] ); + const renderResultsColumn = useCallback((_: unknown, item: SearchHit) => { + const action = item._source as ActionDetails | undefined; + const counts = action?.result_counts; + if (!counts) return <>{'\u2014'}; + + if (action?.pack_id && 'queries_total' in counts) { + const packCounts = counts as PackResultCounts; + + return ( + <> + {packCounts.queries_with_results} of {packCounts.queries_total} + + ); + } + + return <>{counts.total_rows}; + }, []); + const renderActionsColumn = useCallback( (item: any) => ( { [permissions, existingPackIds] ); + const resultsColumn = useMemo( + () => + isHistoryEnabled + ? [ + { + field: 'results', + name: i18n.translate('xpack.osquery.liveQueryActions.table.resultsColumnTitle', { + defaultMessage: 'Results', + }), + width: '120px', + render: renderResultsColumn, + }, + ] + : [], + [isHistoryEnabled, renderResultsColumn] + ); + const columns = useMemo( () => [ { @@ -225,13 +317,14 @@ const ActionsTableComponent = () => { width: '60%', render: renderQueryColumn, }, + ...resultsColumn, { field: 'agents', name: i18n.translate('xpack.osquery.liveQueryActions.table.agentsColumnTitle', { defaultMessage: 'Agents', }), - width: '100px', - render: renderAgentsColumn, + width: isHistoryEnabled ? '120px' : '100px', + render: isHistoryEnabled ? renderHistoryAgentsColumn : renderAgentsColumn, }, { field: 'created_at', @@ -247,7 +340,7 @@ const ActionsTableComponent = () => { defaultMessage: 'Run by', }), width: '200px', - render: renderCreatedByColumn, + render: isHistoryEnabled ? renderRunByColumn : renderCreatedByColumn, }, { name: i18n.translate('xpack.osquery.liveQueryActions.table.viewDetailsColumnTitle', { @@ -271,9 +364,12 @@ const ActionsTableComponent = () => { renderActionsColumn, renderAgentsColumn, renderCreatedByColumn, + renderHistoryAgentsColumn, renderPlayButton, renderQueryColumn, + renderRunByColumn, renderTimestampColumn, + resultsColumn, ] ); @@ -300,7 +396,7 @@ const ActionsTableComponent = () => { return ( ; + isLoadingProfiles: boolean; +} + +const RunByColumnComponent: React.FC = ({ + userId, + userProfileUid, + profilesMap, + isLoadingProfiles, +}) => { + const profile = userProfileUid ? profilesMap.get(userProfileUid) : undefined; + const fallbackUser = useMemo( + () => (userId ? ({ username: userId } as UserProfileWithAvatar['user']) : undefined), + [userId] + ); + + if (profile) { + return ; + } + + if (userProfileUid && isLoadingProfiles && !profilesMap.has(userProfileUid)) { + return null; + } + + if (fallbackUser) { + return ; + } + + return <>-; +}; + +RunByColumnComponent.displayName = 'RunByColumn'; + +export const RunByColumn = React.memo(RunByColumnComponent); diff --git a/x-pack/platform/plugins/shared/osquery/public/actions/components/user_avatar_cell.tsx b/x-pack/platform/plugins/shared/osquery/public/actions/components/user_avatar_cell.tsx new file mode 100644 index 0000000000000..571732a348945 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/public/actions/components/user_avatar_cell.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { UserAvatar } from '@kbn/user-profile-components'; + +interface UserAvatarCellProps { + user: UserProfileWithAvatar['user']; + avatar?: UserProfileWithAvatar['data']['avatar']; +} + +const UserAvatarCellComponent: React.FC = ({ user, avatar }) => ( + + + + + {user.full_name || user.username} + +); + +UserAvatarCellComponent.displayName = 'UserAvatarCell'; + +export const UserAvatarCell = React.memo(UserAvatarCellComponent); diff --git a/x-pack/platform/plugins/shared/osquery/public/actions/use_all_live_queries.ts b/x-pack/platform/plugins/shared/osquery/public/actions/use_all_live_queries.ts index edc38f70b1f44..5c19ff1eb12f2 100644 --- a/x-pack/platform/plugins/shared/osquery/public/actions/use_all_live_queries.ts +++ b/x-pack/platform/plugins/shared/osquery/public/actions/use_all_live_queries.ts @@ -23,6 +23,7 @@ export interface UseAllLiveQueriesConfig { kuery?: string; skip?: boolean; alertId?: string; + withResultCounts?: boolean; } // Make sure we keep this and ACTIONS_QUERY_KEY in osquery_flyout.tsx in sync. @@ -36,6 +37,7 @@ export const useAllLiveQueries = ({ kuery, skip = false, alertId, + withResultCounts = false, }: UseAllLiveQueriesConfig) => { const { http } = useKibana().services; const setErrorToast = useErrorToast(); @@ -43,7 +45,15 @@ export const useAllLiveQueries = ({ return useQuery( [ ACTIONS_QUERY_KEY, - { activePage, direction, limit, sortField, ...(alertId ? { alertId } : {}) }, + { + activePage, + direction, + limit, + sortField, + kuery, + withResultCounts, + ...(alertId ? { alertId } : {}), + }, ], () => http.get<{ data: Omit & { items: ActionEdges } }>( @@ -56,6 +66,7 @@ export const useAllLiveQueries = ({ pageSize: limit, sort: sortField, sortOrder: direction, + ...(withResultCounts ? { withResultCounts } : {}), }, } ), diff --git a/x-pack/platform/plugins/shared/osquery/public/actions/use_user_profiles.ts b/x-pack/platform/plugins/shared/osquery/public/actions/use_user_profiles.ts new file mode 100644 index 0000000000000..e735490a8e4bb --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/public/actions/use_user_profiles.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 { useMemo } from 'react'; +import { useQuery } from '@kbn/react-query'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { useKibana } from '../common/lib/kibana'; +import type { SearchHit } from '../../common/search_strategy'; + +export const useBulkGetUserProfiles = (actionItems: SearchHit[]) => { + const { userProfile } = useKibana().services; + + const uidList = useMemo(() => { + const uidSet = new Set(); + + for (const item of actionItems) { + const uid = (item.fields?.user_profile_uid as string[] | undefined)?.[0]; + if (uid) { + uidSet.add(uid); + } + } + + return Array.from(uidSet).sort(); + }, [actionItems]); + + const { data: userProfiles, isLoading } = useQuery( + ['useBulkGetUserProfiles', ...uidList], + () => userProfile.bulkGet({ uids: new Set(uidList), dataPath: 'avatar' }), + { + enabled: uidList.length > 0, + staleTime: Infinity, + retry: false, + keepPreviousData: true, + } + ); + + const profilesMap = useMemo(() => { + if (!userProfiles) return new Map(); + + return new Map(userProfiles.map((profile) => [profile.uid, profile])); + }, [userProfiles]); + + return { profilesMap, isLoading: isLoading && uidList.length > 0 }; +}; diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/list/index.tsx index 195b649c02b92..9c8032c5547a9 100644 --- a/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/list/index.tsx +++ b/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/list/index.tsx @@ -45,6 +45,13 @@ export interface SavedQuerySO { prebuilt?: boolean; } +const RUN_QUERY_PERMISSION_DENIED = i18n.translate( + 'xpack.osquery.savedQueryList.permissionDeniedRunTooltip', + { + defaultMessage: 'You do not have sufficient permissions to run this query.', + } +); + interface PlayButtonProps { disabled: boolean; savedQuery: SavedQuerySO; @@ -79,8 +86,10 @@ const PlayButtonComponent: React.FC = ({ disabled = false, save [savedQuery] ); + const tooltipContent = disabled ? RUN_QUERY_PERMISSION_DENIED : playText; + return ( - + { ); const renderPlayAction = useCallback( - (item: SavedQuerySO) => - permissions.runSavedQueries || permissions.writeLiveQueries ? ( - - ) : ( - <> - ), - [permissions.runSavedQueries, permissions.writeLiveQueries] + (item: SavedQuerySO) => ( + + ), + [permissions.runSavedQueries] ); const renderUpdatedAt = useCallback((updatedAt: any, item: any) => { diff --git a/x-pack/platform/plugins/shared/osquery/server/create_indices/actions_mapping.ts b/x-pack/platform/plugins/shared/osquery/server/create_indices/actions_mapping.ts index f6757e9d9a5ef..bb00c5e0c94cd 100644 --- a/x-pack/platform/plugins/shared/osquery/server/create_indices/actions_mapping.ts +++ b/x-pack/platform/plugins/shared/osquery/server/create_indices/actions_mapping.ts @@ -61,6 +61,10 @@ export const actionsMapping: MappingTypeMapping = { type: 'keyword', ignore_above: 1024, }, + user_profile_uid: { + type: 'keyword', + ignore_above: 1024, + }, metadata: { type: 'object', enabled: false, diff --git a/x-pack/platform/plugins/shared/osquery/server/create_indices/create_transforms_indices.test.ts b/x-pack/platform/plugins/shared/osquery/server/create_indices/create_transforms_indices.test.ts index 3de1cbd27ca8b..8c799fc71b151 100644 --- a/x-pack/platform/plugins/shared/osquery/server/create_indices/create_transforms_indices.test.ts +++ b/x-pack/platform/plugins/shared/osquery/server/create_indices/create_transforms_indices.test.ts @@ -109,6 +109,7 @@ function makeEsIndexMappings( '@timestamp': { type: 'date' }, action_id: { ignore_above: 1024, type: 'keyword' }, user_id: { ignore_above: 1024, type: 'keyword' }, + user_profile_uid: { ignore_above: 1024, type: 'keyword' }, expiration: { type: 'date' }, event: { properties: { diff --git a/x-pack/platform/plugins/shared/osquery/server/handlers/action/create_action_handler.ts b/x-pack/platform/plugins/shared/osquery/server/handlers/action/create_action_handler.ts index 0237994684ba7..9310953d1da0a 100644 --- a/x-pack/platform/plugins/shared/osquery/server/handlers/action/create_action_handler.ts +++ b/x-pack/platform/plugins/shared/osquery/server/handlers/action/create_action_handler.ts @@ -24,6 +24,7 @@ import { getInternalSavedObjectsClientForSpaceId } from '../../utils/get_interna interface Metadata { currentUser: string | undefined; + userProfileUid: string | undefined; } interface CreateActionHandlerOptions { @@ -95,6 +96,7 @@ export const createActionHandler = async ( agent_policy_ids: params.agent_policy_ids, agents: selectedAgents, user_id: metadata?.currentUser, + user_profile_uid: metadata?.userProfileUid, metadata: params.metadata, pack_id: params.pack_id, pack_name: packSO?.attributes?.name, @@ -165,7 +167,7 @@ export const createActionHandler = async ( } osqueryContext.telemetryEventsSender.reportEvent(TELEMETRY_EBT_LIVE_QUERY_EVENT, { - ...omit(osqueryAction, ['type', 'input_type', 'user_id', 'error']), + ...omit(osqueryAction, ['type', 'input_type', 'user_id', 'user_profile_uid', 'error']), agents: osqueryAction.agents.length, }); diff --git a/x-pack/platform/plugins/shared/osquery/server/lib/get_result_counts_for_actions.test.ts b/x-pack/platform/plugins/shared/osquery/server/lib/get_result_counts_for_actions.test.ts new file mode 100644 index 0000000000000..3eb0c962fd9dd --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/server/lib/get_result_counts_for_actions.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core/server'; +import { getResultCountsForActions } from './get_result_counts_for_actions'; + +const createMockEsClient = (searchResponse: object): ElasticsearchClient => + ({ + search: jest.fn().mockResolvedValue(searchResponse), + } as unknown as ElasticsearchClient); + +describe('getResultCountsForActions', () => { + it('returns empty map when no action IDs provided', async () => { + const esClient = createMockEsClient({}); + const result = await getResultCountsForActions(esClient, []); + + expect(result.size).toBe(0); + expect(esClient.search).not.toHaveBeenCalled(); + }); + + it('returns result counts for action IDs with responses', async () => { + const esClient = createMockEsClient({ + aggregations: { + action_ids: { + buckets: [ + { + key: 'action-1', + doc_count: 3, + rows_count: { value: 42 }, + responses: { + buckets: [ + { key: 'success', doc_count: 2 }, + { key: 'error', doc_count: 1 }, + ], + }, + }, + { + key: 'action-2', + doc_count: 1, + rows_count: { value: 10 }, + responses: { + buckets: [{ key: 'success', doc_count: 1 }], + }, + }, + ], + }, + }, + }); + + const result = await getResultCountsForActions(esClient, ['action-1', 'action-2']); + + expect(result.get('action-1')).toEqual({ + totalRows: 42, + respondedAgents: 3, + successfulAgents: 2, + errorAgents: 1, + }); + expect(result.get('action-2')).toEqual({ + totalRows: 10, + respondedAgents: 1, + successfulAgents: 1, + errorAgents: 0, + }); + }); + + it('fills in zeros for action IDs without responses', async () => { + const esClient = createMockEsClient({ + aggregations: { + action_ids: { + buckets: [ + { + key: 'action-1', + doc_count: 1, + rows_count: { value: 5 }, + responses: { + buckets: [{ key: 'success', doc_count: 1 }], + }, + }, + ], + }, + }, + }); + + const result = await getResultCountsForActions(esClient, ['action-1', 'action-missing']); + + expect(result.get('action-1')).toEqual({ + totalRows: 5, + respondedAgents: 1, + successfulAgents: 1, + errorAgents: 0, + }); + expect(result.get('action-missing')).toEqual({ + totalRows: 0, + respondedAgents: 0, + successfulAgents: 0, + errorAgents: 0, + }); + }); + + it('uses the correct index with namespace', async () => { + const esClient = createMockEsClient({ + aggregations: { action_ids: { buckets: [] } }, + }); + + await getResultCountsForActions(esClient, ['action-1'], 'production'); + + expect(esClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: 'logs-osquery_manager.action.responses-production', + }) + ); + }); + + it('batches requests when action IDs exceed 1000', async () => { + const actionIds = Array.from({ length: 1500 }, (_, i) => `action-${i}`); + + const esClient = createMockEsClient({ + aggregations: { action_ids: { buckets: [] } }, + }); + + await getResultCountsForActions(esClient, actionIds); + + expect(esClient.search).toHaveBeenCalledTimes(2); + + const firstCallArgs = (esClient.search as jest.Mock).mock.calls[0][0]; + expect(firstCallArgs.aggs.action_ids.terms.size).toBe(1000); + + const secondCallArgs = (esClient.search as jest.Mock).mock.calls[1][0]; + expect(secondCallArgs.aggs.action_ids.terms.size).toBe(500); + }); + + it('merges results from multiple batches', async () => { + const actionIds = Array.from({ length: 1500 }, (_, i) => `action-${i}`); + + const esClient = { + search: jest + .fn() + .mockResolvedValueOnce({ + aggregations: { + action_ids: { + buckets: [ + { + key: 'action-0', + doc_count: 1, + rows_count: { value: 10 }, + responses: { buckets: [{ key: 'success', doc_count: 1 }] }, + }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + aggregations: { + action_ids: { + buckets: [ + { + key: 'action-1000', + doc_count: 2, + rows_count: { value: 20 }, + responses: { buckets: [{ key: 'success', doc_count: 2 }] }, + }, + ], + }, + }, + }), + } as unknown as ElasticsearchClient; + + const result = await getResultCountsForActions(esClient, actionIds); + + expect(result.get('action-0')).toEqual({ + totalRows: 10, + respondedAgents: 1, + successfulAgents: 1, + errorAgents: 0, + }); + expect(result.get('action-1000')).toEqual({ + totalRows: 20, + respondedAgents: 2, + successfulAgents: 2, + errorAgents: 0, + }); + expect(result.size).toBe(1500); + }); + + it('handles empty aggregation buckets', async () => { + const esClient = createMockEsClient({ + aggregations: { action_ids: { buckets: [] } }, + }); + + const result = await getResultCountsForActions(esClient, ['action-1']); + + expect(result.get('action-1')).toEqual({ + totalRows: 0, + respondedAgents: 0, + successfulAgents: 0, + errorAgents: 0, + }); + }); + + it('handles missing aggregations gracefully', async () => { + const esClient = createMockEsClient({}); + + const result = await getResultCountsForActions(esClient, ['action-1']); + + expect(result.get('action-1')).toEqual({ + totalRows: 0, + respondedAgents: 0, + successfulAgents: 0, + errorAgents: 0, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/osquery/server/lib/get_result_counts_for_actions.ts b/x-pack/platform/plugins/shared/osquery/server/lib/get_result_counts_for_actions.ts new file mode 100644 index 0000000000000..c3eecce0e9a83 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/server/lib/get_result_counts_for_actions.ts @@ -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 type { ElasticsearchClient } from '@kbn/core/server'; +import type { estypes } from '@elastic/elasticsearch'; +import { chunk } from 'lodash'; +import { ACTION_RESPONSES_DATA_STREAM_INDEX } from '../../common/constants'; + +const MAX_ACTION_IDS_PER_BATCH = 1000; + +export interface ResultCountsEntry { + totalRows: number; + respondedAgents: number; + successfulAgents: number; + errorAgents: number; +} + +export type ResultCountsMap = Map; + +interface ActionResponseAggregation { + action_ids: { + buckets: Array<{ + key: string; + doc_count: number; + rows_count: { value: number }; + responses: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; + }>; + }; +} + +export const getResultCountsForActions = async ( + esClient: ElasticsearchClient, + actionIds: string[], + namespace = 'default' +): Promise => { + if (actionIds.length === 0) { + return new Map(); + } + + const batches = chunk(actionIds, MAX_ACTION_IDS_PER_BATCH); + + const batchResults = await Promise.all( + batches.map((batchIds) => fetchResultCountsBatch(esClient, batchIds, namespace)) + ); + + const result: ResultCountsMap = new Map(); + for (const batchResult of batchResults) { + for (const [actionId, counts] of batchResult) { + result.set(actionId, counts); + } + } + + return result; +}; + +const fetchResultCountsBatch = async ( + esClient: ElasticsearchClient, + actionIds: string[], + namespace: string +): Promise => { + const index = `${ACTION_RESPONSES_DATA_STREAM_INDEX}-${namespace}`; + + const response = await esClient.search({ + index, + size: 0, + query: { + terms: { + action_id: actionIds, + }, + }, + aggs: { + action_ids: { + terms: { + field: 'action_id', + size: actionIds.length, + }, + aggs: { + rows_count: { + sum: { + field: 'action_response.osquery.count', + }, + }, + responses: { + terms: { + script: { + lang: 'painless', + source: + "if (doc['error.keyword'].size()==0) { return 'success' } else { return 'error' }", + } as estypes.Script, + }, + }, + }, + }, + }, + }); + + const result: ResultCountsMap = new Map(); + const buckets = response.aggregations?.action_ids?.buckets ?? []; + + for (const bucket of buckets) { + const successBucket = bucket.responses.buckets.find((b) => b.key === 'success'); + const errorBucket = bucket.responses.buckets.find((b) => b.key === 'error'); + + result.set(bucket.key, { + totalRows: bucket.rows_count.value ?? 0, + respondedAgents: bucket.doc_count, + successfulAgents: successBucket?.doc_count ?? 0, + errorAgents: errorBucket?.doc_count ?? 0, + }); + } + + for (const actionId of actionIds) { + if (!result.has(actionId)) { + result.set(actionId, { + totalRows: 0, + respondedAgents: 0, + successfulAgents: 0, + errorAgents: 0, + }); + } + } + + return result; +}; diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/create_live_query_route.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/create_live_query_route.ts index 5cc8e1fe05fb8..872564e45e7fd 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/create_live_query_route.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/create_live_query_route.ts @@ -19,6 +19,7 @@ import { PARAMETER_NOT_FOUND } from '../../../common/translations/errors'; import { replaceParamsQuery } from '../../../common/utils/replace_params_query'; import { buildRouteValidation } from '../../utils/build_validation/route_validation'; import type { OsqueryAppContext } from '../../lib/osquery_app_context_services'; +import type { StartPlugins } from '../../types'; import { createActionHandler } from '../../handlers'; import { parser as OsqueryParser } from './osquery_parser'; import { getUserInfo } from '../../lib/get_user_info'; @@ -49,7 +50,7 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp }, }, async (context, request, response) => { - const [coreStartServices] = await osqueryContext.getStartServices(); + const [coreStartServices, startPlugins] = await osqueryContext.getStartServices(); const { osquery: { writeLiveQueries, runSavedQueries }, @@ -113,18 +114,20 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp } try { + const securityStart = (startPlugins as StartPlugins).security; const currentUser = await getUserInfo({ request, - security: osqueryContext.security, + security: securityStart, logger: osqueryContext.logFactory.get('liveQuery'), }); const username = currentUser?.username ?? undefined; + const userProfileUid = currentUser?.profile_uid ?? undefined; const space = await osqueryContext.service.getActiveSpace(request); const { response: osqueryAction, fleetActionsCount } = await createActionHandler( osqueryContext, request.body, { - metadata: { currentUser: username }, + metadata: { currentUser: username, userProfileUid }, alertData, space, } diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.test.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.test.ts new file mode 100644 index 0000000000000..02fdc1504a3c9 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.test.ts @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { of } from 'rxjs'; +import { coreMock, httpServerMock, httpServiceMock } from '@kbn/core/server/mocks'; +import type { RequestHandler } from '@kbn/core/server'; +import { API_VERSIONS } from '../../../common/constants'; +import type { OsqueryAppContext } from '../../lib/osquery_app_context_services'; +import { findLiveQueryRoute } from './find_live_query_route'; +import { getResultCountsForActions } from '../../lib/get_result_counts_for_actions'; + +jest.mock('../../lib/get_result_counts_for_actions', () => ({ + getResultCountsForActions: jest.fn(), +})); + +describe('findLiveQueryRoute', () => { + let routeHandler: RequestHandler; + let mockOsqueryContext: OsqueryAppContext; + + const createMockRouter = () => { + const httpService = httpServiceMock.createSetupContract(); + + return httpService.createRouter(); + }; + + const createMockContext = (mockSearchFn: jest.Mock) => { + const mockCoreContext = coreMock.createRequestHandlerContext(); + + return { + core: Promise.resolve(mockCoreContext), + search: Promise.resolve({ + search: mockSearchFn, + saveSession: jest.fn(), + getSession: jest.fn(), + findSessions: jest.fn(), + updateSession: jest.fn(), + cancelSession: jest.fn(), + deleteSession: jest.fn(), + }), + }; + }; + + const mockEsClient = { search: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockOsqueryContext = { + service: { + getActiveSpace: jest.fn().mockResolvedValue({ id: 'default' }), + }, + getStartServices: jest + .fn() + .mockResolvedValue([{ elasticsearch: { client: { asInternalUser: mockEsClient } } }]), + } as unknown as OsqueryAppContext; + }); + + const setupRoute = () => { + const mockRouter = createMockRouter(); + findLiveQueryRoute(mockRouter, mockOsqueryContext); + + const route = mockRouter.versioned.getRoute('get', '/api/osquery/live_queries'); + const routeVersion = route.versions[API_VERSIONS.public.v1]; + if (!routeVersion) { + throw new Error(`Handler for version [${API_VERSIONS.public.v1}] not found!`); + } + + routeHandler = routeVersion.handler; + }; + + it('returns items without result_counts when withResultCounts is not set', async () => { + const edges = [ + { + _source: { + action_id: 'action-1', + queries: [{ action_id: 'query-1', query: 'select 1;', agents: ['agent-1'] }], + }, + fields: { action_id: ['action-1'] }, + }, + ]; + + const mockSearchFn = jest.fn().mockReturnValue( + of({ + edges, + rawResponse: { hits: { total: 1 } }, + total: 1, + }) + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { kuery: undefined, page: 0, pageSize: 20 }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + expect(getResultCountsForActions).not.toHaveBeenCalled(); + expect(mockResponse.ok).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + items: edges, + }), + }), + }) + ); + }); + + it('enriches single query items with result_counts when withResultCounts is true', async () => { + const edges = [ + { + _source: { + action_id: 'action-1', + queries: [{ action_id: 'query-1', query: 'select 1;', agents: ['agent-1'] }], + }, + fields: { action_id: ['action-1'] }, + }, + ]; + + const mockSearchFn = jest.fn().mockReturnValue( + of({ + edges, + rawResponse: { hits: { total: 1 } }, + total: 1, + }) + ); + + (getResultCountsForActions as jest.Mock).mockResolvedValue( + new Map([ + ['query-1', { totalRows: 42, respondedAgents: 3, successfulAgents: 2, errorAgents: 1 }], + ]) + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { kuery: undefined, page: 0, pageSize: 20, withResultCounts: true }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + expect(getResultCountsForActions).toHaveBeenCalledWith(mockEsClient, ['query-1'], 'default'); + + const responseBody = mockResponse.ok.mock.calls[0][0]?.body as { + data: { items: Array<{ _source: Record }> }; + }; + expect(responseBody.data.items[0]._source).toEqual( + expect.objectContaining({ + result_counts: { + total_rows: 42, + responded_agents: 3, + successful_agents: 2, + error_agents: 1, + }, + }) + ); + }); + + it('enriches pack items with aggregated result_counts', async () => { + const edges = [ + { + _source: { + action_id: 'action-1', + pack_id: 'pack-1', + queries: [ + { action_id: 'query-1', query: 'select 1;', agents: ['agent-1'] }, + { action_id: 'query-2', query: 'select 2;', agents: ['agent-1'] }, + { action_id: 'query-3', query: 'select 3;', agents: ['agent-1'] }, + ], + }, + fields: { action_id: ['action-1'] }, + }, + ]; + + const mockSearchFn = jest.fn().mockReturnValue( + of({ + edges, + rawResponse: { hits: { total: 1 } }, + total: 1, + }) + ); + + (getResultCountsForActions as jest.Mock).mockResolvedValue( + new Map([ + ['query-1', { totalRows: 10, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + ['query-2', { totalRows: 0, respondedAgents: 1, successfulAgents: 0, errorAgents: 1 }], + ['query-3', { totalRows: 5, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + ]) + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { kuery: undefined, page: 0, pageSize: 20, withResultCounts: true }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + const responseBody = mockResponse.ok.mock.calls[0][0]?.body as { + data: { items: Array<{ _source: Record }> }; + }; + expect(responseBody.data.items[0]._source).toEqual( + expect.objectContaining({ + result_counts: { + total_rows: 15, + queries_with_results: 2, + queries_total: 3, + successful_agents: 1, + error_agents: 0, + }, + }) + ); + }); + + it('returns items without enrichment when result counts aggregation fails', async () => { + const edges = [ + { + _source: { + action_id: 'action-1', + queries: [{ action_id: 'query-1', query: 'select 1;', agents: ['agent-1'] }], + }, + fields: { action_id: ['action-1'] }, + }, + ]; + + const mockSearchFn = jest.fn().mockReturnValue( + of({ + edges, + rawResponse: { hits: { total: 1 } }, + total: 1, + }) + ); + + (getResultCountsForActions as jest.Mock).mockRejectedValue( + new Error('index_not_found_exception') + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { kuery: undefined, page: 0, pageSize: 20, withResultCounts: true }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + items: edges, + }), + }), + }) + ); + }); + + it('passes custom spaceId to getResultCountsForActions', async () => { + (mockOsqueryContext.service.getActiveSpace as jest.Mock).mockResolvedValue({ + id: 'custom-space', + }); + + const edges = [ + { + _source: { + action_id: 'action-1', + queries: [{ action_id: 'query-1', query: 'select 1;', agents: ['agent-1'] }], + }, + fields: { action_id: ['action-1'] }, + }, + ]; + + const mockSearchFn = jest.fn().mockReturnValue( + of({ + edges, + rawResponse: { hits: { total: 1 } }, + total: 1, + }) + ); + + (getResultCountsForActions as jest.Mock).mockResolvedValue( + new Map([ + ['query-1', { totalRows: 5, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + ]) + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { kuery: undefined, page: 0, pageSize: 20, withResultCounts: true }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + expect(getResultCountsForActions).toHaveBeenCalledWith( + mockEsClient, + ['query-1'], + 'custom-space' + ); + }); + + it('handles items without queries gracefully', async () => { + const edges = [ + { + _source: { action_id: 'action-1' }, + fields: { action_id: ['action-1'] }, + }, + ]; + + const mockSearchFn = jest.fn().mockReturnValue( + of({ + edges, + rawResponse: { hits: { total: 1 } }, + total: 1, + }) + ); + + (getResultCountsForActions as jest.Mock).mockResolvedValue(new Map()); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { kuery: undefined, page: 0, pageSize: 20, withResultCounts: true }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + const responseBody = mockResponse.ok.mock.calls[0][0]?.body as { + data: { items: Array<{ _source: Record }> }; + }; + expect(responseBody.data.items[0]._source).toEqual({ action_id: 'action-1' }); + }); +}); diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts index a1dd8360917bb..551967de0e3d2 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts @@ -18,6 +18,7 @@ import { API_VERSIONS } from '../../../common/constants'; import { PLUGIN_ID } from '../../../common'; import type { + ActionDetails, ActionsRequestOptions, ActionsStrategyResponse, Direction, @@ -25,6 +26,7 @@ import type { import { OsqueryQueries } from '../../../common/search_strategy'; import { findLiveQueryRequestQuerySchema } from '../../../common/api'; import { generateTablePaginationOptions } from '../../../common/utils/build_query'; +import { getResultCountsForActions } from '../../lib/get_result_counts_for_actions'; export const findLiveQueryRoute = ( router: IRouter, @@ -81,11 +83,101 @@ export const findLiveQueryRoute = ( ) ); + let items = res.edges; + + if (request.query.withResultCounts && items.length > 0) { + try { + const [coreStartServices] = await osqueryContext.getStartServices(); + const esClient = coreStartServices.elasticsearch.client.asInternalUser; + + const allActionIds: string[] = []; + for (const item of items) { + const action = item._source as ActionDetails | undefined; + if (action?.queries) { + for (const query of action.queries) { + if (query.action_id) { + allActionIds.push(query.action_id); + } + } + } + } + + const resultCountsMap = await getResultCountsForActions( + esClient, + allActionIds, + spaceId + ); + + items = items.map((item) => { + const action = item._source as ActionDetails | undefined; + if (!action?.queries) return item; + + if (action.pack_id) { + let totalRows = 0; + let queriesWithResults = 0; + let successfulAgents = 0; + let errorAgents = 0; + let maxRespondedAgents = 0; + + for (const query of action.queries) { + if (query.action_id) { + const counts = resultCountsMap.get(query.action_id); + if (counts) { + totalRows += counts.totalRows; + if (counts.totalRows > 0) { + queriesWithResults++; + } + + if (counts.respondedAgents > maxRespondedAgents) { + maxRespondedAgents = counts.respondedAgents; + successfulAgents = counts.successfulAgents; + errorAgents = counts.errorAgents; + } + } + } + } + + return { + ...item, + _source: { + ...action, + result_counts: { + total_rows: totalRows, + queries_with_results: queriesWithResults, + queries_total: action.queries.length, + successful_agents: successfulAgents, + error_agents: errorAgents, + }, + }, + }; + } + + const queryActionId = action.queries[0]?.action_id; + const counts = queryActionId ? resultCountsMap.get(queryActionId) : undefined; + + return { + ...item, + _source: { + ...action, + result_counts: { + total_rows: counts?.totalRows ?? 0, + responded_agents: counts?.respondedAgents ?? 0, + successful_agents: counts?.successfulAgents ?? 0, + error_agents: counts?.errorAgents ?? 0, + }, + }, + }; + }); + } catch { + // Result counts are supplementary — don't fail the listing if aggregation errors + } + } + return response.ok({ body: { data: { ...omit(res, 'edges'), - items: res.edges, + items, }, }, }); diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts index 51edb88ce2528..37d5d8c43af37 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts @@ -55,6 +55,8 @@ describe('getLiveQueryDetailsRoute', () => { actionDetails: { _source: { action_id: 'action-1', + user_id: 'test-user', + user_profile_uid: 'u_test-profile-uid', queries: [ { action_id: 'query-1', @@ -117,6 +119,8 @@ describe('getLiveQueryDetailsRoute', () => { body: { data: expect.objectContaining({ action_id: 'action-1', + user_id: 'test-user', + user_profile_uid: 'u_test-profile-uid', status: 'completed', queries: [ expect.objectContaining({ diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts index c4fa84b8d9c57..12b969bb790c5 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts @@ -108,6 +108,7 @@ export const getLiveQueryDetailsRoute = ( 'agent_selection', 'agents', 'user_id', + 'user_profile_uid', 'pack_id', 'pack_name', 'prebuilt_pack' diff --git a/x-pack/platform/plugins/shared/osquery/server/types.ts b/x-pack/platform/plugins/shared/osquery/server/types.ts index 0c60cc264675e..c5f91811dba5c 100644 --- a/x-pack/platform/plugins/shared/osquery/server/types.ts +++ b/x-pack/platform/plugins/shared/osquery/server/types.ts @@ -50,6 +50,7 @@ export interface StartPlugins { data: DataPluginStart; dataViews: DataViewsPluginStart; fleet?: FleetStartContract; + security: SecurityPluginStart; taskManager?: TaskManagerPluginStart; telemetry?: TelemetryPluginStart; ruleRegistry?: RuleRegistryPluginStartContract; diff --git a/x-pack/platform/plugins/shared/osquery/tsconfig.json b/x-pack/platform/plugins/shared/osquery/tsconfig.json index 0435a6bf6f124..207a78cd7c69a 100644 --- a/x-pack/platform/plugins/shared/osquery/tsconfig.json +++ b/x-pack/platform/plugins/shared/osquery/tsconfig.json @@ -85,6 +85,7 @@ "@kbn/core-http-server", "@kbn/react-query", "@kbn/react-kibana-mount", - "@kbn/tooling-log" + "@kbn/tooling-log", + "@kbn/user-profile-components" ] } diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts index 2da8343b65fee..82b3451cc1fa3 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts @@ -46,12 +46,14 @@ describe('UiamAPIKeys', () => { invalidateSessionTokens: jest.fn(), grantApiKey: jest.fn(), revokeApiKey: jest.fn(), + convertApiKeys: jest.fn(), }; uiamApiKeys = new UiamAPIKeys({ logger, license: mockLicense, uiam: mockUiam, + elasticsearchHost: 'https://es.example.com:9200', }); }); @@ -296,6 +298,98 @@ describe('UiamAPIKeys', () => { }); }); + describe('convert()', () => { + it('returns null when license is not enabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + + const result = await uiamApiKeys.convert({ + keys: [{ key: 'es-api-key' }], + }); + + expect(result).toBeNull(); + expect(mockUiam.convertApiKeys).not.toHaveBeenCalled(); + }); + + it('throws when elasticsearchHost is not configured', async () => { + const uiamApiKeysNoHost = new UiamAPIKeys({ + logger, + license: mockLicense, + uiam: mockUiam, + }); + + await expect( + uiamApiKeysNoHost.convert({ keys: [{ key: 'es-api-key' }] }) + ).rejects.toThrow('Cannot convert API keys: elasticsearch.hosts is not configured'); + + expect(mockUiam.convertApiKeys).not.toHaveBeenCalled(); + }); + + it('successfully converts API keys via UIAM', async () => { + const mockResponse = { + results: [ + { + status: 'success' as const, + id: 'converted-key-id', + key: 'essu_converted_key', + description: 'converted key', + organization_id: 'org-123', + internal: true, + role_assignments: {}, + creation_date: '2026-01-01T00:00:00Z', + expiration_date: null, + }, + ], + }; + mockUiam.convertApiKeys.mockResolvedValue(mockResponse); + + const result = await uiamApiKeys.convert({ + keys: [{ key: 'es-api-key-base64' }], + }); + + expect(result).toEqual(mockResponse); + expect(mockUiam.convertApiKeys).toHaveBeenCalledWith([ + { type: 'elasticsearch', key: 'es-api-key-base64', endpoint: 'https://es.example.com:9200' }, + ]); + expect(logger.debug).toHaveBeenCalledWith('Trying to convert 1 API key(s)'); + expect(logger.debug).toHaveBeenCalledWith('API key(s) converted successfully'); + }); + + it('injects the same elasticsearch host endpoint for all keys', async () => { + const mockResponse = { + results: [ + { status: 'success' as const, id: 'k1', key: 'essu_k1', description: 'key 1', organization_id: 'org-1', internal: true, role_assignments: {}, creation_date: '2026-01-01T00:00:00Z', expiration_date: null }, + { status: 'failed' as const, code: 'ES_API_KEY_AUTHENTICATION_FAILED', message: 'Auth failed', resource: null, type: 'UNKNOWN' }, + ], + }; + mockUiam.convertApiKeys.mockResolvedValue(mockResponse); + + const result = await uiamApiKeys.convert({ + keys: [{ key: 'valid-key' }, { key: 'invalid-key' }], + }); + + expect(result).toEqual(mockResponse); + expect(mockUiam.convertApiKeys).toHaveBeenCalledWith([ + { type: 'elasticsearch', key: 'valid-key', endpoint: 'https://es.example.com:9200' }, + { type: 'elasticsearch', key: 'invalid-key', endpoint: 'https://es.example.com:9200' }, + ]); + }); + + it('logs and throws error when UIAM conversion fails', async () => { + const error = new Error('UIAM service error'); + mockUiam.convertApiKeys.mockRejectedValue(error); + + await expect( + uiamApiKeys.convert({ + keys: [{ key: 'es-api-key' }], + }) + ).rejects.toThrow('UIAM service error'); + + expect(logger.error).toHaveBeenCalledWith( + 'Failed to convert API keys: UIAM service error' + ); + }); + }); + describe('getAuthorizationHeader()', () => { it('extracts authorization header from request', () => { const request = httpServerMock.createKibanaRequest({ diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts index 21aa97ce7b388..263952410610e 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts @@ -8,6 +8,8 @@ import type { KibanaRequest, Logger } from '@kbn/core/server'; import { HTTPAuthorizationHeader, isUiamCredential } from '@kbn/core-security-server'; import type { + ConvertUiamAPIKeyParams, + ConvertUiamAPIKeysResponse, GrantAPIKeyResult, GrantUiamAPIKeyParams, InvalidateAPIKeyResult, @@ -26,6 +28,7 @@ export interface UiamAPIKeysOptions { logger: Logger; license: SecurityLicense; uiam: UiamServicePublic; + elasticsearchHost?: string; } /** @@ -36,11 +39,13 @@ export class UiamAPIKeys implements UiamAPIKeysType { private readonly logger: Logger; private readonly license: SecurityLicense; private readonly uiam: UiamServicePublic; + private readonly elasticsearchHost?: string; - constructor({ logger, license, uiam }: UiamAPIKeysOptions) { + constructor({ logger, license, uiam, elasticsearchHost }: UiamAPIKeysOptions) { this.logger = logger; this.license = license; this.uiam = uiam; + this.elasticsearchHost = elasticsearchHost; } /** @@ -147,6 +152,43 @@ export class UiamAPIKeys implements UiamAPIKeysType { } } + /** + * Converts Elasticsearch API keys into UIAM API keys. + * + * @param params The parameters containing the keys to convert. + * @returns A promise that resolves to a response containing per-key success/failure results, or null if the license is not enabled. + */ + async convert(params: ConvertUiamAPIKeyParams): Promise { + if (!this.license.isEnabled()) { + return null; + } + + if (!this.elasticsearchHost) { + throw new Error( + 'Cannot convert API keys: elasticsearch.hosts is not configured' + ); + } + + this.logger.debug(`Trying to convert ${params.keys.length} API key(s)`); + + try { + const endpoint = this.elasticsearchHost; + const mappedKeys = params.keys.map(({ key }) => ({ + type: 'elasticsearch' as const, + key, + endpoint, + })); + + const response = await this.uiam.convertApiKeys(mappedKeys); + + this.logger.debug('API key(s) converted successfully'); + return response; + } catch (e) { + this.logger.error(`Failed to convert API keys: ${getDetailedErrorMessage(e)}`); + throw e; + } + } + /** * Extracts and returns the authorization header from the request. * diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts b/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts index 2051ce416e7c5..cc046556af0e9 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts @@ -70,6 +70,7 @@ interface AuthenticationServiceStartParams { isElasticCloudDeployment: () => boolean; customLogoutURL?: string; buildFlavor?: BuildFlavor; + elasticsearchHost?: string; } export interface InternalAuthenticationServiceStart extends AuthenticationServiceStart { @@ -352,6 +353,7 @@ export class AuthenticationService { customLogoutURL, buildFlavor = 'traditional', uiam, + elasticsearchHost, }: AuthenticationServiceStartParams): InternalAuthenticationServiceStart { const apiKeys = new APIKeys({ clusterClient, @@ -368,6 +370,7 @@ export class AuthenticationService { logger: this.logger.get('api-key-uiam'), license: this.license, uiam, + elasticsearchHost, }) : null; @@ -420,6 +423,7 @@ export class AuthenticationService { ? { grant: uiamAPIKeys.grant.bind(uiamAPIKeys), invalidate: uiamAPIKeys.invalidate.bind(uiamAPIKeys), + convert: uiamAPIKeys.convert.bind(uiamAPIKeys), } : null, }, diff --git a/x-pack/platform/plugins/shared/security/server/build_delegate_apis.ts b/x-pack/platform/plugins/shared/security/server/build_delegate_apis.ts index 4e90d1eb87d1f..fd1eaf99a3392 100644 --- a/x-pack/platform/plugins/shared/security/server/build_delegate_apis.ts +++ b/x-pack/platform/plugins/shared/security/server/build_delegate_apis.ts @@ -7,6 +7,7 @@ import type { KibanaRequest } from '@kbn/core-http-server'; import type { + ConvertUiamAPIKeyParams, CoreSecurityDelegateContract, GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams, @@ -57,6 +58,8 @@ export const buildSecurityApi = ({ request: KibanaRequest, invalidateUiamApiKeyParams: InvalidateUiamAPIKeyParams ) => getAuthc().apiKeys.uiam!.invalidate(request, invalidateUiamApiKeyParams), + convert: (convertUiamApiKeyParams: ConvertUiamAPIKeyParams) => + getAuthc().apiKeys.uiam!.convert(convertUiamApiKeyParams), } : null, }, diff --git a/x-pack/platform/plugins/shared/security/server/otel/instrumentation.ts b/x-pack/platform/plugins/shared/security/server/otel/instrumentation.ts index 8cb821d724a7e..a984980ed5356 100644 --- a/x-pack/platform/plugins/shared/security/server/otel/instrumentation.ts +++ b/x-pack/platform/plugins/shared/security/server/otel/instrumentation.ts @@ -26,9 +26,15 @@ interface UserAuthenticationAttributes extends BasicAttributes { providerType: string; } -export type SecurityTelemetryAttributes = BasicAttributes & +interface GetCurrentProfileAttributes extends BasicAttributes { + profileActivationRequired?: boolean; + apiKeyRetrievalRequired?: boolean; +} + +export type SecurityTelemetryAttributes = Partial & Partial & - Partial; + Partial & + Partial; class SecurityTelemetry { private readonly meter = metrics.getMeter('kibana.security'); @@ -38,6 +44,7 @@ class SecurityTelemetry { private readonly sessionCreationDuration: Histogram; private readonly logoutCounter: Counter; private readonly privilegeRegistrationDuration: Histogram; + private readonly getCurrentProfileCounter: Counter; // Adds more boundaries in 50-500ms range where most operations typically fall private readonly DEFAULT_BUCKET_BOUNDARIES = [ @@ -103,16 +110,39 @@ class SecurityTelemetry { }, } ); + + this.getCurrentProfileCounter = this.meter.createCounter( + 'user_profiles.get_current.invocations', + { + description: 'Number of invocations of getCurrent', + unit: '1', + valueType: ValueType.INT, + } + ); } private transformAttributes(attributes: T): Attributes { - const { application, providerType, outcome, deletedPrivileges, ...rest } = attributes; + const { + application, + providerType, + outcome, + deletedPrivileges, + profileActivationRequired, + apiKeyRetrievalRequired, + ...rest + } = attributes; const transformed: Attributes = { ...(application ? { application } : {}), ...(deletedPrivileges ? { 'deleted.privileges': deletedPrivileges } : {}), ...(providerType ? { 'auth.provider.type': providerType } : {}), - ...(outcome ? { 'auth.outcome': outcome } : {}), + ...(outcome ? { outcome } : {}), + ...(profileActivationRequired + ? { 'profile.get_current.profile_activation_required': profileActivationRequired } + : {}), + ...(apiKeyRetrievalRequired + ? { 'profile.get_current.api_key_retrieval_required': apiKeyRetrievalRequired } + : {}), ...rest, }; @@ -152,6 +182,11 @@ class SecurityTelemetry { this.transformAttributes(attributes); this.privilegeRegistrationDuration.record(duration, transformedAttributes); }; + + recordGetCurrentProfileInvocation = (attributes: GetCurrentProfileAttributes) => { + const transformedAttributes = this.transformAttributes(attributes); + this.getCurrentProfileCounter.add(1, transformedAttributes); + }; } export const securityTelemetry = new SecurityTelemetry(); diff --git a/x-pack/platform/plugins/shared/security/server/plugin.ts b/x-pack/platform/plugins/shared/security/server/plugin.ts index 94e851ddc8d66..51651200a3b0f 100644 --- a/x-pack/platform/plugins/shared/security/server/plugin.ts +++ b/x-pack/platform/plugins/shared/security/server/plugin.ts @@ -6,7 +6,7 @@ */ import type { Subscription } from 'rxjs'; -import { map } from 'rxjs'; +import { firstValueFrom, map } from 'rxjs'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; import type { TypeOf } from '@kbn/config-schema'; @@ -184,6 +184,8 @@ export class SecurityPlugin private readonly fipsService: FipsService; private fipsServiceSetup?: FipsServiceSetupInternal; + private elasticsearchHost?: string; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -247,6 +249,10 @@ export class SecurityPlugin this.elasticsearchService.setup({ license, status: core.status }); this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); this.sessionManagementService.setup({ config, http: core.http, taskManager }); + firstValueFrom(core.elasticsearch.legacy.config$).then((esConfig) => { + this.elasticsearchHost = esConfig.hosts[0]; + }); + this.authenticationService.setup({ http: core.http, elasticsearch: core.elasticsearch, @@ -433,6 +439,7 @@ export class SecurityPlugin isElasticCloudDeployment: () => cloud?.isCloudEnabled === true, customLogoutURL, buildFlavor: this.initializerContext.env.packageInfo.buildFlavor, + elasticsearchHost: this.elasticsearchHost, }); this.authorizationService.start({ diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.test.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.test.ts index 10ed1ee664c87..050c27f9e69d2 100644 --- a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.test.ts +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.test.ts @@ -558,7 +558,7 @@ describe('AccessControlService', () => { ); }); - it('calls addAuditEventFn with all unauthorized types when access control check fails', () => { + it('calls addAuditEventFn with custom error message and RBAC unauthorized types when access control check fails', () => { const addAuditEventFn = jest.fn(); const authorizationResult = makeAuthResult('partially_authorized', { dashboard: { @@ -585,8 +585,14 @@ describe('AccessControlService', () => { }) ).toThrow(/Access denied/); - // Should be called with all unauthorized types (deduplicated and sorted) - expect(addAuditEventFn).toHaveBeenCalledWith(['dashboard']); + // Should be called with only RBAC unauthorized types (not access control types) + expect(addAuditEventFn).toHaveBeenCalledWith( + 'Access denied: Unable to perform manage access control for types dashboard. ' + + 'The "update" privilege is required to change access control of objects owned by the current user. ' + + 'Unable to manage access control for objects dashboard:obj-2: ' + + 'the "manage_access_control" privilege is required to change access control of objects owned by another user.', + ['dashboard'] + ); }); it('filters unauthorized objects to only include types that failed manage_access_control check', () => { diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.ts index b2c14e2e779f0..388443b0c4d36 100644 --- a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.ts +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.ts @@ -138,7 +138,7 @@ export class AccessControlService { authorizationResult: CheckAuthorizationResult; objectsRequiringPrivilegeCheck: ObjectRequiringPrivilegeCheckResult[]; currentSpace: string; - addAuditEventFn?: (types: string[]) => void; + addAuditEventFn?: (errMessage: string, types: string[]) => void; }) { // Derive typesRequiringAccessControl and typesRequiringRbac from the objects array. // - typesRequiringAccessControl: types where objects are NOT owned by current user (need manage_access_control) @@ -160,8 +160,8 @@ export class AccessControlService { if (authorizationResult.status === 'unauthorized') { const rbacTypeList = [...typesRequiringRbac].sort(); - const allTypes = [...new Set([...typesRequiringRbac, ...typesRequiringAccessControl])].sort(); - addAuditEventFn?.(allTypes); + const errorMsg = buildAccessDeniedMessage(rbacTypeList, objectsRequiringAccessControl); + addAuditEventFn?.(errorMsg, rbacTypeList); throw SavedObjectsErrorHelpers.decorateForbiddenError( new Error(buildAccessDeniedMessage(rbacTypeList, objectsRequiringAccessControl)) ); @@ -209,15 +209,12 @@ export class AccessControlService { if (unauthorizedRbacTypes.size > 0 || unauthorizedAccessControlTypes.size > 0) { const rbacTypeList = [...unauthorizedRbacTypes].sort(); - const accessControlTypeList = [...unauthorizedAccessControlTypes].sort(); - const allUnauthorizedTypes = [...new Set([...rbacTypeList, ...accessControlTypeList])].sort(); const unauthorizedObjects = objectsRequiringAccessControl.filter((obj) => unauthorizedAccessControlTypes.has(obj.type) ); - addAuditEventFn?.(allUnauthorizedTypes); - throw SavedObjectsErrorHelpers.decorateForbiddenError( - new Error(buildAccessDeniedMessage(rbacTypeList, unauthorizedObjects)) - ); + const errorMsg = buildAccessDeniedMessage(rbacTypeList, unauthorizedObjects); + addAuditEventFn?.(errorMsg, rbacTypeList); + throw SavedObjectsErrorHelpers.decorateForbiddenError(new Error(errorMsg)); } } } diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts index 71af8b1e4b863..b990e11ff0dba 100644 --- a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts @@ -6563,12 +6563,40 @@ describe('#authorizeChangeAccessControl', () => { ) ).rejects.toThrow(); - expect(addAuditEventSpy).toHaveBeenCalledWith( + const expectedError = new Error( + 'Access denied: Unable to perform manage access control for types visualization. ' + + 'The "update" privilege is required to change access control of objects owned by the current user. ' + + 'Unable to manage access control for objects dashboard:1: ' + + 'the "manage_access_control" privilege is required to change access control of objects owned by another user.' + ); + + expect(auditHelperSpy).toHaveBeenCalledTimes(1); + + expect(addAuditEventSpy).toHaveBeenCalledTimes(2); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expectedError, + unauthorizedTypes: ['visualization'], + unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[0].type, + id: objectsWithExistingNamespaces[0].id, + }), + }) + ); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ action: AuditAction.UPDATE_OBJECTS_OWNER, - error: expect.any(Error), - unauthorizedTypes: ['dashboard', 'visualization'], + error: expectedError, + unauthorizedTypes: ['visualization'], unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[1].type, + id: objectsWithExistingNamespaces[1].id, + }), }) ); }); @@ -6770,15 +6798,42 @@ describe('#authorizeChangeAccessControl', () => { ) ).rejects.toThrow(); - expect(addAuditEventSpy).toHaveBeenCalledWith( + const expectedError = new Error( + 'Access denied: Unable to perform manage access control for types visualization. ' + + 'The "update" privilege is required to change access control of objects owned by the current user. ' + + 'Unable to manage access control for objects dashboard:1: ' + + 'the "manage_access_control" privilege is required to change access control of objects owned by another user.' + ); + + expect(auditHelperSpy).toHaveBeenCalledTimes(1); + + expect(addAuditEventSpy).toHaveBeenCalledTimes(2); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ action: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, - error: expect.any(Error), - unauthorizedTypes: expect.arrayContaining(['dashboard']), + error: expectedError, + unauthorizedTypes: ['visualization'], unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[0].type, + id: objectsWithExistingNamespaces[0].id, + }), + }) + ); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, + error: expectedError, + unauthorizedTypes: ['visualization'], + unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[1].type, + id: objectsWithExistingNamespaces[1].id, + }), }) ); - expect(auditHelperSpy).not.toHaveBeenCalled(); }); test('audits failure event with multiple types when changeOwnership is unauthorized', async () => { @@ -6817,15 +6872,55 @@ describe('#authorizeChangeAccessControl', () => { ) ).rejects.toThrow(); - expect(addAuditEventSpy).toHaveBeenCalledWith( + const expectedError = new Error( + 'Access denied: Unable to perform manage access control for types map, visualization. ' + + 'The "update" privilege is required to change access control of objects owned by the current user. ' + + 'Unable to manage access control for objects dashboard:1: ' + + 'the "manage_access_control" privilege is required to change access control of objects owned by another user.' + ); + + expect(auditHelperSpy).toHaveBeenCalledTimes(1); + + expect(addAuditEventSpy).toHaveBeenCalledTimes(3); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ action: AuditAction.UPDATE_OBJECTS_OWNER, - error: expect.any(Error), - unauthorizedTypes: expect.arrayContaining(['dashboard']), + error: expectedError, + unauthorizedTypes: ['map', 'visualization'], unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objects[0].type, + id: objects[0].id, + }), + }) + ); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expectedError, + unauthorizedTypes: ['map', 'visualization'], + unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objects[1].type, + id: objects[1].id, + }), + }) + ); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expectedError, + unauthorizedTypes: ['map', 'visualization'], + unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objects[2].type, + id: objects[2].id, + }), }) ); - expect(auditHelperSpy).not.toHaveBeenCalled(); }); test('audits failure event when partially authorized but not in current space', async () => { @@ -6870,15 +6965,71 @@ describe('#authorizeChangeAccessControl', () => { ) ).rejects.toThrow(); - expect(addAuditEventSpy).toHaveBeenCalledWith( + const expectedError = new Error( + 'Access denied: Unable to perform manage access control for types visualization. ' + + 'The "update" privilege is required to change access control of objects owned by the current user. ' + + 'Unable to manage access control for objects dashboard:1: ' + + 'the "manage_access_control" privilege is required to change access control of objects owned by another user.' + ); + + expect(auditHelperSpy).toHaveBeenCalledTimes(1); + expect(auditHelperSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expectedError, + unauthorizedTypes: ['visualization'], + unauthorizedSpaces: [namespace], + objects: [ + { + accessControl: { + accessMode: 'write_restricted', + owner: 'fake_owner_id', + }, + existingNamespaces: [], + id: '1', + type: 'dashboard', + }, + { + accessControl: { + accessMode: 'write_restricted', + owner: 'fake_owner_id', + }, + existingNamespaces: [], + id: '2', + type: 'visualization', + }, + ], + }) + ); + + expect(addAuditEventSpy).toHaveBeenCalledTimes(2); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ action: AuditAction.UPDATE_OBJECTS_OWNER, - error: expect.any(Error), - unauthorizedTypes: ['dashboard', 'visualization'], + error: expectedError, + unauthorizedTypes: ['visualization'], unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[0].type, + id: objectsWithExistingNamespaces[0].id, + }), + }) + ); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expectedError, + unauthorizedTypes: ['visualization'], + unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[1].type, + id: objectsWithExistingNamespaces[1].id, + }), }) ); - expect(auditHelperSpy).not.toHaveBeenCalled(); }); test('audits failure event when partially authorized but not in current space for changeAccessMode', async () => { @@ -6922,15 +7073,71 @@ describe('#authorizeChangeAccessControl', () => { ) ).rejects.toThrow(); - expect(addAuditEventSpy).toHaveBeenCalledWith( + const expectedError = new Error( + 'Access denied: Unable to perform manage access control for types visualization. ' + + 'The "update" privilege is required to change access control of objects owned by the current user. ' + + 'Unable to manage access control for objects dashboard:1: ' + + 'the "manage_access_control" privilege is required to change access control of objects owned by another user.' + ); + + expect(auditHelperSpy).toHaveBeenCalledTimes(1); + expect(auditHelperSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: 'saved_object_update_objects_access_mode', + error: expectedError, + objects: [ + { + accessControl: { + accessMode: 'write_restricted', + owner: 'fake_owner_id', + }, + existingNamespaces: [], + id: '1', + type: 'dashboard', + }, + { + accessControl: { + accessMode: 'write_restricted', + owner: 'fake_owner_id', + }, + existingNamespaces: [], + id: '2', + type: 'visualization', + }, + ], + unauthorizedSpaces: ['x'], + unauthorizedTypes: ['visualization'], + }) + ); + + expect(addAuditEventSpy).toHaveBeenCalledTimes(2); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ action: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, - error: expect.any(Error), - unauthorizedTypes: expect.arrayContaining(['visualization']), + error: expectedError, + unauthorizedTypes: ['visualization'], unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[0].type, + id: objectsWithExistingNamespaces[0].id, + }), + }) + ); + expect(addAuditEventSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, + error: expectedError, + unauthorizedTypes: ['visualization'], + unauthorizedSpaces: [namespace], + savedObject: expect.objectContaining({ + type: objectsWithExistingNamespaces[1].type, + id: objectsWithExistingNamespaces[1].id, + }), }) ); - expect(auditHelperSpy).not.toHaveBeenCalled(); }); test('does not audit success when operation fails', async () => { @@ -6959,7 +7166,7 @@ describe('#authorizeChangeAccessControl', () => { ) ).rejects.toThrow(); - expect(addAuditEventSpy).toHaveBeenCalledTimes(1); - expect(auditHelperSpy).not.toHaveBeenCalled(); + expect(addAuditEventSpy).toHaveBeenCalledTimes(objectsWithExistingNamespaces.length); + expect(auditHelperSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts index 80a160989e8b1..247f6b6dd67b0 100644 --- a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts @@ -1266,11 +1266,11 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten objectsRequiringPrivilegeCheck, authorizationResult, currentSpace: namespaceString, - addAuditEventFn: (types: string[]) => { - const errMessage = `Unable to ${authzAction} for types ${types.join(', ')}`; + addAuditEventFn: (errMessage: string, types: string[]) => { const err = new Error(errMessage); - this.addAuditEvent({ + this.auditHelper({ action: auditAction!, + objects, error: err, unauthorizedTypes: types, unauthorizedSpaces: [...spacesToAuthorize], diff --git a/x-pack/platform/plugins/shared/security/server/uiam/index.ts b/x-pack/platform/plugins/shared/security/server/uiam/index.ts index b542efe912791..ba7600f68ef6c 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/index.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/index.ts @@ -5,4 +5,9 @@ * 2.0. */ -export { UiamService, type UiamServicePublic } from './uiam_service'; +export { + UiamService, + type UiamServicePublic, + type ConvertUiamApiKeyRequestEntry, + type ConvertUiamApiKeysResponse, +} from './uiam_service'; diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts index 25c1b98d8a59e..d3eb058310277 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts @@ -25,5 +25,6 @@ export const uiamServiceMock = { description: 'mock-api-key-name', }), revokeApiKey: jest.fn().mockResolvedValue(undefined), + convertApiKeys: jest.fn().mockResolvedValue({ results: [] }), }), }; diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts index 66b4b67074f4a..477347fd0d934 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts @@ -633,4 +633,115 @@ describe('UiamService', () => { ); }); }); + + describe('#convertApiKeys', () => { + it('properly calls UIAM service to convert API keys', async () => { + const mockResponse = { + results: [ + { + status: 'success', + id: 'converted-key-id', + key: 'essu_converted_key', + description: 'converted key', + organization_id: 'org-123', + internal: true, + role_assignments: {}, + creation_date: '2026-01-01T00:00:00Z', + expiration_date: null, + }, + ], + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const keys = [ + { type: 'elasticsearch' as const, key: 'es-api-key-base64', endpoint: 'https://es.example.com' }, + ]; + + await expect(uiamService.convertApiKeys(keys)).resolves.toEqual(mockResponse); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://uiam.service/uiam/api/v1/api-keys/_convert', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [ES_CLIENT_AUTHENTICATION_HEADER]: 'secret', + }, + body: JSON.stringify({ keys }), + dispatcher: AGENT_MOCK, + } + ); + }); + + it('properly calls UIAM service to convert multiple API keys', async () => { + const mockResponse = { + results: [ + { status: 'success', id: 'key-1', key: 'essu_key_1', description: 'key 1', organization_id: 'org-1', internal: true, role_assignments: {}, creation_date: '2026-01-01T00:00:00Z', expiration_date: null }, + { status: 'failed', code: 'ES_API_KEY_AUTHENTICATION_FAILED', message: 'Auth failed', resource: null, type: 'UNKNOWN' }, + ], + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const keys = [ + { type: 'elasticsearch' as const, key: 'valid-key', endpoint: 'https://es.example.com' }, + { type: 'elasticsearch' as const, key: 'invalid-key', endpoint: 'https://es.example.com' }, + ]; + + await expect(uiamService.convertApiKeys(keys)).resolves.toEqual(mockResponse); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://uiam.service/uiam/api/v1/api-keys/_convert', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [ES_CLIENT_AUTHENTICATION_HEADER]: 'secret', + }, + body: JSON.stringify({ keys }), + dispatcher: AGENT_MOCK, + } + ); + }); + + it('throws error if conversion fails with 400 status code', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 400, + headers: new Headers(), + json: async () => ({ error: { message: 'Must authenticate using mTLS' } }), + }); + + const keys = [ + { type: 'elasticsearch' as const, key: 'es-api-key', endpoint: 'https://es.example.com' }, + ]; + + await expect(uiamService.convertApiKeys(keys)).rejects.toThrowError( + 'Must authenticate using mTLS' + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://uiam.service/uiam/api/v1/api-keys/_convert', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [ES_CLIENT_AUTHENTICATION_HEADER]: 'secret', + }, + body: JSON.stringify({ keys }), + dispatcher: AGENT_MOCK, + } + ); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts index b7082806dda7d..2794d1b142ca5 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts @@ -54,6 +54,32 @@ export interface GrantUiamApiKeyResponse { description: string; } +/** + * Represents a single key entry in the convert API keys request body. + */ +export interface ConvertUiamApiKeyRequestEntry { + type: 'elasticsearch'; + key: string; + endpoint: string; +} + +/** + * Represents the request body for converting API keys via UIAM. + */ +export interface ConvertUiamApiKeysRequestBody { + keys: ConvertUiamApiKeyRequestEntry[]; +} + +/** + * Represents the response from converting API keys via UIAM, containing per-key results. + */ +export interface ConvertUiamApiKeysResponse { + results: Array< + | { status: 'success'; id: string; key: string; description: string; organization_id: string; internal: boolean; role_assignments: Record; creation_date: string; expiration_date: string | null } + | { status: 'failed'; code: string; message: string; resource: string | null; type: string } + >; +} + /** * The service that integrates with UIAM for user authentication and session management. */ @@ -102,6 +128,13 @@ export interface UiamServicePublic { * @param apiKey The API key to revoke; will be used for authentication on this request. */ revokeApiKey(apiKeyId: string, apiKey: string): Promise; + + /** + * Converts Elasticsearch API keys into UIAM API keys. + * @param keys The keys to convert, each with type, base64-encoded key, and Elasticsearch endpoint. + * @returns A promise that resolves to a response containing per-key success/failure results. + */ + convertApiKeys(keys: ConvertUiamApiKeyRequestEntry[]): Promise; } /** @@ -283,6 +316,37 @@ export class UiamService implements UiamServicePublic { } } + /** + * See {@link UiamServicePublic.convertApiKeys}. + */ + async convertApiKeys(keys: ConvertUiamApiKeyRequestEntry[]): Promise { + try { + this.#logger.debug(`Attempting to convert ${keys.length} API key(s).`); + + const body: ConvertUiamApiKeysRequestBody = { keys }; + + const response = await UiamService.#parseUiamResponse( + await fetch(`${this.#config.url}/uiam/api/v1/api-keys/_convert`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [ES_CLIENT_AUTHENTICATION_HEADER]: this.#config.sharedSecret, + }, + body: JSON.stringify(body), + // @ts-expect-error Undici `fetch` supports `dispatcher` option, see https://github.com/nodejs/undici/pull/1411. + dispatcher: this.#dispatcher, + }) + ); + + this.#logger.debug(`Successfully converted API key(s).`); + return response; + } catch (err) { + this.#logger.error(() => `Failed to convert API keys: ${getDetailedErrorMessage(err)}`); + + throw err; + } + } + /** * Creates a custom dispatcher for the native `fetch` to use custom TLS connection settings. */ diff --git a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts index b39c82c253f42..8e44ba983ef3d 100644 --- a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts @@ -25,8 +25,15 @@ import { licenseMock } from '../../common/licensing/index.mock'; import { userProfileMock } from '../../common/model/user_profile.mock'; import { authorizationMock } from '../authorization/index.mock'; import { securityMock } from '../mocks'; +import { securityTelemetry } from '../otel/instrumentation'; import { sessionMock } from '../session_management/session.mock'; +jest.mock('../otel/instrumentation', () => ({ + securityTelemetry: { + recordGetCurrentProfileInvocation: jest.fn(), + }, +})); + const logger = loggingSystemMock.createLogger(); describe('UserProfileService', () => { let mockStartParams: { @@ -71,7 +78,9 @@ describe('UserProfileService', () => { let mockUserProfile: UserProfileWithSecurity; let mockRequest: ReturnType; beforeEach(() => { - mockRequest = httpServerMock.createKibanaRequest(); + mockRequest = httpServerMock.createKibanaRequest({ + headers: { sid: 'some-cookie' }, + }); mockUserProfile = userProfileMock.createWithSecurity({ uid: 'UID', @@ -89,120 +98,208 @@ describe('UserProfileService', () => { } as unknown as SecurityGetUserProfileResponse); }); - it('returns `null` if session is not available', async () => { - const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull(); + describe(`with session`, () => { + beforeEach(() => { + mockStartParams.session.getSID.mockResolvedValue('some-session-id'); + }); - expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); - expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + afterEach(() => { + mockStartParams.session.getSID.mockReset(); + }); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).not.toHaveBeenCalled(); - }); + it('returns `null` if session is not available', async () => { + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull(); - it('returns `null` if session available, but not user profile id', async () => { - mockStartParams.session.get.mockResolvedValue({ - error: null, - value: sessionMock.createValue({ userProfileId: undefined }), + expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); + expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).not.toHaveBeenCalled(); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + }); }); - const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull(); + it('returns `null` if session available, but not user profile id', async () => { + mockStartParams.session.get.mockResolvedValue({ + error: null, + value: sessionMock.createValue({ userProfileId: undefined }), + }); - expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); - expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull(); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).not.toHaveBeenCalled(); - }); + expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); + expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); - it('fails if session retrieval fails', async () => { - const failureReason = new errors.ResponseError( - securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) - ); - mockStartParams.session.get.mockRejectedValue(failureReason); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).not.toHaveBeenCalled(); - const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest })).rejects.toBe(failureReason); + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + }); + }); - expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); - expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + it('fails if session retrieval fails', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) + ); + mockStartParams.session.get.mockRejectedValue(failureReason); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).not.toHaveBeenCalled(); - }); + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockRequest })).rejects.toBe( + failureReason + ); - it('fails if profile retrieval fails', async () => { - mockStartParams.session.get.mockResolvedValue({ - error: null, - value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); + expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).not.toHaveBeenCalled(); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + }); }); - const failureReason = new errors.ResponseError( - securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) - ); - mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockRejectedValue( - failureReason - ); + it('fails if profile retrieval fails', async () => { + mockStartParams.session.get.mockResolvedValue({ + error: null, + value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + }); - const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest })).rejects.toBe(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) + ); + mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockRejectedValue( + failureReason + ); - expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); - expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockRequest })).rejects.toBe( + failureReason + ); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledTimes(1); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledWith({ - uid: 'UID', + expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); + expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledWith({ + uid: 'UID', + }); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + }); }); - }); - it('fails if cannot find user profile', async () => { - mockStartParams.session.get.mockResolvedValue({ - error: null, - value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + it('fails if cannot find user profile', async () => { + mockStartParams.session.get.mockResolvedValue({ + error: null, + value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + }); + + mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({ + profiles: [], + } as unknown as SecurityGetUserProfileResponse); + + const startContract = userProfileService.start(mockStartParams); + await expect( + startContract.getCurrent({ request: mockRequest }) + ).rejects.toMatchInlineSnapshot(`[Error: User profile is not found.]`); + + expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); + expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledWith({ + uid: 'UID', + }); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + }); }); - mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({ - profiles: [], - } as unknown as SecurityGetUserProfileResponse); + it('properly parses returned profile', async () => { + mockStartParams.session.get.mockResolvedValue({ + error: null, + value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + }); - const startContract = userProfileService.start(mockStartParams); - await expect( - startContract.getCurrent({ request: mockRequest }) - ).rejects.toMatchInlineSnapshot(`[Error: User profile is not found.]`); + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockRequest })).resolves + .toMatchInlineSnapshot(` + Object { + "data": Object {}, + "enabled": true, + "labels": Object {}, + "uid": "UID", + "user": Object { + "email": undefined, + "full_name": "full-name-1", + "realm_domain": "some-domain", + "realm_name": "some-realm", + "roles": Array [ + "role-1", + ], + "username": "user-1", + }, + } + `); - expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); - expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); + expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledTimes(1); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledWith({ - uid: 'UID', - }); - }); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledWith({ + uid: 'UID', + }); - it('properly parses returned profile', async () => { - mockStartParams.session.get.mockResolvedValue({ - error: null, - value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'success', + }); }); - const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest })).resolves - .toMatchInlineSnapshot(` + it('should get user profile and application data scoped to Kibana', async () => { + mockStartParams.session.get.mockResolvedValue({ + error: null, + value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + }); + + mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({ + profiles: [ + userProfileMock.createWithSecurity({ + ...mockUserProfile, + data: { kibana: { avatar: 'fun.gif' }, other_app: { secret: 'data' } }, + }), + ], + } as unknown as SecurityGetUserProfileResponse); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockRequest, dataPath: 'one,two' })) + .resolves.toMatchInlineSnapshot(` Object { - "data": Object {}, + "data": Object { + "avatar": "fun.gif", + }, "enabled": true, "labels": Object {}, "uid": "UID", @@ -219,37 +316,259 @@ describe('UserProfileService', () => { } `); - expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); - expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); + expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledWith({ + uid: 'UID', + data: 'kibana.one,kibana.two', + }); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledTimes(1); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledWith({ - uid: 'UID', + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'success', + }); }); }); - it('should get user profile and application data scoped to Kibana', async () => { - mockStartParams.session.get.mockResolvedValue({ - error: null, - value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }), + describe(`with basic authentication`, () => { + const testUsername = 'some-username'; + const testPassword = 'some-password'; + let mockBasicRequest: ReturnType; + + beforeEach(() => { + mockBasicRequest = httpServerMock.createKibanaRequest({ + headers: { + authorization: `basic ${Buffer.from(`${testUsername}:${testPassword}`).toString( + 'base64' + )}`, + }, + }); }); - mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({ - profiles: [ - userProfileMock.createWithSecurity({ - ...mockUserProfile, - data: { kibana: { avatar: 'fun.gif' }, other_app: { secret: 'data' } }, - }), - ], - } as unknown as SecurityGetUserProfileResponse); + it('fails if profile cannot be activated', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) + ); + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile.mockRejectedValue( + failureReason + ); - const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest, dataPath: 'one,two' })).resolves - .toMatchInlineSnapshot(` + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockBasicRequest })).rejects.toBe( + failureReason + ); + + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).toHaveBeenCalledWith({ + grant_type: 'password', + username: testUsername, + password: testPassword, + }); + + expect(mockStartParams.session.get).not.toHaveBeenCalled(); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + profileActivationRequired: true, + }); + }); + + it('should get user profile and application data scoped to Kibana', async () => { + const mockedProfile = + userProfileMock.createWithSecurity() as unknown as SecurityActivateUserProfileResponse; + + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile.mockResolvedValue( + mockedProfile + ); + + mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({ + profiles: [mockedProfile], + } as unknown as SecurityGetUserProfileResponse); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockBasicRequest, dataPath: 'one,two' })) + .resolves.toMatchInlineSnapshot(` + Object { + "data": Object {}, + "enabled": true, + "labels": Object {}, + "uid": "some-profile-uid", + "user": Object { + "email": "some@email", + "full_name": undefined, + "realm_domain": "some-realm-domain", + "realm_name": "some-realm", + "roles": Array [], + "username": "some-username", + }, + } + `); + + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).toHaveBeenCalledWith({ + grant_type: 'password', + username: testUsername, + password: testPassword, + }); + + expect(mockStartParams.session.get).not.toHaveBeenCalled(); + + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledWith({ + uid: mockedProfile.uid, + data: 'kibana.one,kibana.two', + }); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'success', + profileActivationRequired: true, + }); + }); + }); + + describe(`with api key`, () => { + const testApiKeyValue = 'some-api-key-value'; + let mockApiKeyRequest: ReturnType; + + beforeEach(() => { + mockApiKeyRequest = httpServerMock.createKibanaRequest({ + headers: { authorization: `apikey ${testApiKeyValue}` }, + }); + }); + + it('returns `null` if api key retrieval fails (e.g. forbidden)', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) + ); + mockStartParams.clusterClient + .asScoped() + .asCurrentUser.security.getApiKey.mockRejectedValue(failureReason); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockApiKeyRequest })).resolves.toBeNull(); + + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledWith({ with_profile_uid: true }); + + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).not.toHaveBeenCalled(); + expect(mockStartParams.session.get).not.toHaveBeenCalled(); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + apiKeyRetrievalRequired: true, + }); + }); + + it('returns `null` if api key is not found', async () => { + mockStartParams.clusterClient + .asScoped() + .asCurrentUser.security.getApiKey.mockResolvedValue({ + api_keys: [], // no API keys in response + } as any); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockApiKeyRequest })).resolves.toBeNull(); + + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledTimes(1); + expect(mockStartParams.clusterClient.asScoped).toHaveBeenCalled(); + expect(mockStartParams.clusterClient.asScoped).toBeCalledWith(mockApiKeyRequest); + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledWith({ with_profile_uid: true }); + + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).not.toHaveBeenCalled(); + expect(mockStartParams.session.get).not.toHaveBeenCalled(); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + apiKeyRetrievalRequired: true, + }); + }); + + it('returns `null` if api key is found, but has no associated user profile id', async () => { + mockStartParams.clusterClient + .asScoped() + .asCurrentUser.security.getApiKey.mockResolvedValue({ + api_keys: [ + { + profile_uid: undefined, // no profile ID in response + }, + ], + } as any); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockApiKeyRequest })).resolves.toBeNull(); + + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledTimes(1); + expect(mockStartParams.clusterClient.asScoped).toHaveBeenCalled(); + expect(mockStartParams.clusterClient.asScoped).toBeCalledWith(mockApiKeyRequest); + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledWith({ with_profile_uid: true }); + + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).not.toHaveBeenCalled(); + expect(mockStartParams.session.get).not.toHaveBeenCalled(); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'failure', + apiKeyRetrievalRequired: true, + }); + }); + + it('should get user profile and application data scoped to Kibana', async () => { + mockStartParams.clusterClient + .asScoped() + .asCurrentUser.security.getApiKey.mockResolvedValue({ + api_keys: [ + { + profile_uid: 'UID', + }, + ], + } as any); + + mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({ + profiles: [ + userProfileMock.createWithSecurity({ + ...mockUserProfile, + data: { kibana: { avatar: 'fun.gif' }, other_app: { secret: 'data' } }, + }), + ], + } as unknown as SecurityGetUserProfileResponse); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.getCurrent({ request: mockApiKeyRequest, dataPath: 'one,two' })) + .resolves.toMatchInlineSnapshot(` Object { "data": Object { "avatar": "fun.gif", @@ -270,17 +589,34 @@ describe('UserProfileService', () => { } `); - expect(mockStartParams.session.get).toHaveBeenCalledTimes(1); - expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest); + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledTimes(1); + expect(mockStartParams.clusterClient.asScoped).toHaveBeenCalled(); + expect(mockStartParams.clusterClient.asScoped).toBeCalledWith(mockApiKeyRequest); + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.security.getApiKey + ).toHaveBeenCalledWith({ with_profile_uid: true }); + + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.getUserProfile + ).toHaveBeenCalledWith({ + uid: 'UID', + data: 'kibana.one,kibana.two', + }); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledTimes(1); - expect( - mockStartParams.clusterClient.asInternalUser.security.getUserProfile - ).toHaveBeenCalledWith({ - uid: 'UID', - data: 'kibana.one,kibana.two', + expect( + mockStartParams.clusterClient.asInternalUser.security.activateUserProfile + ).not.toHaveBeenCalled(); + expect(mockStartParams.session.get).not.toHaveBeenCalled(); + + expect(securityTelemetry.recordGetCurrentProfileInvocation).toHaveBeenLastCalledWith({ + outcome: 'success', + apiKeyRetrievalRequired: true, + }); }); }); }); diff --git a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts index 926140f0d21f2..ccaed723b5404 100644 --- a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts +++ b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts @@ -31,6 +31,7 @@ import type { } from '../../common'; import type { AuthorizationServiceSetupInternal } from '../authorization'; import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; +import { securityTelemetry } from '../otel/instrumentation'; import { getPrintableSessionId, type Session } from '../session_management'; const KIBANA_DATA_ROOT = 'kibana'; @@ -149,9 +150,9 @@ export class UserProfileService { let activationRetriesLeft = ACTIVATION_MAX_RETRIES; do { try { - const response = await clusterClient.asInternalUser.security.activateUserProfile( - activateRequest - ); + const response = await clusterClient.asInternalUser.security.activateUserProfile({ + ...activateRequest, + }); this.logger.debug(`Successfully activated profile for "${response.user.username}".`); @@ -188,13 +189,38 @@ export class UserProfileService { } /** - * See {@link UserProfileServiceStart} for documentation. + * Determines the type of authorization from the Authorization header. + * @param authHeader The Authorization header value + * @returns The type of authorization ('basic', 'apikey', or null if neither) */ - private async getCurrent( - clusterClient: IClusterClient, + private getAuthHeaderType(authHeader: string | string[] | undefined): 'basic' | 'apikey' | null { + if (!authHeader || typeof authHeader !== 'string') { + return null; + } + + const normalizedHeader = authHeader.trim().toLowerCase(); + + if (normalizedHeader.startsWith('basic ')) { + return 'basic'; + } + + if (normalizedHeader.startsWith('apikey ')) { + return 'apikey'; + } + + return null; + } + + /** + * Retrieves the current user profile ID from a session-authenticated request. + * @param session Session service instance + * @param request The HTTP request + * @returns The profile ID if found, null otherwise + */ + private async getCurrentUserProfileIdViaSession( session: PublicMethodsOf, - { request, dataPath }: UserProfileGetCurrentParams - ) { + request: UserProfileGetCurrentParams['request'] + ): Promise<{ profileId?: string; sessionId?: string }> { let userSession; try { userSession = await session.get(request); @@ -204,7 +230,11 @@ export class UserProfileService { } if (userSession.error) { - return null; + this.logger.debug(`Retrieved user session has error: ${userSession.error.message}`); + return { + profileId: undefined, + sessionId: undefined, + }; } if (!userSession.value.userProfileId) { @@ -213,33 +243,179 @@ export class UserProfileService { userSession.value.sid )}].` ); + } + + return { + profileId: userSession.value.userProfileId ?? undefined, + sessionId: userSession.value.sid, + }; + } + + /** + * Activates the user profile from a Basic auth authenticated request. + * @param clusterClient The cluster client + * @param request The HTTP request + * @returns The activated profile + */ + private async activateProfileViaBasicAuth( + clusterClient: IClusterClient, + request: UserProfileGetCurrentParams['request'] + ): Promise { + const authHeader = request.headers.authorization as string; + const base64Credentials = authHeader.trim().substring('basic '.length); + const [username, password] = Buffer.from(base64Credentials, 'base64').toString().split(':'); + if (!username || !password) { + throw new Error(`Malformed basic credentials in Authorization header.`); + } + + const activatedProfile = await this.activate(clusterClient, { + type: 'password', + username, + password, + }); + + return activatedProfile; + } + + /** + * Retrieves the user profile ID from an API key authenticated request by retrieving the API Key itself. + * @param clusterClient The cluster client + * @param request The HTTP request + * @returns The profile ID if found, undefined otherwise + */ + private async getCurrentUserProfileIdViaApiKey( + clusterClient: IClusterClient, + request: UserProfileGetCurrentParams['request'] + ): Promise { + try { + const response = await clusterClient.asScoped(request).asCurrentUser.security.getApiKey({ + with_profile_uid: true, + }); + + if (response.api_keys && response.api_keys.length > 0) { + return response.api_keys[0].profile_uid; + } else { + this.logger.debug( + `No API keys were returned from query, cannot retrieve associated profile id.` + ); + } + } catch (error) { + this.logger.error( + `Failed to retrieve API key for user profile retrieval: ${getDetailedErrorMessage(error)}` + ); + } + } + + private recordGetCurrentSuccess(params: { + profileActivationRequired?: boolean; + apiKeyRetrievalRequired?: boolean; + }) { + securityTelemetry.recordGetCurrentProfileInvocation({ + profileActivationRequired: params.profileActivationRequired, + apiKeyRetrievalRequired: params.apiKeyRetrievalRequired, + outcome: 'success', + }); + } + + private recordGetCurrentFailure(params: { + profileActivationRequired?: boolean; + apiKeyRetrievalRequired?: boolean; + }) { + securityTelemetry.recordGetCurrentProfileInvocation({ + profileActivationRequired: params.profileActivationRequired, + apiKeyRetrievalRequired: params.apiKeyRetrievalRequired, + outcome: 'failure', + }); + } + + /** + * See {@link UserProfileServiceStart} for documentation. + */ + private async getCurrent( + clusterClient: IClusterClient, + session: PublicMethodsOf, + { request, dataPath }: UserProfileGetCurrentParams + ) { + if (request.auth.isAuthenticated === false) { + throw new Error('Request to get current user profile is not authenticated.'); + } + + let profileId: string | undefined; + let sessionId: string | undefined; + let profileActivationRequired: boolean | undefined; + let apiKeyRetrievalRequired: boolean | undefined; + + if (await session.getSID(request)) { + this.logger.debug(`Request to get current user profile is authenticated via session.`); + ({ profileId, sessionId } = await this.getCurrentUserProfileIdViaSession(session, request)); + } else { + const authType = this.getAuthHeaderType(request.headers.authorization); + + if (authType === 'basic') { + profileActivationRequired = true; + + this.logger.debug( + `Request to get current user profile is authenticated via Basic credentials.` + ); + + let activatedProfile: UserProfileWithSecurity | undefined; + try { + activatedProfile = await this.activateProfileViaBasicAuth(clusterClient, request); + } catch (error) { + this.recordGetCurrentFailure({ profileActivationRequired, apiKeyRetrievalRequired }); + this.logger.debug( + `Failed to activate profile via basic credentials: ${getDetailedErrorMessage(error)}` + ); + throw error; + } + + // It is not possible to select/filter profile data when activating, so unless the dataPath is empty, + // we will need to re-fetch the profile like in the other cases (session, API key). + if (activatedProfile && !dataPath) { + this.recordGetCurrentSuccess({ profileActivationRequired, apiKeyRetrievalRequired }); + return activatedProfile; + } + profileId = activatedProfile?.uid; + } else if (authType === 'apikey') { + apiKeyRetrievalRequired = true; + this.logger.debug(`Request to get current user profile is authenticated via API key.`); + profileId = await this.getCurrentUserProfileIdViaApiKey(clusterClient, request); + } + } + + if (!profileId) { + this.recordGetCurrentFailure({ profileActivationRequired, apiKeyRetrievalRequired }); return null; } let body; try { body = await clusterClient.asInternalUser.security.getUserProfile({ - uid: userSession.value.userProfileId, + uid: profileId, data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined, }); } catch (error) { + this.recordGetCurrentFailure({ profileActivationRequired, apiKeyRetrievalRequired }); this.logger.error( - `Failed to retrieve user profile for the current user [sid=${getPrintableSessionId( - userSession.value.sid - )}]: ${getDetailedErrorMessage(error)}` + `Failed to retrieve user profile for the current user${ + sessionId ? ` [sid=${getPrintableSessionId(sessionId)}]` : '' + }: ${getDetailedErrorMessage(error)}` ); throw error; } if (body.profiles.length === 0) { + this.recordGetCurrentFailure({ profileActivationRequired, apiKeyRetrievalRequired }); this.logger.error( - `The user profile for the current user [sid=${getPrintableSessionId( - userSession.value.sid - )}] is not found.` + `The user profile for the current user${ + sessionId ? ` [sid=${getPrintableSessionId(sessionId)}]` : '' + } is not found.` ); throw new Error(`User profile is not found.`); } + this.recordGetCurrentSuccess({ profileActivationRequired, apiKeyRetrievalRequired }); + this.logger.debug(`Returning current user profile.`); return parseUserProfileWithSecurity(body.profiles[0]); } diff --git a/x-pack/platform/plugins/shared/security/test/scout/.meta/api/standard.json b/x-pack/platform/plugins/shared/security/test/scout/.meta/api/standard.json new file mode 100644 index 0000000000000..6793b413ac557 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/test/scout/.meta/api/standard.json @@ -0,0 +1,33 @@ +{ + "sha1": "e6ed860b0525fd1fa532a47bd54797e417822532", + "tests": [ + { + "id": "d2014f4df575e52-44c5e30280778f9", + "title": "Built-in roles Kibana access validation should have expected Kibana access for all built-in roles", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/security/test/scout/api/tests/builtin_roles_kibana_access.spec.ts", + "line": 122, + "column": 10 + } + }, + { + "id": "d2014f4df575e52-7f69d1210618001", + "title": "Built-in roles Kibana access validation should have consistent Kibana application naming", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/security/test/scout/api/tests/builtin_roles_kibana_access.spec.ts", + "line": 152, + "column": 10 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/.meta/api/parallel.json b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/.meta/api/parallel.json index df322c3cb0620..9608642bb3bfa 100644 --- a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/.meta/api/parallel.json +++ b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/.meta/api/parallel.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-09T18:23:04.470Z", - "sha1": "579ee2a06b8d95f4859fe4cff1128e4b6cc8fb8d", + "sha1": "ccff3738ea647688379ef3f9f4a3edfff68f9bad", "tests": [ { "id": "a0d24173047b2e4-f5d34cb95fb2e09", @@ -26,7 +25,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_invalidate_session.spec.ts", - "line": 69, + "line": 70, "column": 12 } }, @@ -71,6 +70,62 @@ "line": 104, "column": 12 } + }, + { + "id": "66a114b644ef8c1-55923cebb82a3d8", + "title": "[NON-MKI] Use internal UIAM credentials for various purposes in real and fake requests should be able to use internal UIAM API key in fake requests", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts", + "line": 62, + "column": 12 + } + }, + { + "id": "66a114b644ef8c1-925c7ef1e8e1d8c", + "title": "[NON-MKI] Use internal UIAM credentials for various purposes in real and fake requests should be able to use internal UIAM credentials to grant and invalidate native Elasticsearch API keys", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts", + "line": 86, + "column": 12 + } + }, + { + "id": "66a114b644ef8c1-a0da52cb23fc777", + "title": "[NON-MKI] Use internal UIAM credentials for various purposes in real and fake requests should be able to use internal UIAM session token as secondary credentials", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts", + "line": 138, + "column": 12 + } + }, + { + "id": "66a114b644ef8c1-0d2b9cccabad016", + "title": "[NON-MKI] Use internal UIAM credentials for various purposes in real and fake requests should be able to use internal UIAM API key as secondary credentials", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts", + "line": 155, + "column": 12 + } } ] } \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_api_keys.spec.ts b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_api_keys.spec.ts new file mode 100644 index 0000000000000..b36c082ccc3ea --- /dev/null +++ b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_api_keys.spec.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parse as parseCookie } from 'tough-cookie'; + +import { createSAMLResponse, MOCK_IDP_ATTRIBUTE_UIAM_ACCESS_TOKEN } from '@kbn/mock-idp-utils'; +import { apiTest, tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; + +import { COMMON_UNSAFE_HEADERS, extractAttributeValue } from '../fixtures'; + +// These tests cannot be run on MKI because we cannot obtain the raw UIAM tokens and spin up Mock IdP plugin. +apiTest.describe( + '[NON-MKI] UIAM API Keys grant and invalidate functions', + { tag: [...tags.serverless.security.complete] }, + () => { + let userSessionCookieFactory: () => Promise<[string, { accessToken: string }]>; + + apiTest.beforeAll(async ({ apiClient, kbnUrl, config: { organizationId, projectType } }) => { + userSessionCookieFactory = async () => { + const samlResponse = await createSAMLResponse({ + kibanaUrl: kbnUrl.get('/api/security/saml/callback'), + username: '1234567890', + email: 'elastic_admin@elastic.co', + roles: ['admin'], + serverless: { + uiamEnabled: true, + organizationId: organizationId!, + projectType: projectType!, + }, + }); + + const decodedSamlResponse = Buffer.from(samlResponse, 'base64').toString('utf-8'); + return [ + parseCookie( + ( + await apiClient.post('api/security/saml/callback', { + body: `SAMLResponse=${encodeURIComponent(samlResponse)}`, + }) + ).headers['set-cookie'][0] + )!.cookieString(), + { + accessToken: extractAttributeValue( + decodedSamlResponse, + MOCK_IDP_ATTRIBUTE_UIAM_ACCESS_TOKEN + ), + }, + ]; + }; + }); + + apiTest( + 'should be able to grant a UIAM API key with valid UIAM credentials', + async ({ apiClient }) => { + // 1. Log in to obtain a UIAM access token. + const [_, { accessToken }] = await userSessionCookieFactory(); + + // 2. Grant an API key using the UIAM access token via the test endpoint. + const responseUsingAccessToken = await apiClient.post( + 'test_endpoints/uiam/api_keys/_grant', + { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-uiam-api-key-from-token', + authcScheme: 'Bearer', + credential: accessToken, + }, + } + ); + + expect(responseUsingAccessToken).toHaveStatusCode(200); + expect(responseUsingAccessToken.body.id).toBeDefined(); + expect(responseUsingAccessToken.body.name).toBe('test-uiam-api-key-from-token'); + expect(responseUsingAccessToken.body.api_key).toBeDefined(); + expect(typeof responseUsingAccessToken.body.api_key).toBe('string'); + expect(typeof responseUsingAccessToken.body.id).toBe('string'); + + // 3. UIAM API Keys should be able to grant additional UIAM API keys. + const apiKey = responseUsingAccessToken.body.api_key; + + const responseUsingApiKey = await apiClient.post('test_endpoints/uiam/api_keys/_grant', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-uiam-api-key-from-api-key', + authcScheme: 'ApiKey', + credential: apiKey, + }, + }); + + expect(responseUsingApiKey).toHaveStatusCode(200); + expect(responseUsingApiKey.body.id).toBeDefined(); + expect(typeof responseUsingApiKey.body.id).toBe('string'); + expect(responseUsingApiKey.body.name).toBe('test-uiam-api-key-from-api-key'); + expect(responseUsingApiKey.body.api_key).toBeDefined(); + expect(typeof responseUsingApiKey.body.api_key).toBe('string'); + } + ); + + apiTest( + 'should be able to invalidate a UIAM API key with valid UIAM credentials', + async ({ apiClient }) => { + // 1. Log in to obtain a UIAM access token. + const [_, { accessToken }] = await userSessionCookieFactory(); + + // 2. Grant an API key first. + const grantResponse = await apiClient.post('test_endpoints/uiam/api_keys/_grant', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-uiam-api-key-to-invalidate', + authcScheme: 'Bearer', + credential: accessToken, + }, + }); + expect(grantResponse).toHaveStatusCode(200); + + const apiKeyId = grantResponse.body.id; + const apiKey = grantResponse.body.api_key; + + // 3. Invalidate the API key. + const invalidateResponse = await apiClient.post( + 'test_endpoints/uiam/api_keys/_invalidate', + { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + id: apiKeyId, + authcScheme: 'ApiKey', + credential: apiKey, + }, + } + ); + + expect(invalidateResponse).toHaveStatusCode(200); + expect(invalidateResponse.body).toStrictEqual( + expect.objectContaining({ + invalidated_api_keys: [apiKeyId], + error_count: 0, + }) + ); + } + ); + + apiTest('should reject grant request with non-UIAM credentials', async ({ apiClient }) => { + // Attempt to grant an API key using non-UIAM credentials. + const response = await apiClient.post('test_endpoints/uiam/api_keys/_grant', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-invalid-api-key', + authcScheme: 'Bearer', + credential: 'some-invalid-token', + }, + }); + + expect(response).toHaveStatusCode(500); + expect(response.body.message).toBeDefined(); + expect(response.body.message).toContain('not compatible with UIAM'); + }); + + apiTest('should reject invalidate request with non-UIAM credentials', async ({ apiClient }) => { + // Attempt to invalidate an API key using non UIAM API Key. + const response = await apiClient.post('test_endpoints/uiam/api_keys/_invalidate', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + id: 'some-api-key-id', + authcScheme: 'ApiKey', + credential: 'some-api-key-value:', + }, + }); + + expect(response).toHaveStatusCode(500); + expect(response.body.message).toBeDefined(); + expect(response.body.message).toContain('not a UIAM API key'); + }); + } +); diff --git a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_invalidate_session.spec.ts b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_invalidate_session.spec.ts index ce147f841b026..fe7991b4d5533 100644 --- a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_invalidate_session.spec.ts +++ b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_invalidate_session.spec.ts @@ -7,6 +7,7 @@ import { setTimeout as setTimeoutAsync } from 'timers/promises'; import { parse as parseCookie } from 'tough-cookie'; +import { Agent } from 'undici'; import { createSAMLResponse, @@ -114,6 +115,8 @@ const checkUiamAccessToken = async (accessToken: string) => Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({}), + // @ts-expect-error Undici `fetch` supports `dispatcher` option, see https://github.com/nodejs/undici/pull/1411. + dispatcher: new Agent({ connect: { rejectUnauthorized: false } }), }); const checkUiamRefreshToken = async (refreshToken: string) => @@ -125,4 +128,6 @@ const checkUiamRefreshToken = async (refreshToken: string) => [ES_CLIENT_AUTHENTICATION_HEADER]: MOCK_IDP_UIAM_SHARED_SECRET, }, body: JSON.stringify({ refresh_token: refreshToken }), + // @ts-expect-error Undici `fetch` supports `dispatcher` option, see https://github.com/nodejs/undici/pull/1411. + dispatcher: new Agent({ connect: { rejectUnauthorized: false } }), }); diff --git a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts index 269a4192b2e43..7b0ff05cb2427 100644 --- a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts +++ b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_use_uiam_credentials.spec.ts @@ -6,6 +6,7 @@ */ import { parse as parseCookie } from 'tough-cookie'; +import { Agent } from 'undici'; import { createSAMLResponse, @@ -188,4 +189,6 @@ const grantUiamApiKey = async (accessToken: string) => internal: true, role_assignments: { limit: { access: ['application'], resource: ['project'] } }, }), + // @ts-expect-error Undici `fetch` supports `dispatcher` option, see https://github.com/nodejs/undici/pull/1411. + dispatcher: new Agent({ connect: { rejectUnauthorized: false } }), }); diff --git a/x-pack/platform/plugins/shared/spaces/test/scout/.meta/api/standard.json b/x-pack/platform/plugins/shared/spaces/test/scout/.meta/api/standard.json index 6ca468406847f..cdb09606052e8 100644 --- a/x-pack/platform/plugins/shared/spaces/test/scout/.meta/api/standard.json +++ b/x-pack/platform/plugins/shared/spaces/test/scout/.meta/api/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T13:21:52.717Z", "sha1": "50c958125bdc4f27abe403c3a5f590cb23721ad4", "tests": [ { diff --git a/x-pack/platform/plugins/shared/spaces/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/shared/spaces/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..93d40c740726e --- /dev/null +++ b/x-pack/platform/plugins/shared/spaces/test/scout/.meta/ui/standard.json @@ -0,0 +1,77 @@ +{ + "sha1": "fcf527bdc8e997bdf9a4d08beedbdecff527d08a", + "tests": [ + { + "id": "bd1c362b1516eb7-559491c63e7c12d", + "title": "Spaces selection as Viewer - displays the space selection menu in header", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/spaces/test/scout/ui/tests/spaces_selection_serverless.spec.ts", + "line": 23, + "column": 9 + } + }, + { + "id": "bd1c362b1516eb7-bb10b6d31ed048b", + "title": "Spaces selection as Viewer - does not display the manage button in the space selection menu", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/spaces/test/scout/ui/tests/spaces_selection_serverless.spec.ts", + "line": 37, + "column": 9 + } + }, + { + "id": "bd1c362b1516eb7-cd734578c5fd5b7", + "title": "Spaces selection as Admin - displays the space selection menu in header", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/spaces/test/scout/ui/tests/spaces_selection_serverless.spec.ts", + "line": 53, + "column": 9 + } + }, + { + "id": "bd1c362b1516eb7-91246ff5dfa23db", + "title": "Spaces selection as Admin - displays the manage button in the space selection menu", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete", + "@local-serverless-search", + "@cloud-serverless-search", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/spaces/test/scout/ui/tests/spaces_selection_serverless.spec.ts", + "line": 67, + "column": 9 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/streams/common/constants.ts b/x-pack/platform/plugins/shared/streams/common/constants.ts index 1c3f0862205a5..4041b8fd078c4 100644 --- a/x-pack/platform/plugins/shared/streams/common/constants.ts +++ b/x-pack/platform/plugins/shared/streams/common/constants.ts @@ -55,5 +55,3 @@ export const STREAMS_TIERED_FEATURES = [ ]; export const FAILURE_STORE_SELECTOR = '::failures'; - -export const MAX_STREAM_NAME_LENGTH = 200; diff --git a/x-pack/platform/plugins/shared/streams/common/queries.ts b/x-pack/platform/plugins/shared/streams/common/queries.ts index f32d5131bc82d..b01bbcf2618a0 100644 --- a/x-pack/platform/plugins/shared/streams/common/queries.ts +++ b/x-pack/platform/plugins/shared/streams/common/queries.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { StreamQuery } from '@kbn/streams-schema'; +import type { StreamQuery, StreamQueryInput } from '@kbn/streams-schema'; // Legacy stored query links may not include rule_backed and should be treated as already backed. export const LEGACY_RULE_BACKED_FALLBACK = true; @@ -20,7 +20,9 @@ export interface QueryLink { rule_backed?: boolean; } -export type QueryLinkRequest = Omit; +export type QueryLinkRequest = Omit & { + query: StreamQueryInput; +}; export type QueryUnlinkRequest = Pick; diff --git a/x-pack/platform/plugins/shared/streams/public/index.ts b/x-pack/platform/plugins/shared/streams/public/index.ts index 486a72f435eb2..0a9e8b30a8a68 100644 --- a/x-pack/platform/plugins/shared/streams/public/index.ts +++ b/x-pack/platform/plugins/shared/streams/public/index.ts @@ -11,11 +11,7 @@ import type { StreamsPluginSetup, StreamsPluginStart } from './types'; export type { StreamsPluginSetup, StreamsPluginStart }; -export { - STREAMS_API_PRIVILEGES, - STREAMS_UI_PRIVILEGES, - MAX_STREAM_NAME_LENGTH, -} from '../common/constants'; +export { STREAMS_API_PRIVILEGES, STREAMS_UI_PRIVILEGES } from '../common/constants'; export { excludeFrozenQuery, diff --git a/x-pack/platform/plugins/shared/streams/server/lib/content/stream/export.test.ts b/x-pack/platform/plugins/shared/streams/server/lib/content/stream/export.test.ts index 36449caf74cc2..329824d7dcb28 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/content/stream/export.test.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/content/stream/export.test.ts @@ -18,7 +18,9 @@ const streams = [ { destination: 'logs.foo', where: { always: {} }, status: 'enabled' }, { destination: 'logs.hello', where: { always: {} }, status: 'enabled' }, ], - queries: [{ id: 'logs-query', title: 'logs-query', kql: { query: 'logs' } }], + queries: [ + { id: 'logs-query', title: 'logs-query', kql: { query: 'logs' }, esql: { query: '' } }, + ], }), testContentPackEntry({ name: 'logs.foo', @@ -27,7 +29,9 @@ const streams = [ testContentPackEntry({ name: 'logs.foo.bar' }), testContentPackEntry({ name: 'logs.hello', - queries: [{ id: 'hello-query', title: 'hello-query', kql: { query: 'hello' } }], + queries: [ + { id: 'hello-query', title: 'hello-query', kql: { query: 'hello' }, esql: { query: '' } }, + ], }), ]; @@ -48,7 +52,9 @@ describe('content pack export', () => { { destination: 'foo', where: { always: {} }, status: 'enabled' }, { destination: 'hello', where: { always: {} }, status: 'enabled' }, ], - queries: [{ id: 'logs-query', title: 'logs-query', kql: { query: 'logs' } }], + queries: [ + { id: 'logs-query', title: 'logs-query', kql: { query: 'logs' }, esql: { query: '' } }, + ], }), testContentPackEntry({ name: 'foo', @@ -57,7 +63,9 @@ describe('content pack export', () => { testContentPackEntry({ name: 'foo.bar' }), testContentPackEntry({ name: 'hello', - queries: [{ id: 'hello-query', title: 'hello-query', kql: { query: 'hello' } }], + queries: [ + { id: 'hello-query', title: 'hello-query', kql: { query: 'hello' }, esql: { query: '' } }, + ], }), ]); }); @@ -92,7 +100,9 @@ describe('content pack export', () => { }), testContentPackEntry({ name: 'hello', - queries: [{ id: 'hello-query', title: 'hello-query', kql: { query: 'hello' } }], + queries: [ + { id: 'hello-query', title: 'hello-query', kql: { query: 'hello' }, esql: { query: '' } }, + ], }), ]); }); diff --git a/x-pack/platform/plugins/shared/streams/server/lib/content/stream/tree.test.ts b/x-pack/platform/plugins/shared/streams/server/lib/content/stream/tree.test.ts index 4aadefd51bef1..9da21585579c3 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/content/stream/tree.test.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/content/stream/tree.test.ts @@ -54,7 +54,7 @@ describe('content pack tree helpers', () => { const child2 = testContentPackEntry({ name: 'root.child2' }); const child1Nested = testContentPackEntry({ name: 'root.child1.nested', - queries: [{ id: 'keep', title: 'keep query', kql: { query: 'keep' } }], + queries: [{ id: 'keep', title: 'keep query', kql: { query: 'keep' }, esql: { query: '' } }], }); const tree = asTree({ @@ -80,7 +80,7 @@ describe('content pack tree helpers', () => { expect(tree.children[0].children).toHaveLength(1); expect(tree.children[0].children[0].name).toEqual('root.child1.nested'); expect(tree.children[0].children[0].request.queries).toEqual([ - { id: 'keep', title: 'keep query', kql: { query: 'keep' } }, + { id: 'keep', title: 'keep query', kql: { query: 'keep' }, esql: { query: '' } }, ]); }); @@ -88,8 +88,8 @@ describe('content pack tree helpers', () => { const root = testContentPackEntry({ name: 'root', queries: [ - { id: 'keep', title: 'keep query', kql: { query: 'keep' } }, - { id: 'drop', title: 'drop query', kql: { query: 'drop' } }, + { id: 'keep', title: 'keep query', kql: { query: 'keep' }, esql: { query: '' } }, + { id: 'drop', title: 'drop query', kql: { query: 'drop' }, esql: { query: '' } }, ], }); @@ -106,7 +106,7 @@ describe('content pack tree helpers', () => { }); expect(tree.request.queries).toEqual([ - { id: 'keep', title: 'keep query', kql: { query: 'keep' } }, + { id: 'keep', title: 'keep query', kql: { query: 'keep' }, esql: { query: '' } }, ]); }); @@ -245,7 +245,9 @@ describe('content pack tree helpers', () => { streams: [ testContentPackEntry({ name: 'logs', - queries: [{ id: 'one', title: 'title', kql: { query: 'qty: one' } }], + queries: [ + { id: 'one', title: 'title', kql: { query: 'qty: one' }, esql: { query: '' } }, + ], }), ], include: { objects: { all: {} } }, @@ -256,7 +258,9 @@ describe('content pack tree helpers', () => { streams: [ testContentPackEntry({ name: 'logs', - queries: [{ id: 'one', title: 'title', kql: { query: 'qty: two' } }], + queries: [ + { id: 'one', title: 'title', kql: { query: 'qty: two' }, esql: { query: '' } }, + ], }), ], include: { objects: { all: {} } }, diff --git a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/generate_significant_events.ts b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/generate_significant_events.ts index c1ea909778017..e53ef2d9a3cc5 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/generate_significant_events.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/generate_significant_events.ts @@ -7,7 +7,13 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import type { ChatCompletionTokenCount, InferenceClient } from '@kbn/inference-common'; -import type { GeneratedSignificantEventQuery, Streams, System } from '@kbn/streams-schema'; +import { + buildEsqlQuery, + getIndexPatternsForStream, + type GeneratedSignificantEventQuery, + type Streams, + type System, +} from '@kbn/streams-schema'; import { generateSignificantEvents } from '@kbn/streams-ai'; import type { SignificantEventsToolUsage } from '@kbn/streams-ai'; import type { FeatureClient } from '../streams/feature/feature_client'; @@ -63,11 +69,21 @@ export async function generateSignificantEventDefinitions( }, }); + const feature = system + ? { name: system.name, filter: system.filter, type: system.type } + : undefined; + return { queries: queries.map((query) => ({ title: query.title, kql: query.kql, - feature: system ? { name: system.name, filter: system.filter, type: system.type } : undefined, + feature, + esql: { + query: buildEsqlQuery(getIndexPatternsForStream(definition), { + kql: { query: query.kql }, + feature, + }), + }, severity_score: query.severity_score, evidence: query.evidence, })), diff --git a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/generate_insights.ts b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/generate_insights.ts index bb4c25514d161..50f0d8e9c8fe6 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/generate_insights.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/generate_insights.ts @@ -25,6 +25,7 @@ export async function generateInsights({ inferenceClient, signal, logger, + streamNames, }: { streamsClient: StreamsClient; queryClient: QueryClient; @@ -32,8 +33,15 @@ export async function generateInsights({ inferenceClient: BoundInferenceClient; signal: AbortSignal; logger: Logger; + /** When provided, only generate insights for these streams. Otherwise all streams are used. */ + streamNames?: string[]; }): Promise { - const streams = await streamsClient.listStreams(); + const allStreams = await streamsClient.listStreams(); + let streams = allStreams; + if (streamNames !== undefined && streamNames.length > 0) { + const streamNamesSet = new Set(streamNames); + streams = allStreams.filter((s) => streamNamesSet.has(s.name)); + } const streamInsightsResults = await Promise.all( streams.map(async (stream) => { const streamInsightResult = await generateStreamInsights({ diff --git a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_queries/system_prompt.text b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_queries/system_prompt.text index cd4086fd83fb6..df49a73b929fb 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_queries/system_prompt.text +++ b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_queries/system_prompt.text @@ -38,3 +38,6 @@ Your job is to analyze this data and generate structured insights. For each insi Focus on the observability domain - help users find issues relating to health, performance, and availability. Group related findings into single insights rather than creating one insight per query. Prioritize the most important findings. If there are no significant issues, return an empty insights array. + +**Important:** You must always end your response by calling the submit_insights tool with your insights. Do not reply with plain text only. +If you find no significant issues, call submit_insights with an empty array. Your final output must be a submit_insights tool call. diff --git a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_streams/system_prompt.text b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_streams/system_prompt.text index 3a7ad7f88ae4e..3b852cf6016f2 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_streams/system_prompt.text +++ b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/insights/prompts/summarize_streams/system_prompt.text @@ -33,3 +33,7 @@ For each insight: Focus on correlations and patterns that span multiple streams. If the stream insights are unrelated, you may return fewer or no system-level insights. Do not simply repeat the stream-level insights - add value by showing the bigger picture. + +**Important:** You must always end your response by calling the submit_insights tool with your +system-level insights. Do not reply with plain text only. If you find no cross-stream patterns, +call submit_insights with an empty array. Your final output must be a submit_insights tool call. diff --git a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/preview_significant_events.ts b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/preview_significant_events.ts index 9932256f138df..6235a0d4a62c7 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/preview_significant_events.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/preview_significant_events.ts @@ -7,11 +7,7 @@ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { IScopedClusterClient } from '@kbn/core/server'; -import type { - SignificantEventsPreviewResponse, - StreamQueryKql, - Streams, -} from '@kbn/streams-schema'; +import type { SignificantEventsPreviewResponse, StreamQuery, Streams } from '@kbn/streams-schema'; import { getIndexPatternsForStream } from '@kbn/streams-schema'; import type { InferSearchResponseOf } from '@kbn/es-types'; import { notFound } from '@hapi/boom'; @@ -19,7 +15,7 @@ import type { ChangePointType } from '@kbn/es-types/src'; import type { Condition } from '@kbn/streamlang'; import { conditionToQueryDsl } from '@kbn/streamlang'; -type PreviewStreamQuery = Pick; +type PreviewStreamQuery = Pick; function createSearchRequest({ from, diff --git a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/read_significant_events_from_alerts_indices.ts b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/read_significant_events_from_alerts_indices.ts index 0f313eb4f0d8a..160ca6e74c6ab 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/read_significant_events_from_alerts_indices.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/read_significant_events_from_alerts_indices.ts @@ -11,7 +11,7 @@ import type { } from '@elastic/elasticsearch/lib/api/types'; import type { IScopedClusterClient } from '@kbn/core/server'; import type { ChangePointType } from '@kbn/es-types/src'; -import type { StreamQueryKql, SignificantEventsGetResponse } from '@kbn/streams-schema'; +import type { StreamQuery, SignificantEventsGetResponse } from '@kbn/streams-schema'; import { get, isArray, isEmpty, keyBy } from 'lodash'; import { LEGACY_RULE_BACKED_FALLBACK, type QueryLink } from '../../../common/queries'; import type { QueryClient } from '../streams/assets/query/query_client'; @@ -136,7 +136,7 @@ export async function readSignificantEventsFromAlertsIndices( if (!response.aggregations || !isArray(response.aggregations.by_rule.buckets)) { return { significant_events: queryLinks.map((queryLink) => ({ - ...toStreamQueryKql(queryLink), + ...toStreamQuery(queryLink), stream_name: queryLink.stream_name, occurrences: [], change_points: { @@ -162,7 +162,7 @@ export async function readSignificantEventsFromAlertsIndices( const changePoints = get(bucket, 'change_points') ?? {}; return { - ...toStreamQueryKql(queryLink), + ...toStreamQuery(queryLink), stream_name: queryLink.stream_name, occurrences: isArray(occurrences) ? occurrences.map((occurrence) => ({ @@ -179,7 +179,7 @@ export async function readSignificantEventsFromAlertsIndices( const notFoundSignificantEvents = queryLinks .filter((queryLink) => !foundSignificantEventsIds.includes(queryLink.query.id)) .map((queryLink) => ({ - ...toStreamQueryKql(queryLink), + ...toStreamQuery(queryLink), stream_name: queryLink.stream_name, occurrences: [], change_points: { @@ -196,7 +196,7 @@ export async function readSignificantEventsFromAlertsIndices( }; } -const toStreamQueryKql = (queryLink: QueryLink): StreamQueryKql => { +const toStreamQuery = (queryLink: QueryLink): StreamQuery => { return { id: queryLink.query.id, title: queryLink.query.title, @@ -204,5 +204,6 @@ const toStreamQueryKql = (queryLink: QueryLink): StreamQueryKql => { feature: queryLink.query.feature, severity_score: queryLink.query.severity_score, evidence: queryLink.query.evidence, + esql: queryLink.query.esql, }; }; diff --git a/x-pack/platform/plugins/shared/streams/server/lib/significant_events/utils.ts b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/utils.ts new file mode 100644 index 0000000000000..4248c314a8327 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams/server/lib/significant_events/utils.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SignificantEventsResponse } from '@kbn/streams-schema'; +import { orderBy } from 'lodash'; + +/** + * Sort queries for the Discovery "Queries" table. + */ +export function sortForQueriesTable( + queries: SignificantEventsResponse[] +): SignificantEventsResponse[] { + return orderBy( + queries, + ['rule_backed', (query) => query.severity_score ?? 0, (query) => query.title], + ['asc', 'desc', 'asc'] + ); +} diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/fields.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/fields.ts index 38c4fbcb8d374..db58ea54671b9 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/fields.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/fields.ts @@ -15,6 +15,7 @@ export const RULE_BACKED = 'rule_backed'; export const QUERY_TITLE = 'query.title'; export const QUERY_KQL_BODY = 'query.kql.query'; +export const QUERY_ESQL_QUERY = 'query.esql.query'; export const QUERY_SEVERITY_SCORE = 'query.severity_score'; // Initially features were called systems, for backward compatibility we need to keep the same field names diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_client.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_client.ts index 737ef8edb54b1..bb87e6dfc37eb 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_client.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_client.ts @@ -10,8 +10,8 @@ import { isBoom } from '@hapi/boom'; import type { RulesClient } from '@kbn/alerting-plugin/server'; import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; import type { IStorageClient } from '@kbn/storage-adapter'; -import type { StreamQuery } from '@kbn/streams-schema'; -import { buildEsqlQuery } from '@kbn/streams-schema'; +import type { StreamQuery, StreamQueryInput, Streams } from '@kbn/streams-schema'; +import { buildEsqlQuery, getIndexPatternsForStream } from '@kbn/streams-schema'; import { isEqual, map, partition } from 'lodash'; import objectHash from 'object-hash'; import pLimit from 'p-limit'; @@ -28,6 +28,7 @@ import { ASSET_ID, ASSET_TYPE, ASSET_UUID, + QUERY_ESQL_QUERY, QUERY_EVIDENCE, QUERY_FEATURE_FILTER, QUERY_FEATURE_NAME, @@ -94,19 +95,25 @@ export function getQueryLinkUuid(name: string, asset: Pick( - name: string, + definition: Streams.all.Definition, asset: TQueryLink ): QueryLink { + const indices = getIndexPatternsForStream(definition); return { ...asset, - [ASSET_UUID]: getQueryLinkUuid(name, asset), - stream_name: name, + query: { + ...asset.query, + esql: { query: buildEsqlQuery(indices, asset.query) }, + }, + [ASSET_UUID]: getQueryLinkUuid(definition.name, asset), + stream_name: definition.name, }; } type QueryLinkStorageFields = Omit & { [QUERY_TITLE]: string; [QUERY_KQL_BODY]: string; + [QUERY_ESQL_QUERY]: string; [QUERY_SEVERITY_SCORE]?: number; [RULE_BACKED]?: boolean; }; @@ -148,6 +155,9 @@ function fromStorage(link: StoredQueryLink): QueryLink { kql: { query: storageFields[QUERY_KQL_BODY], }, + esql: { + query: storageFields[QUERY_ESQL_QUERY], + }, feature: storageFields[QUERY_FEATURE_NAME] ? { name: storageFields[QUERY_FEATURE_NAME], @@ -163,15 +173,19 @@ function fromStorage(link: StoredQueryLink): QueryLink { type QueryLinkRequestWithRuleBacked = QueryLinkRequest & { rule_backed?: boolean }; -function toStorage(name: string, request: QueryLinkRequestWithRuleBacked): StoredQueryLink { - const link = toQueryLink(name, request); +function toStorage( + definition: Streams.all.Definition, + request: QueryLinkRequestWithRuleBacked +): StoredQueryLink { + const link = toQueryLink(definition, request); const { query, stream_name, ...rest } = link; const ruleBacked = request.rule_backed ?? LEGACY_RULE_BACKED_FALLBACK; return { ...rest, - [STREAM_NAME]: name, + [STREAM_NAME]: definition.name, [QUERY_TITLE]: query.title, [QUERY_KQL_BODY]: query.kql.query, + [QUERY_ESQL_QUERY]: query.esql.query, [QUERY_FEATURE_NAME]: query.feature ? query.feature.name : '', [QUERY_FEATURE_FILTER]: query.feature ? JSON.stringify(query.feature.filter) : '', [QUERY_FEATURE_TYPE]: query.feature ? query.feature.type : '', @@ -211,21 +225,22 @@ export class QueryClient { // ==================== Storage Operations ==================== - async linkQuery(name: string, link: QueryLinkRequest): Promise { - const document = toStorage(name, link); + async linkQuery(definition: Streams.all.Definition, link: QueryLinkRequest): Promise { + const document = toStorage(definition, link); await this.dependencies.storageClient.index({ id: document[ASSET_UUID], document, }); - return toQueryLink(name, link); + return toQueryLink(definition, link); } async syncQueryList( - name: string, + definition: Streams.all.Definition, links: QueryLinkRequestWithRuleBacked[] ): Promise<{ deleted: QueryLink[]; indexed: QueryLink[] }> { + const name = definition.name; const assetsResponse = await this.dependencies.storageClient.search({ size: 10_000, track_total_hits: false, @@ -241,7 +256,7 @@ export class QueryClient { }); const nextQueryLinks = links.map((link) => { - return { ...toQueryLink(name, link), rule_backed: link.rule_backed }; + return { ...toQueryLink(definition, link), rule_backed: link.rule_backed }; }); const nextIds = new Set(nextQueryLinks.map((link) => link[ASSET_UUID])); @@ -253,7 +268,7 @@ export class QueryClient { ]; if (operations.length) { - await this.bulkStorage(name, operations); + await this.bulkStorage(definition, operations); } return { @@ -433,12 +448,12 @@ export class QueryClient { return assetsResponse.hits.hits.map((hit) => fromStorage(hit._source)); } - private async bulkStorage(name: string, operations: QueryBulkOperation[]) { + private async bulkStorage(definition: Streams.all.Definition, operations: QueryBulkOperation[]) { return await this.dependencies.storageClient.bulk({ operations: operations.map((operation) => { if ('index' in operation) { const document = toStorage( - name, + definition, Object.values(operation)[0].asset as QueryLinkRequestWithRuleBacked ); return { @@ -449,7 +464,7 @@ export class QueryClient { }; } - const id = getQueryLinkUuid(name, operation.delete.asset); + const id = getQueryLinkUuid(definition.name, operation.delete.asset); return { delete: { _id: id, @@ -481,7 +496,9 @@ export class QueryClient { // ==================== Query Sync with Rules ==================== - public async syncQueries(stream: string, queries: StreamQuery[]) { + public async syncQueries(definition: Streams.all.Definition, queries: StreamQuery[]) { + const stream = definition.name; + if (!this.isSignificantEventsEnabled) { this.dependencies.logger.debug( `Skipping syncQueries for stream "${stream}" because significant events feature is disabled.` @@ -527,11 +544,11 @@ export class QueryClient { await this.installQueries( [...nextQueriesToCreate, ...nextQueriesUpdatedWithBreakingChange], nextQueriesUpdatedWithoutBreakingChange, - stream + definition ); await this.syncQueryList( - stream, + definition, queries.map((query) => ({ [ASSET_ID]: query.id, [ASSET_TYPE]: 'query', @@ -541,7 +558,8 @@ export class QueryClient { ); } - public async upsert(stream: string, query: StreamQuery) { + public async upsert(definition: Streams.all.Definition, query: StreamQueryInput) { + const stream = definition.name; if (!this.isSignificantEventsEnabled) { this.dependencies.logger.debug( `Skipping upsert for stream "${stream}" because significant events feature is disabled.` @@ -549,10 +567,11 @@ export class QueryClient { return; } - await this.bulk(stream, [{ index: query }]); + await this.bulk(definition, [{ index: query }]); } - public async delete(stream: string, queryId: string) { + public async delete(definition: Streams.all.Definition, queryId: string) { + const stream = definition.name; if (!this.isSignificantEventsEnabled) { this.dependencies.logger.debug( `Skipping delete for stream "${stream}" because significant events feature is disabled.` @@ -560,10 +579,11 @@ export class QueryClient { return; } - await this.bulk(stream, [{ delete: { id: queryId } }]); + await this.bulk(definition, [{ delete: { id: queryId } }]); } - public async deleteAll(stream: string) { + public async deleteAll(definition: Streams.all.Definition) { + const stream = definition.name; if (!this.isSignificantEventsEnabled) { this.dependencies.logger.debug( `Skipping deleteAll for stream "${stream}" because significant events feature is disabled.` @@ -573,14 +593,17 @@ export class QueryClient { const { [stream]: currentQueryLinks } = await this.getStreamToQueryLinksMap([stream]); const queriesToDelete = currentQueryLinks.map((link) => ({ delete: { id: link.query.id } })); - await this.bulk(stream, queriesToDelete); + await this.bulk(definition, queriesToDelete); } public async bulk( - stream: string, - operations: Array<{ index?: StreamQuery; delete?: { id: string } }>, + definition: Streams.all.Definition, + operations: Array<{ index?: StreamQueryInput; delete?: { id: string } }>, options?: { createRules?: boolean } ) { + const stream = definition.name; + const indices = getIndexPatternsForStream(definition); + if (!this.isSignificantEventsEnabled) { this.dependencies.logger.debug( `Skipping bulk update for stream "${stream}" because significant events feature is disabled.` @@ -593,7 +616,13 @@ export class QueryClient { const indexOperationsMap = new Map( operations .filter((operation) => operation.index) - .map((operation) => [operation.index!.id, operation.index!]) + .map((operation) => [ + operation.index!.id, + { + ...operation.index!, + esql: { query: buildEsqlQuery(indices, operation.index!) }, + }, + ]) ); const deleteOperationIds = new Set( operations.filter((operation) => operation.delete).map((operation) => operation.delete!.id) @@ -608,7 +637,17 @@ export class QueryClient { }), ...operations .filter((operation) => operation.index && !currentIds.has(operation.index!.id)) - .map((operation) => toQueryLinkFromQuery(operation.index!, stream)), + .map((operation) => + toQueryLinkFromQuery( + { + ...operation.index!, + esql: { + query: buildEsqlQuery(indices, operation.index!), + }, + }, + stream + ) + ), ]; if (options?.createRules === false) { @@ -619,7 +658,7 @@ export class QueryClient { : false, })); await this.syncQueryList( - stream, + definition, nextQueriesWithRuleBacked.map((link) => ({ [ASSET_ID]: link[ASSET_ID], [ASSET_TYPE]: link[ASSET_TYPE], @@ -631,7 +670,7 @@ export class QueryClient { } await this.syncQueries( - stream, + definition, nextQueries.map((link) => link.query) ); } @@ -640,9 +679,11 @@ export class QueryClient { * Creates Kibana rules for stored queries that do not have a backing rule, then marks them as backed. */ public async promoteQueries( - streamName: string, + definition: Streams.all.Definition, queryIds: string[] ): Promise<{ promoted: number }> { + const streamName = definition.name; + if (!this.isSignificantEventsEnabled) { this.dependencies.logger.debug( `Skipping promoteQueries because significant events feature is disabled.` @@ -658,10 +699,10 @@ export class QueryClient { return { promoted: 0 }; } - await this.installQueries(toPromote, [], streamName); + await this.installQueries(toPromote, [], definition); const bulkOperations = toPromote.map((link) => { - const document = toStorage(streamName, { + const document = toStorage(definition, { [ASSET_ID]: link[ASSET_ID], [ASSET_TYPE]: link[ASSET_TYPE], query: link.query, @@ -685,7 +726,7 @@ export class QueryClient { private async installQueries( queriesToCreate: QueryLink[], queriesToUpdate: QueryLink[], - stream: string + definition: Streams.all.Definition ) { const { rulesClient } = this.dependencies; const limiter = pLimit(10); @@ -694,10 +735,12 @@ export class QueryClient { ...queriesToCreate.map((query) => { return limiter(() => rulesClient - .create(this.toCreateRuleParams(query, stream)) + .create(this.toCreateRuleParams(query, definition)) .catch((error) => { if (isBoom(error) && error.output.statusCode === 409) { - return rulesClient.update(this.toUpdateRuleParams(query, stream)); + return rulesClient.update( + this.toUpdateRuleParams(query, definition) + ); } throw error; }) @@ -706,10 +749,12 @@ export class QueryClient { ...queriesToUpdate.map((query) => { return limiter(() => rulesClient - .update(this.toUpdateRuleParams(query, stream)) + .update(this.toUpdateRuleParams(query, definition)) .catch((error) => { if (isBoom(error) && error.output.statusCode === 404) { - return rulesClient.create(this.toCreateRuleParams(query, stream)); + return rulesClient.create( + this.toCreateRuleParams(query, definition) + ); } throw error; }) @@ -734,10 +779,11 @@ export class QueryClient { }); } - private toCreateRuleParams(query: QueryLink, stream: string) { + private toCreateRuleParams(query: QueryLink, definition: Streams.all.Definition) { const ruleId = getRuleIdFromQueryLink(query); + const indices = getIndexPatternsForStream(definition); - const esqlQuery = buildEsqlQuery([stream, `${stream}.*`], query.query, true); + const esqlQuery = buildEsqlQuery(indices, query.query, true); return { data: { name: query.query.title, @@ -749,7 +795,7 @@ export class QueryClient { query: esqlQuery, }, enabled: true, - tags: ['streams', stream], + tags: ['streams', definition.name], schedule: { interval: '1m', }, @@ -760,9 +806,10 @@ export class QueryClient { }; } - private toUpdateRuleParams(query: QueryLink, stream: string) { + private toUpdateRuleParams(query: QueryLink, definition: Streams.all.Definition) { const ruleId = getRuleIdFromQueryLink(query); - const esqlQuery = buildEsqlQuery([stream, `${stream}.*`], query.query, true); + const indices = getIndexPatternsForStream(definition); + const esqlQuery = buildEsqlQuery(indices, query.query, true); return { id: ruleId, data: { @@ -772,7 +819,7 @@ export class QueryClient { timestampField: '@timestamp', query: esqlQuery, }, - tags: ['streams', stream], + tags: ['streams', definition.name], schedule: { interval: '1m', }, diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_service.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_service.ts index 8b68fd6fa6b8b..b62ea2ece6831 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_service.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/query/query_service.ts @@ -9,7 +9,16 @@ import type { CoreSetup, KibanaRequest, Logger } from '@kbn/core/server'; import { OBSERVABILITY_STREAMS_ENABLE_SIGNIFICANT_EVENTS } from '@kbn/management-settings-ids'; import { StorageIndexAdapter } from '@kbn/storage-adapter'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import { buildEsqlQuery } from '@kbn/streams-schema'; +import type { Condition } from '@kbn/streamlang'; import type { StreamsPluginStartDependencies } from '../../../../types'; +import { + QUERY_ESQL_QUERY, + QUERY_KQL_BODY, + QUERY_FEATURE_FILTER, + QUERY_FEATURE_NAME, + STREAM_NAME, +} from '../fields'; import { queryStorageSettings, type QueryStorageSettings } from '../storage_settings'; import { QueryClient, type StoredQueryLink } from './query_client'; @@ -35,7 +44,47 @@ export class QueryService { const adapter = new StorageIndexAdapter( core.elasticsearch.client.asInternalUser, this.logger.get('queries'), - queryStorageSettings + queryStorageSettings, + { + migrateSource: (source) => { + if (source[QUERY_ESQL_QUERY]) { + return source as StoredQueryLink; + } + + const streamName = source[STREAM_NAME] as string; + const featureFilterJson = source[QUERY_FEATURE_FILTER]; + let featureFilter: Condition | undefined; + if ( + featureFilterJson && + typeof featureFilterJson === 'string' && + featureFilterJson !== '' + ) { + try { + featureFilter = JSON.parse(featureFilterJson) as Condition; + } catch { + featureFilter = undefined; + } + } + + const input = { + kql: { query: source[QUERY_KQL_BODY] as string }, + feature: + source[QUERY_FEATURE_NAME] && featureFilter + ? { + name: source[QUERY_FEATURE_NAME] as string, + filter: featureFilter, + type: 'system' as const, + } + : undefined, + }; + + // Uses the wired stream pattern as a best-effort fallback: + // the definition is not available in the sync storage migration callback. + const esqlQuery = buildEsqlQuery([streamName, `${streamName}.*`], input); + + return { ...source, [QUERY_ESQL_QUERY]: esqlQuery } as StoredQueryLink; + }, + } ); return new QueryClient( diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/storage_settings.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/storage_settings.ts index 212aa0773eea0..333c4d4a45019 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/storage_settings.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/assets/storage_settings.ts @@ -11,6 +11,7 @@ import { ASSET_ID, ASSET_TYPE, ASSET_UUID, + QUERY_ESQL_QUERY, QUERY_KQL_BODY, QUERY_SEVERITY_SCORE, QUERY_TITLE, @@ -32,6 +33,7 @@ export const queryStorageSettings = { [ASSET_TYPE]: types.keyword(), [STREAM_NAME]: types.keyword(), [QUERY_KQL_BODY]: types.match_only_text(), + [QUERY_ESQL_QUERY]: types.match_only_text(), [QUERY_TITLE]: types.keyword(), [QUERY_SEVERITY_SCORE]: types.long(), [RULE_BACKED]: types.boolean(), diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts index 39ae38d1baea9..a0daed8a02d70 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts @@ -265,7 +265,7 @@ export class StreamsClient { } ); - await this.syncAssets(stream.name, request); + await this.syncAssets(stream, request); return { acknowledged: true, @@ -274,10 +274,14 @@ export class StreamsClient { } async bulkUpsert(streams: Array<{ name: string; request: Streams.all.UpsertRequest }>) { + const definitions = streams.map(({ name, request }) => { + return { request, definition: convertUpsertRequestIntoDefinition(name, request) }; + }); + const result = await State.attemptChanges( - streams.map(({ name, request }) => ({ + definitions.map(({ definition }) => ({ type: 'upsert', - definition: convertUpsertRequestIntoDefinition(name, request), + definition, })), { ...this.dependencies, @@ -285,7 +289,9 @@ export class StreamsClient { } ); - await Promise.all(streams.map(({ name, request }) => this.syncAssets(name, request))); + await Promise.all( + definitions.map(({ definition, request }) => this.syncAssets(definition, request)) + ); return { acknowledged: true, @@ -813,12 +819,12 @@ export class StreamsClient { }).then((streams) => streams.filter(Streams.WiredStream.Definition.is)); } - private async syncAssets(name: string, request: Streams.all.UpsertRequest) { + private async syncAssets(definition: Streams.all.Definition, request: Streams.all.UpsertRequest) { const { dashboards, queries, rules } = request; await Promise.all([ this.dependencies.attachmentClient.syncAttachmentList( - name, + definition.name, dashboards.map((dashboard) => ({ id: dashboard, type: 'dashboard' as const, @@ -826,14 +832,14 @@ export class StreamsClient { 'dashboard' ), this.dependencies.attachmentClient.syncAttachmentList( - name, + definition.name, rules.map((rule) => ({ id: rule, type: 'rule' as const, })), 'rule' ), - this.dependencies.queryClient.syncQueries(name, queries), + this.dependencies.queryClient.syncQueries(definition, queries), ]); } } diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/component_templates/logs_layer.test.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/component_templates/logs_layer.test.ts index 20a56ebe5f4a6..06523162bd154 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/component_templates/logs_layer.test.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/component_templates/logs_layer.test.ts @@ -6,9 +6,18 @@ */ import type { InheritedFieldDefinition, Streams } from '@kbn/streams-schema'; +import { otelReservedFields } from '@kbn/streams-schema'; import { addAliasesForNamespacedFields, baseMappings, baseFields } from './logs_layer'; describe('logs_layer', () => { + describe('baseMappings and otelReservedFields sync', () => { + it('should have baseMappings keys match otelReservedFields', () => { + const baseMappingsKeys = Object.keys(baseMappings).sort(); + const reservedFieldsSorted = [...otelReservedFields].sort(); + + expect(baseMappingsKeys).toEqual(reservedFieldsSorted); + }); + }); describe('addAliasesForNamespacedFields', () => { let mockStreamDefinition: Streams.WiredStream.Definition; let mockInheritedFields: InheritedFieldDefinition; diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/feature/feature_client.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/feature/feature_client.ts index c49de7ad5270b..17350394318a7 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/feature/feature_client.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/feature/feature_client.ts @@ -8,8 +8,9 @@ import { dateRangeQuery, termQuery, termsQuery } from '@kbn/es-query'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { IStorageClient } from '@kbn/storage-adapter'; -import type { Feature } from '@kbn/streams-schema'; +import type { BaseFeature, Feature } from '@kbn/streams-schema'; import { isNotFoundError } from '@kbn/es-errors'; +import { isEqual } from 'lodash'; import { STREAM_NAME, FEATURE_ID, @@ -205,6 +206,24 @@ export class FeatureClient { (operation) => 'index' in operation || validDeleteIds.has(operation.delete.id) ); } + + findDuplicateFeature({ + existingFeatures, + feature, + }: { + existingFeatures: Feature[]; + feature: BaseFeature; + }): Feature | undefined { + const normalizedId = feature.id.toLowerCase(); + + return existingFeatures.find( + (existing) => + (existing.type === feature.type && + existing.subtype === feature.subtype && + isEqual(existing.properties, feature.properties)) || + existing.id.toLowerCase() === normalizedId + ); + } } function toStorage(stream: string, feature: Feature): StoredFeature { diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/execution_plan.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/execution_plan.ts index 9e7a1da6da70c..1ac76e7f67afb 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/execution_plan.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/execution_plan.ts @@ -259,7 +259,7 @@ export class ExecutionPlan { } return Promise.all( - actions.map((action) => this.dependencies.queryClient.deleteAll(action.request.name)) + actions.map((action) => this.dependencies.queryClient.deleteAll(action.request.definition)) ); } diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/types.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/types.ts index ca8f4101afe2e..63fb931cac126 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/types.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/execution_plan/types.ts @@ -138,7 +138,7 @@ export interface UpdateFailureStoreAction { export interface DeleteQueriesAction { type: 'delete_queries'; request: { - name: string; + definition: Streams.all.Definition; }; } diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/classic_stream.test.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/classic_stream.test.ts new file mode 100644 index 0000000000000..de4b995f5371c --- /dev/null +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/classic_stream.test.ts @@ -0,0 +1,547 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { Streams } from '@kbn/streams-schema'; +import { ClassicStream } from './classic_stream'; +import type { StateDependencies, StreamChange } from '../types'; +import type { State } from '../state'; +import type { StreamChangeStatus } from '../stream_active_record/stream_active_record'; + +interface ClassicStreamChanges { + processing: boolean; + field_overrides: boolean; + failure_store: boolean; + lifecycle: boolean; + settings: boolean; + query_streams: boolean; +} + +interface ClassicStreamTestable { + _changes: ClassicStreamChanges; + doHandleUpsertChange( + definition: Streams.all.Definition, + desiredState: State, + startingState: State + ): Promise<{ cascadingChanges: StreamChange[]; changeStatus: StreamChangeStatus }>; +} + +describe('ClassicStream', () => { + const createMockDependencies = (): StateDependencies => + ({ + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + isServerless: false, + isDev: false, + scopedClusterClient: { + asCurrentUser: { + indices: { + getDataStreamSettings: jest.fn().mockResolvedValue({ + data_streams: [ + { + name: 'logs-test-default', + effective_settings: { + index: {}, + }, + }, + ], + }), + }, + }, + }, + } as unknown as StateDependencies); + + const createMockState = ( + streams: Map = new Map() + ): State => + ({ + get: (name: string) => streams.get(name), + has: (name: string) => streams.has(name), + all: () => Array.from(streams.values()), + } as unknown as State); + + const createBaseClassicStreamDefinition = ( + overrides: Partial = {} + ): Streams.ClassicStream.Definition => ({ + name: 'logs-test-default', + description: 'Test stream', + updated_at: new Date().toISOString(), + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { + field_overrides: undefined, + }, + failure_store: { inherit: {} }, + }, + ...overrides, + }); + + describe('doHandleUpsertChange - _changes flags for new streams', () => { + it('sets processing to false when processing.steps is empty for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.processing).toBe(false); + }); + + it('sets processing to true when processing.steps is non-empty for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { + steps: [ + { action: 'grok', from: 'body.text', patterns: ['%{GREEDYDATA:attributes.data}'] }, + ], + updated_at: new Date().toISOString(), + }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.processing).toBe(true); + }); + + it('sets lifecycle to false when using inherit lifecycle for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedLifecycle()).toBe(false); + }); + + it('sets lifecycle to true when using non-inherit lifecycle for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { dsl: { data_retention: '30d' } }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedLifecycle()).toBe(true); + }); + + it('sets settings to false when settings is empty for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.settings).toBe(false); + }); + + it('sets settings to true when settings is non-empty for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: { 'index.refresh_interval': { value: '5s' } }, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.settings).toBe(true); + }); + + it('sets field_overrides to false when field_overrides is undefined for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.field_overrides).toBe(false); + }); + + it('sets field_overrides to false when field_overrides is empty object for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: {} }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.field_overrides).toBe(false); + }); + + it('sets field_overrides to true when field_overrides is non-empty for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: { test_field: { type: 'keyword' } } }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.field_overrides).toBe(true); + }); + + it('sets failure_store to false when using inherit failure_store for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.failure_store).toBe(false); + }); + + it('sets failure_store to true when using non-inherit failure_store for new stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { lifecycle: { enabled: { data_retention: '7d' } } }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.failure_store).toBe(true); + }); + + it('sets all _changes to false when creating stream with all empty/default values', async () => { + const definition = createBaseClassicStreamDefinition(); + + const stream = new ClassicStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.processing).toBe(false); + expect(stream.hasChangedLifecycle()).toBe(false); + expect((stream as unknown as ClassicStreamTestable)._changes.settings).toBe(false); + expect((stream as unknown as ClassicStreamTestable)._changes.field_overrides).toBe(false); + expect((stream as unknown as ClassicStreamTestable)._changes.failure_store).toBe(false); + }); + }); + + describe('doHandleUpsertChange - _changes flags for existing streams', () => { + it('sets processing to true when processing changed for existing stream', async () => { + const existingDefinition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const newDefinition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { + steps: [ + { action: 'grok', from: 'body.text', patterns: ['%{GREEDYDATA:attributes.data}'] }, + ], + updated_at: new Date().toISOString(), + }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(existingDefinition, createMockDependencies()); + const startingState = createMockState( + new Map([['logs-test-default', { definition: existingDefinition }]]) + ); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + newDefinition, + startingState, + startingState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.processing).toBe(true); + }); + + it('sets processing to false when processing unchanged for existing stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { + steps: [ + { action: 'grok', from: 'body.text', patterns: ['%{GREEDYDATA:attributes.data}'] }, + ], + updated_at: '2024-01-01T00:00:00.000Z', + }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const startingState = createMockState(new Map([['logs-test-default', { definition }]])); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + startingState, + startingState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.processing).toBe(false); + }); + + it('sets lifecycle to true when lifecycle changed for existing stream', async () => { + const existingDefinition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const newDefinition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { dsl: { data_retention: '30d' } }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(existingDefinition, createMockDependencies()); + const startingState = createMockState( + new Map([['logs-test-default', { definition: existingDefinition }]]) + ); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + newDefinition, + startingState, + startingState + ); + + expect(stream.hasChangedLifecycle()).toBe(true); + }); + + it('sets lifecycle to false when lifecycle unchanged for existing stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { dsl: { data_retention: '30d' } }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: undefined }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const startingState = createMockState(new Map([['logs-test-default', { definition }]])); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + startingState, + startingState + ); + + expect(stream.hasChangedLifecycle()).toBe(false); + }); + + it('sets field_overrides to true when field_overrides changed for existing stream', async () => { + const existingDefinition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: { old_field: { type: 'keyword' } } }, + failure_store: { inherit: {} }, + }, + }); + + const newDefinition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: { new_field: { type: 'match_only_text' } } }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(existingDefinition, createMockDependencies()); + const startingState = createMockState( + new Map([['logs-test-default', { definition: existingDefinition }]]) + ); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + newDefinition, + startingState, + startingState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.field_overrides).toBe(true); + }); + + it('sets field_overrides to false when field_overrides unchanged for existing stream', async () => { + const definition = createBaseClassicStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + classic: { field_overrides: { test_field: { type: 'keyword' } } }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new ClassicStream(definition, createMockDependencies()); + const startingState = createMockState(new Map([['logs-test-default', { definition }]])); + + await (stream as unknown as ClassicStreamTestable).doHandleUpsertChange( + definition, + startingState, + startingState + ); + + expect((stream as unknown as ClassicStreamTestable)._changes.field_overrides).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/classic_stream.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/classic_stream.ts index 435ac368cc8c4..117be3d1299da 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/classic_stream.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/classic_stream.ts @@ -15,7 +15,12 @@ import type { IngestStreamLifecycle, IngestStreamSettings, } from '@kbn/streams-schema'; -import { isIlmLifecycle, isInheritLifecycle, Streams } from '@kbn/streams-schema'; +import { + isIlmLifecycle, + isInheritLifecycle, + Streams, + validateStreamName, +} from '@kbn/streams-schema'; import { validateStreamlang } from '@kbn/streamlang'; import { isMappingProperties } from '@kbn/streams-schema/src/fields'; import { @@ -39,7 +44,12 @@ import type { } from '../stream_active_record/stream_active_record'; import { StreamActiveRecord } from '../stream_active_record/stream_active_record'; import type { StateDependencies, StreamChange } from '../types'; -import { formatSettings, settingsUpdateRequiresRollover, validateQueryStreams } from './helpers'; +import { + computeChange, + formatSettings, + settingsUpdateRequiresRollover, + validateQueryStreams, +} from './helpers'; import { validateSettings, validateSettingsWithDryRun } from './validate_settings'; interface ClassicStreamChanges extends StreamChanges { @@ -95,34 +105,58 @@ export class ClassicStream extends StreamActiveRecord 0, + hasChanged: () => + !_.isEqual( + _.omit(this._definition.ingest.processing, ['updated_at']), + _.omit(startingStateStreamDefinition!.ingest.processing, ['updated_at']) + ), + }); - this._changes.settings = - !startingStateStreamDefinition || - !_.isEqual(await this.getEffectiveSettings(), this._definition.ingest.settings); + this._changes.lifecycle = computeChange({ + isExistingStream, + hasMeaningfulValue: !isInheritLifecycle(this._definition.ingest.lifecycle), + hasChanged: () => + !_.isEqual( + this._definition.ingest.lifecycle, + startingStateStreamDefinition!.ingest.lifecycle + ), + }); - this._changes.field_overrides = - !startingStateStreamDefinition || - !_.isEqual( - this._definition.ingest.classic.field_overrides, - startingStateStreamDefinition.ingest.classic.field_overrides - ); + // Prefetch effective settings for existing streams to allow sync comparison + const effectiveSettings = isExistingStream ? await this.getEffectiveSettings() : undefined; + this._changes.settings = computeChange({ + isExistingStream, + hasMeaningfulValue: Object.keys(this._definition.ingest.settings || {}).length > 0, + hasChanged: () => !_.isEqual(effectiveSettings, this._definition.ingest.settings), + }); - this._changes.failure_store = - !startingStateStreamDefinition || - !_.isEqual( - this._definition.ingest.failure_store, - startingStateStreamDefinition.ingest.failure_store - ); + this._changes.field_overrides = computeChange({ + isExistingStream, + hasMeaningfulValue: !!( + this._definition.ingest.classic.field_overrides && + Object.keys(this._definition.ingest.classic.field_overrides).length > 0 + ), + hasChanged: () => + !_.isEqual( + this._definition.ingest.classic.field_overrides, + startingStateStreamDefinition!.ingest.classic.field_overrides + ), + }); + + this._changes.failure_store = computeChange({ + isExistingStream, + hasMeaningfulValue: !isInheritFailureStore(this._definition.ingest.failure_store), + hasChanged: () => + !_.isEqual( + this._definition.ingest.failure_store, + startingStateStreamDefinition!.ingest.failure_store + ), + }); this._changes.query_streams = !startingStateStreamDefinition || @@ -168,6 +202,15 @@ export class ClassicStream extends StreamActiveRecord { + // Validate the stream's name + const nameValidation = validateStreamName(this._definition.name); + if (!nameValidation.valid) { + return { + isValid: false, + errors: [new Error(nameValidation.message)], + }; + } + if (this.dependencies.isServerless) { if (isIlmLifecycle(this.getLifecycle())) { return { isValid: false, errors: [new Error('Using ILM is not supported in Serverless')] }; @@ -549,7 +592,7 @@ export class ClassicStream extends StreamActiveRecord boolean; +} + +/** + * Determines if a change flag should be set for a stream property. + * + * For existing streams (isExistingStream=true): returns true if the values are not equal (hasChanged) + * For new streams (isExistingStream=false): returns true if the value is meaningful/non-empty (hasMeaningfulValue) + */ +export function computeChange({ + isExistingStream, + hasMeaningfulValue, + hasChanged, +}: ComputeChangeOptions): boolean { + return isExistingStream ? hasChanged() : hasMeaningfulValue; +} + export function formatSettings(settings: IngestStreamSettings, isServerless: boolean) { if (isServerless) { return { diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/wired_stream.test.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/wired_stream.test.ts new file mode 100644 index 0000000000000..aa765a9f9f964 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/wired_stream.test.ts @@ -0,0 +1,495 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { Streams } from '@kbn/streams-schema'; +import { WiredStream } from './wired_stream'; +import type { StateDependencies, StreamChange } from '../types'; +import type { State } from '../state'; +import type { StreamChangeStatus } from '../stream_active_record/stream_active_record'; + +interface WiredStreamChanges { + ownFields: boolean; + ownRouting: boolean; + routing: boolean; + processing: boolean; + lifecycle: boolean; + settings: boolean; + failure_store: boolean; +} + +interface WiredStreamTestable { + _changes: WiredStreamChanges; + doHandleUpsertChange( + definition: Streams.all.Definition, + desiredState: State, + startingState: State + ): Promise<{ cascadingChanges: StreamChange[]; changeStatus: StreamChangeStatus }>; +} + +describe('WiredStream', () => { + const createMockDependencies = (): StateDependencies => + ({ + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + isServerless: false, + isDev: false, + } as unknown as StateDependencies); + + const createMockState = ( + streams: Map = new Map() + ): State => + ({ + get: (name: string) => streams.get(name), + has: (name: string) => streams.has(name), + all: () => Array.from(streams.values()), + } as unknown as State); + + const createBaseWiredStreamDefinition = ( + overrides: Partial = {} + ): Streams.WiredStream.Definition => ({ + name: 'logs.test', + description: 'Test stream', + updated_at: new Date().toISOString(), + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { + fields: {}, + routing: [], + }, + failure_store: { inherit: {} }, + }, + ...overrides, + }); + + describe('doHandleUpsertChange - _changes flags for new streams', () => { + it('sets ownFields to false when fields is empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedFields()).toBe(false); + }); + + it('sets ownFields to true when fields is non-empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { + fields: { 'test.field': { type: 'keyword' } }, + routing: [], + }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedFields()).toBe(true); + }); + + it('sets routing to false when routing is empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as WiredStreamTestable)._changes.routing).toBe(false); + }); + + it('sets routing to true when routing is non-empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { + fields: {}, + routing: [{ destination: 'logs.test.child', where: { never: {} }, status: 'enabled' }], + }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as WiredStreamTestable)._changes.routing).toBe(true); + }); + + it('sets processing to false when processing.steps is empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as WiredStreamTestable)._changes.processing).toBe(false); + }); + + it('sets processing to true when processing.steps is non-empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { + steps: [ + { action: 'grok', from: 'body.text', patterns: ['%{GREEDYDATA:attributes.data}'] }, + ], + updated_at: new Date().toISOString(), + }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect((stream as unknown as WiredStreamTestable)._changes.processing).toBe(true); + }); + + it('sets lifecycle to false when using inherit lifecycle for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedLifecycle()).toBe(false); + }); + + it('sets lifecycle to true when using non-inherit lifecycle for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { dsl: { data_retention: '30d' } }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedLifecycle()).toBe(true); + }); + + it('sets settings to false when settings is empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedSettings()).toBe(false); + }); + + it('sets settings to true when settings is non-empty for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: { 'index.refresh_interval': { value: '5s' } }, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedSettings()).toBe(true); + }); + + it('sets failure_store to false when using inherit failure_store for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedFailureStore()).toBe(false); + }); + + it('sets failure_store to true when using non-inherit failure_store for new stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { lifecycle: { enabled: { data_retention: '7d' } } }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedFailureStore()).toBe(true); + }); + + it('sets all _changes to false when creating stream with all empty/default values', async () => { + const definition = createBaseWiredStreamDefinition(); + + const stream = new WiredStream(definition, createMockDependencies()); + const emptyState = createMockState(); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + emptyState, + emptyState + ); + + expect(stream.hasChangedFields()).toBe(false); + expect((stream as unknown as WiredStreamTestable)._changes.routing).toBe(false); + expect((stream as unknown as WiredStreamTestable)._changes.processing).toBe(false); + expect(stream.hasChangedLifecycle()).toBe(false); + expect(stream.hasChangedSettings()).toBe(false); + expect(stream.hasChangedFailureStore()).toBe(false); + }); + }); + + describe('doHandleUpsertChange - _changes flags for existing streams', () => { + it('sets ownFields to true when fields changed for existing stream', async () => { + const existingDefinition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: { 'old.field': { type: 'keyword' } }, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const newDefinition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: { 'new.field': { type: 'match_only_text' } }, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(existingDefinition, createMockDependencies()); + const startingState = createMockState( + new Map([['logs.test', { definition: existingDefinition }]]) + ); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + newDefinition, + startingState, + startingState + ); + + expect(stream.hasChangedFields()).toBe(true); + }); + + it('sets ownFields to false when fields unchanged for existing stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: { 'test.field': { type: 'keyword' } }, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const startingState = createMockState(new Map([['logs.test', { definition }]])); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + startingState, + startingState + ); + + expect(stream.hasChangedFields()).toBe(false); + }); + + it('sets lifecycle to true when lifecycle changed for existing stream', async () => { + const existingDefinition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const newDefinition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { dsl: { data_retention: '30d' } }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(existingDefinition, createMockDependencies()); + const startingState = createMockState( + new Map([['logs.test', { definition: existingDefinition }]]) + ); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + newDefinition, + startingState, + startingState + ); + + expect(stream.hasChangedLifecycle()).toBe(true); + }); + + it('sets lifecycle to false when lifecycle unchanged for existing stream', async () => { + const definition = createBaseWiredStreamDefinition({ + ingest: { + lifecycle: { dsl: { data_retention: '30d' } }, + processing: { steps: [], updated_at: new Date().toISOString() }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }); + + const stream = new WiredStream(definition, createMockDependencies()); + const startingState = createMockState(new Map([['logs.test', { definition }]])); + + await (stream as unknown as WiredStreamTestable).doHandleUpsertChange( + definition, + startingState, + startingState + ); + + expect(stream.hasChangedLifecycle()).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/wired_stream.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/wired_stream.ts index c12a5a4a3ef5a..0b5857a314c2f 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/wired_stream.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/state_management/streams/wired_stream.ts @@ -15,6 +15,7 @@ import { getSegments, isInheritLifecycle, getInheritedFieldsFromAncestors, + validateStreamName, } from '@kbn/streams-schema'; import { getAncestors, @@ -32,7 +33,6 @@ import { isInheritFailureStore, } from '@kbn/streams-schema/src/models/ingest/failure_store'; import { validateStreamlang } from '@kbn/streamlang'; -import { MAX_STREAM_NAME_LENGTH } from '../../../../../common/constants'; import { generateLayer } from '../../component_templates/generate_layer'; import { getComponentTemplateName } from '../../component_templates/name'; import { isDefinitionNotFoundError } from '../../errors/definition_not_found_error'; @@ -63,7 +63,12 @@ import type { } from '../stream_active_record/stream_active_record'; import { StreamActiveRecord } from '../stream_active_record/stream_active_record'; import { hasSupportedStreamsRoot } from '../../root_stream_definition'; -import { formatSettings, settingsUpdateRequiresRollover, validateQueryStreams } from './helpers'; +import { + computeChange, + formatSettings, + settingsUpdateRequiresRollover, + validateQueryStreams, +} from './helpers'; import { validateSettings, validateSettingsWithDryRun } from './validate_settings'; interface WiredStreamChanges extends StreamChanges { @@ -128,41 +133,69 @@ export class WiredStream extends StreamActiveRecord 0, + hasChanged: () => + !_.isEqual( + this._definition.ingest.wired.fields, + startingStateStreamDefinition!.ingest.wired.fields + ), + }); - this._changes.routing = - !startingStateStreamDefinition || - !_.isEqual( - this._definition.ingest.wired.routing, - startingStateStreamDefinition.ingest.wired.routing - ); + this._changes.routing = computeChange({ + isExistingStream, + hasMeaningfulValue: (this._definition.ingest.wired.routing || []).length > 0, + hasChanged: () => + !_.isEqual( + this._definition.ingest.wired.routing, + startingStateStreamDefinition!.ingest.wired.routing + ), + }); - this._changes.failure_store = - !startingStateStreamDefinition || - !_.isEqual( - this._definition.ingest.failure_store, - startingStateStreamDefinition.ingest.failure_store - ); + this._changes.failure_store = computeChange({ + isExistingStream, + hasMeaningfulValue: !isInheritFailureStore(this._definition.ingest.failure_store), + hasChanged: () => + !_.isEqual( + this._definition.ingest.failure_store, + startingStateStreamDefinition!.ingest.failure_store + ), + }); - this._changes.processing = - !startingStateStreamDefinition || - !_.isEqual( - _.omit(this._definition.ingest.processing, ['updated_at']), - _.omit(startingStateStreamDefinition.ingest.processing, ['updated_at']) - ); + this._changes.processing = computeChange({ + isExistingStream, + hasMeaningfulValue: (this._definition.ingest.processing.steps || []).length > 0, + hasChanged: () => + !_.isEqual( + _.omit(this._definition.ingest.processing, ['updated_at']), + _.omit(startingStateStreamDefinition!.ingest.processing, ['updated_at']) + ), + }); - this._changes.lifecycle = - !startingStateStreamDefinition || - !_.isEqual(this._definition.ingest.lifecycle, startingStateStreamDefinition.ingest.lifecycle); + this._changes.lifecycle = computeChange({ + isExistingStream, + hasMeaningfulValue: !isInheritLifecycle(this._definition.ingest.lifecycle), + hasChanged: () => + !_.isEqual( + this._definition.ingest.lifecycle, + startingStateStreamDefinition!.ingest.lifecycle + ), + }); - this._changes.settings = - !startingStateStreamDefinition || - !_.isEqual(this._definition.ingest.settings, startingStateStreamDefinition.ingest.settings); + this._changes.settings = computeChange({ + isExistingStream, + hasMeaningfulValue: Object.keys(this._definition.ingest.settings || {}).length > 0, + hasChanged: () => + !_.isEqual( + this._definition.ingest.settings, + startingStateStreamDefinition!.ingest.settings + ), + }); this._changes.query_streams = !startingStateStreamDefinition || @@ -349,6 +382,15 @@ export class WiredStream extends StreamActiveRecord MAX_NESTING_LEVEL) { @@ -402,12 +444,17 @@ export class WiredStream extends StreamActiveRecord = new Set(); const prefix = this.definition.name + '.'; for (const routing of this._definition.ingest.wired.routing) { - const hasUpperCaseChars = routing.destination !== routing.destination.toLowerCase(); - if (hasUpperCaseChars) { - return { - isValid: false, - errors: [new Error(`Stream name cannot contain uppercase characters.`)], - }; + // Only validate child stream name if the child doesn't exist in desired state. + // If it exists, it will validate its own name in its own doValidateUpsertion call, + // avoiding duplicate error messages. + if (!desiredState.has(routing.destination)) { + const childNameValidation = validateStreamName(routing.destination); + if (!childNameValidation.valid) { + return { + isValid: false, + errors: [new Error(childNameValidation.message)], + }; + } } if (routing.destination.length <= prefix.length) { return { @@ -415,14 +462,6 @@ export class WiredStream extends StreamActiveRecord MAX_STREAM_NAME_LENGTH) { - return { - isValid: false, - errors: [ - new Error(`Stream name cannot be longer than ${MAX_STREAM_NAME_LENGTH} characters.`), - ], - }; - } if (children.has(routing.destination)) { return { isValid: false, @@ -982,7 +1021,7 @@ export class WiredStream extends StreamActiveRecord id), - }); - + const { hits: existingFeatures } = await featureClient.getFeatures(stream.name); const now = Date.now(); const features = identifiedFeatures.map((feature) => { - const existing = existingFeatures.find(({ id }) => id === feature.id); + const existing = featureClient.findDuplicateFeature({ + existingFeatures, + feature, + }); if (existing) { taskContext.logger.debug( `Overwriting feature with id [${ diff --git a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/insights_discovery.ts b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/insights_discovery.ts index 739f48e909b61..2bd44703c3dc4 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/insights_discovery.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/insights_discovery.ts @@ -18,6 +18,8 @@ import { formatInferenceProviderError } from '../../../routes/utils/create_conne export interface InsightsDiscoveryTaskParams { connectorId: string; + /** When provided, only generate insights for these stream names. Otherwise all streams are used. */ + streamNames?: string[]; } export const STREAMS_INSIGHTS_DISCOVERY_TASK_TYPE = 'streams_insights_discovery'; @@ -33,7 +35,7 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { throw new Error('Request is required to run this task'); } - const { connectorId, _task } = runContext.taskInstance + const { connectorId, streamNames, _task } = runContext.taskInstance .params as TaskParams; const { @@ -56,6 +58,7 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { inferenceClient: boundInferenceClient, signal: runContext.abortController.signal, logger: taskContext.logger.get('insights_discovery'), + streamNames, }); taskContext.telemetry.trackInsightsGenerated({ @@ -66,7 +69,7 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { await taskClient.complete( _task, - { connectorId }, + { connectorId, streamNames }, result ); } catch (error) { @@ -90,7 +93,7 @@ export function createStreamsInsightsDiscoveryTask(taskContext: TaskContext) { await taskClient.fail( _task, - { connectorId }, + { connectorId, streamNames }, errorMessage ); return getDeleteTaskRunResult(); diff --git a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts index 3572d941e4562..600a19a216020 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/tasks/task_definitions/onboarding.ts @@ -22,6 +22,7 @@ import type { StreamsTaskType, TaskContext } from '.'; import { getErrorMessage } from '../../streams/errors/parse_error'; import { formatInferenceProviderError } from '../../../routes/utils/create_connector_sse_error'; import type { QueryClient } from '../../streams/assets/query/query_client'; +import type { StreamsClient } from '../../streams/client'; import { cancellableTask } from '../cancellable_task'; import type { TaskClient } from '../task_client'; import type { TaskParams } from '../types'; @@ -65,7 +66,7 @@ export function createStreamsOnboardingTask(taskContext: TaskContext) { const { connectorId, streamName, from, to, steps, _task } = runContext.taskInstance .params as TaskParams; - const { taskClient, inferenceClient, queryClient } = + const { taskClient, inferenceClient, queryClient, streamsClient } = await taskContext.getScopedClients({ request: runContext.fakeRequest, }); @@ -121,7 +122,10 @@ export function createStreamsOnboardingTask(taskContext: TaskContext) { return; } - await saveQueries(streamName, queriesTaskResult.queries, { queryClient }); + await saveQueries(streamName, queriesTaskResult.queries, { + queryClient, + streamsClient, + }); break; default: @@ -253,16 +257,19 @@ export async function saveQueries( queries: GeneratedSignificantEventQuery[], deps: { queryClient: QueryClient; + streamsClient: StreamsClient; } ) { - const { queryClient } = deps; + const { queryClient, streamsClient } = deps; if (queries.length === 0) { return; } + const definition = await streamsClient.getStream(streamName); + await queryClient.bulk( - streamName, + definition, queries.map((query) => ({ index: { id: v4(), diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/insights/route.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/insights/route.ts index 92b4305ebcb6f..b812378202bf6 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/insights/route.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/insights/route.ts @@ -41,6 +41,10 @@ const insightsTaskRoute = createServerRoute({ .describe( 'Optional connector ID. If not provided, the default AI connector from settings will be used.' ), + streamNames: z + .array(z.string()) + .describe('List of stream names to generate insights for.') + .optional(), }), }), handler: async ({ @@ -74,6 +78,7 @@ const insightsTaskRoute = createServerRoute({ return { connectorId, + streamNames: body.streamNames, }; })(), request, diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_condition_noops.test.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_condition_noops.test.ts new file mode 100644 index 0000000000000..ada47730ce812 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_condition_noops.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Condition, StreamlangDSL } from '@kbn/streamlang'; +import { conditionToPainless } from '@kbn/streamlang'; +import { buildSimulationProcessorsWithConditionNoops } from './simulation_condition_noops'; + +describe('buildSimulationProcessorsWithConditionNoops', () => { + it('injects a no-op processor for a condition even if it has no descendants', () => { + const dsl: StreamlangDSL = { + steps: [ + { + customIdentifier: 'cond-1', + condition: { + field: 'foo', + eq: 'bar', + steps: [], + }, + }, + ], + }; + + const processors = buildSimulationProcessorsWithConditionNoops(dsl); + + expect(processors).toHaveLength(2); + expect(processors[0]).toHaveProperty('set'); + expect(processors[0].set?.tag).toBe('cond-1'); + expect(processors[0].set?.field).toBe('_streams_condition_noop'); + expect(typeof processors[0].set?.if).toBe('string'); + expect(processors[1]).toHaveProperty('remove'); + expect(processors[1].remove?.tag).toBe('cond-1:noop-cleanup'); + expect(processors[1].remove?.field).toBe('_streams_condition_noop'); + }); + + it('injects condition no-op before its descendants and keeps descendant processor tags', () => { + const dsl: StreamlangDSL = { + steps: [ + { + customIdentifier: 'cond-1', + condition: { + field: 'foo', + eq: 'bar', + steps: [ + { + customIdentifier: 'proc-1', + action: 'set', + to: 'target', + value: 'value', + }, + ], + }, + }, + ], + }; + + const processors = buildSimulationProcessorsWithConditionNoops(dsl); + + expect(processors).toHaveLength(3); + expect(processors[0].set?.tag).toBe('cond-1'); + expect(processors[1].remove?.tag).toBe('cond-1:noop-cleanup'); + expect(processors[1].remove?.field).toBe('_streams_condition_noop'); + expect(processors[2].set?.tag).toBe('proc-1'); + expect(typeof processors[2].set?.if).toBe('string'); + }); + + it('composes nested condition no-ops with parent conditions', () => { + const parentCondition: Condition = { field: 'a', eq: 1 }; + const childCondition: Condition = { field: 'b', eq: 2 }; + const dsl: StreamlangDSL = { + steps: [ + { + customIdentifier: 'cond-parent', + condition: { + ...parentCondition, + steps: [ + { + customIdentifier: 'cond-child', + condition: { + ...childCondition, + steps: [ + { + customIdentifier: 'proc-1', + action: 'set', + to: 'target', + value: 'value', + }, + ], + }, + }, + ], + }, + }, + ], + }; + + const processors = buildSimulationProcessorsWithConditionNoops(dsl); + + expect(processors).toHaveLength(5); + expect(processors[0].set?.tag).toBe('cond-parent'); + expect(processors[1].remove?.tag).toBe('cond-parent:noop-cleanup'); + expect(processors[1].remove?.field).toBe('_streams_condition_noop'); + expect(processors[2].set?.tag).toBe('cond-child'); + expect(processors[3].remove?.tag).toBe('cond-child:noop-cleanup'); + expect(processors[3].remove?.field).toBe('_streams_condition_noop'); + expect(processors[4].set?.tag).toBe('proc-1'); + + const childSetIf = processors[2].set?.if; + expect(childSetIf).toBe(conditionToPainless({ and: [parentCondition, childCondition] })); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_condition_noops.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_condition_noops.ts new file mode 100644 index 0000000000000..e370cb81dbdf3 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_condition_noops.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IngestProcessorContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { Condition, StreamlangDSL, StreamlangProcessorDefinition } from '@kbn/streamlang'; +import { conditionToPainless, isConditionBlock, transpileIngestPipeline } from '@kbn/streamlang'; + +type StreamlangStep = StreamlangDSL['steps'][number]; + +function combineConditionsAsAnd(condA?: Condition, condB?: Condition): Condition | undefined { + if (!condA) return condB; + if (!condB) return condA; + return { and: [condA, condB] }; +} + +function createConditionNoopProcessor({ + conditionId, + condition, +}: { + conditionId: string; + condition: Condition; +}): IngestProcessorContainer[] { + let painlessIf: string; + try { + painlessIf = conditionToPainless(condition); + } catch { + // While editing, conditions can be temporarily invalid. Treat as "never matches" so: + // - simulation keeps running (live updates) + // - match rate resolves to 0% until the condition becomes valid + painlessIf = 'false'; + } + + // Use set + remove instead of a painless script to avoid compilation overhead. + // This creates a true no-op that doesn't require painless to be enabled. + const tempField = '_streams_condition_noop'; + + // The remove processor uses a distinct tag suffix so it gets filtered out + // but doesn't double-count in processor metrics (which aggregate by tag). + const removeTag = `${conditionId}:noop-cleanup`; + + return [ + { + set: { + tag: conditionId, + field: tempField, + value: true, + if: painlessIf, + }, + }, + { + remove: { + tag: removeTag, + field: tempField, + ignore_missing: true, + if: painlessIf, + }, + }, + ]; +} + +function buildSimulationProcessorsFromSteps({ + steps, + parentCondition, +}: { + steps: StreamlangStep[]; + parentCondition?: Condition; +}): IngestProcessorContainer[] { + const processors: IngestProcessorContainer[] = []; + + for (const step of steps) { + if (isConditionBlock(step)) { + const conditionId = step.customIdentifier; + const { steps: nestedSteps, ...restCondition } = step.condition; + const combinedCondition = combineConditionsAsAnd(parentCondition, restCondition); + + // Only emit no-op processors for identified condition blocks + // (UI blocks always have ids, but Streamlang schema allows them to be omitted). + if (conditionId && combinedCondition) { + // Pre-order insertion: ensure this runs before any nested processors (even if they later fail). + processors.push( + ...createConditionNoopProcessor({ conditionId, condition: combinedCondition }) + ); + } + + processors.push( + ...buildSimulationProcessorsFromSteps({ + steps: nestedSteps, + parentCondition: combinedCondition, + }) + ); + + continue; + } + + const processorStep = step as StreamlangProcessorDefinition; + const combinedWhere = + 'where' in processorStep && processorStep.where + ? combineConditionsAsAnd(parentCondition, processorStep.where) + : parentCondition; + + const stepWithCombinedWhere = + combinedWhere !== undefined + ? ({ + ...processorStep, + where: combinedWhere, + } as StreamlangProcessorDefinition) + : processorStep; + + const transpiled = transpileIngestPipeline( + { steps: [stepWithCombinedWhere] } as StreamlangDSL, + { ignoreMalformed: true, traceCustomIdentifiers: true } + ).processors; + + processors.push(...transpiled); + } + + return processors; +} + +/** + * Builds ingest pipeline processors for simulation runs. + * + * This is identical to normal transpilation, except it injects simulation-only no-op processors + * (set + remove of a temporary field) *under each condition block* (tagged with the condition + * customIdentifier), so simulation metrics can compute condition match rates even if there are + * no descendants or descendants are faulty. + * + * The set processor is tagged with the condition ID for metric tracking. Using set+remove instead + * of a painless script avoids compilation overhead and works even without painless enabled. + * + * These processors are never exposed as steps in the UI; they exist only in the ES `_simulate` request. + */ +export function buildSimulationProcessorsWithConditionNoops( + processing: StreamlangDSL +): IngestProcessorContainer[] { + return buildSimulationProcessorsFromSteps({ steps: processing.steps }); +} diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.test.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.test.ts index 4e5bf0f22ae2b..a2540f55d2a2f 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.test.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.test.ts @@ -28,6 +28,8 @@ const createMockProcessorResult = ( }); describe('computeSimulationDocDiff', () => { + const conditionProcessorTags = new Set(); + describe('detected_fields filtering', () => { it('should NOT include a field that is created then deleted in detected_fields', () => { // Scenario: Processor 1 adds 'temp_field', Processor 2 removes it @@ -39,7 +41,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // temp_field should NOT be in detected_fields (not in final output) expect(result.detected_fields.map((f) => f.name)).not.toContain('temp_field'); @@ -73,7 +81,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // detected_fields should be EMPTY - no new fields in final output vs input expect(result.detected_fields).toHaveLength(0); @@ -99,7 +113,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // new_field should be in detected_fields (exists in final output) expect(result.detected_fields.map((f) => f.name)).toContain('new_field'); @@ -120,7 +140,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // to_be_deleted should NOT be in detected_fields (not in final output) expect(result.detected_fields.map((f) => f.name)).not.toContain('to_be_deleted'); @@ -138,7 +164,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // new_field should be in detected_fields (exists in final output) expect(result.detected_fields.map((f) => f.name)).toContain('new_field'); @@ -172,7 +204,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // Only 'kept' and 'new_in_p2' should be in detected_fields const detectedFieldNames = result.detected_fields.map((f) => f.name); @@ -201,7 +239,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // Both new fields should be detected const detectedFieldNames = result.detected_fields.map((f) => f.name); @@ -216,7 +260,13 @@ describe('computeSimulationDocDiff', () => { processor_results: [], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); expect(result.detected_fields).toHaveLength(0); expect(result.intermediate_field_changes).toHaveLength(0); @@ -238,7 +288,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // processor1 should have field_a attributed to it expect(result.intermediate_field_changes).toContainEqual({ @@ -262,7 +318,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, ['reserved_field']); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: ['reserved_field'], + conditionProcessorTags, + }); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toMatchObject({ @@ -277,7 +339,13 @@ describe('computeSimulationDocDiff', () => { processor_results: [createMockProcessorResult('processor1', { normal_field: 'modified' })], }; - const result = computeSimulationDocDiff(base, docResult, true, ['reserved_field']); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: ['reserved_field'], + conditionProcessorTags, + }); expect(result.errors).toHaveLength(0); }); @@ -297,7 +365,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // parent.temp should NOT be in detected_fields expect(result.detected_fields.map((f) => f.name)).not.toContain('parent.temp'); @@ -317,7 +391,13 @@ describe('computeSimulationDocDiff', () => { ], }; - const result = computeSimulationDocDiff(base, docResult, true, []); + const result = computeSimulationDocDiff({ + base, + docResult, + isWiredStream: true, + forbiddenFields: [], + conditionProcessorTags, + }); // parent.permanent should be in detected_fields expect(result.detected_fields.map((f) => f.name)).toContain('parent.permanent'); diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.ts index 2f29010dd0c4b..0b13cd99b8bac 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/processing/simulation_handler.ts @@ -38,7 +38,7 @@ import type { import { getInheritedFieldsFromAncestors, Streams } from '@kbn/streams-schema'; import { mapValues, uniq, omit, isEmpty, uniqBy } from 'lodash'; import type { StreamlangDSL } from '@kbn/streamlang'; -import { transpileIngestPipeline, validateStreamlang } from '@kbn/streamlang'; +import { validateStreamlang } from '@kbn/streamlang'; import { getRoot } from '@kbn/streams-schema/src/shared/hierarchy'; import type { FieldMetadataPlain } from '@kbn/fields-metadata-plugin/common'; import { FIELD_DEFINITION_TYPES } from '@kbn/streams-schema/src/fields'; @@ -48,6 +48,7 @@ import { } from '../../../../lib/streams/helpers/normalize_geo_points'; import { getProcessingPipelineName } from '../../../../lib/streams/ingest_pipelines/name'; import type { StreamsClient } from '../../../../lib/streams/client'; +import { buildSimulationProcessorsWithConditionNoops } from './simulation_condition_noops'; export interface ProcessingSimulationParams { path: { @@ -212,10 +213,8 @@ const prepareSimulationProcessors = (processing: StreamlangDSL): IngestProcessor * 1. Force each processor to not ignore failures to collect all errors * 2. Append the error message to the `_errors` field on failure */ - const transpiledIngestPipelineProcessors = transpileIngestPipeline(processing, { - ignoreMalformed: true, - traceCustomIdentifiers: true, - }).processors; + const transpiledIngestPipelineProcessors = + buildSimulationProcessorsWithConditionNoops(processing); return transpiledIngestPipelineProcessors.map((processor) => { const type = Object.keys(processor)[0]; @@ -509,12 +508,10 @@ const computePipelineSimulationResult = ( docReports: SimulationDocReport[]; processorsMetrics: Record; } => { - const transpiledProcessors = transpileIngestPipeline(processing, { - ignoreMalformed: true, - traceCustomIdentifiers: true, - }).processors; + const transpiledProcessors = buildSimulationProcessorsWithConditionNoops(processing); const processorsMap = initProcessorMetricsMap(transpiledProcessors); + const conditionProcessorTags = collectConditionBlockIds(processing); const forbiddenFields = Object.entries(streamFields) .filter(([, { type }]) => type === 'system') @@ -528,15 +525,17 @@ const computePipelineSimulationResult = ( const { errors, status, value } = getLastDoc( pipelineDocResult, sampleDocs[id]._source, - ingestDocErrors + ingestDocErrors, + conditionProcessorTags ); - const diff = computeSimulationDocDiff( - sampleDocs[id]._source, - pipelineDocResult, + const diff = computeSimulationDocDiff({ + base: sampleDocs[id]._source, + docResult: pipelineDocResult, isWiredStream, - forbiddenFields - ); + forbiddenFields, + conditionProcessorTags, + }); pipelineDocResult.processor_results.forEach((processor) => { const procId = processor.tag; @@ -646,13 +645,23 @@ const extractProcessorMetrics = ({ const getDocumentStatus = ( doc: SuccessfulPipelineSimulateDocumentResult, - ingestDocErrors: SimulationError[] + ingestDocErrors: SimulationError[], + conditionProcessorTags: Set ): DocSimulationStatus => { // If there is an ingestion mapping error, the document parsing should be considered failed if (ingestDocErrors.some((error) => error.type === 'field_mapping_failure')) { return 'failed'; } - const processorResults = doc.processor_results; + const processorResults = filterOutConditionNoopProcessorResults( + doc.processor_results, + conditionProcessorTags + ); + + // If a simulation run contains no non-condition processors, treat it as parsed (noop pipeline), + // rather than incorrectly classifying it as "skipped" (Array.every() is true on empty arrays). + if (processorResults.length === 0) { + return 'parsed'; + } if (processorResults.every(isSkippedProcessor)) { return 'skipped'; @@ -678,12 +687,16 @@ const getDocumentStatus = ( const getLastDoc = ( docResult: SuccessfulPipelineSimulateDocumentResult, sample: FlattenRecord, - ingestDocErrors: SimulationError[] + ingestDocErrors: SimulationError[], + conditionProcessorTags: Set ) => { - const status = getDocumentStatus(docResult, ingestDocErrors); + const status = getDocumentStatus(docResult, ingestDocErrors, conditionProcessorTags); + const processorResults = filterOutConditionNoopProcessorResults( + docResult.processor_results, + conditionProcessorTags + ); const lastDocSource = - docResult.processor_results.filter((proc) => !isSkippedProcessor(proc)).at(-1)?.doc?._source ?? - sample; + processorResults.filter((proc) => !isSkippedProcessor(proc)).at(-1)?.doc?._source ?? sample; if (status === 'parsed') { return { @@ -697,6 +710,14 @@ const getLastDoc = ( } }; +interface ComputeSimulationDocDiffParams { + base: FlattenRecord; + docResult: SuccessfulPipelineSimulateDocumentResult; + isWiredStream: boolean; + forbiddenFields: string[]; + conditionProcessorTags: Set; +} + /** * To improve tracking down the errors and the fields detection to the individual processor, * this function computes the detected fields and the errors for each processor. @@ -706,14 +727,18 @@ const getLastDoc = ( * - `detected_fields`: Only fields that exist in the final output compared to input (for overall detection) * - `errors`: Processing errors detected during comparison */ -export const computeSimulationDocDiff = ( - base: FlattenRecord, - docResult: SuccessfulPipelineSimulateDocumentResult, - isWiredStream: boolean, - forbiddenFields: string[] -) => { +export const computeSimulationDocDiff = ({ + base, + docResult, + isWiredStream, + forbiddenFields, + conditionProcessorTags, +}: ComputeSimulationDocDiffParams) => { // Keep only the successful processors defined from the user, skipping the on_failure processors from the simulation - const successfulProcessors = docResult.processor_results.filter(isSuccessfulProcessor); + const successfulProcessors = filterOutConditionNoopProcessorResults( + docResult.processor_results, + conditionProcessorTags + ).filter(isSuccessfulProcessor); const comparisonDocs = [ { processor_id: 'base', value: base }, @@ -796,6 +821,7 @@ const collectProcessedByProcessorIds = ( const processedBy = new Set(); processorResults.forEach((processor) => { + // Include condition-noop tags as well: the UI uses them to filter docs by condition match. if (!processor.tag || isSkippedProcessor(processor)) { return; } @@ -806,6 +832,42 @@ const collectProcessedByProcessorIds = ( return Array.from(processedBy); }; +const NOOP_CLEANUP_SUFFIX = ':noop-cleanup'; + +const filterOutConditionNoopProcessorResults = ( + processorResults: SuccessfulPipelineSimulateDocumentResult['processor_results'], + conditionProcessorTags: Set +) => { + return processorResults.filter((proc) => { + if (!proc.tag) return true; + // Filter out condition noop processors (set processor tagged with condition ID) + if (conditionProcessorTags.has(proc.tag)) return false; + // Filter out noop cleanup processors (remove processor tagged with conditionId:noop-cleanup) + if (proc.tag.endsWith(NOOP_CLEANUP_SUFFIX)) { + const conditionId = proc.tag.slice(0, -NOOP_CLEANUP_SUFFIX.length); + if (conditionProcessorTags.has(conditionId)) return false; + } + return true; + }); +}; + +const collectConditionBlockIds = (processing: StreamlangDSL): Set => { + const ids = new Set(); + const traverse = (steps: StreamlangDSL['steps']) => { + for (const step of steps) { + if ('condition' in step && !('action' in step)) { + if (step.customIdentifier) { + ids.add(step.customIdentifier); + } + traverse(step.condition.steps); + } + } + }; + + traverse(processing.steps); + return ids; +}; + const collectIngestDocumentErrors = (docResult: SimulateIngestSimulateIngestDocumentResult) => { const errors: SimulationError[] = []; diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/queries/route.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/queries/route.ts index 5230e6e30e439..18d5e2801dab3 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/queries/route.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/queries/route.ts @@ -6,9 +6,24 @@ */ import { z } from '@kbn/zod'; +import type { QueriesGetResponse, QueriesOccurrencesGetResponse } from '@kbn/streams-schema'; +import { sortForQueriesTable } from '../../../../lib/significant_events/utils'; import { STREAMS_API_PRIVILEGES } from '../../../../../common/constants'; import { createServerRoute } from '../../../create_server_route'; import { assertSignificantEventsAccess } from '../../../utils/assert_significant_events_access'; +import { readSignificantEventsFromAlertsIndices } from '../../../../lib/significant_events/read_significant_events_from_alerts_indices'; + +const dateFromString = z.string().transform((input) => new Date(input)); + +const requestParamsSchema = z.object({ + from: dateFromString.describe('Start of the time range'), + to: dateFromString.describe('End of the time range'), + bucketSize: z.string().describe('Size of time buckets for aggregation'), + query: z.string().optional().describe('Query string to filter significant events queries'), + streamNames: z + .preprocess((val) => (typeof val === 'string' ? [val] : val), z.array(z.string()).optional()) + .describe('Stream names to filter significant events'), +}); export const getUnbackedQueriesCountRoute = createServerRoute({ endpoint: 'GET /internal/streams/queries/_unbacked_count', @@ -56,8 +71,14 @@ export const promoteUnbackedQueriesRoute = createServerRoute({ }) .nullish(), }), - handler: async ({ params, request, getScopedClients, server }): Promise<{ promoted: number }> => { - const { queryClient, licensing, uiSettingsClient } = await getScopedClients({ + handler: async ({ + params, + request, + getScopedClients, + server, + logger, + }): Promise<{ promoted: number }> => { + const { queryClient, streamsClient, licensing, uiSettingsClient } = await getScopedClients({ request, }); @@ -81,16 +102,139 @@ export const promoteUnbackedQueriesRoute = createServerRoute({ return acc; }, {}); + const streamDefinitions = await streamsClient.listStreams(); + const streamDefinitionsByName = new Map( + streamDefinitions.map((streamDefinition) => [streamDefinition.name, streamDefinition]) + ); + let promoted = 0; for (const [streamName, queryIds] of Object.entries(byStream)) { - const result = await queryClient.promoteQueries(streamName, queryIds); + const definition = streamDefinitionsByName.get(streamName); + if (!definition) { + logger.warn(`Skipping promotion for missing stream ${streamName}`); + continue; + } + const result = await queryClient.promoteQueries(definition, queryIds); promoted += result.promoted; } return { promoted }; }, }); +const getDiscoveryQueriesRoute = createServerRoute({ + endpoint: 'GET /internal/streams/_queries', + params: z.object({ + query: requestParamsSchema.extend({ + page: z.coerce.number().int().min(1).optional().describe('Page number (1-based)'), + perPage: z.coerce + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe('Number of items per page'), + }), + }), + options: { + access: 'internal', + summary: 'Read paginated significant-event queries for the discovery table', + description: 'Returns significant-event queries as table rows, with server-side pagination.', + }, + security: { + authz: { + requiredPrivileges: [STREAMS_API_PRIVILEGES.read], + }, + }, + handler: async ({ params, request, getScopedClients, server }): Promise => { + const { queryClient, scopedClusterClient, licensing, uiSettingsClient } = + await getScopedClients({ + request, + }); + + await assertSignificantEventsAccess({ server, licensing, uiSettingsClient }); + + const { from, to, bucketSize, query, streamNames, page = 1, perPage = 10 } = params.query; + + const { significant_events: queries } = await readSignificantEventsFromAlertsIndices( + { + from, + to, + bucketSize, + query, + streamNames, + }, + { queryClient, scopedClusterClient } + ); + + const sortedQueries = sortForQueriesTable(queries); + const total = queries.length; + const start = (page - 1) * perPage; + const queriesPage = start >= total ? [] : sortedQueries.slice(start, start + perPage); + + return { queries: queriesPage, page, perPage, total }; + }, +}); + +const getDiscoveryQueriesOccurrencesRoute = createServerRoute({ + endpoint: 'GET /internal/streams/_queries/_occurrences', + params: z.object({ + query: requestParamsSchema, + }), + options: { + access: 'internal', + summary: 'Read aggregated occurrences for the discovery histogram', + description: + 'Returns the aggregated occurrences histogram series for the chart above the queries table.', + }, + security: { + authz: { + requiredPrivileges: [STREAMS_API_PRIVILEGES.read], + }, + }, + handler: async ({ + params, + request, + getScopedClients, + server, + }): Promise => { + const { queryClient, scopedClusterClient, licensing, uiSettingsClient } = + await getScopedClients({ + request, + }); + + await assertSignificantEventsAccess({ server, licensing, uiSettingsClient }); + + const { from, to, bucketSize, query, streamNames } = params.query; + + const { aggregated_occurrences: aggregatedOccurrenceBuckets } = + await readSignificantEventsFromAlertsIndices( + { + from, + to, + bucketSize, + query, + streamNames, + }, + { queryClient, scopedClusterClient } + ); + + const occurrencesHistogram = aggregatedOccurrenceBuckets.map((bucket) => ({ + x: bucket.date, + y: bucket.count, + })); + + const totalOccurrences = aggregatedOccurrenceBuckets.reduce( + (sum, bucket) => sum + bucket.count, + 0 + ); + + return { occurrences_histogram: occurrencesHistogram, total_occurrences: totalOccurrences }; + }, +}); + export const internalQueriesRoutes = { ...getUnbackedQueriesCountRoute, ...promoteUnbackedQueriesRoute, + ...getDiscoveryQueriesRoute, + ...getDiscoveryQueriesOccurrencesRoute, }; diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/significant_events/route.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/significant_events/route.ts index 5a8420a2ed62a..e7d7b11b18f04 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/significant_events/route.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/significant_events/route.ts @@ -4,13 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { SignificantEventsGetResponse } from '@kbn/streams-schema'; import { + buildEsqlQuery, + getIndexPatternsForStream, systemSchema, + type Streams, type SignificantEventsQueriesGenerationResult, type SignificantEventsQueriesGenerationTaskResult, - type SignificantEventsGetResponse, } from '@kbn/streams-schema'; import { z } from '@kbn/zod'; +import { readSignificantEventsFromAlertsIndices } from '../../../../lib/significant_events/read_significant_events_from_alerts_indices'; import { STREAMS_API_PRIVILEGES } from '../../../../../common/constants'; import { getSignificantEventsQueriesGenerationTaskId, @@ -20,7 +24,6 @@ import { import { taskActionSchema } from '../../../../lib/tasks/task_action_schema'; import { createServerRoute } from '../../../create_server_route'; import { assertSignificantEventsAccess } from '../../../utils/assert_significant_events_access'; -import { readSignificantEventsFromAlertsIndices } from '../../../../lib/significant_events/read_significant_events_from_alerts_indices'; import { handleTaskAction } from '../../../utils/task_helpers'; import { resolveConnectorId } from '../../../utils/resolve_connector_id'; @@ -28,6 +31,34 @@ import { resolveConnectorId } from '../../../utils/resolve_connector_id'; // Date, without breaking the OpenAPI generator const dateFromString = z.string().transform((input) => new Date(input)); +/** + * Back-fills `esql.query` on task results for legacy tasks that were completed + * before the `esql.query` property was introduced. Without this, the client + * would receive queries without the required `esql.query` field. + */ +const ensureEsqlQuery = ( + result: SignificantEventsQueriesGenerationTaskResult, + definition: Streams.all.Definition +): SignificantEventsQueriesGenerationTaskResult => { + if (!('queries' in result)) { + return result; + } + + const indices = getIndexPatternsForStream(definition); + return { + ...result, + queries: result.queries.map((query) => ({ + ...query, + esql: query.esql ?? { + query: buildEsqlQuery(indices, { + kql: { query: query.kql }, + feature: query.feature, + }), + }, + })), + }; +}; + const significantEventsQueriesGenerationStatusRoute = createServerRoute({ endpoint: 'GET /internal/streams/{name}/significant_events/_status', params: z.object({ @@ -55,14 +86,16 @@ const significantEventsQueriesGenerationStatusRoute = createServerRoute({ }); await assertSignificantEventsAccess({ server, licensing, uiSettingsClient }); - await streamsClient.ensureStream(params.path.name); const { name } = params.path; + const definition = await streamsClient.getStream(name); - return taskClient.getStatus< + const result = await taskClient.getStatus< SignificantEventsQueriesGenerationTaskParams, SignificantEventsQueriesGenerationResult >(getSignificantEventsQueriesGenerationTaskId(name)); + + return ensureEsqlQuery(result, definition); }, }); @@ -111,9 +144,9 @@ const significantEventsQueriesGenerationTaskRoute = createServerRoute({ }); await assertSignificantEventsAccess({ server, licensing, uiSettingsClient }); - await streamsClient.ensureStream(params.path.name); const { name } = params.path; + const definition = await streamsClient.getStream(name); const { body } = params; const taskId = getSignificantEventsQueriesGenerationTaskId(name); @@ -144,7 +177,7 @@ const significantEventsQueriesGenerationTaskRoute = createServerRoute({ } as const) : ({ action: body.action } as const); - return handleTaskAction< + const result = await handleTaskAction< SignificantEventsQueriesGenerationTaskParams, SignificantEventsQueriesGenerationResult >({ @@ -152,6 +185,8 @@ const significantEventsQueriesGenerationTaskRoute = createServerRoute({ taskId, ...actionParams, }); + + return ensureEsqlQuery(result, definition); }, }); diff --git a/x-pack/platform/plugins/shared/streams/server/routes/queries/route.ts b/x-pack/platform/plugins/shared/streams/server/routes/queries/route.ts index 41c5b5f5e92c5..820370047938d 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/queries/route.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/queries/route.ts @@ -6,7 +6,7 @@ */ import type { ErrorCause } from '@elastic/elasticsearch/lib/api/types'; import type { StreamQuery } from '@kbn/streams-schema'; -import { streamQuerySchema, upsertStreamQueryRequestSchema } from '@kbn/streams-schema'; +import { streamQueryInputSchema, upsertStreamQueryRequestSchema } from '@kbn/streams-schema'; import { z } from '@kbn/zod'; import { STREAMS_API_PRIVILEGES } from '../../../common/constants'; import { QueryNotFoundError } from '../../lib/streams/errors/query_not_found_error'; @@ -96,13 +96,13 @@ const upsertQueryRoute = createServerRoute({ } = params; await assertEnterpriseLicense(licensing); - await streamsClient.ensureStream(streamName); + const definition = await streamsClient.getStream(streamName); await assertFeatureNotChanged({ queryClient, streamName, queries: [{ id: queryId, feature: body.feature }], }); - await queryClient.upsert(streamName, { + await queryClient.upsert(definition, { id: queryId, title: body.title, feature: body.feature, @@ -150,14 +150,14 @@ const deleteQueryRoute = createServerRoute({ path: { queryId, name: streamName }, } = params; - await streamsClient.ensureStream(streamName); + const definition = await streamsClient.getStream(streamName); const queryLink = await queryClient.bulkGetByIds(streamName, [queryId]); if (queryLink.length === 0) { throw new QueryNotFoundError(`Query [${queryId}] not found in stream [${streamName}]`); } - await queryClient.delete(streamName, queryId); + await queryClient.delete(definition, queryId); logger.get('significant_events').debug(`Deleting query ${queryId} for stream ${streamName}`); @@ -190,7 +190,7 @@ const bulkQueriesRoute = createServerRoute({ operations: z.array( z.union([ z.object({ - index: streamQuerySchema, + index: streamQueryInputSchema, }), z.object({ delete: z.object({ id: z.string() }), @@ -213,14 +213,14 @@ const bulkQueriesRoute = createServerRoute({ body: { operations }, } = params; - await streamsClient.ensureStream(streamName); + const definition = await streamsClient.getStream(streamName); const indexOperations = operations.flatMap((op) => 'index' in op ? [{ id: op.index.id, feature: op.index.feature }] : [] ); await assertFeatureNotChanged({ queryClient, streamName, queries: indexOperations }); - await queryClient.bulk(streamName, operations); + await queryClient.bulk(definition, operations); logger .get('significant_events') diff --git a/x-pack/platform/plugins/shared/streams/server/routes/utils/assert_feature_not_changed.ts b/x-pack/platform/plugins/shared/streams/server/routes/utils/assert_feature_not_changed.ts index 5597a6dc15415..6ce563f2a3f08 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/utils/assert_feature_not_changed.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/utils/assert_feature_not_changed.ts @@ -6,7 +6,7 @@ */ import { isEqual } from 'lodash'; -import type { StreamQueryKql } from '@kbn/streams-schema'; +import type { StreamQuery } from '@kbn/streams-schema'; import type { QueryClient } from '../../lib/streams/assets/query/query_client'; import { StatusError } from '../../lib/streams/errors/status_error'; @@ -21,7 +21,7 @@ export async function assertFeatureNotChanged({ }: { queryClient: QueryClient; streamName: string; - queries: Array<{ id: string; feature: StreamQueryKql['feature'] }>; + queries: Array<{ id: string; feature: StreamQuery['feature'] }>; }): Promise { if (queries.length === 0) return; diff --git a/x-pack/platform/plugins/shared/streams/test/scout/.meta/api/standard.json b/x-pack/platform/plugins/shared/streams/test/scout/.meta/api/standard.json index 0889655f38ac0..eec86f3ec3c9d 100644 --- a/x-pack/platform/plugins/shared/streams/test/scout/.meta/api/standard.json +++ b/x-pack/platform/plugins/shared/streams/test/scout/.meta/api/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-09T18:40:19.390Z", - "sha1": "4b500cbbebb18d28d562eed10fcfb5492d58f97c", + "sha1": "d9adb39d43a613275404451bd50b87eb8872392b", "tests": [ { "id": "dc6084aa7695e8a-26220f9b20676c1", @@ -25,7 +24,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 98, + "line": 105, "column": 12 } }, @@ -41,7 +40,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 119, + "line": 126, "column": 12 } }, @@ -57,7 +56,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 140, + "line": 147, "column": 12 } }, @@ -73,7 +72,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 181, + "line": 188, "column": 12 } }, @@ -89,7 +88,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 220, + "line": 227, "column": 12 } }, @@ -105,7 +104,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 255, + "line": 262, "column": 12 } }, @@ -121,7 +120,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 290, + "line": 297, "column": 12 } }, @@ -137,7 +136,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 329, + "line": 336, "column": 12 } }, @@ -153,7 +152,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 389, + "line": 396, "column": 12 } }, @@ -169,7 +168,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 447, + "line": 454, "column": 12 } }, @@ -185,7 +184,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 470, + "line": 477, "column": 12 } }, @@ -201,7 +200,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 514, + "line": 521, "column": 12 } }, @@ -217,7 +216,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 530, + "line": 537, "column": 12 } }, @@ -233,7 +232,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 559, + "line": 566, "column": 12 } }, @@ -249,7 +248,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/lifecycle_retention.spec.ts", - "line": 589, + "line": 596, "column": 12 } }, @@ -265,7 +264,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 76, + "line": 83, "column": 12 } }, @@ -281,7 +280,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 123, + "line": 130, "column": 12 } }, @@ -297,7 +296,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 166, + "line": 173, "column": 12 } }, @@ -313,7 +312,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 221, + "line": 228, "column": 12 } }, @@ -329,7 +328,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 293, + "line": 300, "column": 12 } }, @@ -345,7 +344,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 357, + "line": 364, "column": 12 } }, @@ -361,7 +360,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 409, + "line": 416, "column": 12 } }, @@ -377,7 +376,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 476, + "line": 483, "column": 12 } }, @@ -393,7 +392,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 545, + "line": 552, "column": 12 } }, @@ -409,7 +408,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 604, + "line": 611, "column": 12 } }, @@ -425,7 +424,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/processing_persistence.spec.ts", - "line": 638, + "line": 645, "column": 12 } }, @@ -905,7 +904,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 27, + "line": 30, "column": 12 } }, @@ -921,7 +920,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 60, + "line": 63, "column": 12 } }, @@ -937,7 +936,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 78, + "line": 81, "column": 12 } }, @@ -953,7 +952,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 99, + "line": 102, "column": 12 } }, @@ -969,7 +968,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 120, + "line": 123, "column": 12 } }, @@ -985,7 +984,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 141, + "line": 144, "column": 12 } }, @@ -1001,7 +1000,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 162, + "line": 165, "column": 12 } }, @@ -1017,7 +1016,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 184, + "line": 187, "column": 12 } }, @@ -1033,7 +1032,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 213, + "line": 216, "column": 12 } }, @@ -1049,7 +1048,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 246, + "line": 249, "column": 12 } }, @@ -1065,7 +1064,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 292, + "line": 295, "column": 12 } }, @@ -1081,7 +1080,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 331, + "line": 334, "column": 12 } }, @@ -1097,7 +1096,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 364, + "line": 367, "column": 12 } }, @@ -1113,7 +1112,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 387, + "line": 390, "column": 12 } }, @@ -1129,7 +1128,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 410, + "line": 413, "column": 12 } }, @@ -1145,7 +1144,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 430, + "line": 433, "column": 12 } }, @@ -1161,7 +1160,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 458, + "line": 461, "column": 12 } }, @@ -1177,7 +1176,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 476, + "line": 479, "column": 12 } }, @@ -1193,7 +1192,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 505, + "line": 508, "column": 12 } }, @@ -1209,7 +1208,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 527, + "line": 530, "column": 12 } }, @@ -1225,7 +1224,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 543, + "line": 546, "column": 12 } }, @@ -1241,7 +1240,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 573, + "line": 576, "column": 12 } }, @@ -1257,7 +1256,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 593, + "line": 596, "column": 12 } }, @@ -1273,7 +1272,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 609, + "line": 612, "column": 12 } }, @@ -1289,7 +1288,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 626, + "line": 629, "column": 12 } }, @@ -1305,7 +1304,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 694, + "line": 697, "column": 12 } }, @@ -1321,7 +1320,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 759, + "line": 762, "column": 12 } }, @@ -1337,439 +1336,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/routing_fork_stream.spec.ts", - "line": 853, - "column": 12 - } - }, - { - "id": "f3127db8132e066-2d81909c8010534", - "title": "Stream schema - field mapping API should get unmapped fields for a stream", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 18, - "column": 12 - } - }, - { - "id": "f3127db8132e066-0be104fbd03fbf8", - "title": "Stream schema - field mapping API should return error for non-existent stream unmapped fields", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 34, - "column": 12 - } - }, - { - "id": "f3127db8132e066-83b1ecc4fdfe407", - "title": "Stream schema - field mapping API should simulate field mapping with keyword type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 53, - "column": 12 - } - }, - { - "id": "f3127db8132e066-fb6c04b25e0ba43", - "title": "Stream schema - field mapping API should simulate single keyword field", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 75, - "column": 12 - } - }, - { - "id": "f3127db8132e066-05ac03a7fb45010", - "title": "Stream schema - field mapping API should simulate field mapping with match_only_text type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 94, - "column": 12 - } - }, - { - "id": "f3127db8132e066-184338feb2495d9", - "title": "Stream schema - field mapping API should simulate field mapping with long type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 116, - "column": 12 - } - }, - { - "id": "f3127db8132e066-8c2d43ffdfbc4ba", - "title": "Stream schema - field mapping API should simulate single long field", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 137, - "column": 12 - } - }, - { - "id": "f3127db8132e066-3c25de221d638fe", - "title": "Stream schema - field mapping API should simulate field mapping with double type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 156, - "column": 12 - } - }, - { - "id": "f3127db8132e066-fa8c1f744093729", - "title": "Stream schema - field mapping API should simulate single double field", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 177, - "column": 12 - } - }, - { - "id": "f3127db8132e066-7d31f8801130a27", - "title": "Stream schema - field mapping API should simulate field mapping with boolean type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 196, - "column": 12 - } - }, - { - "id": "f3127db8132e066-74cacc695f4601f", - "title": "Stream schema - field mapping API should simulate multiple boolean fields", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 214, - "column": 12 - } - }, - { - "id": "f3127db8132e066-671af81b51cdb3a", - "title": "Stream schema - field mapping API should simulate field mapping with date type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 237, - "column": 12 - } - }, - { - "id": "f3127db8132e066-eb119f8dd624a2d", - "title": "Stream schema - field mapping API should simulate multiple date fields", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 255, - "column": 12 - } - }, - { - "id": "f3127db8132e066-6728fa1703813b2", - "title": "Stream schema - field mapping API should simulate field mapping with ip type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 278, - "column": 12 - } - }, - { - "id": "f3127db8132e066-cceb01b877ea4be", - "title": "Stream schema - field mapping API should simulate single ip field", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 299, - "column": 12 - } - }, - { - "id": "f3127db8132e066-6a4486f044d1ede", - "title": "Stream schema - field mapping API should simulate field mapping with geo_point type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 318, - "column": 12 - } - }, - { - "id": "f3127db8132e066-fad83f4b17b3e65", - "title": "Stream schema - field mapping API should simulate multiple geo_point fields", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 339, - "column": 12 - } - }, - { - "id": "f3127db8132e066-67c61df9e5b530a", - "title": "Stream schema - field mapping API should simulate multiple field definitions of different types", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 361, - "column": 12 - } - }, - { - "id": "f3127db8132e066-f8ce8ce1b03da5f", - "title": "Stream schema - field mapping API should handle deeply nested field names", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 391, - "column": 12 - } - }, - { - "id": "f3127db8132e066-c841dfc0d7c672c", - "title": "Stream schema - field mapping API should handle ECS-style field names", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 412, - "column": 12 - } - }, - { - "id": "f3127db8132e066-2cd6d3f29db291e", - "title": "Stream schema - field mapping API should return error for invalid field type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 437, - "column": 12 - } - }, - { - "id": "f3127db8132e066-860b828f66428c7", - "title": "Stream schema - field mapping API should handle empty field definitions", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 454, - "column": 12 - } - }, - { - "id": "f3127db8132e066-e9db9fadbe3e671", - "title": "Stream schema - field mapping API should return error for non-existent stream", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 473, - "column": 12 - } - }, - { - "id": "f3127db8132e066-fde1a7bced25313", - "title": "Stream schema - field mapping API should handle missing field name", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 491, - "column": 12 - } - }, - { - "id": "f3127db8132e066-e450695e5f203af", - "title": "Stream schema - field mapping API should handle missing field type", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 508, - "column": 12 - } - }, - { - "id": "f3127db8132e066-15396eb1c1f45d8", - "title": "Stream schema - field mapping API should return simulation error for incompatible mapping", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 526, - "column": 12 - } - }, - { - "id": "f3127db8132e066-9dd7d64d44a032b", - "title": "Stream schema - field mapping API should provide simulation details in response", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_field_mapping.spec.ts", - "line": 553, + "line": 856, "column": 12 } }, @@ -1785,7 +1352,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 76, + "line": 83, "column": 12 } }, @@ -1801,7 +1368,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 119, + "line": 126, "column": 12 } }, @@ -1817,7 +1384,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 158, + "line": 165, "column": 12 } }, @@ -1833,7 +1400,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 197, + "line": 204, "column": 12 } }, @@ -1849,7 +1416,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 236, + "line": 243, "column": 12 } }, @@ -1865,7 +1432,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 275, + "line": 282, "column": 12 } }, @@ -1881,7 +1448,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 314, + "line": 321, "column": 12 } }, @@ -1897,7 +1464,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 360, + "line": 367, "column": 12 } }, @@ -1913,7 +1480,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 432, + "line": 439, "column": 12 } }, @@ -1929,7 +1496,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 498, + "line": 505, "column": 12 } }, @@ -1945,7 +1512,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 564, + "line": 571, "column": 12 } }, @@ -1961,7 +1528,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 606, + "line": 613, "column": 12 } }, @@ -1977,7 +1544,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams/test/scout/api/tests/schema_persistence.spec.ts", - "line": 638, + "line": 645, "column": 12 } } diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/flyout/add_field_flyout.test.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/flyout/add_field_flyout.test.tsx new file mode 100644 index 0000000000000..4fa3d73690c4a --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/flyout/add_field_flyout.test.tsx @@ -0,0 +1,340 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@kbn/i18n-react'; +import { AddFieldFlyout } from './add_field_flyout'; +import { + createMockClassicStreamDefinition, + createMockWiredStreamDefinition, +} from '../../shared/mocks'; +import { SchemaEditorContextProvider } from '../schema_editor_context'; + +jest.mock('../../../../hooks/use_kibana', () => ({ + useKibana: () => ({ + core: { + docLinks: { + links: { + elasticsearch: { + mappingParameters: 'https://elastic.co/docs/mapping-parameters', + }, + }, + }, + }, + dependencies: { + start: { + streams: { + streamsRepositoryClient: { + fetch: jest.fn(), + }, + }, + fieldsMetadata: { + useFieldsMetadata: () => ({ + fieldsMetadata: {}, + loading: false, + }), + }, + }, + }, + }), +})); + +jest.mock('../../../../hooks/use_streams_app_router', () => ({ + useStreamsAppRouter: () => ({ + link: jest.fn(() => '/mock-link'), + }), +})); + +jest.mock('@kbn/code-editor', () => ({ + CodeEditor: () =>
CodeEditor
, +})); + +const renderAddFieldFlyout = ( + streamType: 'wired' | 'classic', + existingFieldNames: string[] = [] +) => { + const definition = + streamType === 'wired' + ? createMockWiredStreamDefinition() + : createMockClassicStreamDefinition(); + + const fields = existingFieldNames.map((name) => ({ + name, + type: 'keyword' as const, + parent: definition.stream.name, + status: 'mapped' as const, + })); + + const onClose = jest.fn(); + const onAddField = jest.fn(); + + return { + onClose, + onAddField, + ...render( + + + + + + ), + }; +}; + +const typeFieldName = async (user: ReturnType, fieldName: string) => { + const comboBox = screen.getByTestId('streamsAppSchemaEditorAddFieldFlyoutFieldName'); + const input = comboBox.querySelector('input'); + if (!input) throw new Error('Could not find input element'); + await user.clear(input); + await user.type(input, fieldName); + await user.keyboard('{Enter}'); +}; + +const getFieldNameError = () => { + const formRow = screen + .getByTestId('streamsAppSchemaEditorAddFieldFlyoutFieldName') + .closest('.euiFormRow'); + return formRow?.querySelector('.euiFormErrorText')?.textContent ?? null; +}; + +describe('AddFieldFlyout', () => { + describe('Field name validation for wired streams', () => { + it('shows error for non-namespaced field names', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'invalid_field'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toContain( + "Field invalid_field is not allowed to be defined as it doesn't match the namespaced ECS or OTel schema" + ); + }); + }); + + it('shows error for OTel reserved field names that are also not namespaced (message)', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + // `message` is not in keepFields and doesn't start with namespace prefix, + // so it fails the namespacing check first (matching server-side behavior) + await typeFieldName(user, 'message'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toContain("doesn't match the namespaced ECS or OTel schema"); + }); + }); + + it('shows error for OTel reserved fields that are also not namespaced (trace.id)', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + // `trace.id` is not in keepFields and doesn't start with namespace prefix, + // so it fails the namespacing check first + await typeFieldName(user, 'trace.id'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toContain("doesn't match the namespaced ECS or OTel schema"); + }); + }); + + it('shows error for body passthrough object (which is in keepFields)', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + // `body` IS in keepFields, so it passes the namespacing check, + // but then fails the OTel reserved check + await typeFieldName(user, 'body'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toContain('automatic alias'); + }); + }); + + it('shows error for attributes passthrough object (not in keepFields)', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + // `attributes` is not in keepFields and doesn't start with namespace prefix, + // so it fails the namespacing check first + await typeFieldName(user, 'attributes'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toContain("doesn't match the namespaced ECS or OTel schema"); + }); + }); + + it('allows namespaced field names with attributes prefix', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'attributes.custom_field'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toBeNull(); + }); + }); + + it('allows namespaced field names with body.structured prefix', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'body.structured.data'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toBeNull(); + }); + }); + + it('allows namespaced field names with resource.attributes prefix', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'resource.attributes.host.name'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toBeNull(); + }); + }); + + it('allows keepFields like @timestamp', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, '@timestamp'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toBeNull(); + }); + }); + + it('allows keepFields like trace_id', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'trace_id'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toBeNull(); + }); + }); + }); + + describe('Field name validation for classic streams', () => { + it('allows non-namespaced field names', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('classic'); + + await typeFieldName(user, 'custom_field'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toBeNull(); + }); + }); + + it('allows OTel reserved field names', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('classic'); + + await typeFieldName(user, 'message'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toBeNull(); + }); + }); + }); + + describe('Common validation for all stream types', () => { + it('shows error for duplicate field names in wired streams', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired', ['attributes.existing_field']); + + await typeFieldName(user, 'attributes.existing_field'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toContain('A field with this name already exists'); + }); + }); + + it('shows error for duplicate field names in classic streams', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('classic', ['existing_field']); + + await typeFieldName(user, 'existing_field'); + + await waitFor(() => { + const error = getFieldNameError(); + expect(error).toContain('A field with this name already exists'); + }); + }); + }); + + describe('Add field button state', () => { + it('disables the Add field button when field name validation fails', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'invalid_field'); + + await waitFor(() => { + const addFieldButton = screen.getByTestId('streamsAppSchemaEditorAddFieldButton'); + expect(addFieldButton).toBeDisabled(); + }); + }); + + it('disables the Add field button when field name is valid but type is not selected', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'attributes.custom_field'); + + await waitFor(() => { + const addFieldButton = screen.getByTestId('streamsAppSchemaEditorAddFieldButton'); + expect(addFieldButton).toBeDisabled(); + }); + }); + + it('enables the Add field button when all required fields are valid', async () => { + const user = userEvent.setup(); + renderAddFieldFlyout('wired'); + + await typeFieldName(user, 'attributes.custom_field'); + const typeSelect = screen.getByTestId('streamsAppFieldFormTypeSelect'); + await user.click(typeSelect); + const keywordOption = screen.getByTestId('option-type-keyword'); + await user.click(keywordOption); + + await waitFor(() => { + const addFieldButton = screen.getByTestId('streamsAppSchemaEditorAddFieldButton'); + expect(addFieldButton).not.toBeDisabled(); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/flyout/add_field_flyout.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/flyout/add_field_flyout.tsx index 158bfe4cb97fd..5ad0de3f1e8a3 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/flyout/add_field_flyout.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/flyout/add_field_flyout.tsx @@ -23,7 +23,13 @@ import { } from '@elastic/eui'; import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { isSchema, recursiveRecord, Streams } from '@kbn/streams-schema'; +import { + isSchema, + recursiveRecord, + Streams, + isNamespacedEcsField, + isOtelReservedField, +} from '@kbn/streams-schema'; import type { SubmitHandler } from 'react-hook-form'; import { FormProvider, useController, useForm, useFormContext, useWatch } from 'react-hook-form'; import { CodeEditor } from '@kbn/code-editor'; @@ -125,7 +131,7 @@ export const AddFieldFlyout = ({ onAddField, onClose }: AddFieldFlyoutProps) => {i18n.translate('xpack.streams.schemaEditor.addFieldFlyout.addButtonLabel', { defaultMessage: 'Add field', @@ -149,6 +155,8 @@ export const FieldNameSelector = () => { source: ['ecs', 'otel'], }); + const isWiredStream = Streams.WiredStream.Definition.is(stream); + const { field, fieldState } = useController({ name: 'name', rules: { @@ -162,6 +170,28 @@ export const FieldNameSelector = () => { { defaultMessage: 'A field with this name already exists.' } ); } + if (isWiredStream) { + if (!isNamespacedEcsField(name)) { + return i18n.translate( + 'xpack.streams.schemaEditor.addFieldFlyout.fieldNameNotNamespacedError', + { + defaultMessage: + "Field {fieldName} is not allowed to be defined as it doesn't match the namespaced ECS or OTel schema.", + values: { fieldName: name }, + } + ); + } + if (isOtelReservedField(name)) { + return i18n.translate( + 'xpack.streams.schemaEditor.addFieldFlyout.fieldNameOtelReservedError', + { + defaultMessage: + 'Field {fieldName} is an automatic alias of another field because of OTel compatibility mode.', + values: { fieldName: name }, + } + ); + } + } return true; }, }, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_display.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_display.tsx index bcfb6b6502014..ac95e4e481609 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_display.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_display.tsx @@ -62,13 +62,21 @@ export const EditableConditionPanel = ({ condition, isEditingCondition, setCondition, + onValidityChange, }: { condition: Condition; isEditingCondition: boolean; setCondition: (condition: Condition) => void; + onValidityChange?: (isValid: boolean) => void; }) => { + const handleValidityChange = onValidityChange ?? (() => {}); return isEditingCondition ? ( - + ) : ( ); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.test.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.test.tsx index d8c188155f934..722a1bb6ccdaa 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.test.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.test.tsx @@ -10,7 +10,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n-react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import userEvent from '@testing-library/user-event'; -import type { FilterCondition } from '@kbn/streamlang'; +import type { Condition, FilterCondition } from '@kbn/streamlang'; import { ConditionEditor } from './condition_editor'; import type { Suggestion } from './autocomplete_selector'; @@ -54,6 +54,7 @@ const renderWithIntl = (component: React.ReactElement) => { describe('ConditionEditor', () => { const mockOnConditionChange = jest.fn(); + const mockOnValidityChange = jest.fn(); const defaultFieldSuggestions: Suggestion[] = [ { name: 'status', type: 'keyword' }, @@ -77,6 +78,7 @@ describe('ConditionEditor', () => { condition={{ field: 'severity_text', eq: 'info' }} status="enabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} /> ); @@ -90,6 +92,7 @@ describe('ConditionEditor', () => { condition={{ field: 'severity_text', eq: 'info' }} status="enabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} /> ); @@ -105,6 +108,7 @@ describe('ConditionEditor', () => { condition={{ field: 'severity_text', eq: 'info' }} status="disabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} /> ); @@ -121,6 +125,7 @@ describe('ConditionEditor', () => { condition={{ field: 'severity_text', eq: 'info' }} status="enabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} /> ); @@ -154,6 +159,7 @@ describe('ConditionEditor', () => { condition={{ field: 'severity_text', eq: 'info' }} status="enabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} /> ); @@ -187,6 +193,7 @@ describe('ConditionEditor', () => { condition={{ field: 'severity_text', eq: 'info' }} status="enabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} /> ); @@ -210,6 +217,7 @@ describe('ConditionEditor', () => { condition={{ field: 'severity_text', eq: 'info' }} status="enabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} /> ); @@ -242,6 +250,119 @@ describe('ConditionEditor', () => { condition={invalidCondition} status="enabled" onConditionChange={mockOnConditionChange} + onValidityChange={mockOnValidityChange} + /> + ); + + expect( + screen.getByText(/The condition is invalid or in unrecognized format/i) + ).toBeInTheDocument(); + }); + + it('should NOT call onConditionChange when JSON parsing fails in syntax editor', async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + // Toggle to syntax editor + const switchButton = screen.getByTestId('streamsAppConditionEditorSwitch'); + await user.click(switchButton); + + // Clear any previous calls from initialization + mockOnConditionChange.mockClear(); + + const codeEditor = screen.getByTestId('streamsAppConditionEditorCodeEditor'); + + // Clear the editor to simulate empty/invalid JSON + await user.clear(codeEditor); + + // Verify onConditionChange was NOT called when JSON is invalid + // This prevents overriding user's partial input while typing + expect(mockOnConditionChange).not.toHaveBeenCalled(); + }); + + it('should NOT call onConditionChange when syntax editor contains invalid JSON', async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + // Toggle to syntax editor + const switchButton = screen.getByTestId('streamsAppConditionEditorSwitch'); + await user.click(switchButton); + + // Clear any previous calls from initialization + mockOnConditionChange.mockClear(); + + const codeEditor = screen.getByTestId('streamsAppConditionEditorCodeEditor'); + + // Type invalid JSON + await user.clear(codeEditor); + await user.type(codeEditor, '{{invalid'); + + // Verify onConditionChange was NOT called when JSON is invalid + // This prevents overriding user's partial input while typing + expect(mockOnConditionChange).not.toHaveBeenCalled(); + }); + + it('should call onConditionChange when syntax editor contains valid JSON', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithProviders( + + ); + + // Toggle to syntax editor + const switchButton = screen.getByTestId('streamsAppConditionEditorSwitch'); + await user.click(switchButton); + + // Clear any previous calls from initialization + mockOnConditionChange.mockClear(); + + const codeEditor = screen.getByTestId('streamsAppConditionEditorCodeEditor'); + + // Set valid JSON via fireEvent.change (userEvent.type types character by character which is problematic) + const validJson = JSON.stringify({ field: 'test', eq: 'value' }, null, 2); + fireEvent.change(codeEditor, { target: { value: validJson } }); + + // Wait for debounce to complete + act(() => { + jest.advanceTimersByTime(400); + }); + + // Verify onConditionChange was called with the parsed JSON + expect(mockOnConditionChange).toHaveBeenCalled(); + expect(mockOnConditionChange).toHaveBeenCalledWith({ field: 'test', eq: 'value' }); + + jest.useRealTimers(); + }); + + it('should show error message when condition becomes invalid via syntax editor', () => { + // Render with an invalid condition (simulating what happens after the fix) + const invalidCondition = {} as Condition; + + renderWithProviders( + ); @@ -263,6 +384,7 @@ describe('ConditionEditor', () => { condition={condition} status="enabled" onConditionChange={jest.fn()} + onValidityChange={jest.fn()} fieldSuggestions={defaultFieldSuggestions} valueSuggestions={defaultValueSuggestions} /> @@ -283,6 +405,7 @@ describe('ConditionEditor', () => { condition={condition} status="enabled" onConditionChange={jest.fn()} + onValidityChange={jest.fn()} fieldSuggestions={defaultFieldSuggestions} valueSuggestions={defaultValueSuggestions} /> @@ -302,6 +425,7 @@ describe('ConditionEditor', () => { condition={condition} status="enabled" onConditionChange={jest.fn()} + onValidityChange={jest.fn()} fieldSuggestions={defaultFieldSuggestions} valueSuggestions={defaultValueSuggestions} /> @@ -321,6 +445,7 @@ describe('ConditionEditor', () => { condition={condition} status="enabled" onConditionChange={jest.fn()} + onValidityChange={jest.fn()} fieldSuggestions={defaultFieldSuggestions} valueSuggestions={defaultValueSuggestions} /> @@ -340,6 +465,7 @@ describe('ConditionEditor', () => { condition={condition} status="enabled" onConditionChange={jest.fn()} + onValidityChange={jest.fn()} fieldSuggestions={defaultFieldSuggestions} valueSuggestions={defaultValueSuggestions} /> @@ -353,4 +479,89 @@ describe('ConditionEditor', () => { expect(link).toHaveAttribute('target', '_blank'); }); }); + + describe('Validity plumbing', () => { + it('should report invalid JSON without changing the condition', async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.click(screen.getByTestId('streamsAppConditionEditorSwitch')); + + const editor = screen.getByTestId('streamsAppConditionEditorCodeEditor'); + await user.clear(editor); + await user.paste('{'); + + expect(mockOnConditionChange).not.toHaveBeenCalled(); + expect(mockOnValidityChange).toHaveBeenLastCalledWith(false); + }); + + it('should not clobber local syntax text on rerender while JSON is invalid', async () => { + const user = userEvent.setup(); + const { rerender } = renderWithProviders( + + ); + + await user.click(screen.getByTestId('streamsAppConditionEditorSwitch')); + + const editor = screen.getByTestId('streamsAppConditionEditorCodeEditor'); + await user.clear(editor); + await user.paste('{'); + + rerender( + + + + ); + + expect(screen.getByTestId('streamsAppConditionEditorCodeEditor')).toHaveValue('{'); + }); + + it('should report valid JSON and update condition on parse', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithProviders( + + ); + + await user.click(screen.getByTestId('streamsAppConditionEditorSwitch')); + + const editor = screen.getByTestId('streamsAppConditionEditorCodeEditor'); + + // Use fireEvent.change to set valid JSON directly + const validJson = '{"field":"severity_text","eq":"warn"}'; + fireEvent.change(editor, { target: { value: validJson } }); + + // Wait for debounce to complete + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(mockOnConditionChange).toHaveBeenCalledWith({ field: 'severity_text', eq: 'warn' }); + expect(mockOnValidityChange).toHaveBeenLastCalledWith(true); + + jest.useRealTimers(); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx index 27c1cb5548158..54238d7aac354 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx @@ -49,12 +49,19 @@ export interface ConditionEditorProps { condition: Condition; status: RoutingStatus; onConditionChange: (condition: Condition) => void; + onValidityChange: (isValid: boolean) => void; fieldSuggestions?: Suggestion[]; valueSuggestions?: Suggestion[]; } export function ConditionEditor(props: ConditionEditorProps) { - const { status, onConditionChange, fieldSuggestions = [], valueSuggestions = [] } = props; + const { + status, + onConditionChange, + onValidityChange, + fieldSuggestions = [], + valueSuggestions = [], + } = props; const { core } = useKibana(); const isInvalidCondition = !isCondition(props.condition); @@ -65,6 +72,58 @@ export function ConditionEditor(props: ConditionEditorProps) { const [usingSyntaxEditor, toggleSyntaxEditor] = useToggle(!conditionEditableInUi); + const serializedCondition = useMemo(() => JSON.stringify(condition, null, 2), [condition]); + const [syntaxEditorValue, setSyntaxEditorValue] = useState(serializedCondition); + const syntaxEditorValueRef = useRef(syntaxEditorValue); + const lastSyncedSerializedConditionRef = useRef(serializedCondition); + const prevUsingSyntaxEditorRef = useRef(usingSyntaxEditor); + const lastReportedValidityRef = useRef(undefined); + const onValidityChangeRef = useRef(onValidityChange ?? (() => {})); + + const reportValidityChange = useCallback((isValid: boolean) => { + if (lastReportedValidityRef.current === isValid) { + return; + } + lastReportedValidityRef.current = isValid; + onValidityChangeRef.current(isValid); + }, []); + + useEffect(() => { + onValidityChangeRef.current = onValidityChange ?? (() => {}); + }, [onValidityChange]); + + useEffect(() => { + // Ensure consumers start in a valid state. + reportValidityChange(true); + }, [reportValidityChange]); + + useEffect(() => { + // When switching modes, reset validity and ensure the editor starts from the canonical condition. + if (prevUsingSyntaxEditorRef.current !== usingSyntaxEditor) { + reportValidityChange(true); + prevUsingSyntaxEditorRef.current = usingSyntaxEditor; + } + }, [reportValidityChange, usingSyntaxEditor]); + + useEffect(() => { + if (!usingSyntaxEditor) { + // Keep syntax editor text in sync while in UI mode so switching to syntax starts + // from the current canonical condition. + setSyntaxEditorValue(serializedCondition); + syntaxEditorValueRef.current = serializedCondition; + lastSyncedSerializedConditionRef.current = serializedCondition; + return; + } + + // If the parent updates the condition while the user hasn't edited the syntax editor, + // sync the text. If the user has edited locally, keep their text to avoid clobbering. + if (syntaxEditorValueRef.current === lastSyncedSerializedConditionRef.current) { + setSyntaxEditorValue(serializedCondition); + syntaxEditorValueRef.current = serializedCondition; + } + lastSyncedSerializedConditionRef.current = serializedCondition; + }, [serializedCondition, usingSyntaxEditor]); + // Check if the selected field is a date type AND the operator is "in range" const isDateFieldWithRange = useMemo(() => { if (!conditionEditableInUi || fieldSuggestions.length === 0) { @@ -89,11 +148,6 @@ export function ConditionEditor(props: ConditionEditorProps) { [onConditionChange] ); - const serializedCondition = useMemo(() => JSON.stringify(condition, null, 2), [condition]); - const [syntaxEditorValue, setSyntaxEditorValue] = useState(serializedCondition); - const syntaxEditorValueRef = useRef(syntaxEditorValue); - const lastSyncedSerializedConditionRef = useRef(serializedCondition); - const debouncedEmitConditionChange = useMemo(() => { return debounce( (nextCondition: Condition) => { @@ -112,16 +166,6 @@ export function ConditionEditor(props: ConditionEditorProps) { }; }, [debouncedEmitConditionChange]); - useEffect(() => { - // Keep the syntax editor in sync with external condition updates, but only when the user - // hasn't diverged from the last serialized value (so we don't clobber in-progress edits). - if (syntaxEditorValueRef.current === lastSyncedSerializedConditionRef.current) { - setSyntaxEditorValue(serializedCondition); - syntaxEditorValueRef.current = serializedCondition; - } - lastSyncedSerializedConditionRef.current = serializedCondition; - }, [serializedCondition]); - const flushSyntaxEditorCondition = useCallback(() => { const currentValue = syntaxEditorValueRef.current; if (currentValue === lastSyncedSerializedConditionRef.current) { @@ -205,8 +249,10 @@ export function ConditionEditor(props: ConditionEditorProps) { setSyntaxEditorValue(value); try { const parsed = JSON.parse(value) as Condition; + reportValidityChange(true); debouncedEmitConditionChange(parsed); } catch (error: unknown) { + reportValidityChange(false); debouncedEmitConditionChange.cancel(); } }} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/hooks/use_condition_filtering_enabled.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/hooks/use_condition_filtering_enabled.ts index 92138d933a0e5..89a46fe745d1b 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/hooks/use_condition_filtering_enabled.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/hooks/use_condition_filtering_enabled.ts @@ -15,7 +15,8 @@ import { /** * Determine if condition filtering is enabled for a given condition block. * The filtering on a condition is enabled either if the condition is currently - * selected or it has at least one new descendant processor in the current simulation. + * selected, the condition itself is newly created, or it has at least one new descendant processor + * in the current simulation. */ export function useConditionFilteringEnabled(conditionId: string) { const stepRefs = useInteractiveModeSelector((state) => state.context.stepRefs); @@ -23,6 +24,11 @@ export function useConditionFilteringEnabled(conditionId: string) { (snapshot) => snapshot.context.selectedConditionId === conditionId ); + const isConditionNew = useMemo(() => { + const stepRef = stepRefs.find((ref) => ref.id === conditionId); + return Boolean(stepRef?.getSnapshot()?.context.isNew); + }, [stepRefs, conditionId]); + const newProcessorsForCondition = useMemo(() => { const newSteps = stepRefs .filter((ref) => ref.getSnapshot()?.context.isNew) @@ -31,5 +37,5 @@ export function useConditionFilteringEnabled(conditionId: string) { return collectDescendantProcessorIdsForCondition(newSteps, conditionId); }, [stepRefs, conditionId]); - return isConditionSelected || newProcessorsForCondition.length !== 0; + return isConditionSelected || isConditionNew || newProcessorsForCondition.length !== 0; } diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/processor_outcome_preview.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/processor_outcome_preview.tsx index d0fb0bdde1fab..c5deabc8d3819 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/processor_outcome_preview.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/processor_outcome_preview.tsx @@ -58,7 +58,6 @@ import { selectHasSimulatedRecords, selectOriginalPreviewRecords, selectPreviewRecords, - selectSamplesForSimulation, } from './state_management/simulation_state_machine/selectors'; import { isStepUnderEdit } from './state_management/steps_state_machine'; import { @@ -121,29 +120,51 @@ const PreviewDocumentsGroupBy = () => { useStreamEnrichmentEvents(); const previewDocsFilter = useSimulatorSelector((state) => state.context.previewDocsFilter); - const hasMetrics = useSimulatorSelector((state) => !!state.context.simulation?.documents_metrics); - const simulationFailedRate = useSimulatorSelector((state) => - formatRateToPercentage(state.context.simulation?.documents_metrics.failed_rate) - ); - const simulationSkippedRate = useSimulatorSelector((state) => - formatRateToPercentage(state.context.simulation?.documents_metrics.skipped_rate) - ); - const simulationPartiallyParsedRate = useSimulatorSelector((state) => - formatRateToPercentage(state.context.simulation?.documents_metrics.partially_parsed_rate) - ); - const simulationParsedRate = useSimulatorSelector((state) => - formatRateToPercentage(state.context.simulation?.documents_metrics.parsed_rate) - ); - const simulationDroppedRate = useSimulatorSelector((state) => - formatRateToPercentage(state.context.simulation?.documents_metrics.dropped_rate) + const derivedDocumentMetrics = useSimulatorSelector((state) => { + const docs = state.context.simulation?.documents; + if (!docs) return undefined; + + const selectedConditionId = state.context.selectedConditionId; + const filteredDocs = selectedConditionId + ? docs.filter((doc) => doc.processed_by?.includes(selectedConditionId) ?? false) + : docs; + + const total = filteredDocs.length; + if (total === 0) return undefined; + + const counts = filteredDocs.reduce((acc, doc) => { + acc[doc.status] = (acc[doc.status] ?? 0) + 1; + return acc; + }, {} as Record); + + return { + failed_rate: (counts.failed ?? 0) / total, + partially_parsed_rate: (counts.partially_parsed ?? 0) / total, + skipped_rate: (counts.skipped ?? 0) / total, + parsed_rate: (counts.parsed ?? 0) / total, + dropped_rate: (counts.dropped ?? 0) / total, + }; + }); + + const hasMetrics = Boolean(derivedDocumentMetrics); + const simulationFailedRate = formatRateToPercentage(derivedDocumentMetrics?.failed_rate); + const simulationSkippedRate = formatRateToPercentage(derivedDocumentMetrics?.skipped_rate); + const simulationPartiallyParsedRate = formatRateToPercentage( + derivedDocumentMetrics?.partially_parsed_rate ); + const simulationParsedRate = formatRateToPercentage(derivedDocumentMetrics?.parsed_rate); + const simulationDroppedRate = formatRateToPercentage(derivedDocumentMetrics?.dropped_rate); const selectedConditionId = useSimulatorSelector((state) => state.context.selectedConditionId); - const totalSamples = useSimulatorSelector((state) => state.context.samples.length); - const activeSamples = useSimulatorSelector( - (state) => selectSamplesForSimulation(state.context).length - ); - const conditionPercentage = - totalSamples > 0 ? Math.round((activeSamples / totalSamples) * 100) : 0; + const conditionPercentage = useSimulatorSelector((state) => { + const conditionId = state.context.selectedConditionId; + if (!conditionId) return 0; + const metrics = state.context.simulation?.processors_metrics?.[conditionId]; + if (!metrics) return 0; + // Condition match rate is tracked via the simulation-only condition noop processor: + // it is skipped when the condition doesn't match. + const matchedRate = 1 - (metrics.skipped_rate ?? 0); + return Math.round(matchedRate * 100); + }); const getFilterButtonPropsFor = (filter: PreviewDocsFilterOption) => ({ isToggle: previewDocsFilter === filter, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/interactive_mode_machine.test.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/interactive_mode_machine.test.ts new file mode 100644 index 0000000000000..cd371e9d58e22 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/interactive_mode_machine.test.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { GrokCollection } from '@kbn/grok-ui'; +import { ALWAYS_CONDITION, type StreamlangProcessorDefinition } from '@kbn/streamlang'; +import type { StreamlangConditionBlock, StreamlangDSL } from '@kbn/streamlang/types/streamlang'; +import { createActor } from 'xstate'; +import { interactiveModeMachine } from './interactive_mode_machine'; +import type { InteractiveModeParentRef } from './types'; + +// Mock htmlIdGenerator to return unique IDs (the default EUI test-env mock returns +// the same 'generated-id' for all calls, which breaks tests that create multiple steps) +let mockIdCounter = 0; +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + htmlIdGenerator: () => () => `test-id-${mockIdCounter++}`, +})); + +const createParentRef = () => { + const send = jest.fn(); + + const mockSimulatorRef = { + getSnapshot: () => ({ + context: { + samples: [], + previewDocsFilter: undefined, + simulation: undefined, + selectedConditionId: undefined, + }, + }), + }; + + const parentRef: InteractiveModeParentRef = { + send, + getSnapshot: () => ({ + context: { + // Only required for some actions (e.g. default processor creation); keep minimal for this test. + simulatorRef: mockSimulatorRef as unknown as ReturnType< + InteractiveModeParentRef['getSnapshot'] + >['context']['simulatorRef'], + dataSourcesRefs: [], + schemaErrors: [], + validationErrors: new Map(), + }, + }), + }; + + return { parentRef, send }; +}; + +describe('interactiveModeMachine condition focus behavior', () => { + beforeEach(() => { + // Reset the ID counter before each test + mockIdCounter = 0; + }); + + it('does not clear auto-selected condition focus on processor save (Update)', () => { + const { parentRef, send } = createParentRef(); + + const actor = createActor(interactiveModeMachine, { + input: { + dsl: { steps: [] } as unknown as StreamlangDSL, + newStepIds: [], + parentRef, + privileges: { manage: true, simulate: true }, + simulationMode: 'partial', + streamName: 'test-stream', + grokCollection: { setCustomPatterns: jest.fn() } as unknown as GrokCollection, + }, + }); + + actor.start(); + send.mockClear(); // ignore initial sync/simulation traffic + + // Create a condition block and persist it (exit `creating` state). + actor.send({ + type: 'step.addCondition', + condition: { condition: { ...ALWAYS_CONDITION, steps: [] } } as StreamlangConditionBlock, + }); + + const autoFilterEvent = send.mock.calls + .map(([event]) => event) + .find((event) => event?.type === 'simulation.filterByConditionAuto') as + | { type: 'simulation.filterByConditionAuto'; conditionId: string } + | undefined; + + expect(autoFilterEvent).toBeDefined(); + const conditionId = autoFilterEvent!.conditionId; + + // Find the condition stepRef and save it (send to child, not parent) + const conditionStepRef = actor + .getSnapshot() + .context.stepRefs.find((ref) => ref.id === conditionId); + expect(conditionStepRef).toBeDefined(); + conditionStepRef!.send({ type: 'step.save' }); + + // Create a processor under that condition and persist it. + const processor: StreamlangProcessorDefinition = { + action: 'set', + to: 'foo', + value: 'bar', + override: true, + ignore_failure: false, + where: ALWAYS_CONDITION, + }; + + actor.send({ + type: 'step.addProcessor', + processor, + options: { parentId: conditionId }, + }); + + // Find the processor stepRef + const processorStepRef = actor.getSnapshot().context.stepRefs.find((ref) => { + const { step } = ref.getSnapshot().context; + return 'action' in step && step.action === 'set' && step.parentId === conditionId; + }); + + expect(processorStepRef).toBeDefined(); + const processorId = processorStepRef!.id; + + // Save the processor (send to child) + processorStepRef!.send({ type: 'step.save' }); + + // Start editing; the machine should auto-select the parent condition via a parent event. + send.mockClear(); + actor.send({ type: 'step.edit', id: processorId }); + + const editAutoSelect = send.mock.calls + .map(([event]) => event) + .find((event) => event?.type === 'simulation.filterByConditionAuto') as + | { type: 'simulation.filterByConditionAuto'; conditionId: string } + | undefined; + + expect(editAutoSelect?.conditionId).toBe(conditionId); + + // Simulate parent applying the filter back to the interactive mode machine. + actor.send({ type: 'step.filterByCondition', conditionId }); + expect(actor.getSnapshot().context.selectedConditionId).toBe(conditionId); + + // Saving (clicking "Update") must NOT clear the auto condition filter/focus. + send.mockClear(); + // Send save to the child stepRef (this is how the UI does it) + processorStepRef!.send({ type: 'step.save' }); + + const sentEventTypes = send.mock.calls.map(([event]) => event?.type); + expect(sentEventTypes).not.toContain('simulation.clearAutoConditionFilter'); + expect(actor.getSnapshot().context.selectedConditionId).toBe(conditionId); + + actor.stop(); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/interactive_mode_machine.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/interactive_mode_machine.ts index c0b43344c32ed..1b5d9be8f663f 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/interactive_mode_machine.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/interactive_mode_machine.ts @@ -11,6 +11,8 @@ import { ALWAYS_CONDITION, convertStepsForUI, convertUIStepsToDSL, + isActionBlock, + isConditionBlock, type StreamlangProcessorDefinition, } from '@kbn/streamlang'; import type { StreamlangConditionBlock, StreamlangDSL } from '@kbn/streamlang/types/streamlang'; @@ -107,6 +109,20 @@ export const interactiveModeMachine = setup({ conversionOptions.parentId ); + // If the processor is created under a condition block, automatically select that condition. + const parentId = conversionOptions.parentId; + if (parentId) { + const parentStep = assignArgs.context.stepRefs + .find((ref) => ref.id === parentId) + ?.getSnapshot()?.context.step; + if (parentStep && isConditionBlock(parentStep)) { + assignArgs.context.parentRef.send({ + type: 'simulation.filterByConditionAuto', + conditionId: parentId, + }); + } + } + return { stepRefs: insertAtIndex(assignArgs.context.stepRefs, newProcessorRef, insertIndex), }; @@ -163,10 +179,14 @@ export const interactiveModeMachine = setup({ const conversionOptions = options ?? { parentId: null }; const convertedCondition = stepConverter.toUIDefinition(condition, conversionOptions); + const conditionId = convertedCondition.customIdentifier ?? createId(); const parentRef: StepParentActor = assignArgs.self; const newProcessorRef = spawnStep( - convertedCondition, + { + ...convertedCondition, + customIdentifier: conditionId, + }, parentRef, assignArgs.spawn as StepSpawner, assignArgs.context.grokCollection, @@ -177,11 +197,34 @@ export const interactiveModeMachine = setup({ conversionOptions.parentId ); + // Automatically filter the simulation by the newly created condition. + // This uses the simulation-only noop processor tagged with the condition id. + assignArgs.context.parentRef.send({ + type: 'simulation.filterByConditionAuto', + conditionId, + }); + return { stepRefs: insertAtIndex(assignArgs.context.stepRefs, newProcessorRef, insertIndex), }; } ), + maybeAutoSelectParentConditionForProcessor: ({ context }, params: { id?: string }) => { + if (!params.id) return; + + const stepRef = context.stepRefs.find((ref) => ref.id === params.id); + const step = stepRef?.getSnapshot()?.context.step; + if (!step || !isActionBlock(step)) return; + + const parentId = step.parentId; + if (!parentId) return; + + const parentStep = context.stepRefs.find((ref) => ref.id === parentId)?.getSnapshot() + ?.context.step; + if (parentStep && isConditionBlock(parentStep)) { + context.parentRef.send({ type: 'simulation.filterByConditionAuto', conditionId: parentId }); + } + }, deleteStep: assign(({ context }, params: { id: string }) => { const steps = context.stepRefs.map((ref) => ref.getSnapshot().context.step); const idsToDelete = collectDescendantStepIds(steps, params.id); @@ -535,6 +578,12 @@ export const interactiveModeMachine = setup({ }, 'step.edit': { guard: 'hasSimulatePrivileges', + actions: [ + { + type: 'maybeAutoSelectParentConditionForProcessor', + params: ({ event }) => event, + }, + ], target: 'editing', }, 'step.reorder': { @@ -618,10 +667,29 @@ export const interactiveModeMachine = setup({ { type: 'deleteStep', params: ({ event }) => event }, ], }, + 'step.cancel': { + target: 'idle', + }, 'step.save': { target: 'idle', actions: [{ type: 'reassignSteps' }, { type: 'syncToDSL' }], }, + 'step.filterByCondition': { + actions: [ + { type: 'storeConditionFilter', params: ({ event }) => event }, + { + type: 'sendStepsToSimulator', + }, + ], + }, + 'step.clearConditionFilter': { + actions: [ + { type: 'storeConditionFilter', params: () => ({ conditionId: undefined }) }, + { + type: 'sendStepsToSimulator', + }, + ], + }, }, }, editing: { @@ -630,7 +698,9 @@ export const interactiveModeMachine = setup({ 'step.change': { actions: [{ type: 'syncToDSL' }, { type: 'sendStepsToSimulator' }], }, - 'step.cancel': 'idle', + 'step.cancel': { + target: 'idle', + }, 'step.delete': { target: 'idle', guard: 'hasManagePrivileges', @@ -643,6 +713,22 @@ export const interactiveModeMachine = setup({ target: 'idle', actions: [{ type: 'reassignSteps' }, { type: 'syncToDSL' }], }, + 'step.filterByCondition': { + actions: [ + { type: 'storeConditionFilter', params: ({ event }) => event }, + { + type: 'sendStepsToSimulator', + }, + ], + }, + 'step.clearConditionFilter': { + actions: [ + { type: 'storeConditionFilter', params: () => ({ conditionId: undefined }) }, + { + type: 'sendStepsToSimulator', + }, + ], + }, }, }, }, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/types.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/types.ts index fb1e77074df4d..1d5510f642fc6 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/types.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/interactive_mode_machine/types.ts @@ -29,7 +29,9 @@ export interface InteractiveModeMachineDeps { export type InteractiveModeToParentEvent = | { type: 'mode.dslUpdated'; dsl: StreamlangDSL } | { type: 'simulation.reset' } - | { type: 'simulation.updateSteps'; steps: StreamlangStepWithUIAttributes[] }; + | { type: 'simulation.updateSteps'; steps: StreamlangStepWithUIAttributes[] } + | { type: 'simulation.filterByConditionAuto'; conditionId: string } + | { type: 'simulation.clearAutoConditionFilter' }; interface InteractiveModeParentSnapshot { context: { @@ -86,8 +88,8 @@ export interface InteractiveModeInput { } export type InteractiveModeEvent = - | { type: 'step.edit' } - | { type: 'step.cancel' } + | { type: 'step.edit'; id?: string } + | { type: 'step.cancel'; id?: string } | { type: 'step.save'; id: string } | { type: 'step.changeProcessor'; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/selectors.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/selectors.ts index 3936aee56deca..d4982f3173605 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/selectors.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/selectors.ts @@ -10,10 +10,7 @@ import { createSelector } from 'reselect'; import { flattenObjectNestedLast } from '@kbn/object-utils'; import type { FlattenRecord } from '@kbn/streams-schema'; import type { SimulationContext } from './types'; -import { - collectActiveDocumentsForSelectedCondition, - getFilterSimulationDocumentsFn, -} from './utils'; +import { getFilterSimulationDocumentsFn } from './utils'; /** * Selects the simulated documents with applied filtering by @@ -24,13 +21,22 @@ export const selectPreviewRecords = createSelector( (context: Pick) => context.samples, (context: Pick) => context.previewDocsFilter, (context: Pick) => context.simulation?.documents, + (context: Pick) => context.selectedConditionId, ], - (samples, previewDocsFilter, documents) => { + (samples, previewDocsFilter, documents, selectedConditionId) => { if (!previewDocsFilter || !documents) { return samples.map((sample) => flattenObjectNestedLast(sample.document)) as FlattenRecord[]; } const filterFn = getFilterSimulationDocumentsFn(previewDocsFilter); - return documents.filter(filterFn).map((doc) => doc.value); + const conditionFilterFn = selectedConditionId + ? (doc: (typeof documents)[number]) => + doc.processed_by?.includes(selectedConditionId) ?? false + : (_doc: (typeof documents)[number]) => true; + + return documents + .filter(conditionFilterFn) + .filter(filterFn) + .map((doc) => doc.value); } ); @@ -43,8 +49,9 @@ export const selectOriginalPreviewRecords = createSelector( (context: SimulationContext) => context.samples, (context: SimulationContext) => context.previewDocsFilter, (context: SimulationContext) => context.simulation?.documents, + (context: SimulationContext) => context.selectedConditionId, ], - (samples, previewDocsFilter, documents) => { + (samples, previewDocsFilter, documents, selectedConditionId) => { if (!previewDocsFilter || !documents) { return samples; } @@ -52,47 +59,15 @@ export const selectOriginalPreviewRecords = createSelector( // return the samples where the filterFn matches the documents at the same index return samples.filter((_, index) => { const doc = documents[index]; - return doc ? filterFn(doc) : false; + if (!doc) return false; + if (selectedConditionId && !(doc.processed_by?.includes(selectedConditionId) ?? false)) { + return false; + } + return filterFn(doc); }); } ); -/** - * Selects an subset of samples be sent - * for a simulation taking into account the currently - * selected condition filter. - * - * If no condition is selected, all samples are returned. - * - * If a condition is selected, samples are filtered to include - * only those that correspond to documents processed by - * the processors which are direct descendants of the selected - * condition. - */ -export const selectSamplesForSimulation = createSelector( - [ - (context: SimulationContext) => context.samples, - (context: SimulationContext) => context.baseSimulation?.documents, - (context: SimulationContext) => context.steps, - (context: SimulationContext) => context.selectedConditionId, - ], - (samples, baseSimulationDocuments = [], steps, selectedConditionId) => { - if (!selectedConditionId || baseSimulationDocuments.length === 0) { - return samples; - } - - const docIndexes = collectActiveDocumentsForSelectedCondition( - baseSimulationDocuments, - steps, - selectedConditionId - ).map((doc) => baseSimulationDocuments.indexOf(doc)); - - return docIndexes - .filter((docIndex) => samples.at(docIndex) !== undefined) - .map((index) => samples[index]); - } -); - export const selectHasSimulatedRecords = createSelector( [(context: SimulationContext) => context.simulation?.documents], (documents) => { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/simulation_state_machine.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/simulation_state_machine.ts index 6bbe87a15e427..bc41cd01bdc61 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/simulation_state_machine.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/simulation_state_machine.ts @@ -13,7 +13,6 @@ import type { ActorRefFrom, MachineImplementationsFrom, SnapshotFrom } from 'xst import { assign, setup } from 'xstate'; import type { MappedSchemaField } from '../../../schema_editor/types'; import { getValidSteps } from '../../utils'; -import { selectSamplesForSimulation } from './selectors'; import type { PreviewDocsFilterOption } from './simulation_documents_search'; import { createSimulationRunFailureNotifier, @@ -64,7 +63,9 @@ export const simulationMachine = setup({ })), storeSimulation: assign(({ context }, params: { simulation: Simulation | undefined }) => ({ simulation: params.simulation, - baseSimulation: context.selectedConditionId ? context.baseSimulation : params.simulation, + baseSimulation: context.selectedConditionId + ? context.baseSimulation ?? params.simulation + : params.simulation, })), storeExplicitlyEnabledPreviewColumns: assign(({ context }, params: { columns: string[] }) => ({ explicitlyEnabledPreviewColumns: params.columns, @@ -281,7 +282,7 @@ export const simulationMachine = setup({ src: 'runSimulation', input: ({ context }) => ({ streamName: context.streamName, - documents: selectSamplesForSimulation(context) + documents: context.samples .map((doc) => doc.document) .map(flattenObjectNestedLast) as FlattenRecord[], steps: getValidSteps(context.steps), diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.test.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.test.ts index 03995b39140ab..f87a788569710 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.test.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.test.ts @@ -81,24 +81,24 @@ describe('Simulation utils', () => { }); describe('collectActiveDocumentsForSelectedCondition', () => { + // Note: Documents now include condition IDs directly in processed_by, + // as the simulation backend injects condition-noop processors tagged with condition IDs. const documents = [ { processed_by: ['p1'], status: 'parsed', value: {}, errors: [], metrics: {} }, - { processed_by: ['p3'], status: 'parsed', value: {}, errors: [], metrics: {} }, + { processed_by: ['c1', 'p3'], status: 'parsed', value: {}, errors: [], metrics: {} }, ] as unknown as Simulation['documents']; it('returns all documents when no condition is selected', () => { - expect(collectActiveDocumentsForSelectedCondition(documents, steps, undefined)).toEqual( - documents - ); + expect(collectActiveDocumentsForSelectedCondition(documents, undefined)).toEqual(documents); }); - it('returns only documents touched by processors in the selected condition', () => { - const filtered = collectActiveDocumentsForSelectedCondition(documents, steps, 'c1'); + it('returns only documents touched by the selected condition noop processor', () => { + const filtered = collectActiveDocumentsForSelectedCondition(documents, 'c1'); expect(filtered).toEqual([documents[1]]); }); it('returns empty when documents are undefined', () => { - expect(collectActiveDocumentsForSelectedCondition(undefined, steps, 'c1')).toEqual([]); + expect(collectActiveDocumentsForSelectedCondition(undefined, 'c1')).toEqual([]); }); }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.ts index 79f68430d4ce2..43bf11041c21d 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/utils.ts @@ -85,7 +85,6 @@ export function collectDescendantProcessorIdsForCondition( */ export function collectActiveDocumentsForSelectedCondition( documents: Simulation['documents'] | undefined, - steps: StreamlangStepWithUIAttributes[], selectedConditionId: string | undefined ): Simulation['documents'] { if (!documents) { @@ -96,7 +95,10 @@ export function collectActiveDocumentsForSelectedCondition( return documents; } - const processorIds = collectDescendantProcessorIdsForCondition(steps, selectedConditionId); + // Condition filtering is based on the simulation-only noop processor that is tagged + // with the condition customIdentifier. This allows tracking match rates even when + // the subtree is empty or descendants are faulty. + const processorIds = [selectedConditionId]; return collectDocumentsAffectedByProcessors(documents, processorIds); } diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/steps_state_machine.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/steps_state_machine.ts index 1d134a3985096..bab9d4270ecb3 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/steps_state_machine.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/steps_state_machine.ts @@ -94,6 +94,27 @@ export const stepMachine = setup({ isUpdated: true, })), forwardEventToParent: forwardTo(({ context }) => context.parentRef), + notifyStepSave: sendTo( + ({ context }) => context.parentRef, + ({ context }) => ({ + type: 'step.save', + id: context.step.customIdentifier, + }) + ), + notifyStepCancel: sendTo( + ({ context }) => context.parentRef, + ({ context }) => ({ + type: 'step.cancel', + id: context.step.customIdentifier, + }) + ), + notifyStepEdit: sendTo( + ({ context }) => context.parentRef, + ({ context }) => ({ + type: 'step.edit', + id: context.step.customIdentifier, + }) + ), forwardChangeEventToParent: sendTo( ({ context }) => context.parentRef, ({ context }) => ({ @@ -167,9 +188,12 @@ export const stepMachine = setup({ on: { 'step.save': { target: '#configured', - actions: [{ type: 'markAsUpdated' }, { type: 'forwardEventToParent' }], + actions: [{ type: 'markAsUpdated' }, { type: 'notifyStepSave' }], + }, + 'step.cancel': { + target: '#deleted', + actions: [{ type: 'notifyStepCancel' }], }, - 'step.cancel': '#deleted', 'step.changeProcessor': { actions: [ { type: 'changeProcessor', params: ({ event }) => event }, @@ -199,7 +223,7 @@ export const stepMachine = setup({ on: { 'step.edit': { target: 'editing', - actions: [{ type: 'forwardEventToParent' }], + actions: [{ type: 'notifyStepEdit' }], }, 'step.changeDescription': { actions: [ @@ -222,11 +246,11 @@ export const stepMachine = setup({ on: { 'step.save': { target: 'idle', - actions: [{ type: 'markAsUpdated' }, { type: 'forwardEventToParent' }], + actions: [{ type: 'markAsUpdated' }, { type: 'notifyStepSave' }], }, 'step.cancel': { target: 'idle', - actions: [{ type: 'resetToPrevious' }, { type: 'forwardEventToParent' }], + actions: [{ type: 'resetToPrevious' }, { type: 'notifyStepCancel' }], }, 'step.changeProcessor': { actions: [ diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/types.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/types.ts index a1e60432e348d..2d512838c5dd5 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/types.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/steps_state_machine/types.ts @@ -18,7 +18,7 @@ export type StepToParentEvent = | { type: 'step.change'; id: string } | { type: 'step.parentChanged'; id: string } | { type: 'step.delete'; id: string } - | { type: 'step.edit' } + | { type: 'step.edit'; id: string } | { type: 'step.save'; id: string }; export interface StepInput { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/stream_enrichment_state_machine.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/stream_enrichment_state_machine.ts index 68d237aa6ee29..dc13c1957d30e 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/stream_enrichment_state_machine.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/stream_enrichment_state_machine.ts @@ -284,11 +284,16 @@ export const streamEnrichmentMachine = setup({ type: 'simulation.clearConditionFilter', }); }, + storeAutoSelectedConditionId: assign((_, params: { conditionId: string }) => ({ + autoSelectedConditionId: params.conditionId, + })), + clearAutoSelectedConditionId: assign(() => ({ autoSelectedConditionId: undefined })), }, guards: { /* Staged changes are determined by comparing previous and next DSL */ hasManagePrivileges: ({ context }) => context.definition.privileges.manage, hasSimulatePrivileges: ({ context }) => context.definition.privileges.simulate, + hasAutoSelectedConditionId: ({ context }) => Boolean(context.autoSelectedConditionId), canUpdateStream: ({ context }) => { const hasSchemaErrors = context.schemaErrors.length > 0; const hasValidationErrors = context.validationErrors.size > 0; @@ -329,6 +334,7 @@ export const streamEnrichmentMachine = setup({ urlState: defaultEnrichmentUrlState, validationErrors: new Map(), fieldTypesByProcessor: new Map(), + autoSelectedConditionId: undefined, suggestedPipeline: undefined, simulatorRef: spawn('simulationMachine', { id: 'simulator', @@ -549,8 +555,23 @@ export const streamEnrichmentMachine = setup({ 'simulation.updateSteps': { actions: forwardTo('simulator'), }, + 'simulation.filterByConditionAuto': { + actions: [ + { + type: 'storeAutoSelectedConditionId', + params: ({ event }) => ({ conditionId: event.conditionId }), + }, + { + type: 'filterByCondition', + params: ({ event }) => ({ conditionId: event.conditionId }), + }, + ], + }, 'simulation.filterByCondition': { actions: [ + { + type: 'clearAutoSelectedConditionId', + }, { type: 'filterByCondition', params: ({ event }) => ({ conditionId: event.conditionId }), @@ -559,11 +580,21 @@ export const streamEnrichmentMachine = setup({ }, 'simulation.clearConditionFilter': { actions: [ + { + type: 'clearAutoSelectedConditionId', + }, { type: 'clearConditionFilter', }, ], }, + 'simulation.clearAutoConditionFilter': { + guard: 'hasAutoSelectedConditionId', + actions: [ + { type: 'clearConditionFilter' }, + { type: 'clearAutoSelectedConditionId' }, + ], + }, // Forward other step events to interactive mode machine 'step.*': { actions: forwardTo('interactiveMode'), diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/types.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/types.ts index e0b1e31c504e5..0bfa9e40ec141 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/types.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/types.ts @@ -76,6 +76,12 @@ export interface StreamEnrichmentContextType { // Validation errors for processors (namespace, reserved fields, type mismatches) validationErrors: Map; fieldTypesByProcessor: Map>; + /** + * Tracks whether the current condition filter was applied automatically by the UI + * (e.g. right after creating a condition block). If set, some user actions (save/cancel + * processor edits) will clear the filter for convenience. + */ + autoSelectedConditionId?: string; } export type StreamEnrichmentEvent = @@ -106,8 +112,10 @@ export type StreamEnrichmentEvent = | { type: 'mode.resetSimulator' } | { type: 'simulation.reset' } | { type: 'simulation.updateSteps'; steps: StreamlangStepWithUIAttributes[] } + | { type: 'simulation.filterByConditionAuto'; conditionId: string } | { type: 'simulation.filterByCondition'; conditionId: string } | { type: 'simulation.clearConditionFilter' } + | { type: 'simulation.clearAutoConditionFilter' } // Step events forwarded to interactive mode machine | { type: 'step.addProcessor'; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/editor.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/editor.tsx index f1559583ad4b6..3c8286998f2de 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/editor.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/editor.tsx @@ -60,6 +60,7 @@ import { SetProcessorForm } from './set'; import { TransformStringProcessorForm } from './transform_string'; import { ConcatProcessorForm } from './concat'; import { JoinProcessorForm } from './join'; +import { NetworkDirectionProcessorForm } from './network_direction'; export const ActionBlockEditor = forwardRef((props, ref) => { const { processorMetrics, stepRef } = props; @@ -203,6 +204,7 @@ export const ActionBlockEditor = forwardRef((p )} {type === 'concat' && } {type === 'join' && } + {type === 'network_direction' && } {!SPECIALISED_TYPES.includes(type) && ( )} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/index.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/index.tsx new file mode 100644 index 0000000000000..fcc68e3d9c0c0 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { IgnoreFailureToggle, IgnoreMissingToggle } from '../ignore_toggles'; +import { + SourceIpField, + DestinationIpField, + NetworkDirectionTargetField, +} from './network_direction_inputs'; +import { ProcessorConditionEditor } from '../processor_condition_editor'; +import { FieldsAccordion } from '../optional_fields_accordion'; +import { InternalNetworksSelector } from './internal_networks_selector'; + +export const NetworkDirectionProcessorForm = () => { + return ( + <> + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/internal_networks_selector.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/internal_networks_selector.tsx new file mode 100644 index 0000000000000..d49c64c51c4a2 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/internal_networks_selector.tsx @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonIcon, + EuiCheckableCard, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormFieldset, + EuiFormRow, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { Fragment, useState, type ReactNode } from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import type { NetworkDirectionFormState } from '../../../../types'; +import { ProcessorFieldSelector } from '../processor_field_selector'; + +interface InternalNetworksFieldInputProps { + index: number; + onRemove: (index: number) => void; +} + +const InternalNetworksFieldInput = ({ index, onRemove }: InternalNetworksFieldInputProps) => { + const { control } = useFormContext(); + + return ( + + + ( + + + + )} + /> + + + onRemove(index)} + aria-label={i18n.translate( + 'xpack.streams.streamDetailView.managementTab.enrichment.processor.networkDirectionsSelectorInternalNetworksRemoveButton', + { defaultMessage: 'Remove' } + )} + /> + + + ); +}; + +const InternalNetworksContent = () => { + const { fields, append, remove } = useFieldArray< + Pick + >({ + name: 'internal_networks', + }); + + const handleAdd = () => append({ value: '' }); + + const handleRemove = (index: number) => remove(index); + + return ( + + + {fields.map((field, index) => ( + + + + ))} + + + + ); +}; + +const InternalNetworksFieldContent = () => ( + +); + +interface InternalNetworksOptions { + id: string; + label: string; + content: ReactNode; +} + +const internalNetworksOptions: InternalNetworksOptions[] = [ + { + id: 'internal_networks', + label: i18n.translate( + 'xpack.streams.streamDetailView.managementTab.enrichment.processor.networkDirectionsSelectorInternalNetworksLabel', + { defaultMessage: 'Define them manually.' } + ), + content: , + }, + { + id: 'internal_networks_field', + label: i18n.translate( + 'xpack.streams.streamDetailView.managementTab.enrichment.processor.networkDirectionSelectorInternalNetworksFieldLabel', + { defaultMessage: 'Read them from a field.' } + ), + content: , + }, +]; + +export const InternalNetworksSelector = () => { + const { unregister, watch } = useFormContext(); + const internalNetworks = watch('internal_networks'); + const initialSelectedOption = internalNetworks ? 'internal_networks' : 'internal_networks_field'; + const [selectedOption, setSelectedOption] = useState(initialSelectedOption); + + const handleOptionChange = (optionId: string) => { + setSelectedOption(optionId); + const unregisterOption = + optionId === 'internal_networks' ? 'internal_networks_field' : 'internal_networks'; + unregister(unregisterOption); + }; + + return ( + + + {i18n.translate( + 'xpack.streams.streamDetailView.managementTab.enrichment.processor.networkDirectionInternalNetworksLabel', + { defaultMessage: 'How do you want to define internal networks?' } + )} + + + ), + }} + > + {internalNetworksOptions.map(({ id, label, content }) => ( + + handleOptionChange(id)} + > + {selectedOption === id && content} + + + + ))} + + ); +}; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/network_direction_inputs.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/network_direction_inputs.tsx new file mode 100644 index 0000000000000..1b9fd4bbdc2bc --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/network_direction/network_direction_inputs.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; +import { Controller, useFormContext } from 'react-hook-form'; +import { FieldNameWithIcon } from '@kbn/react-field'; +import { useEnrichmentFieldSuggestions } from '../../../../../../../hooks/use_field_suggestions'; +import { ProcessorFieldSelector } from '../processor_field_selector'; + +interface IpRequiredFieldProps { + name: string; + label: string; + dataTestSubj: string; +} + +const IpRequiredField = ({ name, label, dataTestSubj }: IpRequiredFieldProps) => { + const { control } = useFormContext(); + const fieldSuggestions = useEnrichmentFieldSuggestions(); + const options = fieldSuggestions.map((suggestion) => ({ + value: suggestion.name, + inputDisplay: , + })); + + return ( + ( + + + + )} + /> + ); +}; + +export const SourceIpField = () => { + return ( + + ); +}; + +export const DestinationIpField = () => { + return ( + + ); +}; + +export const NetworkDirectionTargetField = () => { + return ( + + ); +}; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_condition_editor.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_condition_editor.tsx index 34859f7a3ec12..933b3da2a6f66 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_condition_editor.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_condition_editor.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import React from 'react'; -import { useController } from 'react-hook-form'; +import React, { useCallback } from 'react'; +import { useController, useFormContext } from 'react-hook-form'; import { isConditionComplete } from '@kbn/streamlang'; +import { i18n } from '@kbn/i18n'; import type { ProcessorFormState } from '../../../types'; import { ProcessorConditionEditorWrapper } from '../../../processor_condition_editor'; @@ -18,12 +19,35 @@ export const ProcessorConditionEditor = () => { validate: isConditionComplete, }, }); + const { setError, clearErrors } = useFormContext(); + + const handleValidityChange = useCallback( + (isValid: boolean) => { + if (isValid) { + clearErrors('where'); + return; + } + + setError('where', { + type: 'manual', + message: i18n.translate( + 'xpack.streams.streamDetailView.managementTab.enrichment.invalidProcessorWhereJsonError', + { defaultMessage: 'Invalid JSON' } + ), + }); + }, + [clearErrors, setError] + ); if (field.value === undefined) { return null; } return ( - + ); }; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_metrics.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_metrics.tsx index dec1fc6e3fcc4..cc09c7f573ad2 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_metrics.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/processor_metrics.tsx @@ -78,12 +78,10 @@ const ProcessorErrorMessage = ({ message }: { message: string }) => { export const ProcessorMetricBadges = ({ detected_fields, failed_rate, - skipped_rate, parsed_rate, }: ProcessorMetricBadgesProps) => { const detectedFieldsCount = detected_fields.length; const parsedRate = parsed_rate > 0 ? formatter.format(parsed_rate) : null; - const skippedRate = skipped_rate > 0 ? formatter.format(skipped_rate) : null; const failedRate = failed_rate > 0 ? formatter.format(failed_rate) : null; return ( @@ -132,20 +130,6 @@ export const ProcessorMetricBadges = ({
)} - {skippedRate && ( - - - {skippedRate} - - - )} {detectedFieldsCount > 0 && ( { + return ( + + {i18n.translate('xpack.streams.availableProcessors.networkDirectionLinkLabel', { + defaultMessage: 'Network direction', + })} + + ), + }} + /> + ); + }, + }, ...configDrivenProcessors, ...(isWired ? {} @@ -565,6 +596,7 @@ const PROCESSOR_GROUP_MAP: Record< trim: 'set', join: 'set', concat: 'set', + network_direction: 'set', }; const getProcessorDescription = diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils.ts index e655287206b44..9b6efc5e09a19 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils.ts @@ -188,6 +188,18 @@ export const getStepDescription = (step: StreamlangProcessorDefinitionWithUIAttr }, } ); + } else if (step.action === 'network_direction') { + const { source_ip, destination_ip } = step; + return i18n.translate( + 'xpack.streams.streamDetailView.managementTab.enrichment.networkDirectionProcessorDescription', + { + defaultMessage: 'Network direction from "{source_ip}" to "{destination_ip}".', + values: { + source_ip, + destination_ip, + }, + } + ); } else { const { action, parentId, customIdentifier, ignore_failure, ...rest } = step; // Remove 'where' if it exists (some processors have it, some don't) diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils/pattern_suggestion_helpers.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils/pattern_suggestion_helpers.ts index 25e1617bc2840..4b08e08dbfb5e 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils/pattern_suggestion_helpers.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/action/utils/pattern_suggestion_helpers.ts @@ -56,7 +56,8 @@ export async function prepareSamplesForPatternExtraction( samples = selectPreviewRecords.resultFunc( originalSamples, previewDocsFilter, - simulation.documents + simulation.documents, + undefined // no condition filter for pattern extraction ); } diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/configuration.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/configuration.tsx index fb39d9f647e4d..8c82726ac86bd 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/configuration.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/configuration.tsx @@ -17,9 +17,9 @@ import { i18n } from '@kbn/i18n'; import type { Condition, StreamlangConditionBlockWithUIAttributes } from '@kbn/streamlang'; import { isConditionComplete } from '@kbn/streamlang'; import { isEqual } from 'lodash'; -import React, { useState, useEffect, forwardRef } from 'react'; +import React, { useState, useEffect, forwardRef, useCallback } from 'react'; import type { SubmitHandler } from 'react-hook-form'; -import { useForm, FormProvider, useController } from 'react-hook-form'; +import { useForm, FormProvider, useController, useFormContext } from 'react-hook-form'; import type { DeepPartial } from 'utility-types'; import { useSelector } from '@xstate/react'; import { useDiscardConfirm } from '../../../../../../hooks/use_discard_confirm'; @@ -77,6 +77,7 @@ export const WhereBlockConfiguration = forwardRef { const { unsubscribe } = methods.watch((value) => { @@ -144,7 +145,7 @@ export const WhereBlockConfiguration = forwardRef {isConfigured ? i18n.translate( @@ -172,6 +173,25 @@ export const WhereBlockConditionEditor = () => { validate: (value) => isConditionComplete(value as Condition | undefined), }, }); + const { setError, clearErrors } = useFormContext(); + + const handleValidityChange = useCallback( + (isValid: boolean) => { + if (isValid) { + clearErrors('condition'); + return; + } + + setError('condition', { + type: 'manual', + message: i18n.translate( + 'xpack.streams.streamDetailView.managementTab.enrichment.invalidConditionJsonError', + { defaultMessage: 'Invalid JSON' } + ), + }); + }, + [clearErrors, setError] + ); if (field.value === undefined) { return null; @@ -181,6 +201,7 @@ export const WhereBlockConditionEditor = () => { void} + onValidityChange={handleValidityChange} /> ); }; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/summary.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/summary.tsx index ebcab3b9b89cb..d06768335206b 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/summary.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/steps/blocks/where/summary.tsx @@ -5,7 +5,14 @@ * 2.0. */ -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTextColor, + EuiToolTip, +} from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import { isConditionBlock } from '@kbn/streamlang'; @@ -13,6 +20,8 @@ import { useSelector } from '@xstate/react'; import React from 'react'; import { ConditionDisplay } from '../../../../shared'; import { CreateStepButton } from '../../../create_step_button'; +import { getPercentageFormatter } from '../../../../../../util/formatters'; +import { useSimulatorSelector } from '../../../state_management/stream_enrichment_state_machine'; import type { StepConfigurationProps } from '../../steps_list'; import { BlockDisableOverlay } from '../block_disable_overlay'; import { StepContextMenu } from '../context_menu'; @@ -22,6 +31,8 @@ interface WhereBlockSummaryProps extends StepConfigurationProps { onClick?: () => void; } +const formatter = getPercentageFormatter(); + export const WhereBlockSummary = ({ stepRef, rootLevelMap, @@ -33,6 +44,13 @@ export const WhereBlockSummary = ({ onClick, }: WhereBlockSummaryProps) => { const step = useSelector(stepRef, (snapshot) => snapshot.context.step); + const conditionMatchRate = useSimulatorSelector((snapshot) => { + const metrics = snapshot.context.simulation?.processors_metrics?.[stepRef.id]; + if (!metrics) return undefined; + return 1 - (metrics.skipped_rate ?? 0); + }); + const conditionMatchPercentage = + conditionMatchRate !== undefined ? formatter.format(conditionMatchRate) : undefined; const handleTitleClick = (event?: React.MouseEvent) => { event?.stopPropagation(); @@ -112,6 +130,33 @@ export const WhereBlockSummary = ({ /> + {conditionMatchPercentage !== undefined && ( + + + + + + + + {conditionMatchPercentage} + + + + + )} + {!readOnly && ( & { patterns: GrokPatternField[]; }; +export interface InternalNetworksValue { + value: string; +} + export type DissectFormState = DissectProcessor; export type DateFormState = DateProcessor; export type DropFormState = DropDocumentProcessor; @@ -63,6 +68,13 @@ export type LowercaseFormState = LowercaseProcessor; export type TrimFormState = TrimProcessor; export type JoinFormState = JoinProcessor; export type ConcatFormState = ConcatProcessor; +export type NetworkDirectionFormState = Omit< + NetworkDirectionProcessor, + 'internal_networks' | 'internal_networks_field' +> & { + internal_networks?: InternalNetworksValue[]; + internal_networks_field?: string; +}; export type SpecialisedFormState = | GrokFormState @@ -79,7 +91,8 @@ export type SpecialisedFormState = | LowercaseFormState | TrimFormState | JoinFormState - | ConcatFormState; + | ConcatFormState + | NetworkDirectionFormState; export type ProcessorFormState = SpecialisedFormState | ConfigDrivenProcessorFormState; export type ConditionBlockFormState = StreamlangConditionBlockWithUIAttributes; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.ts index d8d3451ddb940..e9c4d4c9a0fed 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.ts @@ -13,6 +13,7 @@ import type { JoinProcessor, LowercaseProcessor, MathProcessor, + NetworkDirectionProcessor, ProcessorType, RedactProcessor, ReplaceProcessor, @@ -34,7 +35,7 @@ import { isConditionBlock } from '@kbn/streamlang/types/streamlang'; import type { FlattenRecord } from '@kbn/streams-schema'; import { Streams, isSchema, type FieldDefinition } from '@kbn/streams-schema'; import type { IngestUpsertRequest } from '@kbn/streams-schema/src/models/ingest'; -import { countBy, isEmpty, mapValues, orderBy } from 'lodash'; +import { countBy, isEmpty, mapValues, omit, orderBy } from 'lodash'; import type { EnrichmentDataSource } from '../../../../common/url_schema'; import type { StreamEnrichmentContextType } from './state_management/stream_enrichment_state_machine/types'; import { configDrivenProcessors } from './steps/blocks/action/config_driven'; @@ -55,6 +56,7 @@ import type { LowercaseFormState, ManualIngestPipelineFormState, MathFormState, + NetworkDirectionFormState, ProcessorFormState, RedactFormState, ReplaceFormState, @@ -81,6 +83,7 @@ export const SPECIALISED_TYPES = [ 'trim', 'join', 'concat', + 'network_direction', ]; interface FormStateDependencies { @@ -291,6 +294,17 @@ const defaultConcatProcessorFormState = (): ConcatFormState => ({ where: ALWAYS_CONDITION, }); +const defaultNetworkDirectionProcessorFormState = (): NetworkDirectionFormState => ({ + action: 'network_direction' as const, + source_ip: '', + destination_ip: '', + internal_networks: [], + target_field: 'attributes.network.direction', + ignore_failure: true, + ignore_missing: true, + where: ALWAYS_CONDITION, +}); + const configDrivenDefaultFormStates = mapValues( configDrivenProcessors, (config) => () => config.defaultFormState @@ -317,6 +331,7 @@ const defaultProcessorFormStateByType: Record< set: defaultSetProcessorFormState, join: defaultJoinProcessorFormState, concat: defaultConcatProcessorFormState, + network_direction: defaultNetworkDirectionProcessorFormState, ...configDrivenDefaultFormStates, }; @@ -351,6 +366,24 @@ export const getFormStateFromActionStep = ( }; } + if (step.action === 'network_direction') { + const clone: NetworkDirectionFormState = structuredClone({ + ...omit(step, 'internal_networks', 'internal_networks_field'), + }); + + if ('internal_networks' in step) { + clone.internal_networks = step.internal_networks?.map((internalNetwork) => ({ + value: internalNetwork, + })); + } + + if ('internal_networks_field' in step) { + clone.internal_networks_field = step.internal_networks_field; + } + + return clone; + } + if ( step.action === 'dissect' || step.action === 'manual_ingest_pipeline' || @@ -677,6 +710,29 @@ export const convertFormStateToProcessor = ( }; } + if (formState.action === 'network_direction') { + const { source_ip, destination_ip, target_field, ignore_failure, ignore_missing } = formState; + + return { + processorDefinition: { + action: 'network_direction', + source_ip, + destination_ip, + internal_networks: + 'internal_networks' in formState + ? formState.internal_networks?.map((internalNetwork) => internalNetwork.value) + : undefined, + internal_networks_field: + 'internal_networks_field' in formState ? formState.internal_networks_field : undefined, + target_field, + ignore_failure, + ignore_missing, + description, + where: 'where' in formState ? formState.where : undefined, + } as NetworkDirectionProcessor, + }; + } + if (configDrivenProcessors[formState.action]) { return { processorDefinition: { @@ -780,13 +836,9 @@ export const getValidSteps = ( return false; } - // Valid but has no children (compilation of this step would be pointless) - const hasChildren = steps.some((s) => s.parentId === step.customIdentifier); - if (!hasChildren) { - return false; - } - - // Valid where block with children + // Valid where block. + // Note: even if it has no children, we still allow it to participate in simulation. + // The server injects a simulation-only noop processor for each condition so we can track match rates. validSteps.push(step); return true; } else { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/base_metric_card.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/base_metric_card.tsx index 0740b5f9e0fa5..b8a3010e110dc 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/base_metric_card.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/base_metric_card.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText, useEuiTheme } from '@elastic/eui'; interface Metric { data: React.ReactNode; @@ -29,10 +29,11 @@ export const BaseMetricCard: React.FC = ({ 'data-test-subj': dataTestSubj, }) => { const metric = metrics[0]; + const { euiTheme } = useEuiTheme(); return ( - - + + = ({ alignItems="center" justifyContent="spaceBetween" responsive={false} - css={{ minHeight: '32px' }} + css={{ minHeight: euiTheme.size.xxl }} > @@ -57,6 +58,9 @@ export const BaseMetricCard: React.FC = ({ + + +

{metric.data} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/chart_components.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/chart_components.tsx index 05a2e7c28c8a7..8e39d53aa317a 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/chart_components.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/chart_components.tsx @@ -220,7 +220,7 @@ function ChartBarPhasesSeriesBase({ css={{ width: '100%', height: '100%' }} gutterSize="s" > - + {Object.entries(ingestionRate.buckets).map(([tier, buckets]) => ( @@ -342,7 +342,7 @@ function PhasesLegend({ phases }: { phases?: IlmPolicyPhases }) { }} /> ) : ( - + )} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx index 2b62122438a2f..affb60695cae0 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx @@ -63,25 +63,35 @@ export const DataLifecycleSummary = ({ const phaseColumnSpans = getPhaseColumnSpans(phases, timelineSegments); return ( - - + + - -
- {i18n.translate('xpack.streams.streamDetailLifecycle.dataLifecycle', { - defaultMessage: 'Data lifecycle', - })} -
-
+ + + +
+ {i18n.translate('xpack.streams.streamDetailLifecycle.dataLifecycle', { + defaultMessage: 'Data lifecycle', + })} +
+
+
+
- + {showSkeleton ? ( - - {downsample.fixed_interval}{' '} - {i18n.translate('xpack.streams.downsamplingPhaseBar.b.intervalLabel', { - defaultMessage: 'interval', - })} - + {downsample.fixed_interval}{' '} + {i18n.translate('xpack.streams.downsamplingPhaseBar.b.intervalLabel', { + defaultMessage: 'interval', + })} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.tsx index dc29989efb8df..eaf9d4b8efcbb 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.tsx @@ -117,7 +117,7 @@ export const LifecyclePhase = (props: LifecyclePhaseProps) => { - + {i18n.translate('xpack.streams.streamDetailLifecycle.phasePopoverTitleLabel', { defaultMessage: '{phase} phase', @@ -128,6 +128,7 @@ export const LifecyclePhase = (props: LifecyclePhaseProps) => { - + ) : ( @@ -94,9 +101,10 @@ export const LifecyclePhaseButton = ({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%', + fontWeight: euiTheme.font.weight.semiBold, }} > - {capitalize(label)} + {capitalize(label)} {size && ( - + @@ -59,9 +59,7 @@ export function IngestionRatePanel({ - - {children} - + {children}
); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/section_panel.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/section_panel.tsx index fb8e62d7d9d8f..22c4b3f6bc7f4 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/section_panel.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/section_panel.tsx @@ -17,26 +17,18 @@ export const SectionPanel = ({ topCard, bottomCard, children }: SectionPanelProp const { euiTheme } = useEuiTheme(); return ( - - + + - - {topCard} - - + {topCard} + {bottomCard} - - + + {children} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_policy_modal/edit_policy_modal.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_policy_modal/edit_policy_modal.tsx index 4881f9c791506..79991957be5a9 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_policy_modal/edit_policy_modal.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_policy_modal/edit_policy_modal.tsx @@ -157,6 +157,7 @@ export function EditPolicyModal({ }} color="subdued" data-test-subj="editPolicyModal-affectedResourcesList" + tabIndex={0} > {affectedResources.map((resource) => ( diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/edit_routing_stream_entry.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/edit_routing_stream_entry.tsx index 3239233a910d7..2bf1394b10235 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/edit_routing_stream_entry.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/edit_routing_stream_entry.tsx @@ -12,6 +12,7 @@ import { RoutingConditionEditor } from './routing_condition_editor'; import { EditRoutingRuleControls } from './control_bars'; import { StreamNameFormRow, useChildStreamInput } from '../../stream_name_form_row'; import type { RoutingDefinitionWithUIAttributes } from './types'; +import { useStreamRoutingEvents } from './state_management/stream_routing_state_machine'; export function EditRoutingStreamEntry({ onChange, @@ -22,6 +23,7 @@ export function EditRoutingStreamEntry({ }) { const { euiTheme } = useEuiTheme(); const { partitionName, prefix } = useChildStreamInput(routingRule.destination, true); + const { setConditionEditorValidity } = useStreamRoutingEvents(); return ( onChange({ where: cond })} + onValidityChange={setConditionEditorValidity} onStatusChange={(status) => onChange({ status })} /> (null); const { euiTheme } = useEuiTheme(); - const { changeRule, changeRuleDebounced } = useStreamRoutingEvents(); + const { changeRule, changeRuleDebounced, setConditionEditorValidity } = useStreamRoutingEvents(); const currentRule = useStreamsRoutingSelector((snapshot) => selectCurrentRule(snapshot.context)); useEffect(() => { @@ -61,6 +61,7 @@ export function NewRoutingStreamEntry() { condition={currentRule.where} status={currentRule.status} onConditionChange={(cond) => changeRule({ where: cond })} + onValidityChange={setConditionEditorValidity} onStatusChange={(status) => changeRule({ status })} /> diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/review_suggestions_form/suggested_stream_panel.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/review_suggestions_form/suggested_stream_panel.tsx index 0cae4b23c68c7..76f2ac7d6f058 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/review_suggestions_form/suggested_stream_panel.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/review_suggestions_form/suggested_stream_panel.tsx @@ -52,8 +52,12 @@ export function SuggestedStreamPanel({ onEdit(index: number, suggestion: PartitionSuggestion): void; onSave?: (suggestion: PartitionSuggestion) => void; }) { - const { changeSuggestionNameDebounced, changeSuggestionCondition, reviewSuggestedRule } = - useStreamRoutingEvents(); + const { + changeSuggestionNameDebounced, + changeSuggestionCondition, + reviewSuggestedRule, + setConditionEditorValidity, + } = useStreamRoutingEvents(); const isEditing = useStreamsRoutingSelector( (snapshot) => @@ -124,6 +128,7 @@ export function SuggestedStreamPanel({ status="enabled" condition={currentSuggestion.condition} onConditionChange={handleConditionChange} + onValidityChange={setConditionEditorValidity} onStatusChange={() => {}} isSuggestionRouting={true} /> diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/stream_routing_state_machine.test.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/stream_routing_state_machine.test.ts new file mode 100644 index 0000000000000..422776be9926a --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/stream_routing_state_machine.test.ts @@ -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 { createActor, fromEventObservable, fromObservable } from 'xstate'; +import type { Observable } from 'rxjs'; +import { EMPTY, of } from 'rxjs'; +import { ALWAYS_CONDITION } from '@kbn/streamlang'; +import { isSchema, routingDefinitionListSchema } from '@kbn/streams-schema'; +import type { SampleDocument, Streams } from '@kbn/streams-schema'; +import { streamRoutingMachine } from './stream_routing_state_machine'; +import { routingSamplesMachine } from './routing_samples_state_machine'; +import type { RoutingSamplesInput } from './routing_samples_state_machine'; +import { routingConverter } from '../../utils'; + +const stubRoutingSamplesMachine = routingSamplesMachine.provide({ + actors: { + collectDocuments: fromObservable< + SampleDocument[], + Pick + >(() => of([])), + collectDocumentsCount: fromObservable< + number | null | undefined, + Pick + >(() => of(undefined)), + subscribeTimeUpdates: fromEventObservable( + () => EMPTY as unknown as Observable<{ type: string }> + ), + }, +}); + +describe('streamRoutingMachine condition editor validity', () => { + it('disables routing updates when the condition editor is invalid JSON', async () => { + const definition = { + privileges: { manage: true, simulate: true }, + inherited_fields: {}, + stream: { + name: 'logs', + ingest: { + wired: { + fields: {}, + routing: [ + { + destination: 'logs.child', + where: ALWAYS_CONDITION, + status: 'enabled', + }, + ], + }, + }, + }, + } as unknown as Streams.WiredStream.GetResponse; + + const actor = createActor( + streamRoutingMachine.provide({ + actors: { + routingSamplesMachine: stubRoutingSamplesMachine, + }, + }), + { input: { definition } } + ); + + actor.start(); + + const initialSnapshot = actor.getSnapshot(); + expect(initialSnapshot.value).toEqual({ ready: { ingestMode: 'idle' } }); + const firstRuleId = initialSnapshot.context.routing[0].id; + + expect(actor.getSnapshot().can({ type: 'routingRule.edit', id: firstRuleId })).toBe(true); + actor.send({ type: 'routingRule.edit', id: firstRuleId }); + await Promise.resolve(); + expect(actor.getSnapshot().context.currentRuleId).toBe(firstRuleId); + + // Ensure there is an actual change, otherwise saving may legitimately be disabled + actor.send({ + type: 'routingRule.change', + routingRule: { + where: { field: 'service.name', eq: 'updated-service' }, + }, + }); + await Promise.resolve(); + expect(actor.getSnapshot().context.definition.privileges.manage).toBe(true); + expect(actor.getSnapshot().context.isConditionEditorValid).toBe(true); + expect( + isSchema( + routingDefinitionListSchema, + actor.getSnapshot().context.routing.map(routingConverter.toAPIDefinition) + ) + ).toBe(true); + + expect(actor.getSnapshot().value).toEqual({ + ready: { ingestMode: { editingRule: 'changing' } }, + }); + + expect(actor.getSnapshot().can({ type: 'routingRule.save' })).toBe(true); + + actor.send({ type: 'routingRule.setConditionEditorValidity', isValid: false }); + await Promise.resolve(); + expect(actor.getSnapshot().can({ type: 'routingRule.save' })).toBe(false); + + actor.send({ type: 'routingRule.setConditionEditorValidity', isValid: true }); + await Promise.resolve(); + expect(actor.getSnapshot().can({ type: 'routingRule.save' })).toBe(true); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/stream_routing_state_machine.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/stream_routing_state_machine.ts index 8a1da8bc627d5..9077b53e06649 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/stream_routing_state_machine.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/stream_routing_state_machine.ts @@ -85,6 +85,7 @@ export const streamRoutingMachine = setup({ resetRoutingChanges: assign(({ context }) => ({ currentRuleId: null, routing: context.initialRouting, + isConditionEditorValid: true, })), setupRouting: assign((_, params: { definition: Streams.WiredStream.GetResponse }) => { const routing = params.definition.stream.ingest.wired.routing.map( @@ -125,22 +126,47 @@ export const streamRoutingMachine = setup({ clearEditingSuggestion: assign(() => ({ editingSuggestionIndex: null, editedSuggestion: null, + isConditionEditorValid: true, })), setRefreshing: assign(() => ({ isRefreshing: true })), clearRefreshing: assign(() => ({ isRefreshing: false })), + setConditionEditorValidity: assign((_, params: { isValid: boolean }) => ({ + isConditionEditorValid: params.isValid, + })), notifyQueryStreamSuccess: getPlaceholderFor(createQueryStreamSuccessNotifier), }, guards: { - canForkStream: and(['hasManagePrivileges', 'isValidRouting', 'isValidChild']), + canForkStream: and([ + 'hasManagePrivileges', + 'isValidRouting', + 'isValidChild', + 'isConditionEditorValid', + ]), canReorderRules: and(['hasManagePrivileges', 'hasMultipleRoutingRules']), - canUpdateStream: and(['hasManagePrivileges', 'isValidRouting']), - canSaveSuggestion: and(['hasManagePrivileges', 'isValidEditedSuggestion']), + canUpdateStream: and([ + 'hasManagePrivileges', + 'isValidRouting', + 'hasRoutingChanges', + 'isConditionEditorValid', + ]), + canSaveSuggestion: and([ + 'hasManagePrivileges', + 'isValidEditedSuggestion', + 'isConditionEditorValid', + ]), hasMultipleRoutingRules: ({ context }) => context.routing.length > 1, hasManagePrivileges: ({ context }) => context.definition.privileges.manage, hasSimulatePrivileges: ({ context }) => context.definition.privileges.simulate, isAlreadyEditing: ({ context }, params: { id: string }) => context.currentRuleId === params.id, + isConditionEditorValid: ({ context }) => context.isConditionEditorValid, isValidRouting: ({ context }) => isSchema(routingDefinitionListSchema, context.routing.map(routingConverter.toAPIDefinition)), + hasRoutingChanges: ({ context }) => { + // Compare current routing with initial routing to detect changes + const currentRouting = context.routing.map(routingConverter.toAPIDefinition); + const initialRouting = context.initialRouting.map(routingConverter.toAPIDefinition); + return JSON.stringify(currentRouting) !== JSON.stringify(initialRouting); + }, isValidEditedSuggestion: ({ context }) => { if (!context.editedSuggestion) return false; const { name, condition } = context.editedSuggestion; @@ -169,6 +195,7 @@ export const streamRoutingMachine = setup({ editingSuggestionIndex: null, editedSuggestion: null, isRefreshing: false, + isConditionEditorValid: true, }), initial: 'initializing', states: { @@ -182,6 +209,9 @@ export const streamRoutingMachine = setup({ { type: 'setupRouting', params: ({ context }) => ({ definition: context.definition }) }, ], on: { + 'routingRule.setConditionEditorValidity': { + actions: [{ type: 'setConditionEditorValidity', params: ({ event }) => event }], + }, 'stream.received': { target: '#ready', actions: [ diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/types.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/types.ts index 491978e381854..02aafb9931bef 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/types.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/types.ts @@ -41,6 +41,7 @@ export interface StreamRoutingContext { editingSuggestionIndex: number | null; editedSuggestion: PartitionSuggestion | null; isRefreshing: boolean; + isConditionEditorValid: boolean; } export type StreamRoutingEvent = @@ -57,6 +58,7 @@ export type StreamRoutingEvent = | { type: 'routingRule.reorder'; routing: RoutingDefinitionWithUIAttributes[] } | { type: 'routingRule.remove' } | { type: 'routingRule.save' } + | { type: 'routingRule.setConditionEditorValidity'; isValid: boolean } | { type: 'routingSamples.setDocumentMatchFilter'; filter: DocumentMatchFilterOptions } | { type: 'routingSamples.setSelectedPreview'; preview: RoutingSamplesContext['selectedPreview'] } | { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/use_stream_routing.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/use_stream_routing.tsx index af7ce8be1cdcd..c6c6036a86f2e 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/use_stream_routing.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/state_management/stream_routing_state_machine/use_stream_routing.tsx @@ -105,6 +105,9 @@ export const useStreamRoutingEvents = () => { saveChanges: () => { service.send({ type: 'routingRule.save' }); }, + setConditionEditorValidity: (isValid: boolean) => { + service.send({ type: 'routingRule.setConditionEditorValidity', isValid }); + }, setDocumentMatchFilter: (filter: DocumentMatchFilterOptions) => { service.send({ type: 'routingSamples.setDocumentMatchFilter', filter }); }, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/utils.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/utils.ts index bb7975e33af74..414e7e8dc82fb 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/utils.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/utils.ts @@ -25,7 +25,7 @@ const toUIDefinition = ( const toAPIDefinition = ( routingDefinitionWithAttributes: RoutingDefinitionWithUIAttributes ): RoutingDefinition => { - return omit(routingDefinitionWithAttributes, 'id'); + return omit(routingDefinitionWithAttributes, 'id', 'isNew'); }; export const routingConverter = { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/summary.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/summary.tsx index 6173b664c39e4..898bdd4c6d1eb 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/summary.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/summary.tsx @@ -86,7 +86,12 @@ export function Summary({ count }: { count: number }) { } }, [task, notifications.toasts]); - useTaskPolling(task, getInsightsDiscoveryTaskStatus, getTaskStatus); + const { cancelTask, isCancellingTask } = useTaskPolling({ + task, + onPoll: getInsightsDiscoveryTaskStatus, + onRefresh: getTaskStatus, + onCancel: cancelInsightsDiscoveryTask, + }); const [insights, setInsights] = useState(null); @@ -101,15 +106,8 @@ export function Summary({ count }: { count: number }) { setInsights(null); }; - const onCancelClick = async () => { - await cancelInsightsDiscoveryTask(); - getTaskStatus(); - }; - const isGenerateButtonPending = - task?.status === TaskStatus.InProgress || - task?.status === TaskStatus.BeingCanceled || - isSchedulingTask; + task?.status === TaskStatus.InProgress || isCancellingTask || isSchedulingTask; if (insights && insights.length > 0) { return ( @@ -163,7 +161,7 @@ export function Summary({ count }: { count: number }) { style={{ minHeight: '30vh', minWidth: '40vh' }} > - + @@ -214,14 +212,13 @@ export function Summary({ count }: { count: number }) { }} /> - {(task?.status === TaskStatus.InProgress || - task?.status === TaskStatus.BeingCanceled) && ( + {(task?.status === TaskStatus.InProgress || isCancellingTask) && ( - {task?.status === TaskStatus.BeingCanceled + {isCancellingTask ? i18n.translate('xpack.streams.insights.cancellingTaskButtonLabel', { defaultMessage: 'Cancelling', }) diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/tab.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/tab.tsx index 6db4b6c419bc9..fda7c6d09388b 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/tab.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/insights/tab.tsx @@ -31,7 +31,7 @@ export function InsightsTab() { const queriesFetch = useStreamsAppFetch( async ({ signal }) => - streamsRepositoryClient.fetch('GET /internal/streams/_significant_events', { + streamsRepositoryClient.fetch('GET /internal/streams/_queries', { params: { query: { from: new Date(Date.now() - 15 * 60 * 1000).toISOString(), @@ -48,10 +48,7 @@ export function InsightsTab() { return ; } - const totalEvents = queriesFetch.value?.aggregated_occurrences.reduce( - (acc, current) => acc + current.count, - 0 - ); + const totalEvents = queriesFetch.value?.total ?? 0; if (totalEvents === 0 || totalEvents === undefined) { return ( @@ -65,7 +62,7 @@ export function InsightsTab() { style={{ minHeight: '30vh', minWidth: '40vh' }} > - + diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/promote_action.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/promote_action.tsx index fbedd15e0cb49..c56de228867e1 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/promote_action.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/promote_action.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; import { useQueryClient, useMutation } from '@kbn/react-query'; +import { DISCOVERY_QUERIES_QUERY_KEY } from '../../../../hooks/use_fetch_discovery_queries'; import { useFetchErrorToast } from '../../../../hooks/use_fetch_error_toast'; import { type SignificantEventItem } from '../../../../hooks/use_fetch_significant_events'; import { useKibana } from '../../../../hooks/use_kibana'; @@ -30,7 +31,7 @@ export function PromoteAction({ item }: { item: SignificantEventItem }) { if (promotedQueryCount === 1) { toasts.addSuccess(getPromoteQuerySuccessToast(item.query.title)); } - queryClient.invalidateQueries({ queryKey: ['significantEvents'] }); + queryClient.invalidateQueries({ queryKey: DISCOVERY_QUERIES_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: ['unbackedQueriesCount'] }); }, onError: showFetchErrorToast, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/queries_table.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/queries_table.tsx index 975c388af8d98..1ab001bca31b2 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/queries_table.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/queries_table/queries_table.tsx @@ -20,18 +20,23 @@ import { EuiTitle, EuiToolTip, useEuiTheme, + type CriteriaWithPagination, type EuiBasicTableColumn, } from '@elastic/eui'; import { css } from '@emotion/react'; import { useMutation, useQueryClient } from '@kbn/react-query'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { DISCOVER_APP_LOCATOR } from '@kbn/deeplinks-analytics'; import type { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; -import { orderBy } from 'lodash'; import { - useFetchSignificantEvents, - type SignificantEventItem, -} from '../../../../hooks/use_fetch_significant_events'; + DISCOVERY_QUERIES_QUERY_KEY, + useFetchDiscoveryQueries, + type SignificantEventQueryRow, +} from '../../../../hooks/use_fetch_discovery_queries'; +import { + DISCOVERY_QUERIES_OCCURRENCES_QUERY_KEY, + useFetchDiscoveryQueriesOccurrences, +} from '../../../../hooks/use_fetch_discovery_queries_occurrences'; import { useKibana } from '../../../../hooks/use_kibana'; import { useQueriesApi } from '../../../../hooks/use_queries_api'; import { @@ -84,6 +89,9 @@ import { PromoteAction } from './promote_action'; import { QueryDetailsFlyout } from './query_details_flyout'; import { formatLastOccurredAt } from './utils'; +const DEFAULT_PAGINATION = { index: 0, size: 10 }; +const PAGE_SIZE_OPTIONS = [10, 25, 50] as const; + export function QueriesTable() { const { euiTheme } = useEuiTheme(); const { @@ -96,14 +104,23 @@ export function QueriesTable() { } = useKibana(); const { timeState } = useTimefilter(); const [searchQuery, setSearchQuery] = useState(''); - const [selectedQuery, setSelectedQuery] = useState(null); + + const [pagination, setPagination] = useState<{ + index: number; + size: number; + }>({ ...DEFAULT_PAGINATION }); + + const [selectedQuery, setSelectedQuery] = useState(null); const { data: queriesData, isLoading: queriesLoading, isError: hasQueriesError, - } = useFetchSignificantEvents({ + } = useFetchDiscoveryQueries({ query: searchQuery, + page: pagination.index + 1, + perPage: pagination.size, }); + const { data: occurrencesData } = useFetchDiscoveryQueriesOccurrences({ query: searchQuery }); const { data: streamsData, isLoading: streamsLoading, @@ -116,7 +133,8 @@ export function QueriesTable() { const invalidateQueriesData = useCallback( async () => Promise.all([ - queryClient.invalidateQueries({ queryKey: ['significantEvents'] }), + queryClient.invalidateQueries({ queryKey: DISCOVERY_QUERIES_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: DISCOVERY_QUERIES_OCCURRENCES_QUERY_KEY }), queryClient.invalidateQueries({ queryKey: UNBACKED_QUERIES_COUNT_QUERY_KEY }), ]), [queryClient] @@ -138,7 +156,7 @@ export function QueriesTable() { const saveQueryMutation = useMutation< void, Error, - { updatedQuery: SignificantEventItem['query']; streamName: string } + { updatedQuery: SignificantEventQueryRow['query']; streamName: string } >({ mutationFn: async ({ updatedQuery, streamName }) => { await upsertQuery({ query: updatedQuery, streamName }); @@ -176,11 +194,149 @@ export function QueriesTable() { }, }); - if (queriesLoading || streamsLoading) { + const onTableChange = useCallback( + ({ page }: CriteriaWithPagination) => { + if (!page) { + return; + } + + setPagination(page); + }, + [] + ); + + const tableItems = queriesData?.queries ?? []; + + const columns: Array> = useMemo(() => { + const streamDefinitions = streamsData?.streams ?? []; + const discoverLocator = share.url.locators.get(DISCOVER_APP_LOCATOR); + + return [ + { + field: 'details', + name: '', + width: '40px', + render: (_: unknown, item: SignificantEventQueryRow) => ( + setSelectedQuery(item)} + /> + ), + }, + { + field: 'query.title', + name: TITLE_COLUMN, + render: (_: unknown, item: SignificantEventQueryRow) => ( + {}}>{item.query.title} + ), + }, + { + field: 'query.severity_score', + name: IMPACT_COLUMN, + render: (_: unknown, item: SignificantEventQueryRow) => { + return ; + }, + }, + { + field: 'occurrences', + name: LAST_OCCURRED_COLUMN, + render: (_: unknown, item: SignificantEventQueryRow) => { + return {formatLastOccurredAt(item.occurrences)}; + }, + }, + { + field: 'occurrences', + name: OCCURRENCES_COLUMN, + width: '160px', + align: 'center', + render: (_: unknown, item: SignificantEventQueryRow) => { + return ( + + ); + }, + }, + { + field: 'stream_name', + name: STREAM_COLUMN, + render: (_: unknown, item: SignificantEventQueryRow) => ( + {item.stream_name} + ), + }, + { + field: 'rule_backed', + name: BACKED_STATUS_COLUMN, + render: (_: unknown, item: SignificantEventQueryRow) => { + return ( + + + {item.rule_backed && {PROMOTED_BADGE_LABEL}} + {!item.rule_backed && ( + {NOT_PROMOTED_BADGE_LABEL} + )} + + + ); + }, + }, + { + name: ACTIONS_COLUMN_TITLE, + actions: [ + { + name: OPEN_IN_DISCOVER_ACTION_TITLE, + type: 'icon', + icon: 'discoverApp', + description: OPEN_IN_DISCOVER_ACTION_DESCRIPTION, + enabled: () => discoverLocator !== undefined, + onClick: (item) => { + const definition = streamDefinitions.find( + (streamItem) => streamItem.stream.name === item.stream_name + ); + + if (!definition) { + return; + } + + discoverLocator?.navigate( + buildDiscoverParams(item.query, definition.stream, timeState) + ); + }, + isPrimary: true, + 'data-test-subj': 'significant_events_table_open_in_discover_action', + }, + { + type: 'button', + color: 'primary', + name: PROMOTE_QUERY_ACTION_TITLE, + description: PROMOTE_QUERY_ACTION_DESCRIPTION, + render: (item: SignificantEventQueryRow) => { + return ; + }, + }, + ], + }, + ]; + }, [share.url.locators, streamsData, timeState]); + + const isLoading = queriesLoading || streamsLoading; + if (isLoading) { return ; } - if (hasQueriesError || hasStreamsError) { + const hasError = hasQueriesError || hasStreamsError; + if (hasError) { return ( event.query.severity_score, (event) => event.query.title], - ['desc', 'desc', 'asc'] - ); - const streamDefinitions = streamsData?.streams ?? []; - const discoverLocator = share.url.locators.get(DISCOVER_APP_LOCATOR); - - const columns: Array> = [ - { - field: 'details', - name: '', - width: '40px', - render: (_: unknown, item: SignificantEventItem) => ( - setSelectedQuery(item)} - /> - ), - }, - { - field: 'query.title', - name: TITLE_COLUMN, - render: (_: unknown, item: SignificantEventItem) => ( - setSelectedQuery(item)}>{item.query.title} - ), - }, - { - field: 'query.severity_score', - name: IMPACT_COLUMN, - render: (_: unknown, item: SignificantEventItem) => { - return ; - }, - }, - { - field: 'occurrences', - name: LAST_OCCURRED_COLUMN, - render: (_: unknown, item: SignificantEventItem) => ( - {formatLastOccurredAt(item.occurrences)} - ), - }, - { - field: 'occurrences', - name: OCCURRENCES_COLUMN, - width: '160px', - align: 'center', - render: (_: unknown, item: SignificantEventItem) => { - return ( - - ); - }, - }, - { - field: 'stream_name', - name: STREAM_COLUMN, - render: (_: unknown, item: SignificantEventItem) => ( - {item.stream_name} - ), - }, - { - field: 'rule_backed', - name: BACKED_STATUS_COLUMN, - render: (_: unknown, item: SignificantEventItem) => { - return ( - - - {item.rule_backed && {PROMOTED_BADGE_LABEL}} - {!item.rule_backed && {NOT_PROMOTED_BADGE_LABEL}} - - - ); - }, - }, - { - name: ACTIONS_COLUMN_TITLE, - actions: [ - { - name: OPEN_IN_DISCOVER_ACTION_TITLE, - type: 'icon', - icon: 'discoverApp', - description: OPEN_IN_DISCOVER_ACTION_DESCRIPTION, - enabled: () => discoverLocator !== undefined, - onClick: (item) => { - const definition = streamDefinitions.find( - (streamItem) => streamItem.stream.name === item.stream_name - ); - - if (!definition) { - return; - } - - discoverLocator?.navigate( - buildDiscoverParams(item.query, definition.stream, timeState) - ); - }, - isPrimary: true, - 'data-test-subj': 'significant_events_table_open_in_discover_action', - }, - { - type: 'button', - color: 'primary', - name: PROMOTE_QUERY_ACTION_TITLE, - description: PROMOTE_QUERY_ACTION_DESCRIPTION, - render: (item: SignificantEventItem) => { - return ; - }, - }, - ], - }, - ]; - return ( {unbackedCount > 0 && ( @@ -351,6 +383,7 @@ export function QueriesTable() { disableQueryLanguageSwitcher onQuerySubmit={(queryPayload) => { setSearchQuery(String(queryPayload.query?.query ?? '')); + setPagination((currentPagination) => ({ index: 0, size: currentPagination.size })); }} query={{ query: searchQuery, @@ -378,7 +411,7 @@ export function QueriesTable() { id="aggregated-occurrences" name={CHART_SERIES_NAME} type="bar" - timeseries={queriesData?.aggregated_occurrences ?? []} + timeseries={occurrencesData?.occurrences_histogram ?? []} annotations={[]} height={180} /> @@ -387,7 +420,7 @@ export function QueriesTable() { - {getEventsCount(queriesData?.significant_events.length ?? 0)} + {getEventsCount(queriesData?.total ?? 0)} item.query.id} - items={sortedQueries} + items={tableItems} loading={queriesLoading || streamsLoading} noItemsMessage={!queriesLoading && !streamsLoading ? NO_ITEMS_MESSAGE : ''} + pagination={{ + pageIndex: pagination.index, + pageSize: pagination.size, + totalItemCount: queriesData?.total ?? 0, + pageSizeOptions: [...PAGE_SIZE_OPTIONS], + }} + onChange={onTableChange} /> {selectedQuery && ( diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/streams_view.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/streams_view.tsx index a8ba48d17659e..35c29e2408ea5 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/streams_view.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/streams_view.tsx @@ -6,21 +6,38 @@ */ import type { EuiSearchBarProps, Query } from '@elastic/eui'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSearchBar, EuiText } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSearchBar, + EuiText, +} from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; +import { toMountPoint } from '@kbn/react-kibana-mount'; import type { OnboardingResult, TaskResult } from '@kbn/streams-schema'; import { TaskStatus } from '@kbn/streams-schema'; import pMap from 'p-map'; import React, { useCallback, useEffect, useRef, useState } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; import type { TableRow } from './utils'; import { useAIFeatures } from '../../../../hooks/use_ai_features'; import { useKibana } from '../../../../hooks/use_kibana'; +import { useInsightsDiscoveryApi } from '../../../../hooks/use_insights_discovery_api'; import { useOnboardingApi } from '../../../../hooks/use_onboarding_api'; +import { useStreamsAppRouter } from '../../../../hooks/use_streams_app_router'; +import { useTaskPolling } from '../../../../hooks/use_task_polling'; import { getFormattedError } from '../../../../util/errors'; import { StreamsAppSearchBar } from '../../../streams_app_search_bar'; import { useOnboardingStatusUpdateQueue } from '../../hooks/use_onboarding_status_update_queue'; import { + DISCOVER_INSIGHTS_BUTTON_LABEL, + getInsightsCompleteToastTitle, + INSIGHTS_COMPLETE_TOAST_VIEW_BUTTON, + INSIGHTS_SCHEDULING_FAILURE_TITLE, + NO_INSIGHTS_TOAST_TITLE, ONBOARDING_FAILURE_TITLE, ONBOARDING_SCHEDULING_FAILURE_TITLE, RUN_BULK_STREAM_ONBOARDING_BUTTON_LABEL, @@ -43,12 +60,14 @@ interface StreamsViewProps { export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { const { + core, core: { notifications: { toasts }, }, } = useKibana(); const isInitialStatusUpdateDone = useRef(false); const [searchQuery, setSearchQuery] = useState(); + const [isWaitingForInsightsTask, setIsWaitingForInsightsTask] = useState(false); const streamsListFetch = useFetchStreams({ select: (result) => { return { @@ -65,10 +84,85 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { const [streamOnboardingResultMap, setStreamOnboardingResultMap] = useState< Record> >({}); + const router = useStreamsAppRouter(); const aiFeatures = useAIFeatures(); const { scheduleOnboardingTask, cancelOnboardingTask } = useOnboardingApi( aiFeatures?.genAiConnectors.selectedConnector ); + const { scheduleInsightsDiscoveryTask, getInsightsDiscoveryTaskStatus } = useInsightsDiscoveryApi( + aiFeatures?.genAiConnectors.selectedConnector + ); + const [{ value: insightsTask }, getInsightsTaskStatus] = useAsyncFn( + getInsightsDiscoveryTaskStatus + ); + useTaskPolling({ + task: insightsTask, + onPoll: getInsightsDiscoveryTaskStatus, + onRefresh: getInsightsTaskStatus, + }); + + const [{ loading: isSchedulingInsights }, scheduleInsightsTask] = useAsyncFn(async () => { + const streamNames = + selectedStreams.length > 0 ? selectedStreams.map((row) => row.stream.name) : undefined; + try { + await scheduleInsightsDiscoveryTask(streamNames); + setIsWaitingForInsightsTask(true); + await getInsightsTaskStatus(); + } catch (error) { + toasts.addError(getFormattedError(error), { + title: INSIGHTS_SCHEDULING_FAILURE_TITLE, + }); + throw error; + } + }, [scheduleInsightsDiscoveryTask, selectedStreams, toasts, getInsightsTaskStatus]); + + // When we started the insights task from this view and it completes, show toast + useEffect(() => { + if (!isWaitingForInsightsTask || !insightsTask) return; + if (insightsTask.status !== TaskStatus.Completed && insightsTask.status !== TaskStatus.Failed) { + return; + } + setIsWaitingForInsightsTask(false); + if (insightsTask.status === TaskStatus.Failed) { + toasts.addError(getFormattedError(new Error(insightsTask.error)), { + title: INSIGHTS_SCHEDULING_FAILURE_TITLE, + }); + return; + } + if (insightsTask.status === TaskStatus.Completed) { + const count = insightsTask.insights?.length ?? 0; + if (count === 0) { + toasts.addInfo({ + title: NO_INSIGHTS_TOAST_TITLE, + }); + } else { + const toast = toasts.addSuccess({ + title: getInsightsCompleteToastTitle(count), + text: toMountPoint( + + + { + toasts.remove(toast); + router.push('/_discovery/{tab}', { + path: { tab: 'insights' }, + query: {}, + }); + }} + > + {INSIGHTS_COMPLETE_TOAST_VIEW_BUTTON} + + + , + core + ), + }); + } + } + }, [isWaitingForInsightsTask, insightsTask, toasts, router, core]); + const onStreamStatusUpdate = useCallback( (streamName: string, taskResult: TaskResult) => { setStreamOnboardingResultMap((currentMap) => ({ @@ -201,6 +295,16 @@ export function StreamsView({ refreshUnbackedQueriesCount }: StreamsViewProps) { > {RUN_BULK_STREAM_ONBOARDING_BUTTON_LABEL} + + scheduleInsightsTask()} + disabled={!aiFeatures?.genAiConnectors?.connectors?.length} + isLoading={isSchedulingInsights || isWaitingForInsightsTask} + data-test-subj="significant_events_discover_insights_button" + > + {DISCOVER_INSIGHTS_BUTTON_LABEL} +
diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/translations.ts b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/translations.ts index a538b20edcee1..d2bb120d6e2f8 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/translations.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/components/streams_view/translations.ts @@ -106,3 +106,41 @@ export const ONBOARDING_SCHEDULING_FAILURE_TITLE = i18n.translate( defaultMessage: 'Could not schedule a task to onboard stream', } ); + +export const DISCOVER_INSIGHTS_BUTTON_LABEL = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.discoverInsightsButtonLabel', + { + defaultMessage: 'Discover Insights', + } +); + +export const INSIGHTS_SCHEDULING_FAILURE_TITLE = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.insightsSchedulingErrorTitle', + { + defaultMessage: 'Could not start insight generation', + } +); + +export function getInsightsCompleteToastTitle(count: number): string { + return i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.insightsCompleteToastTitle', + { + defaultMessage: '{count} {count, plural, one {insight} other {insights}} found', + values: { count }, + } + ); +} + +export const INSIGHTS_COMPLETE_TOAST_VIEW_BUTTON = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.insightsCompleteToastViewButton', + { + defaultMessage: 'View insights', + } +); + +export const NO_INSIGHTS_TOAST_TITLE = i18n.translate( + 'xpack.streams.significantEventsDiscovery.streamsView.noInsightsToastTitle', + { + defaultMessage: 'No insights found', + } +); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/change_point.ts b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/change_point.ts index f8fdfcd88af80..65e32135f052a 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/change_point.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/change_point.ts @@ -7,7 +7,7 @@ import type { EuiThemeComputed } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { StreamQueryKql } from '@kbn/streams-schema'; +import type { StreamQuery } from '@kbn/streams-schema'; import type { $Values } from 'utility-types'; import type { SignificantEventItem } from '../../../hooks/use_fetch_significant_events'; import { pValueToLabel } from './p_value_to_label'; @@ -19,7 +19,7 @@ type EuiThemeColor = $Values<{ }>; export interface FormattedChangePoint { - query: StreamQueryKql; + query: StreamQuery; time: number; impact: 'high' | 'medium' | 'low'; p_value: number; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/discover_helpers.ts b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/discover_helpers.ts index 318d6f4a3cba8..b8ad445b9a4e1 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/discover_helpers.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/significant_events_discovery/utils/discover_helpers.ts @@ -7,7 +7,7 @@ import type { TimeState } from '@kbn/es-query'; import type { StreamQuery, Streams } from '@kbn/streams-schema'; -import { buildEsqlQuery, getIndexPatternsForStream } from '@kbn/streams-schema'; +import { getIndexPatternsForStream } from '@kbn/streams-schema'; import { v4 } from 'uuid'; export function buildDiscoverParams( @@ -15,15 +15,13 @@ export function buildDiscoverParams( definition: Streams.all.Definition, timeState: TimeState ) { - const esqlQuery = buildEsqlQuery(getIndexPatternsForStream(definition), query); - return { timeRange: { from: timeState.timeRange.from, to: timeState.timeRange.to, }, query: { - esql: esqlQuery, + esql: query.esql.query, }, dataViewSpec: { id: v4(), diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/add_significant_event_flyout.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/add_significant_event_flyout.tsx index d93f54be749ed..a6856cd88357b 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/add_significant_event_flyout.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/add_significant_event_flyout.tsx @@ -21,12 +21,14 @@ import { } from '@elastic/eui'; import { omit } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { type StreamQueryKql, type Streams, type System } from '@kbn/streams-schema'; +import type { SignificantEventsQueriesGenerationTaskResult } from '@kbn/streams-schema'; +import { TaskStatus, type StreamQuery, type Streams, type System } from '@kbn/streams-schema'; import { streamQuerySchema } from '@kbn/streams-schema'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { css } from '@emotion/css'; import { v4 } from 'uuid'; import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { useBoolean } from '@kbn/react-hooks'; import { useKibana } from '../../../hooks/use_kibana'; import { useSignificantEventsApi } from '../../../hooks/use_significant_events_api'; import type { AIFeatures } from '../../../hooks/use_ai_features'; @@ -40,12 +42,15 @@ import { useStreamsAppFetch } from '../../../hooks/use_streams_app_fetch'; import { useTaskPolling } from '../../../hooks/use_task_polling'; import { SignificantEventsGenerationPanel } from '../generation_panel'; +const defaultTask: SignificantEventsQueriesGenerationTaskResult = { + status: TaskStatus.NotStarted, +}; interface Props { onClose: () => void; definition: Streams.all.GetResponse; onSave: (data: SaveData) => Promise; systems: System[]; - query?: StreamQueryKql; + query?: StreamQuery; initialFlow?: Flow; initialSelectedSystems: System[]; refreshSystems: () => void; @@ -86,46 +91,66 @@ export function AddSignificantEventFlyout({ isEditMode ? 'manual' : initialFlow ); const flowRef = useRef(selectedFlow); - const [queries, setQueries] = useState([{ ...defaultQuery(), ...query }]); + const [queries, setQueries] = useState([{ ...defaultQuery(), ...query }]); const [canSave, setCanSave] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedSystems, setSelectedSystems] = useState(initialSelectedSystems); - const [generatedQueries, setGeneratedQueries] = useState([]); - const [{ loading: isGettingTask, value: task }, getTask] = useAsyncFn(getGenerationTask); + const [generatedQueries, setGeneratedQueries] = useState([]); + + const [task, setTask] = useState(defaultTask); + const [isGettingTaskStatus, { on: gettingTaskStatus, off: stoppedGettingTaskStatus }] = + useBoolean(false); + const [{ loading: isSchedulingGenerationTask }, doScheduleGenerationTask] = useAsyncFn(scheduleGenerationTask); + const scheduleTask = (connectorId: string, effectiveSystems: System[]) => { + setTask(defaultTask); + doScheduleGenerationTask(connectorId, effectiveSystems).then(setTask); + }; + + const getTaskStatus = useCallback(() => { + gettingTaskStatus(); + getGenerationTask().then(setTask).finally(stoppedGettingTaskStatus); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stoppedGettingTaskStatus, gettingTaskStatus]); + useEffect(() => { - getTask(); - }, [getTask]); + // Skip initial status fetch when we are about to schedule a new generation on mount, + // to avoid a race where the previous completed task resolves after the reset and + // surfaces stale queries alongside the new loading indicator. + if (generateOnMount && initialFlow === 'ai') { + return; + } + + getTaskStatus(); + }, [generateOnMount, getTaskStatus, initialFlow]); - useTaskPolling(task, getGenerationTask, getTask); + const { cancelTask, isCancellingTask } = useTaskPolling({ + task, + onPoll: getGenerationTask, + onRefresh: getTaskStatus, + onCancel: cancelGenerationTask, + }); - const isBeingCanceled = task?.status === 'being_canceled'; const isGenerating = task?.status === 'in_progress' || - isBeingCanceled || - isGettingTask || + task?.status === 'being_canceled' || + isGettingTaskStatus || isSchedulingGenerationTask; - const prevTaskStatusRef = useRef(undefined); + const prevTaskStatusRef = useRef(undefined); useEffect(() => { const prevStatus = prevTaskStatusRef.current; - prevTaskStatusRef.current = task?.status; // Process completed when: - // - First time getting the task (prevStatus is undefined) - // - Transitioning from in_progress to completed - const isFirstLoad = prevStatus === undefined; - const isTransitionFromInProgress = prevStatus === 'in_progress'; - if ( - task?.status === 'completed' && - (isFirstLoad || isTransitionFromInProgress) && - !isGenerating - ) { + // - Transitioning from any non-completed state to completed + const isNewlyCompleted = + task?.status === TaskStatus.Completed && prevStatus !== TaskStatus.Completed; + if (isNewlyCompleted) { setGeneratedQueries( task.queries .filter((nextQuery) => { @@ -138,6 +163,7 @@ export function AddSignificantEventFlyout({ .map((nextQuery) => ({ id: v4(), kql: { query: nextQuery.kql }, + esql: nextQuery.esql, title: nextQuery.title, feature: nextQuery.feature, severity_score: nextQuery.severity_score, @@ -145,15 +171,9 @@ export function AddSignificantEventFlyout({ })) ); } - }, [isGenerating, task]); - const stopGeneration = useCallback(() => { - if (task?.status === 'in_progress') { - cancelGenerationTask().then(() => { - getTask(); - }); - } - }, [cancelGenerationTask, getTask, task?.status]); + prevTaskStatusRef.current = task?.status; + }, [task]); const parsedQueries = useMemo(() => { return streamQuerySchema.array().safeParse(queries); @@ -168,30 +188,19 @@ export function AddSignificantEventFlyout({ } }, [selectedFlow]); - const generateQueries = useCallback( - (systemsOverride?: System[]) => { - const connectorId = aiFeatures?.genAiConnectors.selectedConnector; - if (!connectorId) { - return; - } + const generateQueries = (systemsOverride?: System[]) => { + const connectorId = aiFeatures?.genAiConnectors.selectedConnector; + if (!connectorId) { + return; + } - setSelectedFlow('ai'); - setGeneratedQueries([]); + setSelectedFlow('ai'); + setGeneratedQueries([]); - const effectiveSystems = systemsOverride ?? selectedSystems; + const effectiveSystems = systemsOverride ?? selectedSystems; - (async () => { - await doScheduleGenerationTask(connectorId, effectiveSystems); - getTask(); - })(); - }, - [ - aiFeatures?.genAiConnectors.selectedConnector, - selectedSystems, - doScheduleGenerationTask, - getTask, - ] - ); + scheduleTask(connectorId, effectiveSystems); + }; useEffect(() => { if (initialFlow === 'ai' && generateOnMount) { @@ -289,7 +298,7 @@ export function AddSignificantEventFlyout({ setQueries([next])} + setQuery={(next: StreamQuery) => setQueries([next])} query={queries[0]} setCanSave={(next: boolean) => { setCanSave(next); @@ -303,7 +312,8 @@ export function AddSignificantEventFlyout({ {flowRef.current === 'ai' && ( (q.id === editedQuery.id ? editedQuery : q)) ); }} - stopGeneration={stopGeneration} + stopGeneration={cancelTask} definition={definition.stream} - setQueries={(next: StreamQueryKql[]) => { + setQueries={(next: StreamQuery[]) => { setQueries(next); }} setCanSave={(next: boolean) => { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/preview_data_spark_plot.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/preview_data_spark_plot.tsx index 605e167785f28..36d13298e2fff 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/preview_data_spark_plot.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/preview_data_spark_plot.tsx @@ -16,12 +16,8 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { - buildEsqlQuery, - getIndexPatternsForStream, - type StreamQueryKql, - type Streams, -} from '@kbn/streams-schema'; +import type { StreamQuery, Streams } from '@kbn/streams-schema'; +import { buildEsqlQuery, getIndexPatternsForStream } from '@kbn/streams-schema'; import React, { useMemo } from 'react'; import { useEuiTheme } from '@elastic/eui'; import { DISCOVER_APP_LOCATOR, type DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; @@ -45,7 +41,7 @@ export function PreviewDataSparkPlot({ timeRange, }: { definition: Streams.all.Definition; - query: StreamQueryKql; + query: StreamQuery; isQueryValid: boolean; showTitle?: boolean; compressed?: boolean; @@ -89,16 +85,24 @@ export function PreviewDataSparkPlot({ } = useKibana(); const useUrl = share.url.locators.useUrl; + // Compute the ES|QL query client-side for the Discover link because + // query.esql.query is populated server-side on save and does not exist + // yet during creation or live editing. + const discoverEsqlQuery = useMemo( + () => (isQueryValid ? buildEsqlQuery(getIndexPatternsForStream(definition), query) : ''), + [definition, query, isQueryValid] + ); + const discoverLink = useUrl( () => ({ id: DISCOVER_APP_LOCATOR, params: { query: { - esql: isQueryValid ? buildEsqlQuery(getIndexPatternsForStream(definition), query) : '', + esql: discoverEsqlQuery, }, }, }), - [definition, query, isQueryValid] + [discoverEsqlQuery] ); function renderContent() { @@ -124,12 +128,12 @@ export function PreviewDataSparkPlot({ if (previewFetch.error) { if (compressed) { - return ; + return ; } return ( <> - + {i18n.translate( 'xpack.streams.addSignificantEventFlyout.manualFlow.previewChartError', @@ -144,7 +148,9 @@ export function PreviewDataSparkPlot({ if (noOccurrencesFound) { if (compressed) { - return ; + return ( + + ); } return ( diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/validate_query.ts b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/validate_query.ts index fd904105d06d2..1db660b66a9ea 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/validate_query.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/common/validate_query.ts @@ -5,11 +5,11 @@ * 2.0. */ -import type { StreamQueryKql } from '@kbn/streams-schema'; +import type { StreamQuery } from '@kbn/streams-schema'; import { i18n } from '@kbn/i18n'; import { fromKueryExpression } from '@kbn/es-query'; -export function validateQuery(query: Partial): { +export function validateQuery(query: Partial): { title: { isInvalid: boolean; error?: string }; kql: { isInvalid: boolean; error?: string }; } { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/edit_significant_event_flyout.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/edit_significant_event_flyout.tsx index ebb75a480bc59..556b24ca2ff3f 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/edit_significant_event_flyout.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/edit_significant_event_flyout.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; -import type { StreamQueryKql, Streams, System } from '@kbn/streams-schema'; +import type { StreamQuery, Streams, System } from '@kbn/streams-schema'; import { useSignificantEventsApi } from '../../../hooks/use_significant_events_api'; import { useKibana } from '../../../hooks/use_kibana'; import type { AIFeatures } from '../../../hooks/use_ai_features'; @@ -31,12 +31,12 @@ export const EditSignificantEventFlyout = ({ aiFeatures, }: { refresh: () => void; - setQueryToEdit: React.Dispatch>; + setQueryToEdit: React.Dispatch>; initialFlow?: Flow; selectedSystems: System[]; setSelectedSystems: React.Dispatch>; systems: System[]; - queryToEdit?: StreamQueryKql; + queryToEdit?: StreamQuery; definition: Streams.all.GetResponse; isEditFlyoutOpen: boolean; setIsEditFlyoutOpen: React.Dispatch>; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_event_preview.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_event_preview.tsx index e4a3077755571..acb85d4fcd1df 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_event_preview.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_event_preview.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { StreamQueryKql, Streams, System } from '@kbn/streams-schema'; +import type { StreamQuery, Streams, System } from '@kbn/streams-schema'; import React, { useState } from 'react'; import { EuiButton, @@ -31,8 +31,8 @@ import { ALL_DATA_OPTION } from '../../system_selector'; interface GeneratedEventPreviewProps { definition: Streams.all.Definition; - query: StreamQueryKql; - onSave: (query: StreamQueryKql) => void; + query: StreamQuery; + onSave: (query: StreamQuery) => void; systems: Omit[]; dataViews: DataView[]; isEditing: boolean; @@ -50,7 +50,7 @@ export function GeneratedEventPreview({ }: GeneratedEventPreviewProps) { const { euiTheme } = useEuiTheme(); - const [query, setQuery] = useState(initialQuery); + const [query, setQuery] = useState(initialQuery); const options = [ { value: ALL_DATA_OPTION.value, inputDisplay: ALL_DATA_OPTION.label }, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_flow_form.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_flow_form.tsx index dcdb190a0a16d..115055a7a3f4b 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_flow_form.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/generated_flow_form.tsx @@ -6,7 +6,7 @@ */ import { EuiCallOut } from '@elastic/eui'; -import type { StreamQueryKql, System } from '@kbn/streams-schema'; +import type { StreamQuery, System } from '@kbn/streams-schema'; import type { Streams } from '@kbn/streams-schema'; import React, { useEffect, useState } from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -18,12 +18,13 @@ import { AiFlowWaitingForGeneration } from './waiting_for_generation'; interface Props { isGenerating: boolean; isBeingCanceled: boolean; - generatedQueries: StreamQueryKql[]; - onEditQuery: (query: StreamQueryKql) => void; + isSchedulingGenerationTask: boolean; + generatedQueries: StreamQuery[]; + onEditQuery: (query: StreamQuery) => void; stopGeneration: () => void; definition: Streams.all.Definition; isSubmitting: boolean; - setQueries: (queries: StreamQueryKql[]) => void; + setQueries: (queries: StreamQuery[]) => void; setCanSave: (canSave: boolean) => void; systems: Omit[]; dataViews: DataView[]; @@ -34,6 +35,7 @@ interface Props { export function GeneratedFlowForm({ isGenerating, isBeingCanceled, + isSchedulingGenerationTask, generatedQueries, onEditQuery, stopGeneration, @@ -46,10 +48,10 @@ export function GeneratedFlowForm({ taskStatus, taskError, }: Props) { - const [selectedQueries, setSelectedQueries] = useState([]); + const [selectedQueries, setSelectedQueries] = useState([]); const [isEditingQueries, setIsEditingQueries] = useState(false); - const onSelectionChange = (selectedItems: StreamQueryKql[]) => { + const onSelectionChange = (selectedItems: StreamQuery[]) => { setSelectedQueries(selectedItems); setQueries(selectedItems); }; @@ -87,29 +89,15 @@ export function GeneratedFlowForm({ ); } - if (!isGenerating && generatedQueries.length === 0) { - return ; - } - - if (isGenerating && generatedQueries.length === 0) { - return ( - - ); - } - - if (!isGenerating && generatedQueries.length === 0) { - return ; - } - - if (isGenerating && generatedQueries.length === 0) { - return ( + if (generatedQueries.length === 0) { + return isGenerating ? ( + ) : ( + ); } @@ -131,6 +119,7 @@ export function GeneratedFlowForm({ stopGeneration={stopGeneration} hasInitialResults={true} isBeingCanceled={isBeingCanceled} + isSchedulingGenerationTask={isSchedulingGenerationTask} /> )} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/significant_events_generated_table.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/significant_events_generated_table.tsx index 61a59cf4dbf95..f2e331d7e0c35 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/significant_events_generated_table.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/significant_events_generated_table.tsx @@ -16,7 +16,7 @@ import { EuiCodeBlock, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { StreamQueryKql, Streams, System } from '@kbn/streams-schema'; +import type { StreamQuery, Streams, System } from '@kbn/streams-schema'; import React, { useCallback, useEffect, useState, type ReactNode } from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; import { PreviewDataSparkPlot } from '../common/preview_data_spark_plot'; @@ -26,12 +26,12 @@ import { SeverityBadge } from '../../../significant_events_discovery/components/ interface Props { definition: Streams.all.Definition; - generatedQueries: StreamQueryKql[]; + generatedQueries: StreamQuery[]; setIsEditingQueries: (isEditingQueries: boolean) => void; - onEditQuery: (query: StreamQueryKql) => void; - selectedQueries: StreamQueryKql[]; + onEditQuery: (query: StreamQuery) => void; + selectedQueries: StreamQuery[]; isSubmitting: boolean; - onSelectionChange: (selectedItems: StreamQueryKql[]) => void; + onSelectionChange: (selectedItems: StreamQuery[]) => void; systems: Omit[]; dataViews: DataView[]; } @@ -53,7 +53,7 @@ export function SignificantEventsGeneratedTable({ const [eventsInEditMode, setEventsInEditMode] = useState([]); const setIsEditing = useCallback( - (isEditing: boolean, query: StreamQueryKql) => { + (isEditing: boolean, query: StreamQuery) => { const nextEventsInEditMode = isEditing ? [...eventsInEditMode, query.id] : eventsInEditMode.filter((id) => id !== query.id); @@ -63,7 +63,7 @@ export function SignificantEventsGeneratedTable({ [eventsInEditMode, setIsEditingQueries] ); - const toggleDetails = (query: StreamQueryKql) => { + const toggleDetails = (query: StreamQuery) => { const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; if (itemIdToExpandedRowMapValues[query.id]) { @@ -105,7 +105,7 @@ export function SignificantEventsGeneratedTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventsInEditMode, definition, onEditQuery, systems, dataViews, generatedQueries]); - const columns: Array> = [ + const columns: Array> = [ { align: 'right', width: '40px', @@ -120,7 +120,7 @@ export function SignificantEventsGeneratedTable({ ), mobileOptions: { header: false }, - render: (query: StreamQueryKql) => { + render: (query: StreamQuery) => { const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; return ( @@ -146,7 +146,7 @@ export function SignificantEventsGeneratedTable({ name: i18n.translate('xpack.streams.addSignificantEventFlyout.aiFlow.systemColumn', { defaultMessage: 'System', }), - render: (_, item: StreamQueryKql) => { + render: (_, item: StreamQuery) => { return {item.feature?.name ?? '--'}; }, }, @@ -156,7 +156,7 @@ export function SignificantEventsGeneratedTable({ name: i18n.translate('xpack.streams.addSignificantEventFlyout.aiFlow.queryColumn', { defaultMessage: 'Query', }), - render: (_, item: StreamQueryKql) => { + render: (_, item: StreamQuery) => { return {JSON.stringify(item.kql?.query)}; }, }, @@ -181,7 +181,7 @@ export function SignificantEventsGeneratedTable({ ), width: '20%', - render: (query: StreamQueryKql) => { + render: (query: StreamQuery) => { const validation = validateQuery(query); return ( = { + const selection: EuiTableSelectionType = { onSelectionChange, selected: selectedQueries, selectable: () => !isSubmitting, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/waiting_for_generation.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/waiting_for_generation.tsx index 1d1c12f4ffef8..ecedefa8ecb30 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/waiting_for_generation.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/generated_flow_form/waiting_for_generation.tsx @@ -14,10 +14,12 @@ export function AiFlowWaitingForGeneration({ stopGeneration, hasInitialResults = false, isBeingCanceled = false, + isSchedulingGenerationTask = false, }: { stopGeneration: () => void; hasInitialResults?: boolean; isBeingCanceled?: boolean; + isSchedulingGenerationTask?: boolean; }) { const label = useWaitingForAiMessage(hasInitialResults); @@ -39,7 +41,7 @@ export function AiFlowWaitingForGeneration({ }) : label} - {!isBeingCanceled && ( + {!isBeingCanceled && !isSchedulingGenerationTask && ( void; + setQuery: (query: StreamQuery) => void; setCanSave: (canSave: boolean) => void; systems: System[]; dataViews: DataView[]; @@ -67,7 +67,7 @@ export function ManualFlowForm({ // Create a query object with debounced KQL for the preview chart const debouncedQuery = useMemo( - (): StreamQueryKql => ({ + (): StreamQuery => ({ ...query, kql: { query: debouncedKqlQuery }, }), diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/manual_flow_form/use_spark_plot_data_from_sig_events.ts b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/manual_flow_form/use_spark_plot_data_from_sig_events.ts index 2c48e06a24396..46e27aa4944d0 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/manual_flow_form/use_spark_plot_data_from_sig_events.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/manual_flow_form/use_spark_plot_data_from_sig_events.ts @@ -6,7 +6,7 @@ */ import type { AbortableAsyncState } from '@kbn/react-hooks'; -import type { SignificantEventsPreviewResponse, StreamQueryKql } from '@kbn/streams-schema'; +import type { SignificantEventsPreviewResponse, StreamQuery } from '@kbn/streams-schema'; import { useEuiTheme } from '@elastic/eui'; import type { TickFormatter } from '@elastic/charts'; import { useMemo } from 'react'; @@ -19,7 +19,7 @@ export function useSparkplotDataFromSigEvents({ xFormatter, }: { previewFetch: AbortableAsyncState>; - query: StreamQueryKql; + query: StreamQuery; xFormatter: TickFormatter; }) { const theme = useEuiTheme().euiTheme; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/types.ts b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/types.ts index 78a3ded65fd3b..3af89f85a1954 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/types.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/types.ts @@ -5,10 +5,10 @@ * 2.0. */ -import type { StreamQueryKql } from '@kbn/streams-schema'; +import type { StreamQuery } from '@kbn/streams-schema'; export type Flow = 'manual' | 'ai'; export type SaveData = - | { type: 'single'; query: StreamQueryKql; isUpdating?: boolean } - | { type: 'multiple'; queries: StreamQueryKql[] }; + | { type: 'single'; query: StreamQuery; isUpdating?: boolean } + | { type: 'multiple'; queries: StreamQuery[] }; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/utils/default_query.ts b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/utils/default_query.ts index cb727da16f4e5..5787e454651d1 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/utils/default_query.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/add_significant_event_flyout/utils/default_query.ts @@ -5,16 +5,19 @@ * 2.0. */ -import type { StreamQueryKql } from '@kbn/streams-schema'; +import type { StreamQuery } from '@kbn/streams-schema'; import { v4 } from 'uuid'; -export function defaultQuery(): StreamQueryKql { +export function defaultQuery(): StreamQuery { return { id: v4(), title: '', kql: { query: '', }, + esql: { + query: '', + }, feature: undefined, }; } diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/feature_identification_control.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/feature_identification_control.tsx index 2aabb74907dd4..98c54b17450d1 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/feature_identification_control.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/feature_identification_control.tsx @@ -50,7 +50,12 @@ export function FeatureIdentificationControl({ getTask(); }, [getTask]); - useTaskPolling(task, getFeaturesIdentificationStatus, getTask); + const { cancelTask, isCancellingTask } = useTaskPolling({ + task, + onPoll: getFeaturesIdentificationStatus, + onRefresh: getTask, + onCancel: cancelFeaturesIdentificationTask, + }); // Sync task status with parent component - only trigger on status changes useEffect(() => { @@ -84,11 +89,8 @@ export function FeatureIdentificationControl({ }, [aiFeatures, onTaskStart, scheduleFeaturesIdentificationTask, getTask]); const handleCancelIdentification = useCallback(() => { - cancelFeaturesIdentificationTask().then(() => { - getTask(); - onTaskEnd(); - }); - }, [cancelFeaturesIdentificationTask, getTask, onTaskEnd]); + cancelTask().then(onTaskEnd); + }, [cancelTask, onTaskEnd]); if (error) { return ; @@ -124,7 +126,11 @@ export function FeatureIdentificationControl({ ); case TaskStatus.InProgress: - return ; + return isCancellingTask ? ( + + ) : ( + + ); case TaskStatus.BeingCanceled: return ; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/index.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/index.tsx index b940aaf1ad9af..2e57841425af0 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/index.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/index.tsx @@ -8,7 +8,7 @@ import { niceTimeFormatter } from '@elastic/charts'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText, useEuiTheme } from '@elastic/eui'; import type { TimeRange } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; -import type { StreamQueryKql, Streams, System } from '@kbn/streams-schema'; +import type { StreamQuery, Streams, System } from '@kbn/streams-schema'; import { compact, isEqual } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; import { @@ -63,7 +63,7 @@ export function StreamDetailSignificantEventsView({ definition }: Props) { const [initialFlow, setInitialFlow] = useState('ai'); const [selectedSystems, setSelectedSystems] = useState([]); - const [queryToEdit, setQueryToEdit] = useState(); + const [queryToEdit, setQueryToEdit] = useState(); const [dateRange, setDateRange] = useState({ from: rangeFrom, to: rangeTo }); useEffect(() => { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/system_identification_control.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/system_identification_control.tsx index 49691dd740aa6..965ebbd31a423 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/system_identification_control.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/system_identification_control.tsx @@ -45,7 +45,12 @@ export function SystemIdentificationControl({ useEffect(() => { getTask(); }, [getTask]); - useTaskPolling(task, getSystemIdentificationStatus, getTask); + const { cancelTask, isCancellingTask } = useTaskPolling({ + task, + onPoll: getSystemIdentificationStatus, + onRefresh: getTask, + onCancel: cancelSystemIdentificationTask, + }); const flyout = isFlyoutVisible && ( @@ -140,11 +145,7 @@ export function SystemIdentificationControl({ { - cancelSystemIdentificationTask().then(() => { - getTask(); - }); - }} + onClick={cancelTask} > {i18n.translate( 'xpack.streams.streamDetailView.cancelSystemIdentificationButtonLabel', @@ -158,7 +159,7 @@ export function SystemIdentificationControl({ ); } - if (task.status === 'being_canceled') { + if (isCancellingTask) { return ( ; export interface FormattedChangePoint { - query: StreamQueryKql; + query: StreamQuery; time: number; impact: 'high' | 'medium' | 'low'; p_value: number; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_description/description_generation_control.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_description/description_generation_control.tsx index 35eda54a0b0ef..419a5f7882b63 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_description/description_generation_control.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_description/description_generation_control.tsx @@ -42,7 +42,12 @@ export function DescriptionGenerationControl({ useEffect(() => { refreshTask(); }, [refreshTask]); - useTaskPolling(task, getDescriptionGenerationStatus, refreshTask); + const { cancelTask, isCancellingTask } = useTaskPolling({ + task, + onPoll: getDescriptionGenerationStatus, + onRefresh: refreshTask, + onCancel: cancelDescriptionGenerationTask, + }); if (taskError) { return ( @@ -99,7 +104,7 @@ export function DescriptionGenerationControl({ return triggerButton; } - if (task.status === 'in_progress') { + if (task.status === 'in_progress' && !isCancellingTask) { return ( @@ -120,11 +125,7 @@ export function DescriptionGenerationControl({ { - cancelDescriptionGenerationTask().then(() => { - refreshTask(); - }); - }} + onClick={cancelTask} > {i18n.translate( 'xpack.streams.streamDetailView.streamDescription.cancelDescriptionGenerationButtonLabel', @@ -138,7 +139,7 @@ export function DescriptionGenerationControl({ ); } - if (task.status === 'being_canceled') { + if (isCancellingTask) { return ( ({ + useStreamSystemsApi: () => ({ + upsertSystem: jest.fn().mockResolvedValue(undefined), + }), +})); + +jest.mock('../../../hooks/use_kibana', () => ({ + useKibana: () => ({ + dependencies: { + start: { + streams: { + streamsRepositoryClient: { + fetch: jest.fn().mockResolvedValue(undefined), + }, + }, + share: { + url: { + locators: { + useUrl: () => undefined, + }, + }, + }, + }, + }, + }), +})); + +jest.mock('../../../hooks/use_stream_detail', () => ({ + useStreamDetail: () => ({ + definition: {}, + }), +})); + +jest.mock('../../../hooks/use_timefilter', () => ({ + useTimefilter: () => ({ + timeState: { + asAbsoluteTimeRange: { + from: '2020-01-01T00:00:00.000Z', + to: '2020-01-02T00:00:00.000Z', + }, + }, + }), +})); + +jest.mock('../../../hooks/use_streams_app_fetch', () => ({ + useStreamsAppFetch: () => ({ + value: undefined, + loading: false, + error: undefined, + refresh: jest.fn(), + }), +})); + +jest.mock('../../data_management/shared', () => { + const ReactMock = jest.requireActual('react') as typeof import('react'); + + interface MockEditableConditionPanelProps { + isEditingCondition: boolean; + setCondition: (condition: { field: string; eq: string }) => void; + onValidityChange?: (isValid: boolean) => void; + } + + return { + EditableConditionPanel: (props: MockEditableConditionPanelProps) => { + const { isEditingCondition, setCondition, onValidityChange } = props; + const callbacksRef = ReactMock.useRef({ setCondition, onValidityChange }); + callbacksRef.current = { setCondition, onValidityChange }; + + ReactMock.useEffect(() => { + if (!isEditingCondition) return; + + callbacksRef.current.setCondition({ field: 'service.name', eq: 'updated' }); + callbacksRef.current.onValidityChange?.(false); + }, [isEditingCondition]); + return ReactMock.createElement('div', { 'data-test-subj': 'mockEditableConditionPanel' }); + }, + }; +}); + +describe('StreamSystemDetailsFlyout', () => { + it('disables save changes when the condition editor is invalid', async () => { + const user = userEvent.setup(); + const system: System = { + type: 'system', + name: 'test-system', + description: 'desc', + filter: { field: 'service.name', eq: 'initial' }, + }; + + const definition = { name: 'logs' } as unknown as Streams.all.Definition; + + render( + + {}} + refreshSystems={() => {}} + /> + + ); + + await user.click(screen.getByTestId('system_identification_existing_save_filter_button')); + + await waitFor(() => { + expect( + screen.getByTestId('system_identification_existing_save_changes_button') + ).toBeDisabled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_systems/stream_system_details_flyout.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_systems/stream_system_details_flyout.tsx index 8398975acd65c..ce172bbfa9e52 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_systems/stream_system_details_flyout.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_systems/stream_system_details_flyout.tsx @@ -45,6 +45,13 @@ export const StreamSystemDetailsFlyout = ({ const { upsertSystem } = useStreamSystemsApi(definition); const [isUpdating, setIsUpdating] = React.useState(false); const [isEditingCondition, toggleIsEditingCondition] = useToggle(false); + const [isFilterValid, setIsFilterValid] = React.useState(true); + + React.useEffect(() => { + if (!isEditingCondition) { + setIsFilterValid(true); + } + }, [isEditingCondition]); const updateSystem = () => { setIsUpdating(true); @@ -127,6 +134,7 @@ export const StreamSystemDetailsFlyout = ({ setCondition={(condition) => setUpdatedSystem({ ...updatedSystem, filter: condition }) } + onValidityChange={setIsFilterValid} /> @@ -157,7 +165,7 @@ export const StreamSystemDetailsFlyout = ({ isLoading={isUpdating} onClick={updateSystem} fill - isDisabled={isEqual(system, updatedSystem)} + isDisabled={isEqual(system, updatedSystem) || !isFilterValid} data-test-subj="system_identification_existing_save_changes_button" > { +export const getHelpText = (isStreamNameEmpty: boolean, readOnly: boolean): string | undefined => { if (isStreamNameEmpty && !readOnly) { return i18n.translate('xpack.streams.streamDetailRouting.minimumNameHelpText', { defaultMessage: `Stream name is required.`, }); - } else if (isStreamNameTooLong && !readOnly) { - return i18n.translate('xpack.streams.streamDetailRouting.maximumNameHelpText', { - defaultMessage: `Stream name cannot be longer than {maxLength} characters.`, - values: { - maxLength: MAX_STREAM_NAME_LENGTH, - }, - }); - } else { - return undefined; } + return undefined; }; export const getErrorMessage = ( - containsUpperCaseChars: boolean, - containsSpaces: boolean, + baseValidationError: string | undefined, isDuplicatedName: boolean, rootChildExists: boolean, isDotPresent: boolean, @@ -73,15 +60,9 @@ export const getErrorMessage = ( rootChild: string, router: StatefulStreamsAppRouter ): ReactNode | string | undefined => { - if (containsUpperCaseChars) { - return i18n.translate('xpack.streams.streamDetailRouting.uppercaseCharsError', { - defaultMessage: 'Stream name cannot contain uppercase characters.', - }); - } - if (containsSpaces) { - return i18n.translate('xpack.streams.streamDetailRouting.containsSpacesError', { - defaultMessage: 'Stream name cannot contain spaces.', - }); + // Return base validation errors from the shared validator first + if (baseValidationError) { + return baseValidationError; } if (isDuplicatedName) { return i18n.translate('xpack.streams.streamDetailRouting.nameConflictError', { @@ -174,19 +155,20 @@ export const useChildStreamInput = ( [routing, prefix, rootChild] ); + // Use shared validation for basic stream name checks + const baseValidation = validateStreamName(localStreamName); const isStreamNameEmpty = localStreamName.length <= prefix.length; - const isStreamNameTooLong = localStreamName.length > MAX_STREAM_NAME_LENGTH; - const isLengthValid = !isStreamNameEmpty && !isStreamNameTooLong; - const containsUpperCaseChars = localStreamName !== localStreamName.toLowerCase(); - const containsSpaces = localStreamName.includes(' '); + // Base validation passes if the name is valid according to shared validator + // However, we also need to check if the partition (the part after the prefix) is empty + const baseValidationError = + !baseValidation.valid && !isStreamNameEmpty ? baseValidation.message : undefined; - const helpText = getHelpText(isStreamNameEmpty, isStreamNameTooLong, readOnly); + const helpText = getHelpText(isStreamNameEmpty, readOnly); const isDotPresent = !readOnly && partitionName.includes('.'); const errorMessage = getErrorMessage( - containsUpperCaseChars, - containsSpaces, + baseValidationError, isDuplicatedName, rootChildExists, isDotPresent, @@ -199,11 +181,7 @@ export const useChildStreamInput = ( localStreamName, setLocalStreamName, isStreamNameValid: - isLengthValid && - !isDotPresent && - !isDuplicatedName && - !containsUpperCaseChars && - !containsSpaces, + baseValidation.valid && !isStreamNameEmpty && !isDotPresent && !isDuplicatedName, prefix, partitionName, helpText, diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/stream_name_form_row/stream_name_form_row.test.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/stream_name_form_row/stream_name_form_row.test.tsx index 496c3c396f80a..23642f968c599 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/stream_name_form_row/stream_name_form_row.test.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/stream_name_form_row/stream_name_form_row.test.tsx @@ -107,26 +107,20 @@ describe('StreamNameFormRow', () => { describe('getHelpText', () => { it('should return empty error help text when stream name is shorter than the prefix', () => { - const result = getHelpText(true, false, false); + const result = getHelpText(true, false); expect(result).toBe('Stream name is required.'); }); - it('should return name too long error help text when stream name is longer than 200 characters', () => { - const result = getHelpText(false, true, false); - expect(result).toBe('Stream name cannot be longer than 200 characters.'); - }); - it('should return undefined help text when input is valid', () => { - const result = getHelpText(false, false, false); + const result = getHelpText(false, false); expect(result).toBeUndefined(); }); }); describe('getErrorMessage', () => { - it('should return uppercase chars error message when stream name contains uppercase characters', () => { + it('should return the base validation error message when provided', () => { const result = getErrorMessage( - true, - false, + 'Stream name cannot contain uppercase characters.', false, false, false, @@ -137,66 +131,25 @@ describe('StreamNameFormRow', () => { expect(result).toBe('Stream name cannot contain uppercase characters.'); }); - it('should return spaces error message when stream name contains spaces', () => { - const result = getErrorMessage( - false, - true, - false, - false, - false, - 'logs.', - 'linux', - mockRouter - ); - expect(result).toBe('Stream name cannot contain spaces.'); - }); - it('should return name conflict error message when stream name is duplicated', () => { - const result = getErrorMessage( - false, - false, - true, - false, - false, - 'logs.', - 'linux', - mockRouter - ); + const result = getErrorMessage(undefined, true, false, false, 'logs.', 'linux', mockRouter); expect(result).toBe('A stream with this name already exists'); }); it('should return root child does not exist error message when root child stream does not exist', () => { - const result = getErrorMessage( - false, - false, - false, - false, - true, - 'logs.', - 'linux', - mockRouter - ); + const result = getErrorMessage(undefined, false, false, true, 'logs.', 'linux', mockRouter); expect(result).toBe('The child stream logs.linux does not exist. Please create it first.'); }); it('should return name contains dot error message component when stream name contains a dot', () => { - const result = getErrorMessage(false, false, false, true, true, 'logs.', 'linux', mockRouter); + const result = getErrorMessage(undefined, false, true, true, 'logs.', 'linux', mockRouter); render({result}); expect(screen.getByText(/Stream name cannot contain the "." character/)).toBeInTheDocument(); expect(screen.getByTestId('streamsAppChildStreamLink')).toHaveTextContent('logs.linux'); }); it('should return undefined error message when input is valid', () => { - const result = getErrorMessage( - false, - false, - false, - false, - false, - 'logs.', - 'linux', - mockRouter - ); + const result = getErrorMessage(undefined, false, false, false, 'logs.', 'linux', mockRouter); expect(result).toBeUndefined(); }); }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_fetch_discovery_queries.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_fetch_discovery_queries.ts new file mode 100644 index 0000000000000..a01c6f2c18666 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_fetch_discovery_queries.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { calculateAuto } from '@kbn/calculate-auto'; +import { type QueryFunctionContext, useQuery } from '@kbn/react-query'; +import type { + QueriesGetResponse, + SignificantEventsResponse, + StreamQuery, +} from '@kbn/streams-schema'; +import moment from 'moment'; +import { useKibana } from './use_kibana'; +import { useTimefilter } from './use_timefilter'; +import { useFetchErrorToast } from './use_fetch_error_toast'; + +export interface SignificantEventQueryRow { + query: StreamQuery; + stream_name: string; + occurrences: Array<{ x: number; y: number }>; + change_points: SignificantEventsResponse['change_points']; + rule_backed: boolean; +} + +export interface QueriesTableFetchResult { + queries: SignificantEventQueryRow[]; + page: number; + perPage: number; + total: number; +} + +export const DISCOVERY_QUERIES_QUERY_KEY = ['discoveryQueries'] as const; + +export const useFetchDiscoveryQueries = ( + options: { name?: string; query?: string; page: number; perPage: number }, + deps: unknown[] = [] +) => { + const { name, query, page, perPage } = options; + const { + dependencies: { + start: { + streams: { streamsRepositoryClient }, + data, + }, + }, + } = useKibana(); + const showFetchErrorToast = useFetchErrorToast(); + + const { timeState } = useTimefilter(); + + const fetchDiscoveryQueries = async ({ + signal, + }: QueryFunctionContext): Promise => { + const isoFrom = new Date(timeState.start).toISOString(); + const isoTo = new Date(timeState.end).toISOString(); + + const { min, max } = data.query.timefilter.timefilter.calculateBounds({ + from: isoFrom, + to: isoTo, + }); + + if (!min || !max) { + return undefined; + } + + const bucketSize = calculateAuto.near(50, moment.duration(max.diff(min))); + if (!bucketSize) { + return undefined; + } + + const intervalString = `${bucketSize.asSeconds()}s`; + + const response: QueriesGetResponse = await streamsRepositoryClient.fetch( + 'GET /internal/streams/_queries', + { + params: { + query: { + from: isoFrom, + to: isoTo, + bucketSize: intervalString, + query: query?.trim() ?? '', + streamNames: name ? [name] : undefined, + page, + perPage, + }, + }, + signal: signal ?? null, + } + ); + + return { + page: response.page, + perPage: response.perPage, + total: response.total, + queries: response.queries.map((series: SignificantEventsResponse) => { + const { occurrences, change_points, stream_name, rule_backed, ...rest } = series; + return { + query: rest, + stream_name, + change_points, + occurrences: occurrences.map( + (occurrence: SignificantEventsResponse['occurrences'][number]) => ({ + x: new Date(occurrence.date).getTime(), + y: occurrence.count, + }) + ), + rule_backed, + }; + }), + }; + }; + + return useQuery({ + queryKey: [ + ...DISCOVERY_QUERIES_QUERY_KEY, + name, + timeState.start, + timeState.end, + query, + page, + perPage, + ...deps, + ], + queryFn: fetchDiscoveryQueries, + onError: showFetchErrorToast, + keepPreviousData: true, + }); +}; diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_fetch_discovery_queries_occurrences.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_fetch_discovery_queries_occurrences.ts new file mode 100644 index 0000000000000..4281640e9fe10 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_fetch_discovery_queries_occurrences.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 { calculateAuto } from '@kbn/calculate-auto'; +import { type QueryFunctionContext, useQuery } from '@kbn/react-query'; +import type { QueriesOccurrencesGetResponse } from '@kbn/streams-schema'; +import moment from 'moment'; +import { useKibana } from './use_kibana'; +import { useTimefilter } from './use_timefilter'; +import { useFetchErrorToast } from './use_fetch_error_toast'; + +export interface DiscoveryQueriesOccurrencesFetchResult { + occurrences_histogram: Array<{ x: number; y: number }>; + total_occurrences: number; +} + +export const DISCOVERY_QUERIES_OCCURRENCES_QUERY_KEY = ['discoveryQueriesOccurrences'] as const; + +export const useFetchDiscoveryQueriesOccurrences = ( + options: { name?: string; query?: string } | undefined = {}, + deps: unknown[] = [] +) => { + const { name, query } = options ?? {}; + const { + dependencies: { + start: { + streams: { streamsRepositoryClient }, + data, + }, + }, + } = useKibana(); + const showFetchErrorToast = useFetchErrorToast(); + + const { timeState } = useTimefilter(); + + const fetchDiscoveryQueriesOccurrences = async ({ + signal, + }: QueryFunctionContext): Promise => { + const isoFrom = new Date(timeState.start).toISOString(); + const isoTo = new Date(timeState.end).toISOString(); + + const { min, max } = data.query.timefilter.timefilter.calculateBounds({ + from: isoFrom, + to: isoTo, + }); + + if (!min || !max) { + return undefined; + } + + const bucketSize = calculateAuto.near(50, moment.duration(max.diff(min))); + if (!bucketSize) { + return undefined; + } + + const intervalString = `${bucketSize.asSeconds()}s`; + + const response: QueriesOccurrencesGetResponse = await streamsRepositoryClient.fetch( + 'GET /internal/streams/_queries/_occurrences', + { + params: { + query: { + from: isoFrom, + to: isoTo, + bucketSize: intervalString, + query: query?.trim() ?? '', + streamNames: name ? [name] : undefined, + }, + }, + signal: signal ?? null, + } + ); + + return { + occurrences_histogram: response.occurrences_histogram.map( + (bucket: { x: string; y: number }) => ({ + x: new Date(bucket.x).getTime(), + y: bucket.y, + }) + ), + total_occurrences: response.total_occurrences, + }; + }; + + return useQuery({ + queryKey: [ + ...DISCOVERY_QUERIES_OCCURRENCES_QUERY_KEY, + name, + timeState.start, + timeState.end, + query, + ...deps, + ], + queryFn: fetchDiscoveryQueriesOccurrences, + onError: showFetchErrorToast, + keepPreviousData: true, + }); +}; diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_field_suggestions.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_field_suggestions.ts index 0035d59a6705f..1c5856d672fa0 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_field_suggestions.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_field_suggestions.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { flattenObjectNestedLast } from '@kbn/object-utils'; +import type { FlattenRecord } from '@kbn/streams-schema'; import { useMemo } from 'react'; import { useSimulatorSelector } from '../components/data_management/stream_detail_enrichment/state_management/stream_enrichment_state_machine/use_stream_enrichment'; import { selectPreviewRecords } from '../components/data_management/stream_detail_enrichment/state_management/simulation_state_machine/selectors'; @@ -15,14 +17,26 @@ import type { Suggestion } from '../components/data_management/shared/autocomple /** * Hook for providing field suggestions from enrichment simulation data - to be used with Enrichment only + * + * When condition filtering is active and no documents match the condition, + * falls back to all samples to ensure field suggestions are always available. */ export const useEnrichmentFieldSuggestions = (): Suggestion[] => { const previewRecords = useSimulatorSelector((state) => selectPreviewRecords(state.context)); + const allSamples = useSimulatorSelector((state) => state.context.samples); const detectedFields = useSimulatorSelector((state) => state.context.simulation?.detected_fields); return useMemo(() => { - return createFieldSuggestions(previewRecords, detectedFields); - }, [previewRecords, detectedFields]); + // Fall back to all samples when condition-filtered records are empty. + // This ensures field suggestions are always available, even when + // creating/editing processors under conditions with 0% match rate. + const recordsForSuggestions = + previewRecords.length > 0 + ? previewRecords + : (allSamples.map((sample) => flattenObjectNestedLast(sample.document)) as FlattenRecord[]); + + return createFieldSuggestions(recordsForSuggestions, detectedFields); + }, [previewRecords, allSamples, detectedFields]); }; /** diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_insights_discovery_api.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_insights_discovery_api.ts index 763c8e9b64e34..c606d92e14609 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_insights_discovery_api.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_insights_discovery_api.ts @@ -22,13 +22,14 @@ export function useInsightsDiscoveryApi(connectorId?: string) { return useMemo( () => ({ - scheduleInsightsDiscoveryTask: async () => { + scheduleInsightsDiscoveryTask: async (streamNames?: string[]) => { await streamsRepositoryClient.fetch('POST /internal/streams/_insights/_task', { signal, params: { body: { action: 'schedule', connectorId, + ...(streamNames && streamNames.length > 0 ? { streamNames } : {}), }, }, }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_queries_api.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_queries_api.ts index 392e839a95e3c..b8885b17ac8ed 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_queries_api.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_queries_api.ts @@ -39,7 +39,7 @@ export function useQueriesApi(): QueriesApi { }); }, upsertQuery: async ({ query, streamName }: { query: StreamQuery; streamName: string }) => { - const { id, ...body } = query; + const { id, esql, ...body } = query; await streamsRepositoryClient.fetch( 'PUT /api/streams/{name}/queries/{queryId} 2023-10-31', diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_significant_events_api.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_significant_events_api.ts index 02690382d3db0..d9b79f003ec23 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_significant_events_api.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_significant_events_api.ts @@ -7,7 +7,7 @@ import { useAbortController } from '@kbn/react-hooks'; import type { - StreamQueryKql, + StreamQuery, System, SignificantEventsQueriesGenerationTaskResult, } from '@kbn/streams-schema'; @@ -15,7 +15,7 @@ import { useKibana } from './use_kibana'; import { getLast24HoursTimeRange } from '../util/time_range'; interface SignificantEventsApiBulkOperationCreate { - index: StreamQueryKql; + index: StreamQuery; } interface SignificantEventsApiBulkOperationDelete { delete: { id: string }; @@ -26,7 +26,7 @@ type SignificantEventsApiBulkOperation = | SignificantEventsApiBulkOperationDelete; interface SignificantEventsApi { - upsertQuery: (query: StreamQueryKql) => Promise; + upsertQuery: (query: StreamQuery) => Promise; removeQuery: (id: string) => Promise; bulk: (operations: SignificantEventsApiBulkOperation[]) => Promise; abort: () => void; @@ -52,7 +52,7 @@ export function useSignificantEventsApi({ name }: { name: string }): Significant const { signal, abort, refresh } = useAbortController(); return { - upsertQuery: async ({ id, ...body }) => { + upsertQuery: async ({ id, esql, ...body }) => { await streamsRepositoryClient.fetch('PUT /api/streams/{name}/queries/{queryId} 2023-10-31', { signal, params: { @@ -86,7 +86,13 @@ export function useSignificantEventsApi({ name }: { name: string }): Significant name, }, body: { - operations, + operations: operations.map((op) => { + if ('index' in op) { + const { esql: _esql, ...index } = op.index; + return { index }; + } + return op; + }), }, }, }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_task_polling.ts b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_task_polling.ts index eb36bd571c35c..5b3650227c7d7 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/hooks/use_task_polling.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/hooks/use_task_polling.ts @@ -6,38 +6,125 @@ */ import { TaskStatus } from '@kbn/streams-schema'; -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; interface TaskWithStatus { status: TaskStatus; } -export function useTaskPolling( - task: TaskWithStatus | undefined, - poll: () => Promise, - refresh: () => void -) { +interface UseTaskPollingParams { + task: TaskWithStatus | undefined; + onPoll: () => Promise; + onRefresh: () => void | Promise; + onCancel?: () => Promise; + pollIntervalMs?: number; +} + +export function useTaskPolling({ + task, + onPoll, + onRefresh, + onCancel, + pollIntervalMs = 2000, +}: UseTaskPollingParams) { + const [isCancellingRequested, setIsCancellingRequested] = useState(false); + const isCancellingRequestInFlightRef = useRef(false); + + const isCancellingTask = task?.status === TaskStatus.BeingCanceled || isCancellingRequested; + + /** + * Resets optimistic cancellation state once task reaches a terminal status. + */ + useEffect(() => { + if (task?.status !== TaskStatus.InProgress && task?.status !== TaskStatus.BeingCanceled) { + setIsCancellingRequested(false); + isCancellingRequestInFlightRef.current = false; + } + }, [task?.status]); + useEffect(() => { if (task?.status !== TaskStatus.InProgress && task?.status !== TaskStatus.BeingCanceled) { return; } - const intervalId = setInterval(async () => { - const polledTask = await poll(); - - // We expect the polling endpoint to report if a task becomes stale so the UI can poll until that happens - // leaving the server to control the time thresholds for staleness - if ( - polledTask.status !== TaskStatus.InProgress && - polledTask.status !== TaskStatus.BeingCanceled - ) { - clearInterval(intervalId); - refresh(); - } - }, 2000); + let timeoutId: NodeJS.Timeout | undefined; + let isMounted = true; + + const scheduleNextPoll = () => { + timeoutId = setTimeout(async () => { + let polledTask: TaskWithStatus | undefined; + try { + polledTask = await onPoll(); + } catch { + if (isMounted) { + scheduleNextPoll(); + } + return; + } + + if (!isMounted) { + return; + } + + // We expect the polling endpoint to report if a task becomes stale so the UI can poll until that happens + // leaving the server to control the time thresholds for staleness + if ( + polledTask.status !== TaskStatus.InProgress && + polledTask.status !== TaskStatus.BeingCanceled + ) { + try { + await onRefresh(); + } catch { + if (isMounted) { + scheduleNextPoll(); + } + } + return; + } + + scheduleNextPoll(); + }, pollIntervalMs); + }; + + scheduleNextPoll(); return () => { - clearInterval(intervalId); + isMounted = false; + if (timeoutId) { + clearTimeout(timeoutId); + } }; - }, [task?.status, poll, refresh]); + }, [onPoll, onRefresh, pollIntervalMs, task?.status]); + + /** + * Cancels the task if it is in progress. + * If the task is already being canceled, does nothing. + * If the task is not in progress, does nothing. + * If the task is not in progress and is not being canceled, sets the cancellation state to true and waits for the task to be canceled. + */ + const cancelTask = useCallback(async () => { + if (!onCancel || isCancellingRequested || isCancellingRequestInFlightRef.current) { + return; + } + + if (task?.status !== TaskStatus.InProgress && task?.status !== TaskStatus.BeingCanceled) { + return; + } + + setIsCancellingRequested(true); + + if (task?.status === TaskStatus.BeingCanceled) { + return; + } + + isCancellingRequestInFlightRef.current = true; + try { + await onCancel(); + await onRefresh(); + } finally { + isCancellingRequestInFlightRef.current = false; + } + }, [isCancellingRequested, onCancel, onRefresh, task?.status]); + + return { cancelTask, isCancellingTask }; } diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/.meta/ui/standard.json b/x-pack/platform/plugins/shared/streams_app/test/scout/.meta/ui/standard.json index 2379c39c1902c..47c0f7f29d914 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/.meta/ui/standard.json +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/.meta/ui/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-10T11:18:09.022Z", - "sha1": "32c18c66737d0c09c9fa8b33dad1ed671695748c", + "sha1": "e0593e1a8a240c8a051787cfc3673beebb73a9d1", "tests": [ { "id": "3cc9409cebf2cef-744ce32675c025c", @@ -69,6 +68,32 @@ "column": 7 } }, + { + "id": "ca5617d2526fece-4f1df209bb20bf0", + "title": "Advanced tab with basic license should NOT show enterprise features on wired stream Advanced tab with basic license", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_advanced/license_basic.spec.ts", + "line": 34, + "column": 7 + } + }, + { + "id": "ca5617d2526fece-6a2b3b1f642b875", + "title": "Advanced tab with basic license should NOT show enterprise features on classic stream Advanced tab with basic license", + "expectedStatus": "passed", + "tags": [ + "@ess" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_advanced/license_basic.spec.ts", + "line": 76, + "column": 7 + } + }, { "id": "a43be0edd7f2351-5d0e50bdec96eb3", "title": "Advanced tab permissions - Classic streams should NOT show Advanced tab for viewer role on classic stream", @@ -357,6 +382,90 @@ "column": 9 } }, + { + "id": "8be2eb5bc283463-302be79bda1e5b6", + "title": "Stream data processing - condition filtering and match rate should display condition match rate badge on WHERE blocks", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts", + "line": 34, + "column": 9 + } + }, + { + "id": "8be2eb5bc283463-61f2cb157ba13e7", + "title": "Stream data processing - condition filtering and match rate should show 0% match rate when condition matches no documents", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts", + "line": 55, + "column": 9 + } + }, + { + "id": "8be2eb5bc283463-4dc2c601572dd30", + "title": "Stream data processing - condition filtering and match rate should show 100% match rate when condition matches all documents", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts", + "line": 73, + "column": 9 + } + }, + { + "id": "8be2eb5bc283463-63178d57f373ece", + "title": "Stream data processing - condition filtering and match rate should show selected documents percentage when editing processor under a condition", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts", + "line": 91, + "column": 9 + } + }, + { + "id": "8be2eb5bc283463-3a996c31b556da5", + "title": "Stream data processing - condition filtering and match rate should maintain match rate badge after saving condition", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts", + "line": 118, + "column": 9 + } + }, + { + "id": "8be2eb5bc283463-c5f7103f798c76a", + "title": "Stream data processing - condition filtering and match rate should show match rate badge for nested conditions", + "expectedStatus": "passed", + "tags": [ + "@ess", + "@svlOblt" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts", + "line": 140, + "column": 9 + } + }, { "id": "f13bd8d34bc433f-7338c587a1d041e", "title": "Stream data processing - creating steps should not show Technical Preview badge when AI suggestions are unavailable", @@ -385,7 +494,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 45, + "line": 52, "column": 9 } }, @@ -401,7 +510,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 56, + "line": 63, "column": 9 } }, @@ -417,7 +526,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 64, + "line": 71, "column": 9 } }, @@ -433,7 +542,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 86, + "line": 93, "column": 9 } }, @@ -449,7 +558,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 105, + "line": 112, "column": 9 } }, @@ -465,7 +574,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 128, + "line": 135, "column": 9 } }, @@ -481,7 +590,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 144, + "line": 151, "column": 9 } }, @@ -497,7 +606,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 164, + "line": 171, "column": 9 } }, @@ -513,7 +622,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 180, + "line": 187, "column": 9 } }, @@ -529,7 +638,7 @@ ], "location": { "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts", - "line": 195, + "line": 202, "column": 9 } }, @@ -1809,6 +1918,34 @@ "column": 7 } }, + { + "id": "6ff035a70002742-a5e4366c29b6f4c", + "title": "Stream data retention - ILM policy should delete a downsampling step from an ILM policy", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_retention/ilm_policy_retention.spec.ts", + "line": 131, + "column": 7 + } + }, + { + "id": "6ff035a70002742-1bd0048c87dbda0", + "title": "Stream data retention - ILM policy should create a new policy when deleting a downsampling step", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_retention/ilm_policy_retention.spec.ts", + "line": 207, + "column": 7 + } + }, { "id": "0ed00376f219437-265885593f64678", "title": "Stream data retention - display values should display singular and plural time units correctly", diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts index ff06a4baa02ac..c5cd45b4b27b3 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/fixtures/page_objects/streams_app.ts @@ -352,18 +352,42 @@ export class StreamsApp { } async fillConditionEditorWithSyntax(condition: string) { - // Clean previous content - await this.page.getByTestId('streamsAppConditionEditorCodeEditor').click(); - await this.page.keyboard.press('Control+A'); - await this.page.keyboard.press('Backspace'); - // Fill with new condition - await this.page - .getByTestId('streamsAppConditionEditorCodeEditor') - .getByRole('textbox') - .fill(condition); - // Clean trailing content - await this.page.keyboard.press('Shift+Control+ArrowDown'); - await this.page.keyboard.press('Backspace'); + const editor = this.page.getByTestId('streamsAppConditionEditorCodeEditor'); + + // CodeEditor can start in "read mode". Activate edit mode when needed. + const activateEditModeButton = editor.getByRole('button', { name: /activate edit mode/i }); + try { + await activateEditModeButton.click({ timeout: 1000 }); + } catch { + // Button is not present when already in edit mode + } + + // Use Monaco's model API to set value reliably (keyboard interactions can be flaky). + // There can be multiple Monaco models on the page (e.g. YAML editor), so target the condition model. + const conditionModelIndex = await this.page.evaluate(() => { + interface MonacoModel { + getValue(): string; + } + interface MonacoEditorApi { + getModels(): MonacoModel[]; + } + interface MonacoEnv { + monaco?: { editor?: MonacoEditorApi }; + } + const monacoEnv = (window as Window & { MonacoEnvironment?: MonacoEnv }).MonacoEnvironment; + const editorApi = monacoEnv?.monaco?.editor; + if (!editorApi) { + throw new Error('MonacoEnvironment.monaco.editor is not available'); + } + + const values: string[] = editorApi.getModels().map((model) => model.getValue()); + return values.findIndex((value) => value.trim().startsWith('{') && value.includes('"field"')); + }); + + await this.kibanaMonacoEditor.setCodeEditorValue( + condition, + conditionModelIndex >= 0 ? conditionModelIndex : undefined + ); } async toggleConditionEditorWithSyntaxSwitch() { @@ -450,25 +474,44 @@ export class StreamsApp { async clickAddProcessor(handleContextMenuClick: boolean = true) { if (handleContextMenuClick) { // New UI has direct button instead of context menu - await this.page.getByTestId('streamsAppStreamDetailEnrichmentCreateProcessorButton').click(); + const button = this.page.getByTestId('streamsAppStreamDetailEnrichmentCreateProcessorButton'); + await expect(button).toBeVisible({ timeout: 60000 }); + // Locator.click() can get flaky here due to rapid re-renders; use a direct DOM click. + await button.evaluate((el) => (el as HTMLElement).click()); } else { // When called from within a condition's context menu, use the old menu item - await this.page - .getByTestId('streamsAppStreamDetailEnrichmentCreateStepButtonAddProcessor') - .click(); + const menuItem = this.page.getByTestId( + 'streamsAppStreamDetailEnrichmentCreateStepButtonAddProcessor' + ); + await expect(menuItem).toBeVisible({ timeout: 60000 }); + await menuItem.evaluate((el) => (el as HTMLElement).click()); } + + // Wait for the processor configuration panel to be ready before interacting with inputs. + await expect( + this.page.getByTestId('streamsAppProcessorConfigurationSaveProcessorButton') + ).toBeVisible({ timeout: 30000 }); } async clickAddCondition(handleContextMenuClick: boolean = true) { if (handleContextMenuClick) { // New UI has direct button instead of context menu - await this.page.getByTestId('streamsAppStreamDetailEnrichmentCreateConditionButton').click(); + const button = this.page.getByTestId('streamsAppStreamDetailEnrichmentCreateConditionButton'); + await expect(button).toBeVisible({ timeout: 60000 }); + await button.evaluate((el) => (el as HTMLElement).click()); } else { // When called from within a condition's context menu, use the old menu item - await this.page - .getByTestId('streamsAppStreamDetailEnrichmentCreateStepButtonAddCondition') - .click(); + const menuItem = this.page.getByTestId( + 'streamsAppStreamDetailEnrichmentCreateStepButtonAddCondition' + ); + await expect(menuItem).toBeVisible({ timeout: 60000 }); + await menuItem.evaluate((el) => (el as HTMLElement).click()); } + + // Wait for the condition configuration panel to be ready before interacting. + await expect( + this.page.getByTestId('streamsAppConditionConfigurationSaveConditionButton') + ).toBeVisible({ timeout: 30000 }); } async getProcessorPatternText() { return await this.page.getByTestId('fullText').locator('.euiText').textContent(); @@ -680,8 +723,22 @@ export class StreamsApp { } async waitForModifiedFieldsDetection() { - const badge = this.page.getByTestId('streamsAppModifiedFieldsBadge'); - await expect(badge).toBeVisible({ timeout: 30_000 }); + // "Modified fields" badge only renders when there are detected fields; it's not a reliable + // signal that the Processing tab has finished initializing. Instead, wait for stable UI + // primitives that are always present once the tab is ready. + await expect(this.page.getByTestId('streamsAppProcessingDataSourcesList')).toBeVisible({ + timeout: 60_000, + }); + + const readySignal = this.page + .getByTestId('streamsAppStreamDetailEnrichmentRootSteps') + .or(this.page.getByTestId('streamsAppProcessingPreviewEmptyPrompt')) + .or(this.page.getByTestId('streamsAppStreamDetailEnrichmentCreateProcessorButton')) + .or(this.page.getByTestId('streamsAppStreamDetailEnrichmentCreateConditionButton')); + + // `readySignal` can legitimately match multiple elements (e.g. both create buttons), + // so avoid strict-locator assertions like `toBeVisible()` which require a single match. + await expect.poll(async () => readySignal.count(), { timeout: 60_000 }).toBeGreaterThan(0); } async saveStepsListChanges() { diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_advanced/systems_condition_editor_validity.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_advanced/systems_condition_editor_validity.spec.ts new file mode 100644 index 0000000000000..b553d6ff9246b --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_advanced/systems_condition_editor_validity.spec.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expect } from '@kbn/scout/ui'; +import { test } from '../../../fixtures'; + +const STREAM_NAME = 'logs'; +const SYSTEM_NAME = 'streams-system-validity-test'; + +test.describe('Stream systems - condition editor validity', { tag: ['@ess', '@svlOblt'] }, () => { + test.beforeAll(async ({ kbnClient }) => { + // Systems APIs are gated behind this UI setting in the test environment. + await kbnClient.request({ + path: '/api/kibana/settings', + method: 'POST', + body: { + changes: { + 'observability:streamsEnableSignificantEvents': true, + }, + }, + }); + + await kbnClient.request({ + path: `/internal/streams/${STREAM_NAME}/systems/${SYSTEM_NAME}`, + method: 'PUT', + body: { + type: 'system', + name: SYSTEM_NAME, + description: 'System for validity tests', + filter: { field: 'service.name', eq: 'initial' }, + }, + }); + }); + + test.beforeEach(async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsAdmin(); + await pageObjects.streams.gotoAdvancedTab(STREAM_NAME); + }); + + test.afterAll(async ({ kbnClient }) => { + await kbnClient.request({ + path: `/internal/streams/${STREAM_NAME}/systems/_bulk`, + method: 'POST', + body: { + operations: [{ delete: { system: { name: SYSTEM_NAME } } }], + }, + }); + }); + + test('disables saving when syntax editor JSON is invalid', async ({ page, pageObjects }) => { + const row = page.locator('tr').filter({ hasText: SYSTEM_NAME }); + + // In responsive mode, EUI collapses row actions into an "All actions" menu. + await row.getByRole('button', { name: /all actions/i }).click(); + await page.getByTestId('system_identification_existing_start_edit_button').click(); + + await page.getByTestId('system_identification_existing_save_filter_button').click(); + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + + await pageObjects.streams.fillConditionEditorWithSyntax( + '{"field":"service.name","eq":"updated"}' + ); + await expect( + page.getByTestId('system_identification_existing_save_changes_button') + ).toBeEnabled(); + + await pageObjects.streams.fillConditionEditorWithSyntax('{'); + await expect( + page.getByTestId('system_identification_existing_save_changes_button') + ).toBeDisabled(); + + await pageObjects.streams.fillConditionEditorWithSyntax( + '{"field":"service.name","eq":"updated-again"}' + ); + await expect( + page.getByTestId('system_identification_existing_save_changes_button') + ).toBeEnabled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_mapping/wired_streams_schema.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_mapping/wired_streams_schema.spec.ts index 1bf22a4a5c491..41f5cae6d4220 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_mapping/wired_streams_schema.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_mapping/wired_streams_schema.spec.ts @@ -190,5 +190,55 @@ test.describe( value: `Alias for ${ecsFieldName}`, }); }); + + test('validates field names for wired streams', async ({ page, pageObjects }) => { + await pageObjects.streams.expectSchemaEditorTableVisible(); + + // Open the "Add field" flyout + await page.getByTestId('streamsAppContentAddFieldButton').click(); + await expect( + page.getByTestId('streamsAppSchemaEditorAddFieldFlyoutCloseButton') + ).toBeVisible(); + + // Try to add a non-namespaced field - should show error + const invalidFieldName = 'invalid_field'; + await pageObjects.streams.typeFieldName(invalidFieldName); + + // Check that an error is displayed + const formRow = page + .getByTestId('streamsAppSchemaEditorAddFieldFlyoutFieldName') + .locator('..'); + await expect(formRow.locator('.euiFormErrorText')).toContainText( + "doesn't match the namespaced ECS or OTel schema" + ); + + // Check that the Add button is disabled when there's a validation error + const addButton = page.getByTestId('streamsAppSchemaEditorAddFieldButton'); + await expect(addButton).toBeDisabled(); + + // Clear and try with a valid namespaced field + const clearButton = page.getByTestId('comboBoxClearButton'); + await clearButton.click(); + + const validFieldName = 'attributes.valid_field'; + await pageObjects.streams.typeFieldName(validFieldName); + + // Error should be gone + await expect(formRow.locator('.euiFormErrorText')).toBeHidden(); + + // Set field type and verify Add button works + await pageObjects.streams.setFieldMappingType('keyword'); + await addButton.click(); + await expect( + page.getByTestId('streamsAppSchemaEditorAddFieldFlyoutCloseButton') + ).toBeHidden(); + + // Verify the field was staged (visible in review) + await pageObjects.streams.reviewStagedFieldMappingChanges(); + await expect(page.getByText(validFieldName)).toBeVisible(); + + // Close the modal to clean up + await pageObjects.streams.closeSchemaReviewModal(); + }); } ); diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts new file mode 100644 index 0000000000000..997f8b90d27b5 --- /dev/null +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/condition_filtering.spec.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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/scout/ui'; +import { test } from '../../../fixtures'; +import { generateLogsData } from '../../../fixtures/generators'; + +test.describe( + 'Stream data processing - condition filtering and match rate', + { tag: ['@ess', '@svlOblt'] }, + () => { + test.beforeAll(async ({ logsSynthtraceEsClient }) => { + // Generate logs with alternating log levels (50% info, 50% warn) + await generateLogsData(logsSynthtraceEsClient)({ index: 'logs-generic-default' }); + }); + + test.beforeEach(async ({ apiServices, browserAuth, pageObjects }) => { + await browserAuth.loginAsAdmin(); + // Clear existing processors before each test + await apiServices.streams.clearStreamProcessors('logs-generic-default'); + + await pageObjects.streams.gotoProcessingTab('logs-generic-default'); + }); + + test.afterAll(async ({ apiServices, logsSynthtraceEsClient }) => { + await apiServices.streams.clearStreamProcessors('logs-generic-default'); + await logsSynthtraceEsClient.clean(); + }); + + test('should display condition match rate badge on WHERE blocks', async ({ + page, + pageObjects, + }) => { + // Create a condition that matches approximately 50% of documents (log.level equals info) + await pageObjects.streams.clickAddCondition(); + await pageObjects.streams.fillCondition('log.level', 'equals', 'info'); + await pageObjects.streams.clickSaveCondition(); + + // Verify the condition was created + expect(await pageObjects.streams.getConditionsListItems()).toHaveLength(1); + + // Verify the match rate badge is displayed + const matchRateBadge = page.getByTestId('streamsAppConditionMatchRateBadge'); + await expect(matchRateBadge).toBeVisible(); + + // The badge should show approximately 50% (since half the logs are 'info') + // We check it contains a percentage value + await expect(matchRateBadge).toContainText('%'); + }); + + test('should show 0% match rate when condition matches no documents', async ({ + page, + pageObjects, + }) => { + // Create a condition that matches no documents + await pageObjects.streams.clickAddCondition(); + await pageObjects.streams.fillCondition('log.level', 'equals', 'nonexistent_value'); + await pageObjects.streams.clickSaveCondition(); + + // Verify the condition was created + expect(await pageObjects.streams.getConditionsListItems()).toHaveLength(1); + + // Verify the match rate badge shows 0% + const matchRateBadge = page.getByTestId('streamsAppConditionMatchRateBadge'); + await expect(matchRateBadge).toBeVisible(); + await expect(matchRateBadge).toContainText('0%'); + }); + + test('should show 100% match rate when condition matches all documents', async ({ + page, + pageObjects, + }) => { + // Create a condition that matches all documents (service.name equals test-service) + await pageObjects.streams.clickAddCondition(); + await pageObjects.streams.fillCondition('service.name', 'equals', 'test-service'); + await pageObjects.streams.clickSaveCondition(); + + // Verify the condition was created + expect(await pageObjects.streams.getConditionsListItems()).toHaveLength(1); + + // Verify the match rate badge shows 100% + const matchRateBadge = page.getByTestId('streamsAppConditionMatchRateBadge'); + await expect(matchRateBadge).toBeVisible(); + await expect(matchRateBadge).toContainText('100%'); + }); + + test('should show selected documents percentage when editing processor under a condition', async ({ + page, + pageObjects, + }) => { + // Create a condition that matches approximately 50% of documents (log.level equals warn) + await pageObjects.streams.clickAddCondition(); + await pageObjects.streams.fillCondition('log.level', 'equals', 'warn'); + await pageObjects.streams.clickSaveCondition(); + + // Verify the condition was created + expect(await pageObjects.streams.getConditionsListItems()).toHaveLength(1); + + // Add a processor under the condition - this should auto-select the condition + // and show the "Selected X%" indicator for condition-based document filtering + const addStepButton = await pageObjects.streams.getConditionAddStepMenuButton(0); + await addStepButton.click(); + await pageObjects.streams.clickAddProcessor(false); + + // The "Selected" button should show approximately 50% since half the logs match the condition + // This indicates that condition-based document filtering is active + const selectedButton = page.getByRole('button', { name: /Selected.*%/ }); + await expect(selectedButton).toBeVisible(); + + // The percentage should be approximately 50% (half documents match log.level = warn) + await expect(selectedButton).toContainText('50%'); + }); + + test('should maintain match rate badge after saving condition', async ({ + page, + pageObjects, + }) => { + // Create and save a condition + await pageObjects.streams.clickAddCondition(); + await pageObjects.streams.fillCondition('log.level', 'equals', 'info'); + await pageObjects.streams.clickSaveCondition(); + await pageObjects.streams.saveStepsListChanges(); + + // Reload the page + await pageObjects.streams.gotoProcessingTab('logs-generic-default'); + + // Verify the condition is still there + expect(await pageObjects.streams.getConditionsListItems()).toHaveLength(1); + + // Verify the match rate badge is still displayed after reload + const matchRateBadge = page.getByTestId('streamsAppConditionMatchRateBadge'); + await expect(matchRateBadge).toBeVisible(); + await expect(matchRateBadge).toContainText('%'); + }); + + test('should show match rate badge for nested conditions', async ({ page, pageObjects }) => { + // Create a parent condition + await pageObjects.streams.clickAddCondition(); + await pageObjects.streams.fillCondition('log.level', 'equals', 'info'); + await pageObjects.streams.clickSaveCondition(); + + // Add a nested condition under the parent + const addStepButton = await pageObjects.streams.getConditionAddStepMenuButton(0); + await addStepButton.click(); + await pageObjects.streams.clickAddCondition(false); + await pageObjects.streams.fillCondition('service.name', 'equals', 'test-service'); + await pageObjects.streams.clickSaveCondition(); + + // Both conditions should have match rate badges + const matchRateBadges = page.getByTestId('streamsAppConditionMatchRateBadge'); + await expect(matchRateBadges).toHaveCount(2); + }); + } +); diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts index d091824e28383..9f6d302eab810 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts @@ -27,6 +27,8 @@ test.describe( await apiServices.streams.clearStreamProcessors('logs-generic-default'); await pageObjects.streams.gotoProcessingTab('logs-generic-default'); + // Ensure the interactive mode + simulator have finished initializing before interacting. + await pageObjects.streams.waitForModifiedFieldsDetection(); }); test.afterAll(async ({ apiServices, logsSynthtraceEsClient }) => { @@ -68,6 +70,27 @@ test.describe( expect(await pageObjects.streams.getConditionsListItems()).toHaveLength(1); }); + test('should disable saving a condition when syntax JSON is invalid', async ({ + page, + pageObjects, + }) => { + await pageObjects.streams.clickAddCondition(); + + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + await pageObjects.streams.fillConditionEditorWithSyntax( + '{"field":"test_field","contains":"logs"}' + ); + await expect( + page.getByTestId('streamsAppConditionConfigurationSaveConditionButton') + ).toBeEnabled(); + + // Regression check: going from valid JSON to invalid JSON must disable Update. + await pageObjects.streams.fillConditionEditorWithSyntax('{'); + await expect( + page.getByTestId('streamsAppConditionConfigurationSaveConditionButton') + ).toBeDisabled(); + }); + test('should be able to nest steps under conditions', async ({ pageObjects }) => { // Create a condition first await pageObjects.streams.clickAddCondition(); diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/create_routing_rules.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/create_routing_rules.spec.ts index 4f735dc2b14f6..7f2a73776ec4b 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/create_routing_rules.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/create_routing_rules.spec.ts @@ -126,28 +126,6 @@ test.describe( } }); - test('should show validation errors for invalid stream names verified on the server', async ({ - page, - pageObjects, - }) => { - await pageObjects.streams.clickCreateRoutingRule(); - - // Try invalid stream names (these pass client-side validation but fail server-side) - const invalidNames = ['special>chars']; - - for (const invalidName of invalidNames) { - await pageObjects.streams.fillRoutingRuleName(invalidName); - await pageObjects.streams.saveRoutingRule(); - - // Wait for the error toast to appear - await pageObjects.toasts.waitFor(); - - // Should stay in creating state due to validation error - await expect(page.getByTestId('streamsAppRoutingStreamEntryNameField')).toBeVisible(); - await pageObjects.toasts.closeAll(); - } - }); - test('should handle insufficient privileges gracefully', async ({ page, browserAuth, diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/edit_routing_rules.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/edit_routing_rules.spec.ts index 6ec0e5827122c..fca1b60037de7 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/edit_routing_rules.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/edit_routing_rules.spec.ts @@ -58,6 +58,29 @@ test.describe( ); }); + test('should disable update when syntax editor JSON is invalid', async ({ + page, + pageObjects, + }) => { + const rountingRuleName = 'logs.edit-test'; + await pageObjects.streams.clickEditRoutingRule(rountingRuleName); + + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + + await pageObjects.streams.fillConditionEditorWithSyntax( + '{"field":"service.name","eq":"updated-service"}' + ); + await expect(page.getByTestId('streamsAppStreamDetailRoutingUpdateButton')).toBeEnabled(); + + await pageObjects.streams.fillConditionEditorWithSyntax('{'); + await expect(page.getByTestId('streamsAppStreamDetailRoutingUpdateButton')).toBeDisabled(); + + await pageObjects.streams.fillConditionEditorWithSyntax( + '{"field":"service.name","eq":"updated-service"}' + ); + await expect(page.getByTestId('streamsAppStreamDetailRoutingUpdateButton')).toBeEnabled(); + }); + test('should cancel editing routing rule', async ({ page, pageObjects }) => { const rountingRuleName = 'logs.edit-test'; await pageObjects.streams.clickEditRoutingRule(rountingRuleName); @@ -100,6 +123,77 @@ test.describe( expect(await pageObjects.streams.conditionEditorValueComboBox.getSelectedValue()).toBe( 'info' ); + + // Verify rule still exists + await pageObjects.streams.expectRoutingRuleVisible('logs.edit-test'); + }); + + test('should disable Update button when syntax editor has empty condition', async ({ + page, + pageObjects, + }) => { + const routingRuleName = 'logs.edit-test'; + await pageObjects.streams.clickEditRoutingRule(routingRuleName); + + // Switch to syntax editor + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + + // Clear the condition (empty JSON) + await pageObjects.streams.fillConditionEditorWithSyntax(''); + + // Verify Update button is disabled (condition stays at last valid value, no changes made) + const updateButton = page.getByTestId('streamsAppStreamDetailRoutingUpdateButton'); + await expect(updateButton).toBeDisabled(); + + // Note: Error message is NOT shown because invalid JSON is silently ignored + // and the condition remains at its last valid value. This allows users to type + // partial JSON without the state being overridden. + }); + + test('should disable Update button when syntax editor has invalid JSON', async ({ + page, + pageObjects, + }) => { + const routingRuleName = 'logs.edit-test'; + await pageObjects.streams.clickEditRoutingRule(routingRuleName); + + // Switch to syntax editor + await pageObjects.streams.toggleConditionEditorWithSyntaxSwitch(); + + // Enter invalid JSON + await pageObjects.streams.fillConditionEditorWithSyntax('{ invalid json }'); + + // Verify Update button is disabled (condition stays at last valid value, no changes made) + const updateButton = page.getByTestId('streamsAppStreamDetailRoutingUpdateButton'); + await expect(updateButton).toBeDisabled(); + + // Note: Error message is NOT shown because invalid JSON is silently ignored + // and the condition remains at its last valid value. This allows users to type + // partial JSON without the state being overridden. + }); + + test('should disable Update button when no changes have been made', async ({ + page, + pageObjects, + }) => { + const routingRuleName = 'logs.edit-test'; + await pageObjects.streams.clickEditRoutingRule(routingRuleName); + + // Without making any changes, verify Update button is disabled + const updateButton = page.getByTestId('streamsAppStreamDetailRoutingUpdateButton'); + await expect(updateButton).toBeDisabled(); + + // Make a change + await pageObjects.streams.fillConditionEditor({ value: 'updated-service' }); + + // Now the Update button should be enabled + await expect(updateButton).toBeEnabled(); + + // Revert the change back to original value + await pageObjects.streams.fillConditionEditor({ value: 'test-service' }); + + // Update button should be disabled again since we're back to original state + await expect(updateButton).toBeDisabled(); }); test('should remove routing rule with confirmation', async ({ pageObjects }) => { diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/error_handling.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/error_handling.spec.ts index 21ce664f19c0b..ac9e536590a62 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/error_handling.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/error_handling.spec.ts @@ -51,15 +51,23 @@ test.describe( }); test('should recover from API errors during rule updates', async ({ context, pageObjects }) => { - // Create a rule first + // Create a rule first with a field condition (not the default "always" condition) await pageObjects.streams.clickCreateRoutingRule(); await pageObjects.streams.fillRoutingRuleName('error-test'); + await pageObjects.streams.fillConditionEditor({ + field: 'log.level', + value: 'error', + operator: 'equals', + }); await pageObjects.streams.saveRoutingRule(); await pageObjects.toasts.closeAll(); // Edit the rule await pageObjects.streams.clickEditRoutingRule('logs.error-test'); + // Make a change to enable the Update button (hasRoutingChanges guard) + await pageObjects.streams.fillConditionEditor({ value: 'info' }); + // Simulate network failure await context.setOffline(true); diff --git a/x-pack/platform/plugins/shared/task_manager/moon.yml b/x-pack/platform/plugins/shared/task_manager/moon.yml index 6cf2f3e24b53d..3988fb29f92b9 100644 --- a/x-pack/platform/plugins/shared/task_manager/moon.yml +++ b/x-pack/platform/plugins/shared/task_manager/moon.yml @@ -44,6 +44,7 @@ dependsOn: - '@kbn/core-http-server-utils' - '@kbn/licensing-plugin' - '@kbn/rrule' + - '@kbn/response-ops-scheduling-types' - '@kbn/logging-mocks' - '@kbn/spaces-utils' - '@kbn/licensing-types' @@ -51,6 +52,7 @@ dependsOn: - '@kbn/security-plugin-types-server' - '@kbn/connector-specs' - '@kbn/es-errors' + - '@kbn/core-http-server' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts index 9e9d791d51780..24f31e090d019 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts @@ -10,7 +10,9 @@ import type { CoreStart } from '@kbn/core-lifecycle-server'; import type { InvalidateAPIKeyResult, InvalidateAPIKeysParams, + InvalidateUiamAPIKeyParams, } from '@kbn/security-plugin-types-server'; +import type { KibanaRequest } from '@kbn/core/server'; import type { TaskScheduling } from '../task_scheduling'; import type { TaskTypeDictionary } from '../task_type_dictionary'; import { INVALIDATE_API_KEY_SO_NAME, TASK_SO_NAME } from '../saved_objects'; @@ -25,6 +27,11 @@ export type ApiKeyInvalidationFn = ( params: InvalidateAPIKeysParams ) => Promise | undefined; +export type UiamApiKeyInvalidationFn = ( + request: KibanaRequest, + params: InvalidateUiamAPIKeyParams +) => Promise; + export async function scheduleInvalidateApiKeyTask( logger: Logger, taskScheduling: TaskScheduling, diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts index e5a3de46081d1..ef49cfe1b10b0 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts @@ -34,6 +34,17 @@ const mockInvalidatePendingApiKeyObject2 = { references: [], }; +const mockInvalidatePendingUIAMApiKeyObject = { + id: '2', + type: 'api_key_pending_invalidation', + attributes: { + apiKeyId: '111', + createdAt: '2024-04-11T17:08:44.035Z', + uiamApiKey: 'essu_test_uiam_api_key', + }, + references: [], +}; + function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) { return { getDecryptedAsInternalUser: jest.fn(), @@ -45,6 +56,9 @@ function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClien describe('getApiKeyIdsToInvalidate', () => { describe('with encryptedSavedObjectsClient', () => { + afterEach(() => { + jest.clearAllMocks(); + }); const encryptedSavedObjectsClient = createEncryptedSavedObjectsClientMock(); test('should get decrypted api key pending invalidation saved object', async () => { @@ -103,6 +117,67 @@ describe('getApiKeyIdsToInvalidate', () => { apiKeyIdsToExclude: [], }); }); + + test('should get decrypted UIAM api key pending invalidation saved object', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingUIAMApiKeyObject + ); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { + apiKeyId: '111', + createdAt: '2024-04-11T17:08:44.035Z', + uiamApiKey: 'essu_test_uiam_api_key', + }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [], + }); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + 'api_key_pending_invalidation', + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + 'api_key_pending_invalidation', + '2' + ); + expect(internalSavedObjectsRepository.find).not.toHaveBeenCalled(); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '1', apiKeyId: 'abcd====!' }], + uiamApiKeysToInvalidate: [ + { id: '2', apiKeyId: '111', uiamApiKey: 'essu_test_uiam_api_key' }, + ], + apiKeyIdsToExclude: [], + }); + }); }); describe('without encryptedSavedObjectsClient', () => { diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts index 78ce642f442d2..82c5f307d7ec3 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts @@ -17,6 +17,12 @@ export interface ApiKeyIdAndSOId { apiKeyId: string; } +export interface UiamApiKeyAndSOId { + id: string; + apiKeyId: string; + uiamApiKey: string; +} + interface GetApiKeyIdsToInvalidateOpts { apiKeySOsPendingInvalidation: SavedObjectsFindResponse; encryptedSavedObjectsClient?: EncryptedSavedObjectsClient; @@ -27,6 +33,7 @@ interface GetApiKeyIdsToInvalidateOpts { interface GetApiKeysToInvalidateResult { apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; + uiamApiKeysToInvalidate?: UiamApiKeyAndSOId[]; apiKeyIdsToExclude: ApiKeyIdAndSOId[]; } @@ -37,38 +44,64 @@ export async function getApiKeyIdsToInvalidate({ savedObjectType, savedObjectTypesToQuery, }: GetApiKeyIdsToInvalidateOpts): Promise { - let apiKeyIds: ApiKeyIdAndSOId[] = []; + const apiKeyIds: ApiKeyIdAndSOId[] = []; + const uiamApiKeys: UiamApiKeyAndSOId[] = []; + if (encryptedSavedObjectsClient) { // Decrypt the apiKeyId for each pending invalidation SO - apiKeyIds = await Promise.all( + await Promise.all( apiKeySOsPendingInvalidation.saved_objects.map(async (apiKeyPendingInvalidationSO) => { const decryptedApiKeyPendingInvalidationObject = await encryptedSavedObjectsClient.getDecryptedAsInternalUser( savedObjectType, apiKeyPendingInvalidationSO.id ); - return { - id: decryptedApiKeyPendingInvalidationObject.id, - apiKeyId: decryptedApiKeyPendingInvalidationObject.attributes.apiKeyId, - }; + + const { uiamApiKey, apiKeyId } = decryptedApiKeyPendingInvalidationObject.attributes; + if (uiamApiKey) { + uiamApiKeys.push({ + id: decryptedApiKeyPendingInvalidationObject.id, + apiKeyId, + uiamApiKey, + }); + } else { + apiKeyIds.push({ + id: decryptedApiKeyPendingInvalidationObject.id, + apiKeyId, + }); + } }) ); } else { // No decryption needed, return the apiKeyId as-is - apiKeyIds = apiKeySOsPendingInvalidation.saved_objects.map((apiKeyPendingInvalidationSO) => ({ - id: apiKeyPendingInvalidationSO.id, - apiKeyId: apiKeyPendingInvalidationSO.attributes.apiKeyId, - })); + apiKeySOsPendingInvalidation.saved_objects.forEach((apiKeyPendingInvalidationSO) => { + const { uiamApiKey, apiKeyId } = apiKeyPendingInvalidationSO.attributes; + if (uiamApiKey) { + uiamApiKeys.push({ + id: apiKeyPendingInvalidationSO.id, + apiKeyId, + uiamApiKey, + }); + } else { + apiKeyIds.push({ + id: apiKeyPendingInvalidationSO.id, + apiKeyId, + }); + } + }); } // Query saved objects index to see if any API keys are in use const apiKeyIdStrings = apiKeyIds.map(({ apiKeyId }) => apiKeyId); + const uiamApiKeyIdStrings = uiamApiKeys.map(({ apiKeyId }) => apiKeyId); + const allApiKeyIdStrings = apiKeyIdStrings.concat(uiamApiKeyIdStrings); + let apiKeyIdsInUseBuckets: AggregationsStringTermsBucketKeys[] = []; for (const type of savedObjectTypesToQuery) { apiKeyIdsInUseBuckets = apiKeyIdsInUseBuckets.concat( await queryForApiKeysInUse({ - apiKeyIds: apiKeyIdStrings, + apiKeyIds: allApiKeyIdStrings, savedObjectTypeToQuery: type, savedObjectsClient, }) @@ -76,7 +109,9 @@ export async function getApiKeyIdsToInvalidate({ } const apiKeyIdsToInvalidate: ApiKeyIdAndSOId[] = []; + const uiamApiKeysToInvalidate: UiamApiKeyAndSOId[] = []; const apiKeyIdsToExclude: ApiKeyIdAndSOId[] = []; + apiKeyIds.forEach(({ id, apiKeyId }) => { if (apiKeyIdsInUseBuckets.find((bucket) => bucket.key === apiKeyId)) { apiKeyIdsToExclude.push({ id, apiKeyId }); @@ -85,5 +120,17 @@ export async function getApiKeyIdsToInvalidate({ } }); - return { apiKeyIdsToInvalidate, apiKeyIdsToExclude }; + uiamApiKeys.forEach(({ id, apiKeyId, uiamApiKey }) => { + if (apiKeyIdsInUseBuckets.find((bucket) => bucket.key === apiKeyId)) { + apiKeyIdsToExclude.push({ id, apiKeyId }); + } else { + uiamApiKeysToInvalidate.push({ id, apiKeyId, uiamApiKey }); + } + }); + + return { + apiKeyIdsToInvalidate, + apiKeyIdsToExclude, + ...(uiamApiKeysToInvalidate.length > 0 ? { uiamApiKeysToInvalidate } : {}), + }; } diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts index 38d9deb59652b..0a6be1be09c72 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts @@ -9,7 +9,10 @@ import type { InvalidateAPIKeysParams, InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, } from '@kbn/security-plugin-types-server'; -import type { ApiKeyInvalidationFn } from '../invalidate_api_keys_task'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; + +import { type FakeRawRequest, type Headers } from '@kbn/core-http-server'; +import type { ApiKeyInvalidationFn, UiamApiKeyInvalidationFn } from '../invalidate_api_keys_task'; export type InvalidateAPIKeyResult = | { apiKeysEnabled: false } @@ -32,3 +35,36 @@ export async function invalidateAPIKeys( result: invalidateAPIKeyResult, }; } + +export async function invalidateUiamAPIKeys( + params: { + uiamApiKey: string; + apiKeyId: string; + }, + invalidateUiamApiKeyFn?: UiamApiKeyInvalidationFn +): Promise { + if (!invalidateUiamApiKeyFn) { + return { apiKeysEnabled: false }; + } + + const requestHeaders: Headers = {}; + requestHeaders.authorization = `ApiKey ${params.uiamApiKey}`; + const fakeRawRequest: FakeRawRequest = { + headers: requestHeaders, + path: '/', + }; + + const fakeRequest = kibanaRequestFactory(fakeRawRequest); + + const invalidateUiamAPIKeyResult = await invalidateUiamApiKeyFn(fakeRequest, { + id: params.apiKeyId, + }); + // Null when Elasticsearch security is disabled + if (!invalidateUiamAPIKeyResult) { + return { apiKeysEnabled: false }; + } + return { + apiKeysEnabled: true, + result: invalidateUiamAPIKeyResult, + }; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts index e10bd2673c50a..3c204946f8f60 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts @@ -6,13 +6,15 @@ */ import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import type { ApiKeyIdAndSOId } from './get_api_key_ids_to_invalidate'; -import { invalidateAPIKeys } from './invalidate_api_keys'; -import type { ApiKeyInvalidationFn } from '../invalidate_api_keys_task'; +import type { ApiKeyIdAndSOId, UiamApiKeyAndSOId } from './get_api_key_ids_to_invalidate'; +import { invalidateAPIKeys, invalidateUiamAPIKeys } from './invalidate_api_keys'; +import type { ApiKeyInvalidationFn, UiamApiKeyInvalidationFn } from '../invalidate_api_keys_task'; interface InvalidateApiKeysAndDeleteSO { apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; + uiamApiKeysToInvalidate?: UiamApiKeyAndSOId[]; invalidateApiKeyFn?: ApiKeyInvalidationFn; + invalidateUiamApiKeyFn?: UiamApiKeyInvalidationFn; logger: Logger; savedObjectsClient: SavedObjectsClientContract; savedObjectType: string; @@ -20,12 +22,16 @@ interface InvalidateApiKeysAndDeleteSO { export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ apiKeyIdsToInvalidate, + uiamApiKeysToInvalidate, invalidateApiKeyFn, + invalidateUiamApiKeyFn, logger, savedObjectsClient, savedObjectType, }: InvalidateApiKeysAndDeleteSO) { let totalInvalidated = 0; + + // ES APIKey invalidation if (apiKeyIdsToInvalidate.length > 0) { const ids = apiKeyIdsToInvalidate.map(({ apiKeyId }) => apiKeyId); const response = await invalidateAPIKeys({ ids }, invalidateApiKeyFn); @@ -33,19 +39,39 @@ export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ logger.error(`Failed to invalidate API Keys [count="${ids.length}"]`); } else { await Promise.all( - apiKeyIdsToInvalidate.map(async ({ id, apiKeyId }) => { + apiKeyIdsToInvalidate.map(async ({ id }) => { try { await savedObjectsClient.delete(savedObjectType, id); totalInvalidated++; } catch (err) { - logger.error( - `Failed to delete invalidated API key "${apiKeyId}". Error: ${err.message}` - ); + logger.error(`Failed to delete invalidated API key. Error: ${err.message}`); } }) ); } } + + // UIAM APIKey invalidation + if (uiamApiKeysToInvalidate && uiamApiKeysToInvalidate.length > 0) { + for (const { uiamApiKey, apiKeyId, id } of uiamApiKeysToInvalidate) { + const response = await invalidateUiamAPIKeys( + { uiamApiKey, apiKeyId }, + invalidateUiamApiKeyFn + ); + + if (response.apiKeysEnabled === true && response.result.error_count > 0) { + logger.error(`Failed to invalidate UIAM APIKey id`); + } else { + try { + await savedObjectsClient.delete(savedObjectType, id); + totalInvalidated++; + } catch (err) { + logger.error(`Failed to delete invalidated UIAM API key. Error: ${err.message}`); + } + } + } + } + logger.debug(`Total invalidated API keys "${totalInvalidated}"`); return totalInvalidated; } diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts index 4168c05df2da4..b06de7b657158 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts @@ -12,7 +12,7 @@ import { getFindFilter } from './get_find_filter'; import { getApiKeyIdsToInvalidate } from './get_api_key_ids_to_invalidate'; import { PAGE_SIZE } from './constants'; import { invalidateApiKeysAndDeletePendingApiKeySavedObject } from './invalidate_api_keys_and_delete_so'; -import type { ApiKeyInvalidationFn } from '../invalidate_api_keys_task'; +import type { ApiKeyInvalidationFn, UiamApiKeyInvalidationFn } from '../invalidate_api_keys_task'; export interface SavedObjectTypesToQuery { type: string; @@ -22,6 +22,7 @@ export interface SavedObjectTypesToQuery { interface RunInvalidateOpts { encryptedSavedObjectsClient?: EncryptedSavedObjectsClient; invalidateApiKeyFn?: ApiKeyInvalidationFn; + invalidateUiamApiKeyFn?: UiamApiKeyInvalidationFn; logger: Logger; removalDelay: string; savedObjectsClient: SavedObjectsClientContract; @@ -33,6 +34,7 @@ export async function runInvalidate(opts: RunInvalidateOpts) { const { encryptedSavedObjectsClient, invalidateApiKeyFn, + invalidateUiamApiKeyFn, logger, removalDelay, savedObjectsClient, @@ -61,17 +63,21 @@ export async function runInvalidate(opts: RunInvalidateOpts) { }); if (apiKeysToInvalidate.total > 0) { - const { apiKeyIdsToExclude, apiKeyIdsToInvalidate } = await getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: apiKeysToInvalidate, - encryptedSavedObjectsClient, - savedObjectsClient, - savedObjectType, - savedObjectTypesToQuery: opts.savedObjectTypesToQuery, - }); + const { apiKeyIdsToExclude, apiKeyIdsToInvalidate, uiamApiKeysToInvalidate } = + await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: apiKeysToInvalidate, + encryptedSavedObjectsClient, + savedObjectsClient, + savedObjectType, + savedObjectTypesToQuery: opts.savedObjectTypesToQuery, + }); apiKeyIdsToExclude.forEach(({ id }) => excludedSOIds.add(id)); + totalInvalidated += await invalidateApiKeysAndDeletePendingApiKeySavedObject({ apiKeyIdsToInvalidate, + uiamApiKeysToInvalidate, invalidateApiKeyFn, + invalidateUiamApiKeyFn, logger, savedObjectsClient, savedObjectType, diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts index 40ace54a4b56a..2a5ba80f01f71 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts @@ -6,7 +6,10 @@ */ import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; -import { apiKeyToInvalidateSchemaV1 } from '../schemas/api_key_to_invalidate'; +import { + apiKeyToInvalidateSchemaV1, + apiKeyToInvalidateSchemaV2, +} from '../schemas/api_key_to_invalidate'; export const apiKeyToInvalidateModelVersions: SavedObjectsModelVersionMap = { '1': { @@ -16,4 +19,11 @@ export const apiKeyToInvalidateModelVersions: SavedObjectsModelVersionMap = { create: apiKeyToInvalidateSchemaV1, }, }, + '2': { + changes: [], + schemas: { + forwardCompatibility: apiKeyToInvalidateSchemaV2.extends({}, { unknowns: 'ignore' }), + create: apiKeyToInvalidateSchemaV2, + }, + }, }; diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts index 48fc5b367c87e..488404844b30d 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts @@ -13,4 +13,11 @@ export const apiKeyToInvalidateSchemaV1 = schema.object({ createdAt: schema.string(), }); -export type ApiKeyToInvalidate = TypeOf; +// Added here as well because alerting and task_manager invalidate tasks are sharing the same saved object type +export const apiKeyToInvalidateSchemaV2 = schema.object({ + apiKeyId: schema.string(), + createdAt: schema.string(), + uiamApiKey: schema.maybe(schema.string()), +}); + +export type ApiKeyToInvalidate = TypeOf; diff --git a/x-pack/platform/plugins/shared/task_manager/server/task.ts b/x-pack/platform/plugins/shared/task_manager/server/task.ts index 9c70165eeedf2..49003dae3524f 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task.ts @@ -11,9 +11,8 @@ import type { ObjectType, TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import { isNumber } from 'lodash'; import type { KibanaRequest } from '@kbn/core/server'; -import type { Frequency } from '@kbn/rrule'; +import type { IntervalSchedule, RruleSchedule } from '@kbn/response-ops-scheduling-types'; import { isErr, tryAsResult } from './lib/result_type'; -import type { Interval } from './lib/intervals'; import { isInterval, parseIntervalAsMillisecond } from './lib/intervals'; import type { DecoratedError } from './task_running'; @@ -254,54 +253,8 @@ export enum TaskLifecycleResult { } export type TaskLifecycle = TaskStatus | TaskLifecycleResult; -export interface IntervalSchedule { - /** - * An interval in minutes (e.g. '5m'). If specified, this is a recurring task. - * */ - interval: Interval; - rrule?: never; -} - -export type Rrule = RruleMonthly | RruleWeekly | RruleDaily | RruleHourly; -export interface RruleSchedule { - rrule: Rrule; - interval?: never; -} -interface RruleCommon { - dtstart?: string; - freq: Frequency; - interval: number; - tzid: string; -} -interface RruleMonthly extends RruleCommon { - freq: Frequency.MONTHLY; - bymonthday?: number[]; - byhour?: number[]; - byminute?: number[]; - byweekday?: string[]; -} -interface RruleWeekly extends RruleCommon { - freq: Frequency.WEEKLY; - byweekday?: string[]; - byhour?: number[]; - byminute?: number[]; - bymonthday?: never; -} -interface RruleDaily extends RruleCommon { - freq: Frequency.DAILY; - byhour?: number[]; - byminute?: number[]; - byweekday?: string[]; - bymonthday?: never; -} -interface RruleHourly extends RruleCommon { - freq: Frequency.HOURLY; - byhour?: never; - byminute?: number[]; - byweekday?: never; - bymonthday?: never; -} +export type { IntervalSchedule, Rrule, RruleSchedule } from '@kbn/response-ops-scheduling-types'; export interface TaskUserScope { apiKeyId: string; diff --git a/x-pack/platform/plugins/shared/task_manager/tsconfig.json b/x-pack/platform/plugins/shared/task_manager/tsconfig.json index 6aaf1e41e66b2..771a63355ff06 100644 --- a/x-pack/platform/plugins/shared/task_manager/tsconfig.json +++ b/x-pack/platform/plugins/shared/task_manager/tsconfig.json @@ -36,6 +36,7 @@ "@kbn/core-http-server-utils", "@kbn/licensing-plugin", "@kbn/rrule", + "@kbn/response-ops-scheduling-types", "@kbn/logging-mocks", "@kbn/spaces-utils", "@kbn/licensing-types", @@ -43,6 +44,7 @@ "@kbn/security-plugin-types-server", "@kbn/connector-specs", "@kbn/es-errors", + "@kbn/core-http-server", ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/test/accessibility/apps/group1/dashboard_panel_options.ts b/x-pack/platform/test/accessibility/apps/group1/dashboard_panel_options.ts index 20b609272d5fc..80188adec9f2d 100644 --- a/x-pack/platform/test/accessibility/apps/group1/dashboard_panel_options.ts +++ b/x-pack/platform/test/accessibility/apps/group1/dashboard_panel_options.ts @@ -116,13 +116,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardPanelActions.openContextMenuByTitle(title); await testSubjects.click('embeddablePanelAction-OPEN_FLYOUT_ADD_DRILLDOWN'); await a11y.testAppSnapshot(); - await testSubjects.click('actionFactoryItem-DASHBOARD_TO_DASHBOARD_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-dashboard_drilldown'); await a11y.testAppSnapshot(); await testSubjects.click('changeDrilldownType'); - await testSubjects.click('actionFactoryItem-URL_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-url_drilldown'); await a11y.testAppSnapshot(); await testSubjects.click('changeDrilldownType'); - await testSubjects.click('actionFactoryItem-OPEN_IN_DISCOVER_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-discover_drilldown'); await a11y.testAppSnapshot(); await testSubjects.click('drilldownWizardSubmit'); }); diff --git a/x-pack/platform/test/agent_builder/smoke_tests/tests/eis_helpers.ts b/x-pack/platform/test/agent_builder/smoke_tests/tests/eis_helpers.ts index b7b84d14a0b65..794b305ddd629 100644 --- a/x-pack/platform/test/agent_builder/smoke_tests/tests/eis_helpers.ts +++ b/x-pack/platform/test/agent_builder/smoke_tests/tests/eis_helpers.ts @@ -13,6 +13,10 @@ import type { ToolingLog } from '@kbn/tooling-log'; const EIS_MODELS_PATH = resolve(REPO_ROOT, 'target/eis_models.json'); +// Whilst we're waiting on EIS returning metadata about which models can reason +// and use tools, we need to manually exclude the smaller models +const EXCLUDED_MODEL_IDS = ['google-gemini-2.5-flash-lite']; + export interface DiscoveredModel { inferenceId: string; modelId: string; @@ -24,7 +28,8 @@ export const getPreDiscoveredEisModels = (): DiscoveredModel[] => { } try { const data = JSON.parse(readFileSync(EIS_MODELS_PATH, 'utf8')); - return data.models || []; + const models: DiscoveredModel[] = data.models || []; + return models.filter((model) => !EXCLUDED_MODEL_IDS.includes(model.modelId)); } catch { return []; } diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/index.ts b/x-pack/platform/test/agent_builder_api_integration/apis/index.ts index 45660ed808a1c..19a51c4b1b2e0 100644 --- a/x-pack/platform/test/agent_builder_api_integration/apis/index.ts +++ b/x-pack/platform/test/agent_builder_api_integration/apis/index.ts @@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./converse')); loadTestFile(require.resolve('./tools/builtin_tools.ts')); loadTestFile(require.resolve('./tools/builtin_tools_internal.ts')); + loadTestFile(require.resolve('./tools/tool_delete_force.ts')); loadTestFile(require.resolve('./tools/esql_tools.ts')); loadTestFile(require.resolve('./tools/esql_tools_internal.ts')); loadTestFile(require.resolve('./tools/legacy_tool_types_migration.ts')); diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/tools/tool_delete_force.ts b/x-pack/platform/test/agent_builder_api_integration/apis/tools/tool_delete_force.ts new file mode 100644 index 0000000000000..894c934fdf589 --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/apis/tools/tool_delete_force.ts @@ -0,0 +1,227 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 type { Agent } from 'supertest'; +import type { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; + +const TOOL_USED_BY_AGENTS_ERROR_CODE = 'TOOL_USED_BY_AGENTS'; + +const IDS = { + public: { + toolInUse: 'ftr-tool-delete-force-public', + toolUnused: 'ftr-tool-unused', + agent: 'ftr-agent-uses-tool-public', + }, + bulk: { + tools: ['ftr-tool-bulk-1', 'ftr-tool-bulk-2'], + agent: 'ftr-agent-uses-tool-bulk', + }, +} as const; + +function esqlToolPayload(id: string, description: string) { + return { + id, + type: 'esql', + description, + tags: [] as string[], + configuration: { + query: 'FROM .kibana | LIMIT 1', + params: {} as Record, + }, + }; +} + +async function deleteAgentIgnoringErrors( + supertest: Agent, + agentId: string, + log: { warning: (msg: string) => void } +) { + try { + const res = await supertest + .delete(`/api/agent_builder/agents/${agentId}`) + .set('kbn-xsrf', 'kibana'); + if (res.status !== 200 && res.status !== 404) { + log.warning(`Cleanup agent ${agentId}: ${res.status} ${JSON.stringify(res.body)}`); + } + } catch (e) { + log.warning(`Cleanup agent ${agentId}: ${e}`); + } +} + +async function deleteToolIgnoringErrors( + supertest: Agent, + toolId: string, + log: { warning: (msg: string) => void } +) { + try { + const res = await supertest + .delete(`/api/agent_builder/tools/${toolId}?force=true`) + .set('kbn-xsrf', 'kibana'); + if (res.status !== 200 && res.status !== 404) { + log.warning(`Cleanup tool ${toolId}: ${res.status} ${JSON.stringify(res.body)}`); + } + } catch (e) { + log.warning(`Cleanup tool ${toolId}: ${e}`); + } +} + +async function ensureNoAgentOrTools( + supertest: Agent, + agentId: string, + toolIds: readonly string[] +): Promise { + await supertest + .delete(`/api/agent_builder/agents/${agentId}`) + .set('kbn-xsrf', 'kibana') + .catch(() => {}); + for (const id of toolIds) { + await supertest + .delete(`/api/agent_builder/tools/${id}?force=true`) + .set('kbn-xsrf', 'kibana') + .catch(() => {}); + } +} + +async function createTool(supertest: Agent, id: string, description: string) { + await supertest + .post('/api/agent_builder/tools') + .set('kbn-xsrf', 'kibana') + .send(esqlToolPayload(id, description)) + .expect(200); +} + +async function createAgentWithTools( + supertest: Agent, + agentId: string, + name: string, + toolIds: readonly string[] +) { + await supertest + .post('/api/agent_builder/agents') + .set('kbn-xsrf', 'kibana') + .send({ + id: agentId, + name, + description: 'FTR agent for tool delete tests', + configuration: { + instructions: 'Test', + tools: [{ tool_ids: toolIds }], + }, + }) + .expect(200); +} + +function expectConflictWithAgents(response: { body: any }) { + expect(response.body).to.have.property('message'); + expect(response.body.message).to.contain('used by'); + expect(response.body).to.have.property('attributes'); + expect(response.body.attributes).to.have.property('code', TOOL_USED_BY_AGENTS_ERROR_CODE); + expect(response.body.attributes).to.have.property('agents'); + expect(response.body.attributes.agents).to.be.an('array'); + expect(response.body.attributes.agents.length).to.be.greaterThan(0); + const agent = response.body.attributes.agents[0]; + expect(agent).to.have.property('id'); + expect(agent).to.have.property('name'); +} + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const log = getService('log'); + + describe('Tool delete (force and agents in use)', () => { + after(async () => { + await deleteAgentIgnoringErrors(supertest, IDS.public.agent, log); + await deleteAgentIgnoringErrors(supertest, IDS.bulk.agent, log); + for (const id of [IDS.public.toolInUse, IDS.public.toolUnused, ...IDS.bulk.tools]) { + await deleteToolIgnoringErrors(supertest, id, log); + } + }); + + describe('DELETE /api/agent_builder/tools/:id (public)', () => { + before(async () => { + await ensureNoAgentOrTools(supertest, IDS.public.agent, [ + IDS.public.toolInUse, + IDS.public.toolUnused, + ]); + await createTool(supertest, IDS.public.toolInUse, 'FTR tool for delete force tests'); + await createTool(supertest, IDS.public.toolUnused, 'FTR tool not used by any agent'); + await createAgentWithTools(supertest, IDS.public.agent, 'FTR Agent Using Tool', [ + IDS.public.toolInUse, + ]); + }); + + it('returns 200 when tool is not used by any agent and force is not set', async () => { + const response = await supertest + .delete(`/api/agent_builder/tools/${IDS.public.toolUnused}`) + .set('kbn-xsrf', 'kibana') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + + it('returns 409 with agents list when tool is in use and force is not set', async () => { + const response = await supertest + .delete(`/api/agent_builder/tools/${IDS.public.toolInUse}`) + .set('kbn-xsrf', 'kibana') + .expect(409); + expectConflictWithAgents(response); + }); + + it('returns 200 and deletes tool when force=true', async () => { + const response = await supertest + .delete(`/api/agent_builder/tools/${IDS.public.toolInUse}?force=true`) + .set('kbn-xsrf', 'kibana') + .expect(200); + expect(response.body).to.have.property('success', true); + await supertest.get(`/api/agent_builder/tools/${IDS.public.toolInUse}`).expect(404); + }); + }); + + describe('POST /internal/agent_builder/tools/_bulk_delete (internal)', () => { + before(async () => { + await ensureNoAgentOrTools(supertest, IDS.bulk.agent, IDS.bulk.tools); + for (const id of IDS.bulk.tools) { + await createTool(supertest, id, `FTR bulk delete tool ${id}`); + } + await createAgentWithTools( + supertest, + IDS.bulk.agent, + 'FTR Agent Using Bulk Tools', + IDS.bulk.tools + ); + }); + + it('returns 409 with TOOL_USED_BY_AGENTS when any tool is in use and force=false', async () => { + const response = await supertest + .post('/internal/agent_builder/tools/_bulk_delete') + .set('kbn-xsrf', 'kibana') + .set('x-elastic-internal-origin', 'kibana') + .send({ ids: IDS.bulk.tools, force: false }) + .expect(409); + expectConflictWithAgents(response); + }); + + it('returns 200 and deletes tools when force=true', async () => { + const response = await supertest + .post('/internal/agent_builder/tools/_bulk_delete') + .set('kbn-xsrf', 'kibana') + .set('x-elastic-internal-origin', 'kibana') + .send({ ids: IDS.bulk.tools, force: true }) + .expect(200); + expect(response.body).to.have.property('results'); + expect(response.body.results).to.have.length(IDS.bulk.tools.length); + for (let i = 0; i < IDS.bulk.tools.length; i++) { + expect(response.body.results[i]).to.have.property('toolId', IDS.bulk.tools[i]); + expect(response.body.results[i]).to.have.property('success', true); + } + for (const id of IDS.bulk.tools) { + await supertest.get(`/api/agent_builder/tools/${id}`).expect(404); + } + }); + }); + }); +} diff --git a/x-pack/platform/test/api_integration/apis/cases/bulk_get_user_profiles.ts b/x-pack/platform/test/api_integration/apis/cases/bulk_get_user_profiles.ts index ad7f25ac0b71a..c092f7e7f32d6 100644 --- a/x-pack/platform/test/api_integration/apis/cases/bulk_get_user_profiles.ts +++ b/x-pack/platform/test/api_integration/apis/cases/bulk_get_user_profiles.ts @@ -37,7 +37,7 @@ export default ({ getService }: FtrProviderContext): void => { } with roles(s) ${user.roles.join()} can bulk get valid user profiles`, async () => { const suggestedProfiles = await suggestUserProfiles({ supertest: supertestWithoutAuth, - req: { name: user.username, owners: [owner], size: 1 }, + req: { name: user.username, owners: [owner] }, auth: { user, space: null }, }); @@ -50,8 +50,9 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user, space: null }, }); - expect(profiles.length).to.be(1); - expect(profiles[0].user.username).to.eql(user.username); + expect(profiles.length).to.be(8); + const found = profiles.find((profile) => profile.user.username === user.username); + expect(found !== undefined).to.be.ok(); }); } diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/basic.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/basic.ts index df2b2c3b346df..c0ea9edac87d1 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/basic.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/basic.ts @@ -798,6 +798,81 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { await putStream(apiClient, 'logs.super.duper.hyper.deeply.nested.streamname', body, 400); }); + + describe('stream name validation', () => { + const validStreamBody: Streams.WiredStream.UpsertRequest = { + ...emptyAssets, + stream: { + description: '', + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [] }, + settings: {}, + wired: { fields: {}, routing: [] }, + failure_store: { inherit: {} }, + }, + }, + }; + + it('fails to create a wired stream with uppercase characters in the name', async () => { + const response = await putStream(apiClient, 'logs.UpperCase', validStreamBody, 400); + expect((response as unknown as { message: string }).message).to.contain( + 'Stream name cannot contain uppercase characters.' + ); + }); + + it('fails to create a wired stream with spaces in the name', async () => { + const response = await putStream(apiClient, 'logs.with space', validStreamBody, 400); + expect((response as unknown as { message: string }).message).to.contain( + 'Stream name cannot contain spaces.' + ); + }); + + it('fails to create a wired stream with asterisk in the name', async () => { + const response = await putStream(apiClient, 'logs.with*asterisk', validStreamBody, 400); + expect((response as unknown as { message: string }).message).to.contain( + 'Stream name cannot contain "*".' + ); + }); + + it('fails to create a wired stream with angle brackets in the name', async () => { + const response = await putStream(apiClient, 'logs.with', validStreamBody, 400); + expect((response as unknown as { message: string }).message).to.contain( + 'Stream name cannot contain "<".' + ); + }); + + it('fails to create a wired stream with question mark in the name', async () => { + const response = await putStream(apiClient, 'logs.with?question', validStreamBody, 400); + expect((response as unknown as { message: string }).message).to.contain( + 'Stream name cannot contain "?".' + ); + }); + + it('fails to create a wired stream with pipe in the name', async () => { + const response = await putStream(apiClient, 'logs.with|pipe', validStreamBody, 400); + expect((response as unknown as { message: string }).message).to.contain( + 'Stream name cannot contain "|".' + ); + }); + + it('fails to fork a wired stream with special characters in the destination name', async () => { + const body = { + stream: { + name: 'logs.with*special', + }, + where: { + field: 'log.logger', + eq: 'test', + }, + status, + }; + const response = await forkStream(apiClient, 'logs', body, 400); + expect((response as unknown as { message: string }).message).to.contain( + 'Stream name cannot contain "*".' + ); + }); + }); }); }); } diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/classic.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/classic.ts index fa8ca9ad590d4..332464b3d5f01 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/classic.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/classic.ts @@ -538,6 +538,94 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }); }); + describe('Classic stream name validation', () => { + const validClassicStreamBody = { + ...emptyAssets, + stream: { + description: '', + ingest: { + lifecycle: { inherit: {} }, + processing: { steps: [] }, + settings: {}, + classic: {}, + failure_store: { inherit: {} }, + }, + }, + }; + + it('fails to create a classic stream with uppercase characters in the name', async () => { + const response = await putStream( + apiClient, + 'logs-UpperCase-default', + validClassicStreamBody, + 400 + ); + expect((response as any).message).to.eql( + 'Desired stream state is invalid: Stream name cannot contain uppercase characters.' + ); + }); + + it('fails to create a classic stream with spaces in the name', async () => { + const response = await putStream( + apiClient, + 'logs-with space-default', + validClassicStreamBody, + 400 + ); + expect((response as any).message).to.eql( + 'Desired stream state is invalid: Stream name cannot contain spaces.' + ); + }); + + it('fails to create a classic stream with asterisk in the name', async () => { + const response = await putStream( + apiClient, + 'logs-with*asterisk-default', + validClassicStreamBody, + 400 + ); + expect((response as any).message).to.eql( + 'Desired stream state is invalid: Stream name cannot contain "*".' + ); + }); + + it('fails to create a classic stream with angle brackets in the name', async () => { + const response = await putStream( + apiClient, + 'logs-with-default', + validClassicStreamBody, + 400 + ); + expect((response as any).message).to.eql( + 'Desired stream state is invalid: Stream name cannot contain "<".' + ); + }); + + it('fails to create a classic stream with question mark in the name', async () => { + const response = await putStream( + apiClient, + 'logs-with?question-default', + validClassicStreamBody, + 400 + ); + expect((response as any).message).to.eql( + 'Desired stream state is invalid: Stream name cannot contain "?".' + ); + }); + + it('fails to create a classic stream with pipe in the name', async () => { + const response = await putStream( + apiClient, + 'logs-with|pipe-default', + validClassicStreamBody, + 400 + ); + expect((response as any).message).to.eql( + 'Desired stream state is invalid: Stream name cannot contain "|".' + ); + }); + }); + describe('Classic streams sharing template/pipeline', () => { const TEMPLATE_NAME = 'my-shared-template'; const FIRST_STREAM_NAME = 'mytest-first'; diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/content.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/content.ts index a803a3f875f71..8f49da1dbe848 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/content.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/content.ts @@ -71,7 +71,15 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { 'logs.branch_a.child1.nested', upsertRequest({ queries: [ - { id: 'my-error-query', title: 'error query', kql: { query: 'message: ERROR' } }, + { + id: 'my-error-query', + title: 'error query', + kql: { query: 'message: ERROR' }, + esql: { + query: + 'FROM logs.branch_a.child1.nested,logs.branch_a.child1.nested.* | WHERE KQL("message: ERROR")', + }, + }, ], }) ); @@ -253,6 +261,10 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'my-error-query', title: 'error query', kql: { query: 'message: ERROR' }, + esql: { + query: + 'FROM logs.branch_a.child1.nested,logs.branch_a.child1.nested.* | WHERE KQL("message: ERROR")', + }, }, ]); }); @@ -497,6 +509,10 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'my-error-query', title: 'error query', kql: { query: 'message: ERROR' }, + esql: { + query: + 'FROM logs.branch_c.nested,logs.branch_c.nested.* | WHERE KQL("message: ERROR")', + }, }, ]); }); @@ -748,7 +764,15 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }, ...emptyAssets, queries: [ - { id: 'my-error-query', title: 'error query', kql: { query: 'message: ERROR' } }, + { + id: 'my-error-query', + title: 'error query', + kql: { query: 'message: ERROR' }, + esql: { + query: + 'FROM logs.branch_a.child1.nested,logs.branch_a.child1.nested.* | WHERE KQL("message: ERROR")', + }, + }, ], }, }, diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/migration_on_read.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/migration_on_read.ts index eef726b606217..fd759b4189e46 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/migration_on_read.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/migration_on_read.ts @@ -208,6 +208,9 @@ const expectedQueriesResponse = { id: '12345', title: 'Test', kql: { query: 'atest' }, + esql: { + query: `FROM ${TEST_STREAM_NAME},${TEST_STREAM_NAME}.* | WHERE KQL("atest")`, + }, }, ], }; diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/queries.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/queries.ts index 44dbddaf0165b..26b34016688f6 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/queries.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/queries.ts @@ -88,11 +88,21 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { it('lists queries when defined on the stream', async () => { const queries = [ - { id: v4(), title: 'OutOfMemoryError', kql: { query: "message:'OutOfMemoryError'" } }, + { + id: v4(), + title: 'OutOfMemoryError', + kql: { query: "message:'OutOfMemoryError'" }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("message:'OutOfMemoryError'")`, + }, + }, { id: v4(), title: 'cluster_block_exception', kql: { query: "message:'cluster_block_exception'" }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("message:'cluster_block_exception'")`, + }, }, ]; @@ -123,6 +133,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: v4(), title: 'initial title', kql: { query: "message:'initial query'" }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("message:'initial query'")`, + }, }; const upsertQueryResponse = await apiClient .fetch('PUT /api/streams/{name}/queries/{queryId} 2023-10-31', { @@ -151,6 +164,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'first', title: 'initial title', kql: { query: 'initial query' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("initial query")` }, }; await putStream(apiClient, STREAM_NAME, { stream, @@ -179,6 +193,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: query.id, title: query.title, kql: { query: 'updated query' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("updated query")` }, }, ]); @@ -193,6 +208,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'first', title: 'initial title', kql: { query: 'initial query' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("initial query")` }, }; await putStream(apiClient, STREAM_NAME, { stream, @@ -221,6 +237,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: query.id, title: 'updated title', kql: { query: query.kql.query }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("initial query")` }, }, ]); @@ -240,6 +257,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'feature-query', title: 'query with feature', kql: { query: 'test query' }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("test query") AND \`host.name\` == "host1"`, + }, feature: initialFeature, }; @@ -291,6 +311,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'feature-query-unchanged', title: 'initial title', kql: { query: 'initial query' }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("initial query") AND \`host.name\` == "host1"`, + }, feature, }; @@ -328,6 +351,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: query.id, title: 'updated title', kql: { query: 'updated query' }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("updated query") AND \`host.name\` == "host1"`, + }, feature, }, ]); @@ -344,6 +370,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: queryId, title: 'Significant Query', kql: { query: "message:'query'" }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("message:'query'")` }, }, ], }); @@ -377,16 +404,19 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'first', title: 'first query', kql: { query: 'query 1' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("query 1")` }, }; const secondQuery = { id: 'second', title: 'second query', kql: { query: 'query 2' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("query 2")` }, }; const thirdQuery = { id: 'third', title: 'third query', kql: { query: 'query 3' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("query 3")` }, }; await putStream(apiClient, STREAM_NAME, { stream, @@ -399,13 +429,18 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'fourth', title: 'fourth query', kql: { query: 'query 4' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("query 4")` }, }; const updateThirdQuery = { id: 'third', title: 'third query', kql: { query: 'query 3 updated' }, + esql: { query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("query 3 updated")` }, }; + const { esql: _ne, ...newQueryInput } = newQuery; + const { esql: _ute, ...updateThirdQueryInput } = updateThirdQuery; + const bulkResponse = await apiClient .fetch('POST /api/streams/{name}/queries/_bulk 2023-10-31', { params: { @@ -413,7 +448,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { body: { operations: [ { - index: newQuery, + index: newQueryInput, }, { delete: { @@ -421,7 +456,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }, }, { - index: updateThirdQuery, + index: updateThirdQueryInput, }, { delete: { @@ -464,6 +499,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'feature-query-bulk', title: 'query with feature', kql: { query: 'test query' }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("test query") AND \`host.name\` == "host1"`, + }, feature: initialFeature, }; @@ -486,6 +524,8 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { type: 'system' as const, }; + const { esql, ...queryWithFeatureInput } = queryWithFeature; + await apiClient .fetch('POST /api/streams/{name}/queries/_bulk 2023-10-31', { params: { @@ -494,7 +534,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { operations: [ { index: { - ...queryWithFeature, + ...queryWithFeatureInput, feature: updatedFeature, }, }, diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/significant_events.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/significant_events.ts index dcc6ab7db198d..97b1113139591 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/significant_events.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/significant_events.ts @@ -78,7 +78,16 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const response = await putStream(apiClient, STREAM_NAME, { stream, ...emptyAssets, - queries: [{ id: 'aaa', title: 'OOM Error', kql: { query: "message: 'OOM Error'" } }], + queries: [ + { + id: 'aaa', + title: 'OOM Error', + kql: { query: "message: 'OOM Error'" }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("message: 'OOM Error'")`, + }, + }, + ], }); expect(response).to.have.property('acknowledged', true); @@ -88,6 +97,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'aaa', title: 'OOM Error', kql: { query: "message: 'OOM Error'" }, + esql: { + query: `FROM ${STREAM_NAME},${STREAM_NAME}.* | WHERE KQL("message: 'OOM Error'")`, + }, }); }); @@ -117,6 +129,10 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'logs.queries-test.query1', title: 'should not be deleted', kql: { query: 'message:"irrelevant"' }, + esql: { + query: + 'FROM logs.queries-test,logs.queries-test.* | WHERE KQL("message:\\"irrelevant\\"")', + }, }, ], }); @@ -156,6 +172,10 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'logs.queries-test.child.query1', title: 'must be deleted', kql: { query: 'message:"irrelevant"' }, + esql: { + query: + 'FROM logs.queries-test.child,logs.queries-test.child.* | WHERE KQL("message:\\"irrelevant\\"")', + }, }, ], }); @@ -169,11 +189,19 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'logs.queries-test.child.first.query1', title: 'must be deleted', kql: { query: 'message:"irrelevant"' }, + esql: { + query: + 'FROM logs.queries-test.child.first,logs.queries-test.child.first.* | WHERE KQL("message:\\"irrelevant\\"")', + }, }, { id: 'logs.queries-test.child.first.query2', title: 'must be deleted', kql: { query: 'message:"irrelevant"' }, + esql: { + query: + 'FROM logs.queries-test.child.first,logs.queries-test.child.first.* | WHERE KQL("message:\\"irrelevant\\"")', + }, }, ], }); @@ -243,7 +271,16 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { await putStream(apiClient, indexName, { ...classicPutBody, - queries: [{ id: 'aaa', title: 'OOM Error', kql: { query: "message: 'OOM Error'" } }], + queries: [ + { + id: 'aaa', + title: 'OOM Error', + kql: { query: "message: 'OOM Error'" }, + esql: { + query: `FROM ${indexName} | WHERE KQL("message: 'OOM Error'")`, + }, + }, + ], }); streamDefinition = await getStream(apiClient, indexName); @@ -253,6 +290,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'aaa', title: 'OOM Error', kql: { query: "message: 'OOM Error'" }, + esql: { + query: `FROM ${indexName} | WHERE KQL("message: 'OOM Error'")`, + }, }); await clean(); diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/snapshot_restore.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/snapshot_restore.ts index 4cb2a144361f3..85e014e7918f8 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/snapshot_restore.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/streams/snapshot_restore.ts @@ -174,6 +174,10 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { id: 'slow-requests', title: 'Slow Requests', kql: { query: 'attributes.response_time_ms > 100' }, + esql: { + query: + 'FROM logs.web-app,logs.web-app.* | WHERE KQL("attributes.response_time_ms > 100")', + }, }, ], }; diff --git a/x-pack/platform/test/cases_api_integration/common/lib/api/configuration.ts b/x-pack/platform/test/cases_api_integration/common/lib/api/configuration.ts index 1fcb1043d97fd..afc8339f48878 100644 --- a/x-pack/platform/test/cases_api_integration/common/lib/api/configuration.ts +++ b/x-pack/platform/test/cases_api_integration/common/lib/api/configuration.ts @@ -48,13 +48,27 @@ export const getConfigurationRequest = ({ }; }; +export const elasticUserProfileId = 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0'; + export const getConfigurationOutput = (update = false, overwrite = {}): Partial => { return { ...getConfigurationRequest(), error: null, mappings: [], - created_by: { email: null, full_name: null, username: 'elastic' }, - updated_by: update ? { email: null, full_name: null, username: 'elastic' } : null, + created_by: { + email: null, + full_name: null, + username: 'elastic', + profile_uid: elasticUserProfileId, + }, + updated_by: update + ? { + email: null, + full_name: null, + username: 'elastic', + profile_uid: elasticUserProfileId, + } + : null, customFields: [], observableTypes: [], ...overwrite, diff --git a/x-pack/platform/test/cases_api_integration/common/lib/api/helpers.ts b/x-pack/platform/test/cases_api_integration/common/lib/api/helpers.ts index 984f7b88309eb..d0a7d51b1df09 100644 --- a/x-pack/platform/test/cases_api_integration/common/lib/api/helpers.ts +++ b/x-pack/platform/test/cases_api_integration/common/lib/api/helpers.ts @@ -21,7 +21,11 @@ export const setupAuth = ({ headers: Record; auth?: { user: User; space: string | null } | null; }): SuperTest.Test => { - if (!Object.hasOwn(headers, 'Cookie') && auth != null) { + if ( + !Object.hasOwn(headers, 'Cookie') && + !Object.hasOwn(headers, 'Authorization') && + auth != null + ) { return apiCall.auth(auth.user.username, auth.user.password); } diff --git a/x-pack/platform/test/cases_api_integration/common/lib/mock.ts b/x-pack/platform/test/cases_api_integration/common/lib/mock.ts index d7e8d9c4e9217..e1c735868c4fe 100644 --- a/x-pack/platform/test/cases_api_integration/common/lib/mock.ts +++ b/x-pack/platform/test/cases_api_integration/common/lib/mock.ts @@ -31,7 +31,12 @@ import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; import { FILE_SO_TYPE } from '@kbn/files-plugin/common'; import type { AttachmentRequest, CasesFindResponse } from '@kbn/cases-plugin/common/types/api'; -export const defaultUser = { email: null, full_name: null, username: 'elastic' }; +export const defaultUser = { + email: null, + full_name: null, + username: 'elastic', + profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', +}; /** * A null filled user will occur when the security plugin is disabled */ diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/analytics_index/synchronization.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/analytics_index/synchronization.ts index 58ba2b0856c95..fd6ffd4cea3a0 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/analytics_index/synchronization.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/analytics_index/synchronization.ts @@ -27,6 +27,7 @@ import { getConfigurationRequest, updateCase, deleteAllCaseAnalyticsItems, + elasticUserProfileId, } from '../../../../../common/lib/api'; import { postCaseReq, @@ -129,7 +130,7 @@ export default ({ getService }: FtrProviderContext): void => { created_by: { email: null, full_name: null, - profile_uid: null, + profile_uid: elasticUserProfileId, username: 'elastic', }, custom_fields: [ @@ -247,6 +248,7 @@ export default ({ getService }: FtrProviderContext): void => { email: null, full_name: null, username: 'elastic', + profile_uid: elasticUserProfileId, }, owner: 'securitySolution', space_ids: ['default'], diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts index 20fe91e879c06..6ceb589591a4a 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts @@ -40,6 +40,11 @@ import { } from '../../../../common/lib/authentication/users'; import { getUserInfo } from '../../../../common/lib/authentication'; +export const secOnlyUserWithProfileId = { + ...getUserInfo(secOnly), + profile_uid: 'u_wkt3gf0rIZhCK_z5XG1De6X-qfAJFidfVACakLFBVDM_0', +}; + export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); @@ -149,7 +154,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment).to.eql({ type: postCommentUserReq.type, comment: postCommentUserReq.comment, - created_by: getUserInfo(secOnly), + created_by: secOnlyUserWithProfileId, pushed_at: null, pushed_by: null, updated_by: null, diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts index 69f0aeaaccee3..7698abb8b71c8 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts @@ -22,6 +22,7 @@ import { obsSec, } from '../../../../../common/lib/authentication/users'; import { getUserInfo } from '../../../../../common/lib/authentication'; +import { secOnlyUserWithProfileId } from '../get_case'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); @@ -70,20 +71,25 @@ export default ({ getService }: FtrProviderContext): void => { } ); + const obsOnlyUserWithProfileId = { + ...getUserInfo(obsOnly), + profile_uid: 'u_kpgtQw15fFoPrDevkTCq5IKvOoyCpASwS3H8Aodsn28_0', + }; + for (const scenario of [ { user: globalRead, - expectedReporters: [getUserInfo(obsOnly), getUserInfo(secOnly)], + expectedReporters: [obsOnlyUserWithProfileId, secOnlyUserWithProfileId], }, { user: superUser, - expectedReporters: [getUserInfo(obsOnly), getUserInfo(secOnly)], + expectedReporters: [obsOnlyUserWithProfileId, secOnlyUserWithProfileId], }, - { user: secOnlyRead, expectedReporters: [getUserInfo(secOnly)] }, - { user: obsOnlyRead, expectedReporters: [getUserInfo(obsOnly)] }, + { user: secOnlyRead, expectedReporters: [secOnlyUserWithProfileId] }, + { user: obsOnlyRead, expectedReporters: [obsOnlyUserWithProfileId] }, { user: obsSecRead, - expectedReporters: [getUserInfo(obsOnly), getUserInfo(secOnly)], + expectedReporters: [obsOnlyUserWithProfileId, secOnlyUserWithProfileId], }, ]) { const reporters = await getReporters({ @@ -157,7 +163,7 @@ export default ({ getService }: FtrProviderContext): void => { query: { owner: 'securitySolutionFixture' }, }); - expect(reporters).to.eql([getUserInfo(secOnly)]); + expect(reporters).to.eql([secOnlyUserWithProfileId]); }); it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { @@ -193,7 +199,7 @@ export default ({ getService }: FtrProviderContext): void => { }); // Only security solution reporters are being returned - expect(reporters).to.eql([getUserInfo(secOnly)]); + expect(reporters).to.eql([secOnlyUserWithProfileId]); }); }); }); diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts index f2ddbc984bd16..b940b1ddd43af 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts @@ -9,6 +9,8 @@ import expect from '@kbn/expect'; import type { Cookie } from 'tough-cookie'; import type { UserProfile } from '@kbn/security-plugin/common'; import { GetCaseUsersResponseRt } from '@kbn/cases-plugin/common/types/api'; +import type { Client } from '@elastic/elasticsearch'; +import type { SecurityRoleDescriptor } from '@elastic/elasticsearch/lib/api/types'; import { securitySolutionOnlyAllSpacesRole } from '../../../../common/lib/authentication/roles'; import { getPostCaseRequest } from '../../../../common/lib/mock'; import { @@ -41,6 +43,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertestWithoutAuth = getService('supertestWithoutAuth'); const es = getService('es'); const kibanaServer = getService('kibanaServer'); + const esClient: Client = getService('es'); // Use simple image data URL to match server side validation of image type const IMAGE_URL_TEST = @@ -53,12 +56,42 @@ export default ({ getService }: FtrProviderContext): void => { describe('no profiles', () => { it('returns the users correctly without profile ids', async () => { - const postedCase = await createCase(supertest, getPostCaseRequest()); + // We need an API key without security or API privileges to ensure that requests + // will not be able to retrieve profile information + const roleDescriptors: Record = { + some_role: { + indices: [{ names: ['*'], privileges: ['read', 'view_index_metadata'] }], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['*'], + resources: ['*'], + }, + ], + }, + }; + const apiKey = await esClient.security.createApiKey({ + name: `No profile key`, + role_descriptors: roleDescriptors, + }); + const headers = { + Authorization: `apikey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString( + 'base64' + )}`, + }; + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + headers + ); await changeCaseTitle({ - supertest, + supertest: supertestWithoutAuth, caseId: postedCase.id, version: postedCase.version, title: 'new title', + headers, }); const { participants, assignees, unassignedUsers, reporter } = await getCaseUsers({ @@ -67,9 +100,11 @@ export default ({ getService }: FtrProviderContext): void => { }); expect(participants).to.eql([ - { user: { username: 'elastic', full_name: null, email: null } }, + { user: { username: 'system_indices_superuser', full_name: null, email: null } }, ]); - expect(reporter).to.eql({ user: { username: 'elastic', full_name: null, email: null } }); + expect(reporter).to.eql({ + user: { username: 'system_indices_superuser', full_name: null, email: null }, + }); expect(assignees).to.eql([]); expect(unassignedUsers).to.eql([]); }); diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/cases/patch_case.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/cases/patch_case.ts index c4ef414d600d4..dc10450c8b49f 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/cases/patch_case.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/cases/patch_case.ts @@ -16,7 +16,12 @@ import { } from '../../../../common/lib/api'; import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; -export const defaultUser = { email: null, full_name: null, username: 'elastic' }; +export const defaultUser = { + email: null, + full_name: null, + username: 'elastic', + profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', +}; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts index 570799d7ef257..4780c8329a0d5 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts @@ -47,6 +47,7 @@ import { getConfigurationRequest, createConfiguration, createComment, + elasticUserProfileId, } from '../../../../../common/lib/api'; import { getPostCaseRequest, postCommentAlertReq } from '../../../../../common/lib/mock'; import type { FtrProviderContext } from '../../../../../common/ftr_provider_context'; @@ -335,11 +336,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'none', type: '.none', }, - created_by: { - email: null, - full_name: null, - username: 'elastic', - }, + created_by: expectedUser, customFields: [], description: "This case was created by the rule ['Test rule'](https://example.com/rules/rule-test-id).", @@ -358,11 +355,7 @@ export default ({ getService }: FtrProviderContext): void => { totalAlerts: 5, totalComment: 0, totalEvents: 0, - updated_by: { - email: null, - full_name: null, - username: 'elastic', - }, + updated_by: expectedUser, observables: [], total_observables: 0, }); @@ -446,11 +439,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'Jira', type: '.jira', }, - created_by: { - email: null, - full_name: null, - username: 'elastic', - }, + created_by: expectedUser, customFields: [ { key: 'first_custom_field_key', @@ -474,11 +463,7 @@ export default ({ getService }: FtrProviderContext): void => { totalAlerts: 5, totalComment: 0, totalEvents: 0, - updated_by: { - email: null, - full_name: null, - username: 'elastic', - }, + updated_by: expectedUser, observables: [], total_observables: 0, }); @@ -825,11 +810,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'none', type: '.none', }, - created_by: { - email: null, - full_name: null, - username: 'elastic', - }, + created_by: expectedUser, customFields: [], description: "This case was created by the rule ['Test rule'](https://example.com/rules/rule-test-id). The assigned alerts are grouped by `host.name: A`.", @@ -855,11 +836,7 @@ export default ({ getService }: FtrProviderContext): void => { totalAlerts: 3, totalComment: 0, totalEvents: 0, - updated_by: { - email: null, - full_name: null, - username: 'elastic', - }, + updated_by: expectedUser, observables: [], total_observables: 0, }); @@ -876,11 +853,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'none', type: '.none', }, - created_by: { - email: null, - full_name: null, - username: 'elastic', - }, + created_by: expectedUser, customFields: [], description: "This case was created by the rule ['Test rule'](https://example.com/rules/rule-test-id). The assigned alerts are grouped by `host.name: B`.", @@ -906,11 +879,7 @@ export default ({ getService }: FtrProviderContext): void => { totalAlerts: 2, totalComment: 0, totalEvents: 0, - updated_by: { - email: null, - full_name: null, - username: 'elastic', - }, + updated_by: expectedUser, observables: [], total_observables: 0, }); @@ -1346,11 +1315,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'none', type: '.none', }, - created_by: { - email: null, - full_name: null, - username: 'elastic', - }, + created_by: expectedUser, customFields: [], description: "This case was created by the rule ['Test rule'](https://example.com/rules/rule-test-id). The assigned alerts are grouped by `field_name_1: field_value_1`.", @@ -1376,11 +1341,7 @@ export default ({ getService }: FtrProviderContext): void => { totalEvents: 0, totalAlerts: 2, totalComment: 1, - updated_by: { - email: null, - full_name: null, - username: 'elastic', - }, + updated_by: expectedUser, observables: [], total_observables: 0, }); @@ -1397,11 +1358,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'none', type: '.none', }, - created_by: { - email: null, - full_name: null, - username: 'elastic', - }, + created_by: expectedUser, customFields: [], description: "This case was created by the rule ['Test rule'](https://example.com/rules/rule-test-id). The assigned alerts are grouped by `field_name_2: field_value_2`.", @@ -1427,11 +1384,7 @@ export default ({ getService }: FtrProviderContext): void => { totalAlerts: 2, totalComment: 2, totalEvents: 0, - updated_by: { - email: null, - full_name: null, - username: 'elastic', - }, + updated_by: expectedUser, observables: [], total_observables: 0, }); @@ -1448,11 +1401,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'none', type: '.none', }, - created_by: { - email: null, - full_name: null, - username: 'elastic', - }, + created_by: expectedUser, customFields: [], description: "This case was created by the rule ['Test rule'](https://example.com/rules/rule-test-id). The assigned alerts are grouped by `field_name_1: field_value_3`.", @@ -1478,11 +1427,7 @@ export default ({ getService }: FtrProviderContext): void => { totalAlerts: 1, totalComment: 0, totalEvents: 0, - updated_by: { - email: null, - full_name: null, - username: 'elastic', - }, + updated_by: expectedUser, observables: [], total_observables: 0, }); @@ -1906,6 +1851,13 @@ const verifyAlertsAttachedToCase = ({ } }; +const expectedUser = { + username: 'elastic', + full_name: null, + email: null, + profile_uid: elasticUserProfileId, +}; + const createCaseWithId = async ({ kibanaServer, caseId, @@ -1940,7 +1892,7 @@ const createCaseWithId = async ({ updated_at: null, updated_by: null, created_at: new Date().toISOString(), - created_by: { username: 'elastic', full_name: null, email: null }, + created_by: expectedUser, duration: 0, external_service: null, total_alerts: 0, diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/internal/suggest_user_profiles.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/internal/suggest_user_profiles.ts index bc7fc84e83ed0..49ff403ec9944 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/internal/suggest_user_profiles.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/internal/suggest_user_profiles.ts @@ -57,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { `); }); - it('find a user who only has read privilege for cases', async () => { + it(`find users with name that includes 'read'`, async () => { const profiles = await suggestUserProfiles({ supertest: supertestWithoutAuth, req: { @@ -77,6 +77,14 @@ export default function ({ getService }: FtrProviderContext) { "username": "sec_only_read", }, }, + Object { + "data": Object {}, + "user": Object { + "email": "sec_only_read_create_comment@elastic.co", + "full_name": "sec only_read_create_comment", + "username": "sec_only_read_create_comment", + }, + }, ] `); }); @@ -116,6 +124,22 @@ export default function ({ getService }: FtrProviderContext) { "username": "sec_only_read", }, }, + Object { + "data": Object {}, + "user": Object { + "email": "sec_only_no_create_comment@elastic.co", + "full_name": "sec only_no_create_comment", + "username": "sec_only_no_create_comment", + }, + }, + Object { + "data": Object {}, + "user": Object { + "email": "sec_only_read_create_comment@elastic.co", + "full_name": "sec only_read_create_comment", + "username": "sec_only_read_create_comment", + }, + }, Object { "data": Object {}, "user": Object { @@ -260,7 +284,7 @@ export default function ({ getService }: FtrProviderContext) { await deleteUsersAndRoles(getService, users, roles); }); - it('finds 4 profiles when searching for the name sec when a user has both security and observability privileges', async () => { + it(`finds 6 profiles when searching for the name 'sec' when a user has both security and observability privileges`, async () => { const profiles = await suggestUserProfiles({ supertest: supertestWithoutAuth, req: { @@ -280,6 +304,22 @@ export default function ({ getService }: FtrProviderContext) { "username": "sec_only_read", }, }, + Object { + "data": Object {}, + "user": Object { + "email": "sec_only_no_create_comment@elastic.co", + "full_name": "sec only_no_create_comment", + "username": "sec_only_no_create_comment", + }, + }, + Object { + "data": Object {}, + "user": Object { + "email": "sec_only_read_create_comment@elastic.co", + "full_name": "sec only_read_create_comment", + "username": "sec_only_read_create_comment", + }, + }, Object { "data": Object {}, "user": Object { diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/user_profiles/get_current.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/user_profiles/get_current.ts index 081236deb0093..fc020babf1802 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/user_profiles/get_current.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/trial/user_profiles/get_current.ts @@ -9,9 +9,12 @@ import expect from '@kbn/expect'; import { AttachmentType } from '@kbn/cases-plugin/common'; import type { CreateCaseUserAction, User } from '@kbn/cases-plugin/common/types/domain'; import { CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; +import type { + SecurityCreateApiKeyResponse, + SecurityRoleDescriptor, +} from '@elastic/elasticsearch/lib/api/types'; import { setupSuperUserProfile } from '../../../../common/lib/api/user_profiles'; import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { superUser } from '../../../../common/lib/authentication/users'; import { createCase, createComment, @@ -34,12 +37,38 @@ export default function ({ getService }: FtrProviderContext) { describe('get_current', () => { let headers: Record; let superUserWithProfile: User; - let superUserInfo: User; + let apiKey: SecurityCreateApiKeyResponse; + let noProfileHeaders: Record; + + // For "profile is not available" tests, we need an API key without security or API key + // privileges to ensure that requests will not be able to retrieve profile info + const roleDescriptors: Record = { + some_role: { + indices: [{ names: ['*'], privileges: ['read', 'view_index_metadata'] }], + applications: [ + { + application: 'kibana-.kibana', + privileges: [ + 'feature_securitySolutionCasesV2.all', + 'feature_securitySolutionFixture.all', + ], + resources: ['*'], + }, + ], + }, + }; before(async () => { - ({ headers, superUserInfo, superUserWithProfile } = await setupSuperUserProfile( - getService - )); + ({ headers, superUserWithProfile } = await setupSuperUserProfile(getService)); + apiKey = await es.security.createApiKey({ + name: `No profile key`, + role_descriptors: roleDescriptors, + }); + noProfileHeaders = { + Authorization: `apikey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString( + 'base64' + )}`, + }; }); afterEach(async () => { @@ -67,10 +96,14 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { - const caseInfo = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { - user: superUser, - space: null, - }); + // Use the API key that cannot get profile info to create the case + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + noProfileHeaders + ); const { userActions } = await findCaseUserActions({ supertest: supertestWithoutAuth, @@ -78,7 +111,11 @@ export default function ({ getService }: FtrProviderContext) { }); const createCaseUserAction = userActions[0] as unknown as CreateCaseUserAction; - expect(createCaseUserAction.created_by).to.eql(superUserInfo); + expect(createCaseUserAction.created_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); }); @@ -98,12 +135,20 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { + // Use the API key that cannot get profile info to create the config const configuration = await createConfiguration( supertestWithoutAuth, - getConfigurationRequest({ id: 'connector-2' }) + getConfigurationRequest({ id: 'connector-2' }), + 200, + null, + noProfileHeaders ); - expect(configuration.created_by).to.eql(superUserInfo); + expect(configuration.created_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); @@ -133,9 +178,13 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { + // Use the API key that cannot get profile info const configuration = await createConfiguration( supertestWithoutAuth, - getConfigurationRequest({ id: 'connector-2' }) + getConfigurationRequest({ id: 'connector-2' }), + 200, + null, + noProfileHeaders ); const newConfiguration = await updateConfiguration( @@ -144,10 +193,17 @@ export default function ({ getService }: FtrProviderContext) { { closure_type: 'close-by-pushing', version: configuration.version, - } + }, + 200, + null, + noProfileHeaders ); - expect(newConfiguration.updated_by).to.eql(superUserInfo); + expect(newConfiguration.updated_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); }); @@ -175,18 +231,28 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { - const caseInfo = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { - user: superUser, - space: null, - }); + // Use the API key that cannot get profile info + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + noProfileHeaders + ); const patchedCase = await createComment({ supertest: supertestWithoutAuth, caseId: caseInfo.id, params: postCommentUserReq, + auth: null, + headers: noProfileHeaders, }); - expect(patchedCase.comments![0].created_by).to.eql(superUserInfo); + expect(patchedCase.comments![0].created_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); @@ -232,15 +298,21 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { - const caseInfo = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { - user: superUser, - space: null, - }); + // Use the API key that cannot get profile info + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + noProfileHeaders + ); const patchedCase = await createComment({ supertest: supertestWithoutAuth, caseId: caseInfo.id, params: postCommentUserReq, + auth: null, + headers: noProfileHeaders, }); const updatedCase = await updateComment({ @@ -253,6 +325,8 @@ export default function ({ getService }: FtrProviderContext) { type: AttachmentType.user, owner: 'securitySolutionFixture', }, + auth: null, + headers: noProfileHeaders, }); const patchedComment = await getComment({ @@ -261,7 +335,11 @@ export default function ({ getService }: FtrProviderContext) { commentId: patchedCase.comments![0].id, }); - expect(patchedComment.updated_by).to.eql(superUserInfo); + expect(patchedComment.updated_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); }); @@ -296,10 +374,14 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { - const caseInfo = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { - user: superUser, - space: null, - }); + // Use the API key that cannot get profile info + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + noProfileHeaders + ); const patchedCases = await updateCase({ supertest: supertestWithoutAuth, @@ -312,9 +394,15 @@ export default function ({ getService }: FtrProviderContext) { }, ], }, + auth: null, + headers: noProfileHeaders, }); - expect(patchedCases[0].closed_by).to.eql(superUserInfo); + expect(patchedCases[0].closed_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); @@ -347,10 +435,14 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { - const caseInfo = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { - user: superUser, - space: null, - }); + // Use the API key that cannot get profile info + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + noProfileHeaders + ); const patchedCases = await updateCase({ supertest: supertestWithoutAuth, @@ -363,9 +455,15 @@ export default function ({ getService }: FtrProviderContext) { }, ], }, + headers: noProfileHeaders, + auth: null, }); - expect(patchedCases[0].updated_by).to.eql(superUserInfo); + expect(patchedCases[0].updated_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); @@ -383,12 +481,20 @@ export default function ({ getService }: FtrProviderContext) { }); it('falls back to authc to get the user information when the profile is not available', async () => { - const caseInfo = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { - user: superUser, - space: null, - }); + // Use the API key that cannot get profile info + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + noProfileHeaders + ); - expect(caseInfo.created_by).to.eql(superUserInfo); + expect(caseInfo.created_by).to.eql({ + email: null, + full_name: null, + username: 'system_indices_superuser', + }); }); }); }); diff --git a/x-pack/platform/test/cloud_integration/plugins/saml_provider/metadata.xml b/x-pack/platform/test/cloud_integration/plugins/saml_provider/metadata.xml index c65972be45b45..d8dc0eb765e0c 100644 --- a/x-pack/platform/test/cloud_integration/plugins/saml_provider/metadata.xml +++ b/x-pack/platform/test/cloud_integration/plugins/saml_provider/metadata.xml @@ -7,25 +7,28 @@ - MIIDYjCCAkqgAwIBAgIUZ2p8K7GMXGk6xwCS9S91BUl1JnAwDQYJKoZIhvcNAQEL -BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l -cmF0ZWQgQ0EwIBcNMjMwOTIzMTUyMDE0WhgPMjA3MzA5MTAxNTIwMTRaMBExDzAN -BgNVBAMTBmtpYmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMOU -r52dbZ5dY0BoP2p7CEnOpG+qHTNrOAqZO/OJfniPMtpGmwAMl3WZDca6u2XkV2KE -qQyevQ2ADk6G3o8S2RU8mO/+UweuCDF7LHuSdxEGTpucidZErmVhEGUOFosL5UeB -AtIDWxvWwgK+W9Yzt5IEN2HzNCZ6h0dOSk2r9EjVMG5yF4Q6kuqOYxBT7jxoaOtO -OCrgBRummtUga4T13WZ/ZIyyHpXj2+JD4YEmrDyoTa7NLaphv0hnVhHXYoYBI/c6 -2SwwAoBlmtDmlinwSACQ3o/8eLWk0tqkIP14rc3oFh3m7D2c3c2m2HXuyoSDMfGW -beG2IE1Q3idcGmeG3qsCAwEAAaOBjDCBiTAdBgNVHQ4EFgQUMOUM7w5jmIozDvnq -RpM779m5GigwHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wPAYDVR0R -BDUwM4IUaG9zdC5kb2NrZXIuaW50ZXJuYWyCCWxvY2FsaG9zdIIEZXMwM4IEZXMw -MoIEZXMwMTAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCxqvQYXSKqgpdl -SP4gXgwipAnYsoW9qkgWQODTvSBEzUdOWme0d3j7i2l6Ur/nVSv5YjkqAv1hf/yJ -Hrk9h+j29ZO/aQ/KDh5i/gTEUnPw3Bxbw47dfn23tjMWO7NCU1fr5HNztRsa/gQr -e9s07g25u/gTfTi9Fyu0lcRe3bXOLS/mFVcuC5oxuS65R9OlbIsiORkZ2EfwuNUf -wAAYOGPIjM2VlQCvBitefsd/SzRKHdxSPy6KSjkO6MGEGo87fr7B7Nx1qp1DVrK7 -q9XeP1Cuygjg9WTcnsvWvNw8CssyuFM6X/3tGjpPasXwLvNUoG2AairK2AYTWhvS -foE31cFg + MIIEBTCCAu2gAwIBAgIVAKqEEFDGSEs427xhISo7Je1FMv9rMA0GCSqGSIb3DQEB + CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu + ZXJhdGVkIENBMCAXDTI2MDIxODE3NTEwMFoYDzIwNzYwMjA2MTc1MTAwWjARMQ8w + DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDn + WglNV6V9dB8F27upvq8rUT5hbhNrucawkvOJrcH0FP2pvinqHCboWRxMPO8P13Yd + xJ/3oD0gLQZYVF9Jp3jduU9Xss5p/J0MeyhB8p/DPwGIH8mWeDTWgmVGJqkmi0+x + wm4Me5h6W0OXjfuigS1pLRBM3LhfMSnYGuL5c5cVkFxSk/h7mjB+nPTMyqsa5sIc + /pZdJ+yELxnPXM27HaTutvzKc9qmq7aLk+4z6aHx/stTJIwMaavhqEX62xCFhZZV + xKLu8kc32X25lsym92E1eDviCeKcv9319TWe9CFHit40xOJHrLTirWDqrfVTXbIE + Fvus0UzM+qshEJt7iPQzAgMBAAGjggEtMIIBKTAdBgNVHQ4EFgQUOZVqGbUpsDWb + zODy6rn0QlE5F10wHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wgdsG + A1UdEQSB0zCB0IIEZXMwMYI8a2Ita2ItdWlhbS5wcm9qZWN0LWFiY2RlZjEyMzQ1 + Njc4OTAxMjM0NTY3ODkwMTIzNDU2LmtiLmxvY2FsggRlczAygl1FSURFTlQuYWJj + ZGVmMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTYucHJvamVjdC5lbGFzdGljc2Vh + cmNoLnR5cGUub3JnMTIzNDU2Nzg5MC5vcmdhbml6YXRpb26CBGVzMDOCCWxvY2Fs + aG9zdIIUaG9zdC5kb2NrZXIuaW50ZXJuYWwwCQYDVR0TBAIwADANBgkqhkiG9w0B + AQsFAAOCAQEABRwp6ooJzO/nzBO/J+GBV0+brtTgqSXg8hZ6+9BUCv2dNPV7U/io + TkZtY3mbK2cgq0B6Q2e/poiZtHW35Zuo87zYonSyQxqblML7C8lzFEM4qTAOzBAN + xzzbSciumz5p88x5ug/y+0VDB2BkyQ+24Pw2YxbuA7iHjOOmdcgScPfulAx4FImf + p2AZ/DeRdArTZAyI2pTKkOxvNRtToiBII2DdmHe7oYcfXFG02Z9vwz/o4YO+cLTs + 8hMMrL9G1y56wsSWT+kh/j3QDzqMnNmQ+VeEjEvxET6TbHZqgRa4q2qkyS2ls+X8 + NHWZ5uNXYbfnZpDdZ7LyAvvNvkThWc87gw== diff --git a/x-pack/platform/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap b/x-pack/platform/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap index 5c56dbeae7585..b4986e826878f 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap +++ b/x-pack/platform/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap @@ -3,6 +3,7 @@ exports[`Agent policies fleet_agent_policies GET /api/fleet/agent_policies should get a list of agent policies by kuery 1`] = ` Object { "agents": 0, + "agents_per_version": Array [], "fips_agents": 0, "inactivity_timeout": 1209600, "is_managed": false, @@ -23,6 +24,7 @@ Object { exports[`Agent policies fleet_agent_policies GET /api/fleet/agent_policies should get a list of agent policies by kuery 1`] = ` Object { "agents": 0, + "agents_per_version": Array [], "fips_agents": 0, "inactivity_timeout": 1209600, "is_managed": false, @@ -43,6 +45,7 @@ Object { exports[`Agent policies fleet_agent_policies POST /api/fleet/agent_policies/_bulk_get should populate package_policies if called with ?full=true 1`] = ` Object { "agents": 0, + "agents_per_version": Array [], "fips_agents": 0, "inactivity_timeout": 1209600, "is_managed": false, @@ -79,6 +82,7 @@ Object { exports[`Agent policies fleet_agent_policies POST /api/fleet/agent_policies/_bulk_get should populate package_policies if called with ?full=true 1`] = ` Object { "agents": 0, + "agents_per_version": Array [], "fips_agents": 0, "inactivity_timeout": 1209600, "is_managed": false, diff --git a/x-pack/platform/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/platform/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 6d673f6cee74f..ee37abd795339 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -166,6 +166,56 @@ export default function (providerContext: FtrProviderContext) { }); }); + describe('GET /api/fleet/agent_policies/:id/full', () => { + let fullPolicyTestAgentPolicyId: string; + + before(async () => { + await esArchiver.load('x-pack/platform/test/fixtures/es_archives/fleet/empty_fleet_server'); + await kibanaServer.savedObjects.cleanStandardList(); + await fleetAndAgents.setup(); + const { body: res } = await supertest + .post('/api/fleet/agent_policies') + .set('kbn-xsrf', 'xxxx') + .send({ name: 'Test full policy', namespace: 'default' }) + .expect(200); + fullPolicyTestAgentPolicyId = res.item.id; + }); + + after(async () => { + await supertest + .post('/api/fleet/agent_policies/delete') + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId: fullPolicyTestAgentPolicyId }) + .expect(200); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + it('should return 400 if revision and kubernetes or standalone are used together', async () => { + await supertest + .get( + `/api/fleet/agent_policies/${fullPolicyTestAgentPolicyId}/full?revision=1&kubernetes=true` + ) + .set('kbn-xsrf', 'xxxx') + .expect(400); + + await supertest + .get( + `/api/fleet/agent_policies/${fullPolicyTestAgentPolicyId}/full?revision=1&standalone=true` + ) + .set('kbn-xsrf', 'xxxx') + .expect(400); + }); + + it('should return fleet-server document if revision is provided', async () => { + const { body } = await supertest + .get(`/api/fleet/agent_policies/${fullPolicyTestAgentPolicyId}/full?revision=1`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + + expect(body.item.id).to.eql(fullPolicyTestAgentPolicyId); + }); + }); + describe('POST /api/fleet/agent_policies', () => { let systemPkgVersion: string; let mockApiServer: http.Server; @@ -2540,5 +2590,77 @@ export default function (providerContext: FtrProviderContext) { .expect(404); }); }); + + describe('GET /api/fleet/agent_policies agents_per_version', () => { + let policyId: string; + const agentId1 = `agent-per-version-1-${Date.now()}`; + const agentId2 = `agent-per-version-2-${Date.now()}`; + const agentId3 = `agent-per-version-3-${Date.now()}`; + + before(async () => { + await esArchiver.load('x-pack/platform/test/fixtures/es_archives/fleet/empty_fleet_server'); + await kibanaServer.savedObjects.cleanStandardList(); + await fleetAndAgents.setup(); + + const { body: agentPolicyResponse } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test agents_per_version policy', + namespace: 'default', + force: true, + }) + .expect(200); + policyId = agentPolicyResponse.item.id; + + await fleetAndAgents.generateAgent('online', agentId1, policyId, '8.15.0'); + await fleetAndAgents.generateAgent('online', agentId2, policyId, '8.16.0'); + await fleetAndAgents.generateAgent('online', agentId3, policyId, '8.16.0'); + }); + + after(async () => { + for (const agentId of [agentId1, agentId2, agentId3]) { + await es.delete({ index: '.fleet-agents', id: agentId, refresh: true }); + } + await supertest + .post(`/api/fleet/agent_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId: policyId }) + .expect(200); + }); + + it('should return agents_per_version when getting a single agent policy', async () => { + const { body } = await supertest.get(`/api/fleet/agent_policies/${policyId}`).expect(200); + + const agentsPerVersion = body.item.agents_per_version; + expect(agentsPerVersion).to.be.an('array'); + expect(agentsPerVersion.length).to.eql(2); + + const sorted = [...agentsPerVersion].sort((a: any, b: any) => + a.version.localeCompare(b.version) + ); + expect(sorted[0]).to.eql({ version: '8.15.0', count: 1 }); + expect(sorted[1]).to.eql({ version: '8.16.0', count: 2 }); + }); + + it('should return agents_per_version when listing agent policies with withAgentCount', async () => { + const { body } = await supertest + .get(`/api/fleet/agent_policies?withAgentCount=true&perPage=100`) + .expect(200); + + const policy = body.items.find((p: { id: string }) => p.id === policyId); + expect(policy).to.be.ok(); + + const agentsPerVersion = policy.agents_per_version; + expect(agentsPerVersion).to.be.an('array'); + expect(agentsPerVersion.length).to.eql(2); + + const sorted = [...agentsPerVersion].sort((a: any, b: any) => + a.version.localeCompare(b.version) + ); + expect(sorted[0]).to.eql({ version: '8.15.0', count: 1 }); + expect(sorted[1]).to.eql({ version: '8.16.0', count: 2 }); + }); + }); }); } diff --git a/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts b/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts index 8620f407659aa..b4912393a70c0 100644 --- a/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts +++ b/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -54,7 +54,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // create drilldown await dashboardDrilldownPanelActions.clickCreateDrilldown(); await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen(); - await testSubjects.click('actionFactoryItem-DASHBOARD_TO_DASHBOARD_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-dashboard_drilldown'); await dashboardDrilldownsManage.fillInDashboardToDashboardDrilldownWizard({ drilldownName: DRILLDOWN_TO_AREA_CHART_NAME, destinationDashboardTitle: dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME, diff --git a/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_url_drilldown.ts b/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_url_drilldown.ts index 5262f8994d7ea..ab9000ea90652 100644 --- a/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_url_drilldown.ts +++ b/x-pack/platform/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_url_drilldown.ts @@ -41,7 +41,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const urlTemplate = `{{kibanaUrl}}/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:'{{date event.from}}',to:'{{date event.to}}'))&_a=(columns:!(_source),filters:{{rison context.panel.filters}},index:'{{context.panel.indexPatternId}}',interval:auto,query:(language:{{context.panel.query.language}},query:'{{context.panel.query.query}}'),sort:!())`; - await testSubjects.click('actionFactoryItem-URL_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-url_drilldown'); await dashboardDrilldownsManage.fillInDashboardToURLDrilldownWizard({ drilldownName: DRILLDOWN_TO_DISCOVER_URL, destinationURLTemplate: urlTemplate, diff --git a/x-pack/platform/test/functional/apps/discover/group3/saved_search_embeddable.ts b/x-pack/platform/test/functional/apps/discover/group3/saved_search_embeddable.ts index 0a0f1b6d77bec..049f1a0aff269 100644 --- a/x-pack/platform/test/functional/apps/discover/group3/saved_search_embeddable.ts +++ b/x-pack/platform/test/functional/apps/discover/group3/saved_search_embeddable.ts @@ -142,7 +142,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { "{{kibanaUrl}}/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:'{{context.panel.timeRange.from}}',to:'{{context.panel.timeRange.to}}'))" + "&_a=(columns:!(_source),filters:{{rison context.panel.filters}},index:'{{context.panel.indexPatternId}}',interval:auto," + "query:(language:{{context.panel.query.language}},query:'clientip:239.190.189.77'),sort:!())"; - await testSubjects.click('actionFactoryItem-URL_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-url_drilldown'); await dashboardDrilldownsManage.fillInDashboardToURLDrilldownWizard({ drilldownName, destinationURLTemplate: urlTemplate, diff --git a/x-pack/platform/test/functional/apps/lens/group4/dashboard.ts b/x-pack/platform/test/functional/apps/lens/group4/dashboard.ts index 473c9626e8c0c..bb90443b7a5c3 100644 --- a/x-pack/platform/test/functional/apps/lens/group4/dashboard.ts +++ b/x-pack/platform/test/functional/apps/lens/group4/dashboard.ts @@ -347,7 +347,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // add a drilldown to the pie chart await dashboardDrilldownPanelActions.clickCreateDrilldown(); - await testSubjects.click('actionFactoryItem-OPEN_IN_DISCOVER_DRILLDOWN'); + await testSubjects.click('drilldownFactoryItem-discover_drilldown'); await dashboardDrilldownsManage.saveChanges(); await dashboardDrilldownsManage.closeFlyout(); await header.waitUntilLoadingHasFinished(); @@ -355,7 +355,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // check that the drilldown is working now await clickInChart(5, 5); // hardcoded position of the slice, depends heavy on data and charts implementation expect( - await find.existsByCssSelector('[data-test-subj^="embeddablePanelAction-D_ACTION"]') + await find.existsByCssSelector( + '[data-test-subj^="embeddablePanelAction-discover_drilldown"]' + ) ).to.be(true); // save the dashboard @@ -367,7 +369,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await clickInChart(5, 5); // hardcoded position of the slice, depends heavy on data and charts implementation expect( - await find.existsByCssSelector('[data-test-subj^="embeddablePanelAction-D_ACTION"]') + await find.existsByCssSelector( + '[data-test-subj^="embeddablePanelAction-discover_drilldown"]' + ) ).to.be(true); }); }); diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts b/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts index 428df9f8cc478..d5f29d13fb9e0 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts @@ -348,8 +348,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await titleElem.getAttribute('value')).to.equal(dataView); }; - // Failing: See https://github.com/elastic/kibana/issues/252007 - describe.skip('Search source Alert', () => { + describe('Search source Alert', () => { before(async () => { await security.testUser.setRoles(['discover_alert']); @@ -649,8 +648,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should check that there are no errors detected after an alert is created', async () => { + try { + await deleteDataView(SOURCE_DATA_VIEW); + } catch { + // continue + } + const newAlert = 'New Alert for checking its status'; - await createDataView('search-source*'); + await createDataView(SOURCE_DATA_VIEW); await PageObjects.common.navigateToApp('management'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -680,7 +685,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } const dataViewsElem = await testSubjects.find('euiSelectableList'); const sourceDataViewOption = await dataViewsElem.findByCssSelector( - `[title="search-source*"]` + `[title="${SOURCE_DATA_VIEW}"]` ); await sourceDataViewOption.click(); diff --git a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata.xml b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata.xml index cf88307879a02..75920e5f5afd8 100644 --- a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata.xml +++ b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata.xml @@ -7,25 +7,28 @@ - MIIDYjCCAkqgAwIBAgIUZ2p8K7GMXGk6xwCS9S91BUl1JnAwDQYJKoZIhvcNAQEL -BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l -cmF0ZWQgQ0EwIBcNMjMwOTIzMTUyMDE0WhgPMjA3MzA5MTAxNTIwMTRaMBExDzAN -BgNVBAMTBmtpYmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMOU -r52dbZ5dY0BoP2p7CEnOpG+qHTNrOAqZO/OJfniPMtpGmwAMl3WZDca6u2XkV2KE -qQyevQ2ADk6G3o8S2RU8mO/+UweuCDF7LHuSdxEGTpucidZErmVhEGUOFosL5UeB -AtIDWxvWwgK+W9Yzt5IEN2HzNCZ6h0dOSk2r9EjVMG5yF4Q6kuqOYxBT7jxoaOtO -OCrgBRummtUga4T13WZ/ZIyyHpXj2+JD4YEmrDyoTa7NLaphv0hnVhHXYoYBI/c6 -2SwwAoBlmtDmlinwSACQ3o/8eLWk0tqkIP14rc3oFh3m7D2c3c2m2HXuyoSDMfGW -beG2IE1Q3idcGmeG3qsCAwEAAaOBjDCBiTAdBgNVHQ4EFgQUMOUM7w5jmIozDvnq -RpM779m5GigwHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wPAYDVR0R -BDUwM4IUaG9zdC5kb2NrZXIuaW50ZXJuYWyCCWxvY2FsaG9zdIIEZXMwM4IEZXMw -MoIEZXMwMTAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCxqvQYXSKqgpdl -SP4gXgwipAnYsoW9qkgWQODTvSBEzUdOWme0d3j7i2l6Ur/nVSv5YjkqAv1hf/yJ -Hrk9h+j29ZO/aQ/KDh5i/gTEUnPw3Bxbw47dfn23tjMWO7NCU1fr5HNztRsa/gQr -e9s07g25u/gTfTi9Fyu0lcRe3bXOLS/mFVcuC5oxuS65R9OlbIsiORkZ2EfwuNUf -wAAYOGPIjM2VlQCvBitefsd/SzRKHdxSPy6KSjkO6MGEGo87fr7B7Nx1qp1DVrK7 -q9XeP1Cuygjg9WTcnsvWvNw8CssyuFM6X/3tGjpPasXwLvNUoG2AairK2AYTWhvS -foE31cFg + MIIEBTCCAu2gAwIBAgIVAKqEEFDGSEs427xhISo7Je1FMv9rMA0GCSqGSIb3DQEB + CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu + ZXJhdGVkIENBMCAXDTI2MDIxODE3NTEwMFoYDzIwNzYwMjA2MTc1MTAwWjARMQ8w + DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDn + WglNV6V9dB8F27upvq8rUT5hbhNrucawkvOJrcH0FP2pvinqHCboWRxMPO8P13Yd + xJ/3oD0gLQZYVF9Jp3jduU9Xss5p/J0MeyhB8p/DPwGIH8mWeDTWgmVGJqkmi0+x + wm4Me5h6W0OXjfuigS1pLRBM3LhfMSnYGuL5c5cVkFxSk/h7mjB+nPTMyqsa5sIc + /pZdJ+yELxnPXM27HaTutvzKc9qmq7aLk+4z6aHx/stTJIwMaavhqEX62xCFhZZV + xKLu8kc32X25lsym92E1eDviCeKcv9319TWe9CFHit40xOJHrLTirWDqrfVTXbIE + Fvus0UzM+qshEJt7iPQzAgMBAAGjggEtMIIBKTAdBgNVHQ4EFgQUOZVqGbUpsDWb + zODy6rn0QlE5F10wHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wgdsG + A1UdEQSB0zCB0IIEZXMwMYI8a2Ita2ItdWlhbS5wcm9qZWN0LWFiY2RlZjEyMzQ1 + Njc4OTAxMjM0NTY3ODkwMTIzNDU2LmtiLmxvY2FsggRlczAygl1FSURFTlQuYWJj + ZGVmMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTYucHJvamVjdC5lbGFzdGljc2Vh + cmNoLnR5cGUub3JnMTIzNDU2Nzg5MC5vcmdhbml6YXRpb26CBGVzMDOCCWxvY2Fs + aG9zdIIUaG9zdC5kb2NrZXIuaW50ZXJuYWwwCQYDVR0TBAIwADANBgkqhkiG9w0B + AQsFAAOCAQEABRwp6ooJzO/nzBO/J+GBV0+brtTgqSXg8hZ6+9BUCv2dNPV7U/io + TkZtY3mbK2cgq0B6Q2e/poiZtHW35Zuo87zYonSyQxqblML7C8lzFEM4qTAOzBAN + xzzbSciumz5p88x5ug/y+0VDB2BkyQ+24Pw2YxbuA7iHjOOmdcgScPfulAx4FImf + p2AZ/DeRdArTZAyI2pTKkOxvNRtToiBII2DdmHe7oYcfXFG02Z9vwz/o4YO+cLTs + 8hMMrL9G1y56wsSWT+kh/j3QDzqMnNmQ+VeEjEvxET6TbHZqgRa4q2qkyS2ls+X8 + NHWZ5uNXYbfnZpDdZ7LyAvvNvkThWc87gw== diff --git a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_2.xml b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_2.xml index 5e737746e8c1d..d4a2e86509bd6 100644 --- a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_2.xml +++ b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_2.xml @@ -7,25 +7,28 @@ - MIIDYjCCAkqgAwIBAgIUZ2p8K7GMXGk6xwCS9S91BUl1JnAwDQYJKoZIhvcNAQEL -BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l -cmF0ZWQgQ0EwIBcNMjMwOTIzMTUyMDE0WhgPMjA3MzA5MTAxNTIwMTRaMBExDzAN -BgNVBAMTBmtpYmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMOU -r52dbZ5dY0BoP2p7CEnOpG+qHTNrOAqZO/OJfniPMtpGmwAMl3WZDca6u2XkV2KE -qQyevQ2ADk6G3o8S2RU8mO/+UweuCDF7LHuSdxEGTpucidZErmVhEGUOFosL5UeB -AtIDWxvWwgK+W9Yzt5IEN2HzNCZ6h0dOSk2r9EjVMG5yF4Q6kuqOYxBT7jxoaOtO -OCrgBRummtUga4T13WZ/ZIyyHpXj2+JD4YEmrDyoTa7NLaphv0hnVhHXYoYBI/c6 -2SwwAoBlmtDmlinwSACQ3o/8eLWk0tqkIP14rc3oFh3m7D2c3c2m2HXuyoSDMfGW -beG2IE1Q3idcGmeG3qsCAwEAAaOBjDCBiTAdBgNVHQ4EFgQUMOUM7w5jmIozDvnq -RpM779m5GigwHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wPAYDVR0R -BDUwM4IUaG9zdC5kb2NrZXIuaW50ZXJuYWyCCWxvY2FsaG9zdIIEZXMwM4IEZXMw -MoIEZXMwMTAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCxqvQYXSKqgpdl -SP4gXgwipAnYsoW9qkgWQODTvSBEzUdOWme0d3j7i2l6Ur/nVSv5YjkqAv1hf/yJ -Hrk9h+j29ZO/aQ/KDh5i/gTEUnPw3Bxbw47dfn23tjMWO7NCU1fr5HNztRsa/gQr -e9s07g25u/gTfTi9Fyu0lcRe3bXOLS/mFVcuC5oxuS65R9OlbIsiORkZ2EfwuNUf -wAAYOGPIjM2VlQCvBitefsd/SzRKHdxSPy6KSjkO6MGEGo87fr7B7Nx1qp1DVrK7 -q9XeP1Cuygjg9WTcnsvWvNw8CssyuFM6X/3tGjpPasXwLvNUoG2AairK2AYTWhvS -foE31cFg + MIIEBTCCAu2gAwIBAgIVAKqEEFDGSEs427xhISo7Je1FMv9rMA0GCSqGSIb3DQEB + CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu + ZXJhdGVkIENBMCAXDTI2MDIxODE3NTEwMFoYDzIwNzYwMjA2MTc1MTAwWjARMQ8w + DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDn + WglNV6V9dB8F27upvq8rUT5hbhNrucawkvOJrcH0FP2pvinqHCboWRxMPO8P13Yd + xJ/3oD0gLQZYVF9Jp3jduU9Xss5p/J0MeyhB8p/DPwGIH8mWeDTWgmVGJqkmi0+x + wm4Me5h6W0OXjfuigS1pLRBM3LhfMSnYGuL5c5cVkFxSk/h7mjB+nPTMyqsa5sIc + /pZdJ+yELxnPXM27HaTutvzKc9qmq7aLk+4z6aHx/stTJIwMaavhqEX62xCFhZZV + xKLu8kc32X25lsym92E1eDviCeKcv9319TWe9CFHit40xOJHrLTirWDqrfVTXbIE + Fvus0UzM+qshEJt7iPQzAgMBAAGjggEtMIIBKTAdBgNVHQ4EFgQUOZVqGbUpsDWb + zODy6rn0QlE5F10wHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wgdsG + A1UdEQSB0zCB0IIEZXMwMYI8a2Ita2ItdWlhbS5wcm9qZWN0LWFiY2RlZjEyMzQ1 + Njc4OTAxMjM0NTY3ODkwMTIzNDU2LmtiLmxvY2FsggRlczAygl1FSURFTlQuYWJj + ZGVmMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTYucHJvamVjdC5lbGFzdGljc2Vh + cmNoLnR5cGUub3JnMTIzNDU2Nzg5MC5vcmdhbml6YXRpb26CBGVzMDOCCWxvY2Fs + aG9zdIIUaG9zdC5kb2NrZXIuaW50ZXJuYWwwCQYDVR0TBAIwADANBgkqhkiG9w0B + AQsFAAOCAQEABRwp6ooJzO/nzBO/J+GBV0+brtTgqSXg8hZ6+9BUCv2dNPV7U/io + TkZtY3mbK2cgq0B6Q2e/poiZtHW35Zuo87zYonSyQxqblML7C8lzFEM4qTAOzBAN + xzzbSciumz5p88x5ug/y+0VDB2BkyQ+24Pw2YxbuA7iHjOOmdcgScPfulAx4FImf + p2AZ/DeRdArTZAyI2pTKkOxvNRtToiBII2DdmHe7oYcfXFG02Z9vwz/o4YO+cLTs + 8hMMrL9G1y56wsSWT+kh/j3QDzqMnNmQ+VeEjEvxET6TbHZqgRa4q2qkyS2ls+X8 + NHWZ5uNXYbfnZpDdZ7LyAvvNvkThWc87gw== diff --git a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_mock_idp.xml b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_mock_idp.xml index 1049f4392d9ba..94bd29f4ec90a 100644 --- a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_mock_idp.xml +++ b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_mock_idp.xml @@ -7,25 +7,28 @@ - MIIDYjCCAkqgAwIBAgIUZ2p8K7GMXGk6xwCS9S91BUl1JnAwDQYJKoZIhvcNAQEL -BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l -cmF0ZWQgQ0EwIBcNMjMwOTIzMTUyMDE0WhgPMjA3MzA5MTAxNTIwMTRaMBExDzAN -BgNVBAMTBmtpYmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMOU -r52dbZ5dY0BoP2p7CEnOpG+qHTNrOAqZO/OJfniPMtpGmwAMl3WZDca6u2XkV2KE -qQyevQ2ADk6G3o8S2RU8mO/+UweuCDF7LHuSdxEGTpucidZErmVhEGUOFosL5UeB -AtIDWxvWwgK+W9Yzt5IEN2HzNCZ6h0dOSk2r9EjVMG5yF4Q6kuqOYxBT7jxoaOtO -OCrgBRummtUga4T13WZ/ZIyyHpXj2+JD4YEmrDyoTa7NLaphv0hnVhHXYoYBI/c6 -2SwwAoBlmtDmlinwSACQ3o/8eLWk0tqkIP14rc3oFh3m7D2c3c2m2HXuyoSDMfGW -beG2IE1Q3idcGmeG3qsCAwEAAaOBjDCBiTAdBgNVHQ4EFgQUMOUM7w5jmIozDvnq -RpM779m5GigwHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wPAYDVR0R -BDUwM4IUaG9zdC5kb2NrZXIuaW50ZXJuYWyCCWxvY2FsaG9zdIIEZXMwM4IEZXMw -MoIEZXMwMTAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCxqvQYXSKqgpdl -SP4gXgwipAnYsoW9qkgWQODTvSBEzUdOWme0d3j7i2l6Ur/nVSv5YjkqAv1hf/yJ -Hrk9h+j29ZO/aQ/KDh5i/gTEUnPw3Bxbw47dfn23tjMWO7NCU1fr5HNztRsa/gQr -e9s07g25u/gTfTi9Fyu0lcRe3bXOLS/mFVcuC5oxuS65R9OlbIsiORkZ2EfwuNUf -wAAYOGPIjM2VlQCvBitefsd/SzRKHdxSPy6KSjkO6MGEGo87fr7B7Nx1qp1DVrK7 -q9XeP1Cuygjg9WTcnsvWvNw8CssyuFM6X/3tGjpPasXwLvNUoG2AairK2AYTWhvS -foE31cFg + MIIEBTCCAu2gAwIBAgIVAKqEEFDGSEs427xhISo7Je1FMv9rMA0GCSqGSIb3DQEB + CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu + ZXJhdGVkIENBMCAXDTI2MDIxODE3NTEwMFoYDzIwNzYwMjA2MTc1MTAwWjARMQ8w + DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDn + WglNV6V9dB8F27upvq8rUT5hbhNrucawkvOJrcH0FP2pvinqHCboWRxMPO8P13Yd + xJ/3oD0gLQZYVF9Jp3jduU9Xss5p/J0MeyhB8p/DPwGIH8mWeDTWgmVGJqkmi0+x + wm4Me5h6W0OXjfuigS1pLRBM3LhfMSnYGuL5c5cVkFxSk/h7mjB+nPTMyqsa5sIc + /pZdJ+yELxnPXM27HaTutvzKc9qmq7aLk+4z6aHx/stTJIwMaavhqEX62xCFhZZV + xKLu8kc32X25lsym92E1eDviCeKcv9319TWe9CFHit40xOJHrLTirWDqrfVTXbIE + Fvus0UzM+qshEJt7iPQzAgMBAAGjggEtMIIBKTAdBgNVHQ4EFgQUOZVqGbUpsDWb + zODy6rn0QlE5F10wHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wgdsG + A1UdEQSB0zCB0IIEZXMwMYI8a2Ita2ItdWlhbS5wcm9qZWN0LWFiY2RlZjEyMzQ1 + Njc4OTAxMjM0NTY3ODkwMTIzNDU2LmtiLmxvY2FsggRlczAygl1FSURFTlQuYWJj + ZGVmMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTYucHJvamVjdC5lbGFzdGljc2Vh + cmNoLnR5cGUub3JnMTIzNDU2Nzg5MC5vcmdhbml6YXRpb26CBGVzMDOCCWxvY2Fs + aG9zdIIUaG9zdC5kb2NrZXIuaW50ZXJuYWwwCQYDVR0TBAIwADANBgkqhkiG9w0B + AQsFAAOCAQEABRwp6ooJzO/nzBO/J+GBV0+brtTgqSXg8hZ6+9BUCv2dNPV7U/io + TkZtY3mbK2cgq0B6Q2e/poiZtHW35Zuo87zYonSyQxqblML7C8lzFEM4qTAOzBAN + xzzbSciumz5p88x5ug/y+0VDB2BkyQ+24Pw2YxbuA7iHjOOmdcgScPfulAx4FImf + p2AZ/DeRdArTZAyI2pTKkOxvNRtToiBII2DdmHe7oYcfXFG02Z9vwz/o4YO+cLTs + 8hMMrL9G1y56wsSWT+kh/j3QDzqMnNmQ+VeEjEvxET6TbHZqgRa4q2qkyS2ls+X8 + NHWZ5uNXYbfnZpDdZ7LyAvvNvkThWc87gw== diff --git a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_never_login.xml b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_never_login.xml index a2fff3f0a4129..29d9d013e1ffe 100644 --- a/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_never_login.xml +++ b/x-pack/platform/test/security_api_integration/packages/helpers/saml/idp_metadata_never_login.xml @@ -7,25 +7,28 @@ - MIIDYjCCAkqgAwIBAgIUZ2p8K7GMXGk6xwCS9S91BUl1JnAwDQYJKoZIhvcNAQEL -BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l -cmF0ZWQgQ0EwIBcNMjMwOTIzMTUyMDE0WhgPMjA3MzA5MTAxNTIwMTRaMBExDzAN -BgNVBAMTBmtpYmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMOU -r52dbZ5dY0BoP2p7CEnOpG+qHTNrOAqZO/OJfniPMtpGmwAMl3WZDca6u2XkV2KE -qQyevQ2ADk6G3o8S2RU8mO/+UweuCDF7LHuSdxEGTpucidZErmVhEGUOFosL5UeB -AtIDWxvWwgK+W9Yzt5IEN2HzNCZ6h0dOSk2r9EjVMG5yF4Q6kuqOYxBT7jxoaOtO -OCrgBRummtUga4T13WZ/ZIyyHpXj2+JD4YEmrDyoTa7NLaphv0hnVhHXYoYBI/c6 -2SwwAoBlmtDmlinwSACQ3o/8eLWk0tqkIP14rc3oFh3m7D2c3c2m2HXuyoSDMfGW -beG2IE1Q3idcGmeG3qsCAwEAAaOBjDCBiTAdBgNVHQ4EFgQUMOUM7w5jmIozDvnq -RpM779m5GigwHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wPAYDVR0R -BDUwM4IUaG9zdC5kb2NrZXIuaW50ZXJuYWyCCWxvY2FsaG9zdIIEZXMwM4IEZXMw -MoIEZXMwMTAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCxqvQYXSKqgpdl -SP4gXgwipAnYsoW9qkgWQODTvSBEzUdOWme0d3j7i2l6Ur/nVSv5YjkqAv1hf/yJ -Hrk9h+j29ZO/aQ/KDh5i/gTEUnPw3Bxbw47dfn23tjMWO7NCU1fr5HNztRsa/gQr -e9s07g25u/gTfTi9Fyu0lcRe3bXOLS/mFVcuC5oxuS65R9OlbIsiORkZ2EfwuNUf -wAAYOGPIjM2VlQCvBitefsd/SzRKHdxSPy6KSjkO6MGEGo87fr7B7Nx1qp1DVrK7 -q9XeP1Cuygjg9WTcnsvWvNw8CssyuFM6X/3tGjpPasXwLvNUoG2AairK2AYTWhvS -foE31cFg + MIIEBTCCAu2gAwIBAgIVAKqEEFDGSEs427xhISo7Je1FMv9rMA0GCSqGSIb3DQEB + CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu + ZXJhdGVkIENBMCAXDTI2MDIxODE3NTEwMFoYDzIwNzYwMjA2MTc1MTAwWjARMQ8w + DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDn + WglNV6V9dB8F27upvq8rUT5hbhNrucawkvOJrcH0FP2pvinqHCboWRxMPO8P13Yd + xJ/3oD0gLQZYVF9Jp3jduU9Xss5p/J0MeyhB8p/DPwGIH8mWeDTWgmVGJqkmi0+x + wm4Me5h6W0OXjfuigS1pLRBM3LhfMSnYGuL5c5cVkFxSk/h7mjB+nPTMyqsa5sIc + /pZdJ+yELxnPXM27HaTutvzKc9qmq7aLk+4z6aHx/stTJIwMaavhqEX62xCFhZZV + xKLu8kc32X25lsym92E1eDviCeKcv9319TWe9CFHit40xOJHrLTirWDqrfVTXbIE + Fvus0UzM+qshEJt7iPQzAgMBAAGjggEtMIIBKTAdBgNVHQ4EFgQUOZVqGbUpsDWb + zODy6rn0QlE5F10wHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wgdsG + A1UdEQSB0zCB0IIEZXMwMYI8a2Ita2ItdWlhbS5wcm9qZWN0LWFiY2RlZjEyMzQ1 + Njc4OTAxMjM0NTY3ODkwMTIzNDU2LmtiLmxvY2FsggRlczAygl1FSURFTlQuYWJj + ZGVmMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTYucHJvamVjdC5lbGFzdGljc2Vh + cmNoLnR5cGUub3JnMTIzNDU2Nzg5MC5vcmdhbml6YXRpb26CBGVzMDOCCWxvY2Fs + aG9zdIIUaG9zdC5kb2NrZXIuaW50ZXJuYWwwCQYDVR0TBAIwADANBgkqhkiG9w0B + AQsFAAOCAQEABRwp6ooJzO/nzBO/J+GBV0+brtTgqSXg8hZ6+9BUCv2dNPV7U/io + TkZtY3mbK2cgq0B6Q2e/poiZtHW35Zuo87zYonSyQxqblML7C8lzFEM4qTAOzBAN + xzzbSciumz5p88x5ug/y+0VDB2BkyQ+24Pw2YxbuA7iHjOOmdcgScPfulAx4FImf + p2AZ/DeRdArTZAyI2pTKkOxvNRtToiBII2DdmHe7oYcfXFG02Z9vwz/o4YO+cLTs + 8hMMrL9G1y56wsSWT+kh/j3QDzqMnNmQ+VeEjEvxET6TbHZqgRa4q2qkyS2ls+X8 + NHWZ5uNXYbfnZpDdZ7LyAvvNvkThWc87gw== diff --git a/x-pack/platform/test/security_api_integration/plugins/saml_provider/metadata.xml b/x-pack/platform/test/security_api_integration/plugins/saml_provider/metadata.xml index c65972be45b45..d8dc0eb765e0c 100644 --- a/x-pack/platform/test/security_api_integration/plugins/saml_provider/metadata.xml +++ b/x-pack/platform/test/security_api_integration/plugins/saml_provider/metadata.xml @@ -7,25 +7,28 @@ - MIIDYjCCAkqgAwIBAgIUZ2p8K7GMXGk6xwCS9S91BUl1JnAwDQYJKoZIhvcNAQEL -BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l -cmF0ZWQgQ0EwIBcNMjMwOTIzMTUyMDE0WhgPMjA3MzA5MTAxNTIwMTRaMBExDzAN -BgNVBAMTBmtpYmFuYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMOU -r52dbZ5dY0BoP2p7CEnOpG+qHTNrOAqZO/OJfniPMtpGmwAMl3WZDca6u2XkV2KE -qQyevQ2ADk6G3o8S2RU8mO/+UweuCDF7LHuSdxEGTpucidZErmVhEGUOFosL5UeB -AtIDWxvWwgK+W9Yzt5IEN2HzNCZ6h0dOSk2r9EjVMG5yF4Q6kuqOYxBT7jxoaOtO -OCrgBRummtUga4T13WZ/ZIyyHpXj2+JD4YEmrDyoTa7NLaphv0hnVhHXYoYBI/c6 -2SwwAoBlmtDmlinwSACQ3o/8eLWk0tqkIP14rc3oFh3m7D2c3c2m2HXuyoSDMfGW -beG2IE1Q3idcGmeG3qsCAwEAAaOBjDCBiTAdBgNVHQ4EFgQUMOUM7w5jmIozDvnq -RpM779m5GigwHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wPAYDVR0R -BDUwM4IUaG9zdC5kb2NrZXIuaW50ZXJuYWyCCWxvY2FsaG9zdIIEZXMwM4IEZXMw -MoIEZXMwMTAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCxqvQYXSKqgpdl -SP4gXgwipAnYsoW9qkgWQODTvSBEzUdOWme0d3j7i2l6Ur/nVSv5YjkqAv1hf/yJ -Hrk9h+j29ZO/aQ/KDh5i/gTEUnPw3Bxbw47dfn23tjMWO7NCU1fr5HNztRsa/gQr -e9s07g25u/gTfTi9Fyu0lcRe3bXOLS/mFVcuC5oxuS65R9OlbIsiORkZ2EfwuNUf -wAAYOGPIjM2VlQCvBitefsd/SzRKHdxSPy6KSjkO6MGEGo87fr7B7Nx1qp1DVrK7 -q9XeP1Cuygjg9WTcnsvWvNw8CssyuFM6X/3tGjpPasXwLvNUoG2AairK2AYTWhvS -foE31cFg + MIIEBTCCAu2gAwIBAgIVAKqEEFDGSEs427xhISo7Je1FMv9rMA0GCSqGSIb3DQEB + CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu + ZXJhdGVkIENBMCAXDTI2MDIxODE3NTEwMFoYDzIwNzYwMjA2MTc1MTAwWjARMQ8w + DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDn + WglNV6V9dB8F27upvq8rUT5hbhNrucawkvOJrcH0FP2pvinqHCboWRxMPO8P13Yd + xJ/3oD0gLQZYVF9Jp3jduU9Xss5p/J0MeyhB8p/DPwGIH8mWeDTWgmVGJqkmi0+x + wm4Me5h6W0OXjfuigS1pLRBM3LhfMSnYGuL5c5cVkFxSk/h7mjB+nPTMyqsa5sIc + /pZdJ+yELxnPXM27HaTutvzKc9qmq7aLk+4z6aHx/stTJIwMaavhqEX62xCFhZZV + xKLu8kc32X25lsym92E1eDviCeKcv9319TWe9CFHit40xOJHrLTirWDqrfVTXbIE + Fvus0UzM+qshEJt7iPQzAgMBAAGjggEtMIIBKTAdBgNVHQ4EFgQUOZVqGbUpsDWb + zODy6rn0QlE5F10wHwYDVR0jBBgwFoAUMEwqwI5b0MYpNxwaHJ9Tw1Lp3p4wgdsG + A1UdEQSB0zCB0IIEZXMwMYI8a2Ita2ItdWlhbS5wcm9qZWN0LWFiY2RlZjEyMzQ1 + Njc4OTAxMjM0NTY3ODkwMTIzNDU2LmtiLmxvY2FsggRlczAygl1FSURFTlQuYWJj + ZGVmMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTYucHJvamVjdC5lbGFzdGljc2Vh + cmNoLnR5cGUub3JnMTIzNDU2Nzg5MC5vcmdhbml6YXRpb26CBGVzMDOCCWxvY2Fs + aG9zdIIUaG9zdC5kb2NrZXIuaW50ZXJuYWwwCQYDVR0TBAIwADANBgkqhkiG9w0B + AQsFAAOCAQEABRwp6ooJzO/nzBO/J+GBV0+brtTgqSXg8hZ6+9BUCv2dNPV7U/io + TkZtY3mbK2cgq0B6Q2e/poiZtHW35Zuo87zYonSyQxqblML7C8lzFEM4qTAOzBAN + xzzbSciumz5p88x5ug/y+0VDB2BkyQ+24Pw2YxbuA7iHjOOmdcgScPfulAx4FImf + p2AZ/DeRdArTZAyI2pTKkOxvNRtToiBII2DdmHe7oYcfXFG02Z9vwz/o4YO+cLTs + 8hMMrL9G1y56wsSWT+kh/j3QDzqMnNmQ+VeEjEvxET6TbHZqgRa4q2qkyS2ls+X8 + NHWZ5uNXYbfnZpDdZ7LyAvvNvkThWc87gw== diff --git a/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts b/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts index e9915048f5be4..8a6bb661de541 100644 --- a/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts +++ b/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts @@ -5,7 +5,9 @@ * 2.0. */ +import type { SecurityCreateApiKeyResponse } from '@elastic/elasticsearch/lib/api/types'; import { expect } from 'expect'; +import type { Cookie } from 'tough-cookie'; import { parse as parseCookie } from 'tough-cookie'; import type { FtrProviderContext } from '../../ftr_provider_context'; @@ -16,6 +18,10 @@ export default function ({ getService }: FtrProviderContext) { describe('Getting user profile for the current user', () => { const testUserName = 'user_with_profile'; + const testUserPassword = 'changeme'; + const testRoleName = 'test_role_api_key'; + let sessionCookie: Cookie | undefined; + let apiKey: SecurityCreateApiKeyResponse; async function login() { const response = await supertestWithoutAuth @@ -25,53 +31,68 @@ export default function ({ getService }: FtrProviderContext) { providerType: 'basic', providerName: 'basic', currentURL: '/', - params: { username: testUserName, password: 'changeme' }, + params: { username: testUserName, password: testUserPassword }, }) .expect(200); return parseCookie(response.headers['set-cookie'][0])!; } before(async () => { + // This role is required... + // 1. So the test user can create an API key to use during testing + // 2. So the API key the user creates is able to get it's own information (e.g. associated profile UID) + await security.role.create(testRoleName, { + elasticsearch: { cluster: ['manage_own_api_key', 'read_security'] }, + }); await security.user.create(testUserName, { password: 'changeme', - roles: [`viewer`], + roles: [`viewer`, testRoleName], full_name: 'User With Profile', email: 'user_with_profile@get_current_test', }); - }); - after(async () => { - await security.user.delete(testUserName); - }); + sessionCookie = await login(); - it('can get user profile for the current user', async () => { - const sessionCookie = await login(); + const response = await supertestWithoutAuth + .post('/internal/security/api_key') + .set('Cookie', sessionCookie!.cookieString()) + .set('kbn-xsrf', 'xxx') + .send({ name: 'test-api-key', role_descriptors: {} }) + .expect(200); + apiKey = response.body; await supertestWithoutAuth .post('/internal/security/user_profile/_data') .set('kbn-xsrf', 'xxx') - .set('Cookie', sessionCookie.cookieString()) + .set('Cookie', sessionCookie!.cookieString()) .send({ avatar: { initials: 'some-initials', color: '#f3f3f3' }, userSettings: { darkMode: 'dark', contrastMode: 'high' }, }) .expect(200); + }); + after(async () => { + await security.user.delete(testUserName); + await security.role.delete('test_role_api_key'); + }); + + it('with session', async () => { const { body: profileWithoutData } = await supertestWithoutAuth .get('/internal/security/user_profile') - .set('Cookie', sessionCookie.cookieString()) + .set('Cookie', sessionCookie!.cookieString()) .expect(200); const { body: profileWithAllData } = await supertestWithoutAuth .get('/internal/security/user_profile?dataPath=*') - .set('Cookie', sessionCookie.cookieString()) + .set('Cookie', sessionCookie!.cookieString()) .expect(200); const { body: profileWithSomeData } = await supertestWithoutAuth .get('/internal/security/user_profile?dataPath=some') - .set('Cookie', sessionCookie.cookieString()) + .set('Cookie', sessionCookie!.cookieString()) .expect(200); const { body: userWithProfileId } = await supertestWithoutAuth .get('/internal/security/me') - .set('Cookie', sessionCookie.cookieString()) + .set('Cookie', sessionCookie!.cookieString()) .expect(200); // Profile UID is supposed to be stable. @@ -91,6 +112,7 @@ export default function ({ getService }: FtrProviderContext) { "realm_name": "default_native", "roles": Array [ "viewer", + "test_role_api_key", ], "username": "user_with_profile", }, @@ -122,6 +144,7 @@ export default function ({ getService }: FtrProviderContext) { "realm_name": "default_native", "roles": Array [ "viewer", + "test_role_api_key", ], "username": "user_with_profile", }, @@ -143,6 +166,7 @@ export default function ({ getService }: FtrProviderContext) { "realm_name": "default_native", "roles": Array [ "viewer", + "test_role_api_key", ], "username": "user_with_profile", }, @@ -150,5 +174,207 @@ export default function ({ getService }: FtrProviderContext) { `); expect(userWithProfileId.profile_uid).toBe('u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0'); }); + + it('with basic auth', async () => { + const authHeaderValue = `Basic ${Buffer.from(`${testUserName}:${testUserPassword}`).toString( + 'base64' + )}`; + + const { body: profileWithoutData } = await supertestWithoutAuth + .get('/internal/security/user_profile') + .set('Authorization', authHeaderValue) + .expect(200); + const { body: profileWithAllData } = await supertestWithoutAuth + .get('/internal/security/user_profile?dataPath=*') + .set('Authorization', authHeaderValue) + .expect(200); + const { body: profileWithSomeData } = await supertestWithoutAuth + .get('/internal/security/user_profile?dataPath=some') + .set('Authorization', authHeaderValue) + .expect(200); + const { body: userWithProfileId } = await supertestWithoutAuth + .get('/internal/security/me') + .set('Authorization', authHeaderValue) + .expect(200); + + // Profile UID is supposed to be stable. + expectSnapshot(profileWithoutData).toMatchInline(` + Object { + "data": Object {}, + "enabled": true, + "labels": Object {}, + "uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0", + "user": Object { + "authentication_provider": Object { + "name": "__http__", + "type": "http", + }, + "email": "user_with_profile@get_current_test", + "full_name": "User With Profile", + "realm_name": "default_native", + "roles": Array [ + "viewer", + "test_role_api_key", + ], + "username": "user_with_profile", + }, + } + `); + expectSnapshot(profileWithAllData).toMatchInline(` + Object { + "data": Object { + "avatar": Object { + "color": "#f3f3f3", + "imageUrl": null, + "initials": "some-initials", + }, + "userSettings": Object { + "contrastMode": "high", + "darkMode": "dark", + }, + }, + "enabled": true, + "labels": Object {}, + "uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0", + "user": Object { + "authentication_provider": Object { + "name": "__http__", + "type": "http", + }, + "email": "user_with_profile@get_current_test", + "full_name": "User With Profile", + "realm_name": "default_native", + "roles": Array [ + "viewer", + "test_role_api_key", + ], + "username": "user_with_profile", + }, + } + `); + expectSnapshot(profileWithSomeData).toMatchInline(` + Object { + "data": Object {}, + "enabled": true, + "labels": Object {}, + "uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0", + "user": Object { + "authentication_provider": Object { + "name": "__http__", + "type": "http", + }, + "email": "user_with_profile@get_current_test", + "full_name": "User With Profile", + "realm_name": "default_native", + "roles": Array [ + "viewer", + "test_role_api_key", + ], + "username": "user_with_profile", + }, + } + `); + expect(userWithProfileId.profile_uid).toBeUndefined(); // The /me endpoint is only applicable with an active session + }); + + it('with API key', async () => { + const authHeaderValue = `apikey ${apiKey.encoded}`; + + const { body: profileWithoutData } = await supertestWithoutAuth + .get('/internal/security/user_profile') + .set('Authorization', authHeaderValue) + .expect(200); + const { body: profileWithAllData } = await supertestWithoutAuth + .get('/internal/security/user_profile?dataPath=*') + .set('Authorization', authHeaderValue) + .expect(200); + const { body: profileWithSomeData } = await supertestWithoutAuth + .get('/internal/security/user_profile?dataPath=some') + .set('Authorization', authHeaderValue) + .expect(200); + const { body: userWithProfileId } = await supertestWithoutAuth + .get('/internal/security/me') + .set('Authorization', authHeaderValue) + .expect(200); + + // Profile UID is supposed to be stable. + expectSnapshot(profileWithoutData).toMatchInline(` + Object { + "data": Object {}, + "enabled": true, + "labels": Object {}, + "uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0", + "user": Object { + "authentication_provider": Object { + "name": "__http__", + "type": "http", + }, + "email": "user_with_profile@get_current_test", + "full_name": "User With Profile", + "realm_name": "default_native", + "roles": Array [ + "viewer", + "test_role_api_key", + ], + "username": "user_with_profile", + }, + } + `); + expectSnapshot(profileWithAllData).toMatchInline(` + Object { + "data": Object { + "avatar": Object { + "color": "#f3f3f3", + "imageUrl": null, + "initials": "some-initials", + }, + "userSettings": Object { + "contrastMode": "high", + "darkMode": "dark", + }, + }, + "enabled": true, + "labels": Object {}, + "uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0", + "user": Object { + "authentication_provider": Object { + "name": "__http__", + "type": "http", + }, + "email": "user_with_profile@get_current_test", + "full_name": "User With Profile", + "realm_name": "default_native", + "roles": Array [ + "viewer", + "test_role_api_key", + ], + "username": "user_with_profile", + }, + } + `); + expectSnapshot(profileWithSomeData).toMatchInline(` + Object { + "data": Object {}, + "enabled": true, + "labels": Object {}, + "uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0", + "user": Object { + "authentication_provider": Object { + "name": "__http__", + "type": "http", + }, + "email": "user_with_profile@get_current_test", + "full_name": "User With Profile", + "realm_name": "default_native", + "roles": Array [ + "viewer", + "test_role_api_key", + ], + "username": "user_with_profile", + }, + } + `); + expect(userWithProfileId.profile_uid).toBeUndefined(); // The /me endpoint is only applicable with an active session + }); }); } diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml b/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml index 0c1e7de69b397..b37336be6e35f 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml @@ -23,6 +23,8 @@ dependsOn: - '@kbn/task-manager-plugin' - '@kbn/config-schema' - '@kbn/security-plugin-types-server' + - '@kbn/core-http-server' + - '@kbn/core-http-server-utils' tags: - plugin - prod diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts index 61ff48b406aba..96fd539f4aea9 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts @@ -9,6 +9,8 @@ import { type DiagnosticResult, errors } from '@elastic/elasticsearch'; import { schema } from '@kbn/config-schema'; import type { CoreSetup, CoreStart, PluginInitializerContext } from '@kbn/core/server'; +import type { FakeRawRequest, Headers } from '@kbn/core-http-server'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import { ROUTE_TAG_AUTH_FLOW } from '@kbn/security-plugin/server'; import { restApiKeySchema } from '@kbn/security-plugin-types-server'; import type { @@ -495,4 +497,122 @@ export function initRoutes( } } ); + + // UIAM API Key Grant Route + router.post( + { + path: '/test_endpoints/uiam/api_keys/_grant', + validate: { + body: schema.object({ + name: schema.string(), + expiration: schema.maybe(schema.string()), + authcScheme: schema.string(), + credential: schema.string(), + }), + }, + security: { + authc: { enabled: 'optional' }, + authz: { enabled: false, reason: 'Test endpoint for UIAM API key operations' }, + }, + }, + async (context, request, response) => { + try { + const { name, expiration, authcScheme, credential } = request.body; + const [{ security }] = await core.getStartServices(); + + if (!security.authc.apiKeys.uiam) { + return response.badRequest({ + body: { message: 'UIAM API keys service is not available' }, + }); + } + + // Create a new request with the provided authentication header + const requestHeaders: Headers = { + ...request.headers, + authorization: `${authcScheme} ${credential}`, + }; + const fakeRawRequest: FakeRawRequest = { + headers: requestHeaders, + path: request.url.pathname, + }; + const requestToUse = kibanaRequestFactory(fakeRawRequest); + + const result = await security.authc.apiKeys.uiam.grant(requestToUse, { + name, + expiration, + }); + + if (!result) { + return response.badRequest({ + body: { message: 'Failed to grant UIAM API key' }, + }); + } + + return response.ok({ body: result }); + } catch (err) { + logger.error(`Failed to grant UIAM API key: ${err}`, err); + return response.customError({ + statusCode: 500, + body: { message: err.message }, + }); + } + } + ); + + // UIAM API Key Invalidate Route + router.post( + { + path: '/test_endpoints/uiam/api_keys/_invalidate', + validate: { + body: schema.object({ + id: schema.string(), + authcScheme: schema.string(), + credential: schema.string(), + }), + }, + security: { + authc: { enabled: 'optional' }, + authz: { enabled: false, reason: 'Test endpoint for UIAM API key operations' }, + }, + }, + async (context, request, response) => { + try { + const { id, authcScheme, credential } = request.body; + const [{ security }] = await core.getStartServices(); + + if (!security.authc.apiKeys.uiam) { + return response.badRequest({ + body: { message: 'UIAM API keys service is not available' }, + }); + } + + // Create a new request with the provided authentication header + const requestHeaders: Headers = { + ...request.headers, + authorization: `${authcScheme} ${credential}`, + }; + const fakeRawRequest: FakeRawRequest = { + headers: requestHeaders, + path: request.url.pathname, + }; + const requestToUse = kibanaRequestFactory(fakeRawRequest); + + const result = await security.authc.apiKeys.uiam.invalidate(requestToUse, { id }); + + if (!result) { + return response.badRequest({ + body: { message: 'Failed to invalidate UIAM API key' }, + }); + } + + return response.ok({ body: result }); + } catch (err) { + logger.error(`Failed to invalidate UIAM API key: ${err}`, err); + return response.customError({ + statusCode: 500, + body: { message: err.message }, + }); + } + } + ); } diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json b/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json index 02b23636d74d0..9820aa723d455 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json @@ -16,5 +16,7 @@ "@kbn/task-manager-plugin", "@kbn/config-schema", "@kbn/security-plugin-types-server", + "@kbn/core-http-server", + "@kbn/core-http-server-utils", ] } diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts index e0993a42cb964..c1bcd57753359 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts @@ -59,7 +59,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.svlCommonPage.loginAsAdmin(); await PageObjects.common.navigateToApp('discover'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); }); after(async () => { @@ -67,6 +67,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('ES|QL in Discover', () => { + beforeEach(async () => { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.waitUntilTabIsLoaded(); + }); + it('should render esql view correctly', async function () { await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); @@ -88,6 +94,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('discoverFieldListPanelEdit-@message'); await PageObjects.discover.selectTextBaseLang(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); await testSubjects.existOrFail('fieldListFiltersFieldSearch'); @@ -114,14 +121,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should not render the histogram for indices with no @timestamp field', async function () { await PageObjects.discover.selectTextBaseLang(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); const testQuery = `from kibana_sample_data_flights | limit 10`; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); expect(await testSubjects.exists('ESQLEditor')).to.be(true); // I am not rendering the histogram for indices with no @timestamp field @@ -130,18 +137,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should render the histogram for indices with no @timestamp field when the ?_tstart, ?_tend params are in the query', async function () { await PageObjects.discover.selectTextBaseLang(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); const testQuery = `from kibana_sample_data_flights | limit 10 | where timestamp >= ?_tstart and timestamp <= ?_tend`; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); const fromTime = 'Apr 10, 2018 @ 00:00:00.000'; const toTime = 'Nov 15, 2018 @ 00:00:00.000'; await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.waitUntilTabIsLoaded(); expect(await testSubjects.exists('ESQLEditor')).to.be(true); expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); @@ -149,17 +157,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should perform test query correctly', async function () { await PageObjects.timePicker.setDefaultAbsoluteRange(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | sort @timestamp | 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(); + await PageObjects.discover.waitUntilTabIsLoaded(); // here Lens suggests a XY so it is rendered await testSubjects.existOrFail('unifiedHistogramChart'); await testSubjects.existOrFail('xyVisChart'); @@ -169,40 +174,34 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should render when switching to a time range with no data, then back to a time range with data', async () => { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | sort @timestamp | 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(); + await PageObjects.discover.waitUntilTabIsLoaded(); let cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); expect(await cell.getVisibleText()).to.be('1'); await PageObjects.timePicker.setAbsoluteRange( 'Sep 19, 2015 @ 06:31:44.000', 'Sep 19, 2015 @ 06:31:44.000' ); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await testSubjects.existOrFail('discoverNoResults'); await PageObjects.timePicker.setDefaultAbsoluteRange(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); expect(await cell.getVisibleText()).to.be('1'); }); it('should query an index pattern that doesnt translate to a dataview correctly', async function () { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); const testQuery = `from logstash* | sort @timestamp | 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(); + await PageObjects.discover.waitUntilTabIsLoaded(); const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); expect(await cell.getVisibleText()).to.be('1'); @@ -210,14 +209,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should render correctly if there are empty fields', async function () { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); const testQuery = `from logstash-* | limit 10 | keep machine.ram_range, bytes`; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); const cell = await dataGrid.getCellElementExcludingControlColumns(0, 1); expect(await cell.getVisibleText()).to.be(NULL_LABEL); expect((await dataGrid.getHeaders()).slice(-2)).to.eql([ @@ -228,11 +225,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should work without a FROM statement', async function () { await PageObjects.discover.selectTextBaseLang(); + await PageObjects.discover.waitUntilTabIsLoaded(); const testQuery = `ROW a = 1, b = "two", c = null`; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.dragFieldToTable('a'); const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -243,8 +241,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 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(); + await PageObjects.discover.waitUntilTabIsLoaded(); const brokenQueries = [ 'from logstash-* | limit 10*', @@ -255,8 +252,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { for (const testQuery of brokenQueries) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); // error in fetching documents because of the invalid query await PageObjects.discover.showsErrorCallout(); const message = await testSubjects.getVisibleText('discoverErrorCalloutMessage'); @@ -274,7 +270,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('switch modal', () => { beforeEach(async () => { await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.waitUntilTabIsLoaded(); }); it('should show switch modal when switching to a data view', async () => { @@ -362,22 +360,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('inspector', () => { beforeEach(async () => { await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.timePicker.setDefaultAbsoluteRange(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); }); it('shows Discover and Lens requests in Inspector', async () => { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); let retries = 0; await retry.try(async () => { if (retries > 0) { await inspector.close(); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); } await inspector.open(); retries = retries + 1; @@ -393,13 +389,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('query history', () => { beforeEach(async () => { await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.waitUntilTabIsLoaded(); }); it('should see my current query in the history', async () => { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); await testSubjects.click('ESQLEditor-toggle-query-history-icon'); @@ -409,15 +406,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('updating the query should add this to the history', async () => { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); const testQuery = 'from logstash-* | limit 100 | drop @timestamp'; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await testSubjects.click('ESQLEditor-toggle-query-history-icon'); const historyItems = await esql.getHistoryItems(); @@ -429,8 +424,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should select a query from the history and submit it', async () => { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); await testSubjects.click('ESQLEditor-toggle-query-history-icon'); @@ -446,15 +440,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should add a failed query to the history', async () => { await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); const testQuery = 'from logstash-* | limit 100 | woof and meow'; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); await testSubjects.click('ESQLEditor-toggle-query-history-icon'); await testSubjects.click('ESQLEditor-history-starred-queries-run-button'); @@ -468,20 +460,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const savedSearchName = 'testSorting'; await PageObjects.discover.selectTextBaseLang(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); const testQuery = 'from logstash-* | sort @timestamp | limit 100'; await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); await PageObjects.unifiedFieldList.clickFieldListItemAdd('bytes'); - - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains an initial value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -495,7 +483,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickDocSortDesc('bytes', 'Sort High-Low'); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the highest value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -508,9 +496,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.discover.saveSearch(savedSearchName); - - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the same highest value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -520,8 +506,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.refresh(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the same highest value after reload', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -531,13 +516,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.clickNewSearchButton(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.loadSavedSearch(savedSearchName); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await retry.waitFor( 'first cell contains the same highest value after reopening', @@ -550,7 +533,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickDocSortDesc('bytes', 'Sort Low-High'); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the lowest value', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -564,8 +547,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.unifiedFieldList.clickFieldListItemAdd('extension'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await dataGrid.clickDocSortDesc('extension', 'Sort A-Z'); @@ -581,8 +563,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.refresh(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.waitUntilTabIsLoaded(); await retry.waitFor('first cell contains the same lowest value after reload', async () => { const cell = await dataGrid.getCellElementExcludingControlColumns(0, 0); @@ -600,6 +581,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.discover.saveSearch(savedSearchName); + await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts b/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts index a09c78e43f2d6..f3222e4b58daf 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts @@ -384,12 +384,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await titleElem.getAttribute('value')).to.equal(dataView); }; - // Failing: See https://github.com/elastic/kibana/issues/252152 - describe.skip('Search source Alert', function () { + describe('Search source Alert', function () { // Failing in Observability projects: https://github.com/elastic/kibana/issues/203045 // Failing in MKI Search projects: https://github.com/elastic/kibana/issues/207865 - // Failing in MKI Security projects: https://github.com/elastic/kibana/issues/252028 - this.tags(['skipSvlOblt', 'failsOnMKI']); + this.tags(['skipSvlOblt', 'skipSvlSearch']); before(async () => { await security.testUser.setRoles(['discover_alert']); @@ -671,8 +669,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should check that there are no errors detected after an alert is created', async () => { + try { + await deleteDataView(SOURCE_DATA_VIEW); + } catch { + // continue + } + const newAlert = 'New Alert for checking its status'; - await createDataView('search-source*'); + await createDataView(SOURCE_DATA_VIEW); // Navigation to Rule Management is different in Serverless await PageObjects.common.navigateToApp('triggersActions'); @@ -701,7 +705,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } const dataViewsElem = await testSubjects.find('euiSelectableList'); const sourceDataViewOption = await dataViewsElem.findByCssSelector( - `[title="search-source*"]` + `[title="${SOURCE_DATA_VIEW}"]` ); await sourceDataViewOption.click(); diff --git a/x-pack/scripts/synthetics_forge.js b/x-pack/scripts/synthetics_forge.js new file mode 100644 index 0000000000000..78776c5eed6c3 --- /dev/null +++ b/x-pack/scripts/synthetics_forge.js @@ -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. + */ + +require('@kbn/setup-node-env'); +const { spawnSync } = require('node:child_process'); + +const childProcess = spawnSync( + process.execPath, + [ + '-e', + "require('@kbn/setup-node-env'); Promise.resolve(require('@kbn/synthetics-forge').cli()).catch((error) => { console.error(error); process.exitCode = 1; });", + 'synthetics_forge.js', + ...process.argv.slice(2), + ], + { + stdio: 'inherit', + env: process.env, + } +); + +if (childProcess.error) { + throw childProcess.error; +} + +if (childProcess.signal) { + process.kill(process.pid, childProcess.signal); +} else { + process.exitCode = childProcess.status ?? 1; +} diff --git a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/README.md b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/README.md index 957606286f980..ae47c58a41c28 100644 --- a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/README.md +++ b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/README.md @@ -26,7 +26,7 @@ Run evaluations using the following base command: ```bash EVALUATION_CONNECTOR_ID=llm-judge-connector-id \ node scripts/playwright test \ - --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts + --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts ``` #### Configuration Options @@ -52,7 +52,7 @@ USE_QUALITATIVE_EVALUATORS=true \ SCENARIO_REPORTING=true \ EVALUATION_CONNECTOR_ID=llm-judge-connector-id \ node scripts/playwright test \ - --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts \ + --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts \ evals/alerts/alerts.spec.ts \ --project="my-connector" \ ``` @@ -118,7 +118,7 @@ EVALUATION_CLIENT="agent_builder" \ AGENT_BUILDER_AGENT_ID="observability.agent" \ EVALUATION_CONNECTOR_ID="your-connector-id" \ node scripts/playwright test \ - --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts \ + --config x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts \ evals/esql/esql.spec.ts \ --project="your-connector" \ --debug diff --git a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/alert_insight.spec.ts b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/alert_insight.spec.ts index c7e79712a109c..8b60a2a643a2e 100644 --- a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/alert_insight.spec.ts +++ b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/alert_insight.spec.ts @@ -95,7 +95,7 @@ evaluate.describe('Alert AI Insights', { tag: tags.serverless.observability.comp - Errors: "Payment request failed. Invalid token. app.loyalty.level=gold" (apmErrors, last seen within alert window, Direct)---handled error, likely user input or session issue. - Anomalies: None detected (apmServiceSummary, alert window, Unrelated)---no evidence of systemic or performance issues. - Change points: None observed (apmServiceSummary, alert window, Unrelated)---no throughput or latency shifts. - - Downstream: Only checkout:5050 referenced in error trace; no evidence of propagation to flagd or other services (apmDownstreamDependencies, Indirect). + - Downstream: Only checkout:5050 referenced in error trace; no evidence of propagation to flagd or other services (apmServiceTopology, Indirect). - Immediate actions: - Review recent traces for the affected error group to confirm scope and verify if the error is isolated to specific users or requests. diff --git a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/playwright.config.ts similarity index 91% rename from x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts rename to x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/playwright.config.ts index 3e87893d4098a..e033f58f4dcbf 100644 --- a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/playwright.config.ts +++ b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/ai_insights/playwright.config.ts @@ -8,7 +8,7 @@ import Path from 'path'; import { createPlaywrightEvalsConfig } from '@kbn/evals'; export default createPlaywrightEvalsConfig({ - testDir: Path.resolve(__dirname, '../../../evals'), + testDir: Path.resolve(__dirname), // The default Playwright test timeout (5m) is too low for some connector/model combinations. // Keep this high enough to avoid spurious timeouts, and use CI step timeouts to bound runtime. timeout: 20 * 60_000, // 20 minutes diff --git a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/ai_insights.playwright.config.ts b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts similarity index 91% rename from x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/ai_insights.playwright.config.ts rename to x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts index 4f35d1a21bfd9..7d58a2be043e7 100644 --- a/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/test/scout/ui/ai_insights.playwright.config.ts +++ b/x-pack/solutions/observability/packages/kbn-evals-suite-obs-ai-assistant/playwright.config.ts @@ -8,7 +8,7 @@ import Path from 'path'; import { createPlaywrightEvalsConfig } from '@kbn/evals'; export default createPlaywrightEvalsConfig({ - testDir: Path.resolve(__dirname, '../../../ai_insights'), + testDir: Path.resolve(__dirname, './evals'), // The default Playwright test timeout (5m) is too low for some connector/model combinations. // Keep this high enough to avoid spurious timeouts, and use CI step timeouts to bound runtime. timeout: 20 * 60_000, // 20 minutes diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/README.md b/x-pack/solutions/observability/packages/kbn-synthetics-forge/README.md new file mode 100644 index 0000000000000..e3f8888fe1b02 --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/README.md @@ -0,0 +1,172 @@ +# Synthetics Forge + +Creates Synthetics monitors for scalability testing. + +> [!WARNING] +> **Local Development:** This script requires a Fleet Server. For local dev, run `synthetics_private_location` first. See [Using with synthetics_private_location](#using-with-synthetics_private_location-local-development). + +> [!NOTE] +> **Docker Required:** When used with Ensemble workflows, Docker Desktop must be running. The Elastic Agent runs locally in a Docker container to execute monitors against the cloud cluster. + +## Usage + +```bash +# Create monitors +node x-pack/scripts/synthetics_forge.js create + +# Cleanup +node x-pack/scripts/synthetics_forge.js cleanup +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `KIBANA_URL` | `http://localhost:5601` | Kibana URL | +| `KIBANA_USERNAME` | `elastic` | Username | +| `KIBANA_PASSWORD` | `changeme` | Password | +| `HTTP` | `1` | HTTP monitors to create | +| `TCP` | `1` | TCP monitors to create | +| `ICMP` | `1` | ICMP monitors to create | +| `BROWSER` | `1` | Browser monitors to create | +| `RESOURCE_PREFIX` | `scalability-test` | Prefix for all resources | +| `PRIVATE_LOCATION_ID` | - | Use existing private location (skips creating policy/location) | +| `OUTPUT_FILE` | - | Write output JSON to file | + +## Examples + +Create 10 monitors of each type: +```bash +HTTP=10 TCP=10 ICMP=10 BROWSER=5 \ +node x-pack/scripts/synthetics_forge.js create +``` + +Use existing private location (skips creating agent policy and private location): +```bash +PRIVATE_LOCATION_ID="abc-123-def-456" HTTP=10 \ +node x-pack/scripts/synthetics_forge.js create +``` + +Against a remote cluster (with existing private location): +```bash +KIBANA_URL="https://my-cluster.kb.us-west2.gcp.elastic-cloud.com:443" \ +KIBANA_PASSWORD="mypassword" \ +PRIVATE_LOCATION_ID="abc-123-def-456" \ +HTTP=100 \ +node x-pack/scripts/synthetics_forge.js create +``` + +## What it creates + +| Resource | Name | +|----------|------| +| Space | `scalability-test` | +| Agent Policy | `scalability-test-policy` | +| Private Location | `scalability-test-location` | +| Monitors | `Scalability HTTP Monitor 1`, etc. | + +All resources are tagged with the `RESOURCE_PREFIX` value. + +## How cleanup works + +The `cleanup` command identifies resources to delete by: + +1. **Monitors** - Finds monitors tagged with `RESOURCE_PREFIX` or named `Scalability *` +2. **Private Locations** - Finds locations with label containing `RESOURCE_PREFIX` +3. **Agent Policies** - Finds policies with name containing `RESOURCE_PREFIX` + +> [!NOTE] +> Only resources matching these patterns are deleted. Other resources are untouched. + +## Output + +Returns an enrollment token for deploying an Elastic Agent: + +```json +{ + "spaceId": "scalability-test", + "agentPolicyId": "abc-123", + "privateLocationId": "def-456", + "enrollmentToken": "xxxx==", + "kibanaVersion": "8.17.0", + "monitorCount": 35 +} +``` + +> [!TIP] +> Use `OUTPUT_FILE` to save this to a file: +> ```bash +> OUTPUT_FILE=/tmp/forge_output.json node x-pack/scripts/synthetics_forge.js create +> ``` + +## Using with synthetics_private_location (Local Development) + +For local development without a cloud cluster, use `synthetics_private_location` first to set up Fleet Server and Agent, then use `synthetics_forge` to create monitors. + +**Step 1: Run synthetics_private_location** + +This creates Fleet Server, Agent Policy, Private Location, and enrolls an Agent: +```bash +node x-pack/scripts/synthetics_private_location.js +``` + +Look for this output: +``` +════════════════════════════════════════════════════════════════ + SYNTHETICS PRIVATE LOCATION CREATED +════════════════════════════════════════════════════════════════ + Private Location ID: abc-123-def-456 + Private Location Label: Private location + Agent Policy ID: xyz-789 + + To use with synthetics_forge: + PRIVATE_LOCATION_ID="abc-123-def-456" +════════════════════════════════════════════════════════════════ +``` + +**Step 2: Run synthetics_forge with the Private Location ID** +```bash +PRIVATE_LOCATION_ID="abc-123-def-456" \ +HTTP=10 TCP=10 ICMP=10 BROWSER=5 \ +node x-pack/scripts/synthetics_forge.js create +``` + +> [!NOTE] +> This skips creating agent policy and private location, and only creates monitors in the existing location. + +## Using with Cloud Clusters (ESS) + +For cloud clusters, you can use an existing private location. + +**Find or create a Private Location:** + +1. Go to **Synthetics** → **Settings** → **Private Locations** +2. Create a new location or click on an existing one +3. The ID is in the URL: `/app/synthetics/settings/private-locations/{ID}` + +**Then run:** +```bash +KIBANA_URL="https://your-cluster.kb.us-west2.gcp.elastic-cloud.com:443" \ +KIBANA_PASSWORD="password" \ +PRIVATE_LOCATION_ID="your-location-id" \ +HTTP=100 \ +node x-pack/scripts/synthetics_forge.js create +``` + +> [!TIP] +> If no private location exists, omit `PRIVATE_LOCATION_ID` and the script will create one. + +## Using with Ensemble + +This script is used by Ensemble workflows for automated scalability testing. The workflow: + +1. Creates an ESS cluster in the cloud +2. Runs `synthetics_forge` to create monitors +3. Deploys an Elastic Agent in a **local Docker container** +4. The agent connects to the cloud Fleet Server and executes monitors + +**Requirements:** +- Docker Desktop must be running +- The `elastic-agent-complete` image is used (includes Chromium for browser monitors) + +See the [Ensemble README](../../../test/ensemble/README.md) for setup and usage. diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/index.ts b/x-pack/solutions/observability/packages/kbn-synthetics-forge/index.ts new file mode 100644 index 0000000000000..ff973f2bde1ac --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + ForgeConfig, + ForgeOutput, + MonitorCounts, + ApiClientConfig, + PrivateLocation, + AgentPolicy, + Space, + Monitor, +} from './src/types'; +export { run, cleanup } from './src/run'; +export { cli } from './src/cli'; +export { SyntheticsApiClient } from './src/api_client'; diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/kibana.jsonc b/x-pack/solutions/observability/packages/kbn-synthetics-forge/kibana.jsonc new file mode 100644 index 0000000000000..e1de4afc8abc0 --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/kibana.jsonc @@ -0,0 +1,8 @@ +{ + "type": "shared-common", + "id": "@kbn/synthetics-forge", + "owner": "@elastic/obs-ux-management-team", + "group": "platform", + "visibility": "private", + "devOnly": true +} diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/moon.yml b/x-pack/solutions/observability/packages/kbn-synthetics-forge/moon.yml new file mode 100644 index 0000000000000..3771a2746ecff --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/moon.yml @@ -0,0 +1,32 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/synthetics-forge' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/synthetics-forge' +type: unknown +owners: + defaultOwner: '@elastic/obs-ux-management-team' +toolchain: + default: node +language: typescript +project: + name: '@kbn/synthetics-forge' + description: Moon project for @kbn/synthetics-forge + channel: '' + owner: '@elastic/obs-ux-management-team' + metadata: + sourceRoot: x-pack/solutions/observability/packages/kbn-synthetics-forge +dependsOn: + - '@kbn/tooling-log' +tags: + - shared-common + - package + - dev + - group-platform + - private +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' +tasks: {} diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/package.json b/x-pack/solutions/observability/packages/kbn-synthetics-forge/package.json new file mode 100644 index 0000000000000..62d961645cbb4 --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/synthetics-forge", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/api_client.ts b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/api_client.ts new file mode 100644 index 0000000000000..ee9a4983a7abf --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/api_client.ts @@ -0,0 +1,498 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { AxiosInstance, AxiosResponse } from 'axios'; +import axios from 'axios'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { ApiClientConfig, PrivateLocation, AgentPolicy, Space, Monitor } from './types'; + +const DEFAULT_RETRY_COUNT = 3; +const DEFAULT_RETRY_DELAY_MS = 1000; +const BATCH_SIZE = 50; + +export class SyntheticsApiClient { + private client: AxiosInstance; + private kibanaUrl: string; + private maxRetries: number; + private retryDelayMs: number; + private log: ToolingLog; + + constructor(config: ApiClientConfig, log: ToolingLog) { + this.kibanaUrl = config.kibanaUrl.replace(/\/$/, ''); + this.maxRetries = DEFAULT_RETRY_COUNT; + this.retryDelayMs = DEFAULT_RETRY_DELAY_MS; + this.log = log; + this.client = axios.create({ + baseURL: this.kibanaUrl, + auth: { + username: config.username, + password: config.password, + }, + headers: { + 'kbn-xsrf': 'true', + 'x-elastic-internal-origin': 'synthetics-forge', + 'elastic-api-version': '2023-10-31', + }, + validateStatus: () => true, + }); + } + + private async withRetry( + operation: () => Promise, + context: string, + retries: number = this.maxRetries + ): Promise { + let lastError: Error | undefined; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + const isRetryable = + lastError.message?.includes('version_conflict') || + lastError.message?.includes('409') || + lastError.message?.includes('500') || + lastError.message?.includes('ECONNREFUSED') || + lastError.message?.includes('timeout'); + + if (isRetryable && attempt < retries) { + const delay = this.retryDelayMs * Math.pow(2, attempt - 1); + this.log.debug(`Retry ${attempt}/${retries} for ${context} after ${delay}ms`); + await this.delay(delay); + } else { + throw lastError; + } + } + } + throw lastError; + } + + private async delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private isSuccessResponse(response: AxiosResponse): boolean { + return response.status >= 200 && response.status < 300; + } + + private getBasePath(spaceId?: string): string { + return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ''; + } + + async setupFleet(): Promise { + this.log.info('Setting up Fleet...'); + const response = await this.client.post('/api/fleet/setup'); + if (!this.isSuccessResponse(response)) { + throw new Error(`Fleet setup failed: ${JSON.stringify(response.data)}`); + } + this.log.success('Fleet setup complete'); + } + + async enableSynthetics(): Promise { + this.log.info('Enabling Synthetics...'); + const response = await this.client.put('/internal/synthetics/service/enablement'); + if (response.status !== 200 && response.status !== 409) { + throw new Error(`Enable Synthetics failed: ${JSON.stringify(response.data)}`); + } + this.log.success('Synthetics enabled'); + } + + async createSpace(spaceId: string, name: string): Promise { + this.log.info(`Creating space: ${spaceId}`); + + const existingResponse = await this.client.get(`/api/spaces/space/${spaceId}`); + if (existingResponse.status === 200) { + this.log.info(`Space ${spaceId} already exists`); + return existingResponse.data as Space; + } + + const response = await this.client.post('/api/spaces/space', { + id: spaceId, + name, + description: 'Space for Synthetics scalability testing', + }); + + if (!this.isSuccessResponse(response)) { + throw new Error(`Create space failed: ${JSON.stringify(response.data)}`); + } + this.log.success(`Space created: ${spaceId}`); + return response.data as Space; + } + + async createAgentPolicy(name: string): Promise { + this.log.info(`Creating agent policy: ${name}`); + + const existing = await this.getAgentPolicies(); + const found = existing.find((p) => p.name === name); + if (found) { + this.log.info(`Agent policy already exists: ${found.id}`); + return found; + } + + const response = await this.client.post('/api/fleet/agent_policies?sys_monitoring=true', { + name, + description: 'Agent policy for Synthetics scalability testing', + namespace: 'default', + monitoring_enabled: ['logs', 'metrics'], + }); + + if (!this.isSuccessResponse(response)) { + throw new Error(`Create agent policy failed: ${JSON.stringify(response.data)}`); + } + this.log.success(`Agent policy created: ${response.data.item.id}`); + return response.data.item as AgentPolicy; + } + + async getPrivateLocations(spaceId?: string): Promise { + const basePath = this.getBasePath(spaceId); + const response = await this.client.get(`${basePath}/api/synthetics/private_locations`); + if (!this.isSuccessResponse(response)) { + return []; + } + return response.data as PrivateLocation[]; + } + + async getPrivateLocationById(locationId: string, spaceId?: string): Promise { + const locations = await this.getPrivateLocations(spaceId); + const found = locations.find((loc) => loc.id === locationId); + if (!found) { + throw new Error(`Private location not found: ${locationId}`); + } + return found; + } + + async createPrivateLocation( + label: string, + agentPolicyId: string, + spaceId?: string + ): Promise { + this.log.info(`Creating private location: ${label}`); + + const existing = await this.getPrivateLocations(spaceId); + const found = existing.find((loc) => loc.label === label); + if (found) { + const policies = await this.getAgentPolicies(); + const policyExists = policies.some((p) => p.id === found.agentPolicyId); + + if (policyExists) { + this.log.info(`Private location already exists: ${found.id}`); + return found; + } else { + this.log.warning( + `Private location ${found.id} references a deleted agent policy (${found.agentPolicyId}), recreating...` + ); + try { + await this.deletePrivateLocation(found.id, spaceId); + } catch (err) { + this.log.warning(`Failed to delete orphaned location: ${err}`); + } + await this.delay(1000); + } + } + + const basePath = this.getBasePath(spaceId); + const response = await this.client.post(`${basePath}/api/synthetics/private_locations`, { + label, + agentPolicyId, + geo: { lat: 0, lon: 0 }, + ...(spaceId ? { spaces: [spaceId] } : {}), + }); + + if (!this.isSuccessResponse(response)) { + throw new Error(`Create private location failed: ${JSON.stringify(response.data)}`); + } + this.log.success(`Private location created: ${response.data.id}`); + return response.data as PrivateLocation; + } + + async createHttpMonitor( + name: string, + url: string, + privateLocation: PrivateLocation, + spaceId?: string, + tags: string[] = [] + ): Promise { + return this.withRetry(async () => { + this.log.debug(`Creating HTTP monitor: ${name}`); + const basePath = this.getBasePath(spaceId); + const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + type: 'http', + name, + urls: url, + schedule: { number: '3', unit: 'm' }, + locations: [{ id: privateLocation.id, isServiceManaged: false }], + enabled: true, + timeout: '16', + tags, + }); + + if (!this.isSuccessResponse(response)) { + throw new Error(`Create HTTP monitor failed: ${JSON.stringify(response.data)}`); + } + return response.data as Monitor; + }, `HTTP monitor: ${name}`); + } + + async createTcpMonitor( + name: string, + host: string, + privateLocation: PrivateLocation, + spaceId?: string, + tags: string[] = [] + ): Promise { + return this.withRetry(async () => { + this.log.debug(`Creating TCP monitor: ${name}`); + const basePath = this.getBasePath(spaceId); + const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + type: 'tcp', + name, + hosts: host, + schedule: { number: '3', unit: 'm' }, + locations: [{ id: privateLocation.id, isServiceManaged: false }], + enabled: true, + timeout: '16', + tags, + }); + + if (!this.isSuccessResponse(response)) { + throw new Error(`Create TCP monitor failed: ${JSON.stringify(response.data)}`); + } + return response.data as Monitor; + }, `TCP monitor: ${name}`); + } + + async createIcmpMonitor( + name: string, + host: string, + privateLocation: PrivateLocation, + spaceId?: string, + tags: string[] = [] + ): Promise { + return this.withRetry(async () => { + this.log.debug(`Creating ICMP monitor: ${name}`); + const basePath = this.getBasePath(spaceId); + const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + type: 'icmp', + name, + hosts: host, + schedule: { number: '3', unit: 'm' }, + locations: [{ id: privateLocation.id, isServiceManaged: false }], + enabled: true, + timeout: '16', + wait: '1', + tags, + }); + + if (!this.isSuccessResponse(response)) { + throw new Error(`Create ICMP monitor failed: ${JSON.stringify(response.data)}`); + } + return response.data as Monitor; + }, `ICMP monitor: ${name}`); + } + + async createBrowserMonitor( + name: string, + script: string, + privateLocation: PrivateLocation, + spaceId?: string, + tags: string[] = [] + ): Promise { + return this.withRetry(async () => { + this.log.debug(`Creating Browser monitor: ${name}`); + const basePath = this.getBasePath(spaceId); + const response = await this.client.post(`${basePath}/api/synthetics/monitors`, { + type: 'browser', + name, + schedule: { number: '3', unit: 'm' }, + locations: [{ id: privateLocation.id, isServiceManaged: false }], + enabled: true, + timeout: null, + tags, + 'source.inline.script': script, + screenshots: 'on', + synthetics_args: [], + ignore_https_errors: false, + }); + + if (!this.isSuccessResponse(response)) { + throw new Error(`Create Browser monitor failed: ${JSON.stringify(response.data)}`); + } + return response.data as Monitor; + }, `Browser monitor: ${name}`); + } + + async getMonitors(spaceId?: string): Promise { + const basePath = this.getBasePath(spaceId); + const allMonitors: Monitor[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const response = await this.client.get( + `${basePath}/api/synthetics/monitors?perPage=${perPage}&page=${page}` + ); + if (!this.isSuccessResponse(response)) { + throw new Error(`Get monitors failed: ${JSON.stringify(response.data)}`); + } + + const monitors = response.data.monitors as Monitor[]; + allMonitors.push(...monitors); + + if (monitors.length < perPage) { + break; + } + page++; + } + + return allMonitors; + } + + async deleteMonitors(monitorIds: string[], spaceId?: string): Promise { + if (monitorIds.length === 0) return; + + const basePath = this.getBasePath(spaceId); + const response = await this.client.delete(`${basePath}/api/synthetics/monitors`, { + data: { ids: monitorIds }, + }); + + if (!this.isSuccessResponse(response)) { + this.log.warning(`Bulk delete monitors response: ${response.status}`); + } + } + + async deletePrivateLocation(locationId: string, spaceId?: string): Promise { + const basePath = this.getBasePath(spaceId); + const response = await this.client.delete( + `${basePath}/api/synthetics/private_locations/${locationId}` + ); + if (!this.isSuccessResponse(response) && response.status !== 404) { + throw new Error(`Delete private location failed: ${JSON.stringify(response.data)}`); + } + } + + async deleteAgentPolicy(policyId: string, force: boolean = false): Promise { + const response = await this.client.post('/api/fleet/agent_policies/delete', { + agentPolicyId: policyId, + force, + }); + if (!this.isSuccessResponse(response) && response.status !== 404) { + throw new Error(`Delete agent policy failed: ${JSON.stringify(response.data)}`); + } + } + + async getAgentsForPolicy(agentPolicyId: string): Promise> { + const response = await this.client.get( + `/api/fleet/agents?kuery=policy_id:${agentPolicyId}&perPage=1000` + ); + if (!this.isSuccessResponse(response)) { + return []; + } + return response.data.items || []; + } + + async bulkUnenrollAgents(agentPolicyId: string): Promise { + // Use bulk unenroll API + const response = await this.client.post('/api/fleet/agents/bulk_unenroll', { + agents: `policy_id:${agentPolicyId}`, + force: true, + revoke: true, + }); + + if (!this.isSuccessResponse(response)) { + this.log.warning( + `Bulk unenroll response: ${response.status} - ${JSON.stringify(response.data)}` + ); + } + } + + async getAgentPolicies(): Promise { + const response = await this.client.get('/api/fleet/agent_policies?perPage=1000'); + if (!this.isSuccessResponse(response)) { + return []; + } + return response.data.items as AgentPolicy[]; + } + + async getEnrollmentToken(agentPolicyId: string): Promise { + this.log.info(`Fetching enrollment token for policy: ${agentPolicyId}`); + const response = await this.client.get( + `/api/fleet/enrollment_api_keys?kuery=policy_id:${agentPolicyId}` + ); + + if (!this.isSuccessResponse(response) || !response.data?.list?.length) { + throw new Error(`Failed to get enrollment token: ${JSON.stringify(response.data)}`); + } + + const token = response.data.list[0].api_key; + this.log.success('Enrollment token retrieved'); + return token; + } + + async getKibanaVersion(): Promise { + const response = await this.client.get('/api/status'); + if (!this.isSuccessResponse(response)) { + throw new Error(`Failed to get Kibana version: ${JSON.stringify(response.data)}`); + } + return response.data.version.number; + } + + async deletePackagePoliciesForMonitors( + monitors: Array<{ + config_id?: string; + id: string; + locations?: Array<{ id: string; isServiceManaged: boolean }>; + }>, + spaceId: string + ): Promise<{ deleted: number; failed: number }> { + const policyIds: string[] = []; + + for (const monitor of monitors) { + const configId = monitor.config_id || monitor.id; + if (monitor.locations) { + for (const loc of monitor.locations) { + if (!loc.isServiceManaged) { + const policyId = `${configId}-${loc.id}-${spaceId}`; + if (!policyIds.includes(policyId)) { + policyIds.push(policyId); + } + } + } + } + } + + if (policyIds.length === 0) { + return { deleted: 0, failed: 0 }; + } + + this.log.info(`Deleting ${policyIds.length} package policies`); + + let totalDeleted = 0; + let totalFailed = 0; + + for (let i = 0; i < policyIds.length; i += BATCH_SIZE) { + const batch = policyIds.slice(i, i + BATCH_SIZE); + + try { + const response = await this.client.post('/api/fleet/package_policies/delete', { + packagePolicyIds: batch, + force: true, + }); + + if (this.isSuccessResponse(response)) { + totalDeleted += batch.length; + } else { + totalFailed += batch.length; + } + } catch (err) { + this.log.warning(`Error deleting package policies: ${err}`); + totalFailed += batch.length; + } + } + + return { deleted: totalDeleted, failed: totalFailed }; + } +} diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/cli.ts b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/cli.ts new file mode 100644 index 0000000000000..e6ce23d827245 --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/cli.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line @kbn/eslint/require_kbn_fs +import { writeFileSync } from 'fs'; +import { ToolingLog } from '@kbn/tooling-log'; +import { run, cleanup } from './run'; +import type { ForgeConfig, ForgeOutput } from './types'; + +const DEFAULT_RESOURCE_PREFIX = 'scalability-test'; + +function parseIntEnv(envVar: string, defaultValue: number): number { + const value = process.env[envVar]; + if (!value) return defaultValue; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : parsed; +} + +function getConfigFromEnv(): ForgeConfig { + const kibanaUrl = process.env.KIBANA_URL || 'http://localhost:5601'; + const resourcePrefix = process.env.RESOURCE_PREFIX || DEFAULT_RESOURCE_PREFIX; + + return { + kibanaUrl, + username: process.env.KIBANA_USERNAME || 'elastic', + password: process.env.KIBANA_PASSWORD || 'changeme', + spaceId: resourcePrefix, + resourcePrefix, + concurrency: parseIntEnv('CONCURRENCY', 1), + monitorCounts: { + http: parseIntEnv('HTTP', 1), + tcp: parseIntEnv('TCP', 1), + icmp: parseIntEnv('ICMP', 1), + browser: parseIntEnv('BROWSER', 1), + }, + // Optional: use existing private location (skip creating agent policy + private location) + privateLocationId: process.env.PRIVATE_LOCATION_ID || undefined, + }; +} + +function writeOutputFile(output: ForgeOutput, log: ToolingLog): void { + const outputFile = process.env.OUTPUT_FILE; + if (outputFile) { + writeFileSync(outputFile, JSON.stringify(output, null, 2)); + log.info(`Output written to: ${outputFile}`); + } +} + +export async function cli(): Promise { + const log = new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }); + + const action = process.env.FORGE_ACTION || process.argv[2] || 'create'; + const config = getConfigFromEnv(); + + try { + switch (action) { + case 'create': + const output = await run(config, log); + log.info(`Enrollment Token: ${output.enrollmentToken}`); + log.info(`Kibana Version: ${output.kibanaVersion}`); + log.info(`Total Monitors: ${output.monitorCount}`); + writeOutputFile(output, log); + break; + + case 'cleanup': + await cleanup(config, log); + break; + + default: + log.error(`Unknown action: ${action}. Use 'create' or 'cleanup'`); + process.exitCode = 1; + } + } catch (error) { + log.error('Synthetics Forge failed'); + log.error(error as Error); + process.exitCode = 1; + } +} diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/run.ts b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/run.ts new file mode 100644 index 0000000000000..08c57fac53549 --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/run.ts @@ -0,0 +1,310 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { SyntheticsApiClient } from './api_client'; +import type { ForgeConfig, ForgeOutput, PrivateLocation, CleanupResult } from './types'; + +const BROWSER_SCRIPT = ` +step('Navigate to Elastic', async () => { + await page.goto('https://www.elastic.co'); +}); +`; + +const ICMP_HOSTS = [ + '8.8.8.8', + '8.8.4.4', + '1.1.1.1', + '1.0.0.1', + '9.9.9.9', + '149.112.112.112', + '208.67.222.222', + '208.67.220.220', + '4.2.2.1', + '4.2.2.2', + '8.26.56.26', + '8.20.247.20', + '94.140.14.14', + '94.140.15.15', +]; + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function createMonitorsInBatches( + items: T[], + concurrency: number, + createFn: (item: T) => Promise +): Promise { + const results: string[] = []; + + for (let i = 0; i < items.length; i += concurrency) { + const batch = items.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map(createFn)); + results.push(...batchResults); + } + + return results; +} + +export async function cleanup(config: ForgeConfig, log: ToolingLog): Promise { + const client = new SyntheticsApiClient( + { + kibanaUrl: config.kibanaUrl, + username: config.username, + password: config.password, + }, + log + ); + + const result: CleanupResult = { + monitorsDeleted: 0, + packagePoliciesDeleted: 0, + privateLocationsDeleted: 0, + agentsUnenrolled: 0, + agentPoliciesDeleted: 0, + }; + + const spaceId = config.spaceId; + log.info(`Cleaning up ALL resources in space: ${spaceId}`); + + // Collect agent policy IDs from private locations BEFORE deleting anything + const agentPolicyIds: string[] = []; + + // Get ALL private locations in our space + const privateLocations = await client.getPrivateLocations(spaceId); + log.info(`Found ${privateLocations.length} private location(s) in space`); + for (const loc of privateLocations) { + log.info(` - "${loc.label}" -> agent policy: ${loc.agentPolicyId}`); + if (loc.agentPolicyId && !agentPolicyIds.includes(loc.agentPolicyId)) { + agentPolicyIds.push(loc.agentPolicyId); + } + } + + // Get ALL monitors in our space + const monitors = await client.getMonitors(spaceId); + log.info(`Found ${monitors.length} monitor(s) in space`); + + // Step 1: Delete package policies for monitors + log.info('Step 1: Deleting package policies...'); + if (monitors.length > 0) { + const { deleted, failed } = await client.deletePackagePoliciesForMonitors(monitors, spaceId); + result.packagePoliciesDeleted = deleted; + if (deleted > 0) { + log.success(`Deleted ${deleted} package policies`); + } + if (failed > 0) { + log.warning(`${failed} package policies failed to delete (may not exist)`); + } + } else { + log.info('No package policies to delete'); + } + + // Step 2: Delete ALL monitors in the space + log.info('Step 2: Deleting monitors...'); + if (monitors.length > 0) { + const monitorIds = monitors.map((m) => m.config_id || m.id); + await client.deleteMonitors(monitorIds, spaceId); + result.monitorsDeleted = monitors.length; + log.success(`Deleted ${monitors.length} monitors`); + } else { + log.info('No monitors to delete'); + } + + // Step 3: Delete ALL private locations in the space + log.info('Step 3: Deleting private locations...'); + for (const loc of privateLocations) { + try { + await client.deletePrivateLocation(loc.id, spaceId); + result.privateLocationsDeleted++; + log.success(`Deleted private location: ${loc.label}`); + } catch (err) { + log.warning(`Failed to delete private location ${loc.label}: ${err}`); + } + } + if (privateLocations.length === 0) { + log.info('No private locations to delete'); + } + + // Step 4: Unenroll agents and delete agent policies + log.info('Step 4: Unenrolling agents and deleting agent policies...'); + for (const policyId of agentPolicyIds) { + // Unenroll agents first + const agents = await client.getAgentsForPolicy(policyId); + if (agents.length > 0) { + log.info(`Unenrolling ${agents.length} agents from policy ${policyId}`); + await client.bulkUnenrollAgents(policyId); + result.agentsUnenrolled += agents.length; + await delay(2000); + } + + // Delete policy + try { + await client.deleteAgentPolicy(policyId, true); + result.agentPoliciesDeleted++; + log.success(`Deleted agent policy: ${policyId}`); + } catch (err) { + log.warning(`Failed to delete agent policy ${policyId}: ${err}`); + } + } + if (agentPolicyIds.length === 0) { + log.info('No agent policies to delete'); + } + + log.info(` +=== CLEANUP SUMMARY === + Monitors deleted: ${result.monitorsDeleted} + Package policies deleted: ${result.packagePoliciesDeleted} + Private locations deleted: ${result.privateLocationsDeleted} + Agents unenrolled: ${result.agentsUnenrolled} + Agent policies deleted: ${result.agentPoliciesDeleted} +=== CLEANUP COMPLETE === +`); + return result; +} + +export async function run(config: ForgeConfig, log: ToolingLog): Promise { + const client = new SyntheticsApiClient( + { + kibanaUrl: config.kibanaUrl, + username: config.username, + password: config.password, + }, + log + ); + + const { monitorCounts, concurrency, resourcePrefix } = config; + const totalMonitors = + monitorCounts.http + monitorCounts.tcp + monitorCounts.icmp + monitorCounts.browser; + + log.info('========================================'); + log.info('Synthetics Forge - Creating Resources'); + log.info('========================================'); + log.info(`HTTP Monitors: ${monitorCounts.http}`); + log.info(`TCP Monitors: ${monitorCounts.tcp}`); + log.info(`ICMP Monitors: ${monitorCounts.icmp}`); + log.info(`Browser Monitors: ${monitorCounts.browser}`); + log.info(`Total: ${totalMonitors}`); + log.info(`Concurrency: ${concurrency}`); + log.info(`Resource Prefix: ${resourcePrefix}`); + if (config.privateLocationId) { + log.info(`Using existing Private Location: ${config.privateLocationId}`); + } + + await client.setupFleet(); + await client.enableSynthetics(); + + const space = await client.createSpace(config.spaceId, 'Scalability Test Space'); + log.success(`Space ready: ${space.id}`); + + let agentPolicyId: string; + let agentPolicyName: string; + let privateLocation: PrivateLocation; + let privateLocationLabel: string; + + if (config.privateLocationId) { + log.info(`Fetching existing private location: ${config.privateLocationId}`); + privateLocation = await client.getPrivateLocationById(config.privateLocationId, config.spaceId); + privateLocationLabel = privateLocation.label; + agentPolicyId = privateLocation.agentPolicyId; + agentPolicyName = `existing-policy-${agentPolicyId}`; + log.success( + `Using existing Private Location: ${privateLocation.label} (${privateLocation.id})` + ); + } else { + agentPolicyName = `${resourcePrefix}-policy`; + const agentPolicy = await client.createAgentPolicy(agentPolicyName); + agentPolicyId = agentPolicy.id; + log.success(`Agent Policy ready: ${agentPolicyId}`); + + privateLocationLabel = `${resourcePrefix}-location`; + privateLocation = await client.createPrivateLocation( + privateLocationLabel, + agentPolicyId, + config.spaceId + ); + log.success(`Private Location ready: ${privateLocation.id}`); + } + + const enrollmentToken = await client.getEnrollmentToken(agentPolicyId); + const kibanaVersion = await client.getKibanaVersion(); + + log.info('--- Creating Monitors ---'); + + const monitorIds: string[] = []; + + const monitorCreationTasks = [ + { + type: 'HTTP', + count: monitorCounts.http, + creator: client.createHttpMonitor.bind(client), + getArg: () => 'https://www.elastic.co', + }, + { + type: 'TCP', + count: monitorCounts.tcp, + creator: client.createTcpMonitor.bind(client), + getArg: () => 'elastic.co:443', + }, + { + type: 'ICMP', + count: monitorCounts.icmp, + creator: client.createIcmpMonitor.bind(client), + getArg: (i: number) => ICMP_HOSTS[i % ICMP_HOSTS.length], + extraLog: `Distributing across ${ICMP_HOSTS.length} hosts to avoid rate limiting`, + }, + { + type: 'Browser', + count: monitorCounts.browser, + creator: client.createBrowserMonitor.bind(client), + getArg: () => BROWSER_SCRIPT, + }, + ]; + + for (const task of monitorCreationTasks) { + if (task.count > 0) { + log.info(`Creating ${task.count} ${task.type} monitor(s) with concurrency ${concurrency}`); + if (task.extraLog) { + log.info(task.extraLog); + } + const indices = Array.from({ length: task.count }, (_, i) => i); + const ids = await createMonitorsInBatches(indices, concurrency, async (i) => { + const monitor = await task.creator( + `Scalability ${task.type} Monitor ${i + 1}`, + task.getArg(i), + privateLocation, + config.spaceId, + [resourcePrefix] + ); + return monitor.id; + }); + monitorIds.push(...ids); + log.success(`Created ${ids.length} ${task.type} monitor(s)`); + } + } + + const monitors = await client.getMonitors(config.spaceId); + log.success(`Verified ${monitors.length} monitors in space ${config.spaceId}`); + + log.info('========================================'); + log.success('Synthetics Forge Complete'); + log.info('========================================'); + + return { + spaceId: space.id, + agentPolicyId, + agentPolicyName, + privateLocationId: privateLocation.id, + privateLocationLabel, + enrollmentToken, + kibanaVersion, + monitorIds, + monitorCount: monitorIds.length, + kibanaUrl: config.kibanaUrl, + }; +} diff --git a/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/types.ts b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/types.ts new file mode 100644 index 0000000000000..cd0f4634274b6 --- /dev/null +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/src/types.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface ApiClientConfig { + kibanaUrl: string; + username: string; + password: string; +} + +export interface PrivateLocation { + id: string; + label: string; + agentPolicyId: string; + geo: { lat: number; lon: number }; +} + +export interface AgentPolicy { + id: string; + name: string; +} + +export interface Space { + id: string; + name: string; +} + +export interface MonitorLocation { + id: string; + isServiceManaged: boolean; +} + +export interface Monitor { + id: string; + name: string; + type: string; + config_id: string; + tags?: string[]; + locations?: MonitorLocation[]; +} + +export interface MonitorCounts { + http: number; + tcp: number; + icmp: number; + browser: number; +} + +export interface ForgeConfig { + kibanaUrl: string; + username: string; + password: string; + spaceId: string; + monitorCounts: MonitorCounts; + concurrency: number; + resourcePrefix: string; + /** Optional: Use existing private location instead of creating new one */ + privateLocationId?: string; +} + +export interface ForgeOutput { + spaceId: string; + agentPolicyId: string; + agentPolicyName: string; + privateLocationId: string; + privateLocationLabel: string; + enrollmentToken: string; + kibanaVersion: string; + monitorIds: string[]; + monitorCount: number; + kibanaUrl: string; +} + +export interface CleanupResult { + monitorsDeleted: number; + packagePoliciesDeleted: number; + privateLocationsDeleted: number; + agentsUnenrolled: number; + agentPoliciesDeleted: number; +} diff --git a/src/platform/packages/shared/kbn-security-solution-common/tsconfig.json b/x-pack/solutions/observability/packages/kbn-synthetics-forge/tsconfig.json similarity index 60% rename from src/platform/packages/shared/kbn-security-solution-common/tsconfig.json rename to x-pack/solutions/observability/packages/kbn-synthetics-forge/tsconfig.json index 4d33b40eda45c..e9b84f8214fb1 100644 --- a/src/platform/packages/shared/kbn-security-solution-common/tsconfig.json +++ b/x-pack/solutions/observability/packages/kbn-synthetics-forge/tsconfig.json @@ -2,12 +2,18 @@ "extends": "@kbn/tsconfig-base/tsconfig.json", "compilerOptions": { "outDir": "target/types", + "types": [ + "jest", + "node" + ] }, "include": [ - "**/*.ts", + "**/*.ts" ], "exclude": [ "target/**/*" ], - "kbn_references": [] + "kbn_references": [ + "@kbn/tooling-log" + ] } diff --git a/x-pack/solutions/observability/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap b/x-pack/solutions/observability/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap index 98f40ac908e28..fbcdf8f76aa07 100644 --- a/x-pack/solutions/observability/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap +++ b/x-pack/solutions/observability/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap @@ -73,6 +73,8 @@ exports[`Error DURATION 1`] = `undefined`; exports[`Error ELASTIC_PROFILER_STACK_TRACE_IDS 1`] = `undefined`; +exports[`Error ERROR_CODE 1`] = `undefined`; + exports[`Error ERROR_CULPRIT 1`] = `"handleOopsie"`; exports[`Error ERROR_EXC_HANDLED 1`] = `undefined`; @@ -178,6 +180,8 @@ exports[`Error KUBERNETES_NODE_NAME 1`] = `undefined`; exports[`Error KUBERNETES_POD_NAME 1`] = `undefined`; +exports[`Error KUBERNETES_POD_NAME_OTEL 1`] = `undefined`; + exports[`Error KUBERNETES_POD_UID 1`] = `undefined`; exports[`Error KUBERNETES_REPLICASET 1`] = `undefined`; @@ -509,6 +513,8 @@ exports[`Span DURATION 1`] = `undefined`; exports[`Span ELASTIC_PROFILER_STACK_TRACE_IDS 1`] = `undefined`; +exports[`Span ERROR_CODE 1`] = `undefined`; + exports[`Span ERROR_CULPRIT 1`] = `undefined`; exports[`Span ERROR_EXC_HANDLED 1`] = `undefined`; @@ -601,6 +607,8 @@ exports[`Span KUBERNETES_NODE_NAME 1`] = `undefined`; exports[`Span KUBERNETES_POD_NAME 1`] = `undefined`; +exports[`Span KUBERNETES_POD_NAME_OTEL 1`] = `undefined`; + exports[`Span KUBERNETES_POD_UID 1`] = `undefined`; exports[`Span KUBERNETES_REPLICASET 1`] = `undefined`; @@ -932,6 +940,8 @@ exports[`Transaction DURATION 1`] = `undefined`; exports[`Transaction ELASTIC_PROFILER_STACK_TRACE_IDS 1`] = `undefined`; +exports[`Transaction ERROR_CODE 1`] = `undefined`; + exports[`Transaction ERROR_CULPRIT 1`] = `undefined`; exports[`Transaction ERROR_EXC_HANDLED 1`] = `undefined`; @@ -1034,6 +1044,8 @@ exports[`Transaction KUBERNETES_NODE_NAME 1`] = `undefined`; exports[`Transaction KUBERNETES_POD_NAME 1`] = `undefined`; +exports[`Transaction KUBERNETES_POD_NAME_OTEL 1`] = `undefined`; + exports[`Transaction KUBERNETES_POD_UID 1`] = `"pod1234567890abcdef"`; exports[`Transaction KUBERNETES_REPLICASET 1`] = `undefined`; diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/alerts_overview/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/alerts_overview/index.tsx index 3d85b513da1d0..28fd74d14e3ee 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/alerts_overview/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/alerts_overview/index.tsx @@ -12,6 +12,8 @@ import { EuiPanel, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import type { BoolQuery, Filter } from '@kbn/es-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ObservabilityAlertsTable } from '@kbn/observability-plugin/public'; +import { SLO_BURN_RATE_RULE_TYPE_ID } from '@kbn/rule-data-utils'; +import { ALL_VALUE } from '@kbn/slo-schema'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { APM_ALERTING_CONSUMERS, @@ -73,8 +75,23 @@ export function AlertsOverview() { if (isEnvironmentDefined(environment)) { filters.push({ query: { - match_phrase: { - [SERVICE_ENVIRONMENT]: environment, + bool: { + should: [ + { match_phrase: { [SERVICE_ENVIRONMENT]: environment } }, + { + bool: { + filter: [ + { term: { 'kibana.alert.rule.rule_type_id': SLO_BURN_RATE_RULE_TYPE_ID } }, + ], + should: [ + { term: { [SERVICE_ENVIRONMENT]: ALL_VALUE } }, + { bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } } }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, }, }, meta: {}, diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx index e21b6637358ae..edac627cf02d8 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx @@ -261,7 +261,7 @@ export function ErrorSampleDetails({ }, })} > - + {transaction.transaction.name} @@ -368,17 +368,20 @@ export function ErrorSampleDetailTabContent({ }) { const codeLanguage = error?.service.language?.name; const exceptions = error?.error.exception || []; + const hasExceptions = exceptions.length > 0; const logStackframes = error?.error.log?.stacktrace; - const isPlaintextException = - !!error.error.stack_trace && exceptions.length === 1 && !exceptions[0].stacktrace; + const isPlaintextException = hasExceptions + ? !!error.error.stack_trace && exceptions.length === 1 && !exceptions[0].stacktrace + : !!error.error.stack_trace; + switch (currentTab.key) { case ErrorTabKey.LogStackTrace: return ; case ErrorTabKey.ExceptionStacktrace: return isPlaintextException ? ( diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx index 94d2ec5c93070..8a29a4d41a46a 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx @@ -19,12 +19,12 @@ const Label = styled.div` interface Props { error: { - error: Pick; + error: Pick; }; } export function SampleSummary({ error }: Props) { const logMessage = error.error.log?.message; - const excMessage = error.error.exception?.[0].message; + const excMessage = error.error.exception?.[0].message || error.error.message; const culprit = error.error.culprit; return ( diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx index 5136763c735df..046f47d9cd9f4 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx @@ -187,7 +187,7 @@ export function ErrorGroupList({ serviceName={serviceName} query={{ ...query, - kuery: `error.exception.type:"${type}"`, + kuery: `error.exception.type:"${type}" OR error.type:"${type}"`, }} > {type} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/index.tsx index 311e2a1befc80..a6ef1f6c6c231 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/index.tsx @@ -24,7 +24,7 @@ const INITIAL_STATE = { }; export function InfraTabs() { - const { serviceName } = useApmServiceContext(); + const { serviceName, agentName } = useApmServiceContext(); const history = useHistory(); const { query: { environment, kuery, rangeFrom, rangeTo, detailTab }, @@ -42,12 +42,13 @@ export function InfraTabs() { kuery, start, end, + agentName, }, }, }); } }, - [environment, kuery, serviceName, start, end] + [environment, kuery, agentName, serviceName, start, end] ); const { containerIds, podNames, hostNames } = data; @@ -56,6 +57,7 @@ export function InfraTabs() { containerIds, podNames, hostNames, + agentName, start, end, }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/use_tabs.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/use_tabs.tsx index fe618b22adf35..85b043b36dc93 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/use_tabs.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/infra_overview/infra_tabs/use_tabs.tsx @@ -10,8 +10,9 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import React from 'react'; import { EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { hasOpenTelemetryPrefix } from '@kbn/elastic-agent-utils'; import type { ApmPluginStartDeps } from '../../../../plugin'; -import { KUBERNETES_POD_NAME, HOST_NAME, CONTAINER_ID } from '../../../../../common/es_fields/apm'; +import { HOST_NAME, CONTAINER_ID } from '../../../../../common/es_fields/apm'; import { buildKqlFilter } from './build_kql_filter'; type Tab = NonNullable[0] & { @@ -29,12 +30,14 @@ export function useTabs({ containerIds, podNames, hostNames, + agentName, start, end, }: { containerIds: string[]; podNames: string[]; hostNames: string[]; + agentName?: string; start: string; end: string; }) { @@ -44,6 +47,11 @@ export function useTabs({ const ContainerMetricsTable = metricsDataAccess?.ContainerMetricsTable; const PodMetricsTable = metricsDataAccess?.PodMetricsTable; + const isOtel = useMemo( + () => Boolean(agentName && hasOpenTelemetryPrefix(agentName)), + [agentName] + ); + const timerange = useMemo( () => ({ from: start, @@ -52,8 +60,16 @@ export function useTabs({ [start, end] ); + const k8sFilterFields = useMemo( + () => (isOtel ? 'k8s.pod.name' : 'kubernetes.pod.name'), + [isOtel] + ); + const hostsFilter = useMemo(() => buildKqlFilter(HOST_NAME, hostNames), [hostNames]); - const podsFilter = useMemo(() => buildKqlFilter(KUBERNETES_POD_NAME, podNames), [podNames]); + const podsFilter = useMemo( + () => buildKqlFilter(k8sFilterFields, podNames), + [k8sFilterFields, podNames] + ); const containersFilter = useMemo( () => buildKqlFilter(CONTAINER_ID, containerIds), [containerIds] @@ -66,6 +82,8 @@ export function useTabs({ ContainerMetricsTable({ timerange, kuery: containersFilter, + isOtel, + isK8sContainer: podNames.length > 0, })} ); @@ -77,6 +95,7 @@ export function useTabs({ PodMetricsTable({ timerange, kuery: podsFilter, + isOtel, })} ); @@ -88,6 +107,7 @@ export function useTabs({ HostMetricsTable({ timerange, kuery: hostsFilter, + isOtel, })} ); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/apm_services_table.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/apm_services_table.tsx index 2d87c12528b1c..9c75e0db8899e 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/apm_services_table.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/apm_services_table.tsx @@ -172,13 +172,9 @@ export function getServiceColumns({ width: `${unit * 8}px`, sortable: true, render: (_, { serviceName, agentName, sloStatus, sloCount }) => { - if (!sloStatus) { - return null; - } - return ( onSloBadgeClick(serviceName, agentName)} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/service_actions.ts b/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/service_actions.ts index 38ac498e33996..4d7ff9ce3d3be 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/service_actions.ts +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_inventory/service_list/service_actions.ts @@ -11,8 +11,8 @@ import { ApmRuleType } from '@kbn/rule-data-utils'; import { useMemo } from 'react'; import type { ServiceListItem } from '../../../../../common/service_inventory'; import type { ApmIndicatorType } from '../../../../../common/slo_indicator_types'; -import { APM_SLO_INDICATOR_TYPES } from '../../../../../common/slo_indicator_types'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { getManageSlosUrl } from '../../../../hooks/use_manage_slos_url'; import { getAlertingCapabilities } from '../../../alerting/utils/get_alerting_capabilities'; import type { TableActions } from '../../../shared/managed_table'; import type { IndexType } from '../../../shared/links/discover_links/get_esql_query'; @@ -148,42 +148,7 @@ export function useServiceActions({ defaultMessage: 'Manage SLOs', }), icon: 'tableOfContents', - href: (item) => - sloListLocator?.getRedirectUrl({ - filters: [ - { - meta: { - alias: null, - disabled: false, - key: 'service.name', - negate: false, - params: { query: item.serviceName }, - type: 'phrase', - }, - query: { - match_phrase: { 'service.name': item.serviceName }, - }, - }, - { - meta: { - alias: null, - disabled: false, - key: 'slo.indicator.type', - negate: false, - params: [...APM_SLO_INDICATOR_TYPES], - type: 'phrases', - }, - query: { - bool: { - minimum_should_match: 1, - should: APM_SLO_INDICATOR_TYPES.map((type) => ({ - match_phrase: { 'slo.indicator.type': type }, - })), - }, - }, - }, - ], - }), + href: (item) => getManageSlosUrl(sloListLocator, { serviceName: item.serviceName }), }, ], }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover.test.tsx index 839fce47a0bd0..1c50e4b19441a 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover.test.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover.test.tsx @@ -332,4 +332,51 @@ describe('MapPopover', () => { renderPopover(); expect(screen.queryByTestId('serviceMapPopoverContent')).not.toBeInTheDocument(); }); + + describe('accessibility', () => { + it('service node popover content has accessible structure', () => { + const serviceNode: ServiceMapNode = { + id: 'opbeans-java', + type: 'service', + position: { x: 100, y: 100 }, + data: { + id: 'opbeans-java', + label: 'opbeans-java', + isService: true, + agentName: 'java', + } as ServiceNodeData, + }; + + renderPopover({ selectedNode: serviceNode }); + + const content = screen.getByTestId('serviceMapPopoverContent'); + expect(content).toBeInTheDocument(); + + const heading = screen.getByTestId('serviceMapPopoverTitle'); + expect(heading).toBeInTheDocument(); + expect(heading.tagName).toBe('H3'); + expect(heading).toHaveTextContent('opbeans-java'); + }); + + it('dependency node popover content has accessible heading', () => { + const dependencyNode: ServiceMapNode = { + id: '>postgresql', + type: 'dependency', + position: { x: 200, y: 200 }, + data: { + id: '>postgresql', + label: 'postgresql', + isService: false, + } as DependencyNodeData, + }; + + renderPopover({ selectedNode: dependencyNode }); + + const content = screen.getByTestId('serviceMapPopoverContent'); + expect(content).toBeInTheDocument(); + const heading = screen.getByTestId('serviceMapPopoverTitle'); + expect(heading.tagName).toBe('H3'); + expect(heading).toHaveTextContent('postgresql'); + }); + }); }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/anomaly_detection.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/anomaly_detection.tsx index 60542f58f610a..cbd59d90781ae 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/anomaly_detection.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/anomaly_detection.tsx @@ -14,9 +14,9 @@ import { useEuiFontSize, useEuiTheme, } from '@elastic/eui'; +import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import styled from '@emotion/styled'; import type { ServiceAnomalyStats } from '../../../../../common/anomaly_detection'; import { getSeverity } from '../../../../../common/anomaly_detection'; import { @@ -28,37 +28,38 @@ import { asDuration, asInteger } from '../../../../../common/utils/formatters'; import { MLSingleMetricLink } from '../../../shared/links/machine_learning_links/mlsingle_metric_link'; import { POPOVER_WIDTH } from './constants'; -const HealthStatusTitle = styled(EuiTitle)` - display: inline; - text-transform: uppercase; -`; - -const VerticallyCentered = styled.div` - display: flex; - align-items: center; -`; - -const SubduedText = styled.span` - color: ${({ theme }) => theme.euiTheme.colors.textSubdued}; -`; - -const EnableText = styled.section` - color: ${({ theme }) => theme.euiTheme.colors.textSubdued}; - line-height: 1.4; - font-size: ${() => useEuiFontSize('s').fontSize}; - width: ${POPOVER_WIDTH}px; -`; - -export const ContentLine = styled.section` - line-height: 2; -`; - interface Props { serviceName: string; serviceAnomalyStats: ServiceAnomalyStats | undefined; } export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) { const { euiTheme } = useEuiTheme(); + const { fontSize: fontSizeS } = useEuiFontSize('s'); + + const healthStatusTitleStyles = css` + display: inline; + text-transform: uppercase; + `; + + const verticallyCenteredStyles = css` + display: flex; + align-items: center; + `; + + const subduedTextStyles = css` + color: ${euiTheme.colors.textSubdued}; + `; + + const enableTextStyles = css` + color: ${euiTheme.colors.textSubdued}; + line-height: 1.4; + font-size: ${fontSizeS}; + width: ${POPOVER_WIDTH}px; + `; + + const contentLineStyles = css` + line-height: 2; + `; const anomalyScore = serviceAnomalyStats?.anomalyScore; const severity = getSeverity(anomalyScore); @@ -72,36 +73,38 @@ export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) { return ( <>
- +

{ANOMALY_DETECTION_TITLE}

-
+   - {!mlJobId && {ANOMALY_DETECTION_DISABLED_TEXT}} + {!mlJobId &&
{ANOMALY_DETECTION_DISABLED_TEXT}
}
{hasAnomalyDetectionScore && ( - +
- +
- {ANOMALY_DETECTION_SCORE_METRIC} - + {ANOMALY_DETECTION_SCORE_METRIC} +
{getDisplayedAnomalyScore(anomalyScore as number)} - {actualValue &&  ({asDuration(actualValue)})} + {actualValue && ( +  ({asDuration(actualValue)}) + )}
- +
)} {mlJobId && !hasAnomalyDetectionScore && ( - {ANOMALY_DETECTION_NO_DATA_TEXT} +
{ANOMALY_DETECTION_NO_DATA_TEXT}
)} {mlJobId && ( - +
{ANOMALY_DETECTION_LINK} - +
)} ); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/resource_contents.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/resource_contents.tsx index daeef14597df7..d8d7b3a5c2605 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/resource_contents.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/popover/resource_contents.tsx @@ -5,26 +5,26 @@ * 2.0. */ -import { EuiDescriptionListDescription, EuiDescriptionListTitle } from '@elastic/eui'; +import { EuiDescriptionListDescription, EuiDescriptionListTitle, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import styled from '@emotion/styled'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { isEdge } from './utils'; import type { ContentsProps } from './popover_content'; import { isDependencyNodeData, type DependencyNodeData } from '../../../../../common/service_map'; -const ItemRow = styled.div` +const itemRowStyles = css` line-height: 2; `; -const SubduedDescriptionListTitle = styled(EuiDescriptionListTitle)` - &&& { - color: ${({ theme }) => theme.euiTheme.colors.textSubdued}; - } -`; - export function ResourceContents({ selection }: ContentsProps) { + const { euiTheme } = useEuiTheme(); + + const subduedDescriptionListTitleStyles = css` + color: ${euiTheme.colors.textSubdued}; + `; + if (isEdge(selection)) { return null; } @@ -55,10 +55,12 @@ export function ResourceContents({ selection }: ContentsProps) { ({ title, description }) => description && (
- - {title} +
+ + {title} + {description} - +
) )} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/apm_overview/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/apm_overview/index.tsx index 24a96ff5124ee..803fe88e2baf2 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/apm_overview/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/apm_overview/index.tsx @@ -17,6 +17,7 @@ import { isServerlessAgentName, } from '../../../../../common/agent_name'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { useServiceSloContext } from '../../../../context/service_slo/use_service_slo_context'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useApmRouter } from '../../../../hooks/use_apm_router'; import { useBreakpoints } from '../../../../hooks/use_breakpoints'; @@ -47,7 +48,7 @@ export function ApmOverview() { const { serviceName, fallbackToTransactions, agentName, serverlessType } = useApmServiceContext(); const { query, - query: { kuery, environment, rangeFrom, rangeTo, transactionType }, + query: { kuery, environment, rangeFrom, rangeTo }, } = useApmParams('/services/{serviceName}/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -82,6 +83,8 @@ export function ApmOverview() { const nonLatencyChartHeight = isSingleColumn ? latencyChartHeight : chartHeight; const rowDirection: EuiFlexGroupProps['direction'] = isSingleColumn ? 'column' : 'row'; + const { hasSlos } = useServiceSloContext(); + const [sloCalloutDismissed, setSloCalloutDismissed] = useLocalStorage( 'apm.sloCalloutDismissed', false @@ -96,7 +99,7 @@ export function ApmOverview() { return ( <> - {!sloCalloutDismissed && ( + {!sloCalloutDismissed && !hasSlos && ( <> { @@ -104,7 +107,6 @@ export function ApmOverview() { }} serviceName={serviceName} environment={environment} - transactionType={transactionType} /> diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/index.tsx index 369e992721ecc..65c65134b803f 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/index.tsx @@ -19,8 +19,6 @@ import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { replace } from '../../shared/links/url_helpers'; import { TransactionDetailsTabs } from './transaction_details_tabs'; import { isServerlessAgentName } from '../../../../common/agent_name'; -import { useLocalStorage } from '../../../hooks/use_local_storage'; -import { SloCallout } from '../../shared/slo_callout'; export function TransactionDetails() { const { path, query } = useAnyOfApmParams( @@ -35,6 +33,7 @@ export function TransactionDetails() { comparisonEnabled, offset, environment, + kuery, } = query; const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const apmRouter = useApmRouter(); @@ -60,24 +59,9 @@ export function TransactionDetails() { ); const isServerless = isServerlessAgentName(serverlessType); - const [sloCalloutDismissed, setSloCalloutDismissed] = useLocalStorage( - 'apm.sloCalloutDismissed', - false - ); return ( <> - {!sloCalloutDismissed && ( - { - setSloCalloutDismissed(true); - }} - serviceName={serviceName} - environment={environment} - transactionType={transactionType} - transactionName={transactionName} - /> - )} {fallbackToTransactions && } @@ -90,8 +74,8 @@ export function TransactionDetails() { ; + return ; } // omit icon for other spans @@ -151,11 +152,11 @@ function PrefixIcon({ item }: { item: IWaterfallSpanOrTransaction }) { case 'transaction': { // icon for RUM agent transactions if (isRumAgentName(item.doc.agent.name)) { - return ; + return ; } // icon for other transactions - return ; + return ; } default: return null; @@ -200,14 +201,21 @@ function HttpStatusCode({ item }: { item: IWaterfallSpanOrTransaction }) { return {httpStatusCode}; } -function ServiceNameBadge({ item }: { item: IWaterfallSpanOrTransaction }) { +function ServiceNameBadge({ item, color }: { item: IWaterfallSpanOrTransaction; color: string }) { const serviceName = item.doc.service.name; - if (!serviceName) { - return null; - } + + if (!serviceName) return null; return ( - + {serviceName} ); @@ -344,7 +352,7 @@ export function WaterfallItem({ {item.missingDestination ? : null} - + {isEmbeddable ? ( { @@ -73,16 +66,6 @@ export function TransactionOverview() { return ( <> - {!sloCalloutDismissed && ( - { - setSloCalloutDismissed(true); - }} - serviceName={serviceName} - environment={environment} - transactionType={transactionType} - /> - )} {fallbackToTransactions && ( <> diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.test.tsx index 72d4ff8d7d6e9..bdd6d1e2addcf 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.test.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.test.tsx @@ -12,8 +12,6 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; const mockGetCreateSLOFormFlyout = jest.fn(); -const mockGetRedirectUrl = jest.fn().mockReturnValue('/app/slos?search=test'); - jest.mock('@kbn/kibana-react-plugin/public', () => ({ useKibana: () => ({ services: { @@ -25,15 +23,6 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({ prepend: (path: string) => path, }, }, - share: { - url: { - locators: { - get: () => ({ - getRedirectUrl: mockGetRedirectUrl, - }), - }, - }, - }, }, }), })); @@ -46,6 +35,15 @@ jest.mock('../../../../hooks/use_apm_params', () => ({ }), })); +jest.mock('../../../../hooks/use_manage_slos_url', () => ({ + useManageSlosUrl: () => '/app/slos?filters=apm', +})); + +const mockUseServiceName = jest.fn().mockReturnValue(undefined); +jest.mock('../../../../hooks/use_service_name', () => ({ + useServiceName: () => mockUseServiceName(), +})); + function renderSloPopover(props: { canReadSlos: boolean; canWriteSlos: boolean }) { return render( @@ -62,213 +60,240 @@ describe('SloPopoverAndFlyout', () => { ); }); - describe('rendering', () => { - it('renders SLOs header link', () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('renders SLOs header link', () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - expect(screen.getByTestId('apmSlosHeaderLink')).toBeInTheDocument(); - expect(screen.getByText('SLOs')).toBeInTheDocument(); - }); + expect(screen.getByTestId('apmSlosHeaderLink')).toBeInTheDocument(); + expect(screen.getByText('SLOs')).toBeInTheDocument(); + }); - it('renders with arrow down icon', () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('renders with arrow down icon', () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - const headerLink = screen.getByTestId('apmSlosHeaderLink'); - expect(headerLink.querySelector('[data-euiicon-type="arrowDown"]')).toBeInTheDocument(); - }); + const headerLink = screen.getByTestId('apmSlosHeaderLink'); + expect(headerLink.querySelector('[data-euiicon-type="arrowDown"]')).toBeInTheDocument(); }); - describe('popover behavior', () => { - it('opens popover when clicking header link', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('opens popover when clicking header link', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); }); + }); - it('closes popover when clicking header link again', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('closes popover when clicking header link again', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - const headerLink = screen.getByTestId('apmSlosHeaderLink'); - fireEvent.click(headerLink); + const headerLink = screen.getByTestId('apmSlosHeaderLink'); + fireEvent.click(headerLink); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); + }); - fireEvent.click(headerLink); + fireEvent.click(headerLink); - await waitFor(() => { - expect(screen.queryByTestId('apmSlosMenuItemCreateLatencySlo')).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.queryByTestId('apmSlosMenuItemCreateLatencySlo')).not.toBeInTheDocument(); }); }); - describe('menu items based on permissions', () => { - it('shows create SLO options when user can write SLOs', async () => { - renderSloPopover({ canReadSlos: false, canWriteSlos: true }); + it('shows create SLO options when user can write SLOs', async () => { + renderSloPopover({ canReadSlos: false, canWriteSlos: true }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); - expect(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); + expect(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')).toBeInTheDocument(); }); + }); - it('hides create SLO options when user cannot write SLOs', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: false }); + it('hides create SLO options when user cannot write SLOs', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: false }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.queryByTestId('apmSlosMenuItemCreateLatencySlo')).not.toBeInTheDocument(); - expect( - screen.queryByTestId('apmSlosMenuItemCreateAvailabilitySlo') - ).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.queryByTestId('apmSlosMenuItemCreateLatencySlo')).not.toBeInTheDocument(); + expect(screen.queryByTestId('apmSlosMenuItemCreateAvailabilitySlo')).not.toBeInTheDocument(); }); + }); - it('shows manage SLOs option when user can read SLOs', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: false }); + it('shows manage SLOs option when user can read SLOs', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: false }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemManageSlos')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemManageSlos')).toBeInTheDocument(); }); + }); - it('hides manage SLOs option when user cannot read SLOs', async () => { - renderSloPopover({ canReadSlos: false, canWriteSlos: true }); + it('hides manage SLOs option when user cannot read SLOs', async () => { + renderSloPopover({ canReadSlos: false, canWriteSlos: true }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.queryByTestId('apmSlosMenuItemManageSlos')).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.queryByTestId('apmSlosMenuItemManageSlos')).not.toBeInTheDocument(); }); + }); - it('shows all options when user has full permissions', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('shows all options when user has full permissions', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); - expect(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')).toBeInTheDocument(); - expect(screen.getByTestId('apmSlosMenuItemManageSlos')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); + expect(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')).toBeInTheDocument(); + expect(screen.getByTestId('apmSlosMenuItemManageSlos')).toBeInTheDocument(); }); }); - describe('flyout behavior', () => { - it('opens latency SLO flyout when clicking create latency SLO', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('opens latency SLO flyout when clicking create latency SLO', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); + }); - fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')); + fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')); - await waitFor(() => { - expect(mockGetCreateSLOFormFlyout).toHaveBeenCalledWith( - expect.objectContaining({ - initialValues: { - indicator: { - type: 'sli.apm.transactionDuration', - params: { - environment: 'production', - }, + await waitFor(() => { + expect(mockGetCreateSLOFormFlyout).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: { + indicator: { + type: 'sli.apm.transactionDuration', + params: { + environment: 'production', }, }, - formSettings: { - allowedIndicatorTypes: [ - 'sli.apm.transactionDuration', - 'sli.apm.transactionErrorRate', - ], - }, - }) - ); - }); + }, + formSettings: { + allowedIndicatorTypes: ['sli.apm.transactionDuration', 'sli.apm.transactionErrorRate'], + }, + }) + ); }); + }); - it('opens availability SLO flyout when clicking create availability SLO', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('opens availability SLO flyout when clicking create availability SLO', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')).toBeInTheDocument(); + }); - fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')); + fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateAvailabilitySlo')); - await waitFor(() => { - expect(mockGetCreateSLOFormFlyout).toHaveBeenCalledWith( - expect.objectContaining({ - initialValues: { - indicator: { - type: 'sli.apm.transactionErrorRate', - params: { - environment: 'production', - }, + await waitFor(() => { + expect(mockGetCreateSLOFormFlyout).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: { + indicator: { + type: 'sli.apm.transactionErrorRate', + params: { + environment: 'production', }, }, - formSettings: { - allowedIndicatorTypes: [ - 'sli.apm.transactionDuration', - 'sli.apm.transactionErrorRate', - ], - }, - }) - ); - }); + }, + formSettings: { + allowedIndicatorTypes: ['sli.apm.transactionDuration', 'sli.apm.transactionErrorRate'], + }, + }) + ); }); + }); - it('closes popover after clicking create SLO option', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + it('closes popover after clicking create SLO option', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - await waitFor(() => { - expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); + }); - fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')); + fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')); - await waitFor(() => { - expect(screen.queryByTestId('apmSlosMenuItemCreateLatencySlo')).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.queryByTestId('apmSlosMenuItemCreateLatencySlo')).not.toBeInTheDocument(); }); }); - describe('manage SLOs link', () => { - it('has correct href from SLO list locator', async () => { - renderSloPopover({ canReadSlos: true, canWriteSlos: false }); - - fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); - - await waitFor(() => { - const manageSlosLink = screen.getByTestId('apmSlosMenuItemManageSlos'); - expect(manageSlosLink).toHaveAttribute('href', '/app/slos?search=test'); - expect(mockGetRedirectUrl).toHaveBeenCalledWith({ - filters: [ - expect.objectContaining({ - meta: expect.objectContaining({ - key: 'slo.indicator.type', - type: 'phrases', + it('has correct manage SLOs href from useManageSlosUrl', async () => { + renderSloPopover({ canReadSlos: true, canWriteSlos: false }); + + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + + await waitFor(() => { + const manageSlosLink = screen.getByTestId('apmSlosMenuItemManageSlos'); + expect(manageSlosLink).toHaveAttribute('href', '/app/slos?filters=apm'); + }); + }); + + it('prefills flyout with service name when on a service page', async () => { + mockUseServiceName.mockReturnValue('my-service'); + + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')); + + await waitFor(() => { + expect(mockGetCreateSLOFormFlyout).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: expect.objectContaining({ + name: 'APM SLO for my-service', + indicator: expect.objectContaining({ + params: expect.objectContaining({ + service: 'my-service', }), }), - ], - }); - }); + }), + }) + ); + }); + + mockUseServiceName.mockReturnValue(undefined); + }); + + it('does not prefill service name when not on a service page', async () => { + mockUseServiceName.mockReturnValue(undefined); + + renderSloPopover({ canReadSlos: true, canWriteSlos: true }); + + fireEvent.click(screen.getByTestId('apmSlosHeaderLink')); + + await waitFor(() => { + expect(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('apmSlosMenuItemCreateLatencySlo')); + + await waitFor(() => { + expect(mockGetCreateSLOFormFlyout).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: expect.not.objectContaining({ + name: expect.any(String), + }), + }) + ); }); }); }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.tsx index c9c75d413d61e..2274dc6c22327 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/apm_header_action_menu/slo_popover_flyout.tsx @@ -7,16 +7,17 @@ import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiContextMenu, EuiHeaderLink, EuiPopover } from '@elastic/eui'; -import { sloListLocatorID, type SloListLocatorParams } from '@kbn/deeplinks-observability'; import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ALL_VALUE } from '@kbn/slo-schema'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; import type { ApmIndicatorType } from '../../../../../common/slo_indicator_types'; import { APM_SLO_INDICATOR_TYPES } from '../../../../../common/slo_indicator_types'; import type { ApmPluginStartDeps } from '../../../../plugin'; import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useManageSlosUrl } from '../../../../hooks/use_manage_slos_url'; +import { useServiceName } from '../../../../hooks/use_service_name'; const sloLabel = i18n.translate('xpack.apm.home.sloMenu.slosHeaderLink', { defaultMessage: 'SLOs', @@ -40,7 +41,7 @@ interface Props { } export function SloPopoverAndFlyout({ canReadSlos, canWriteSlos }: Props) { - const { slo, share } = useKibana().services; + const { slo } = useKibana().services; const { query } = useApmParams('/*'); const [popoverOpen, setPopoverOpen] = useState(false); const [flyoutState, setFlyoutState] = useState<{ @@ -51,8 +52,10 @@ export function SloPopoverAndFlyout({ canReadSlos, canWriteSlos }: Props) { indicatorType: null, }); + const serviceName = useServiceName(); const apmEnvironment = ('environment' in query && query.environment) || ENVIRONMENT_ALL.value; const sloEnvironment = apmEnvironment === ENVIRONMENT_ALL.value ? ALL_VALUE : apmEnvironment; + const manageSlosUrl = useManageSlosUrl(); const openFlyout = useCallback((indicatorType: ApmIndicatorType) => { setFlyoutState({ isOpen: true, indicatorType }); @@ -63,32 +66,6 @@ export function SloPopoverAndFlyout({ canReadSlos, canWriteSlos }: Props) { setFlyoutState({ isOpen: false, indicatorType: null }); }, []); - const manageSlosUrl = useMemo(() => { - const sloListLocator = share?.url.locators.get(sloListLocatorID); - if (!sloListLocator) return undefined; - - const apmIndicatorTypeFilter = { - meta: { - alias: null, - disabled: false, - key: 'slo.indicator.type', - negate: false, - params: [...APM_SLO_INDICATOR_TYPES], - type: 'phrases', - }, - query: { - bool: { - minimum_should_match: 1, - should: APM_SLO_INDICATOR_TYPES.map((type) => ({ - match_phrase: { 'slo.indicator.type': type }, - })), - }, - }, - }; - - return sloListLocator.getRedirectUrl({ filters: [apmIndicatorTypeFilter] }); - }, [share?.url.locators]); - const panels: EuiContextMenuPanelDescriptor[] = [ { id: 0, @@ -126,9 +103,11 @@ export function SloPopoverAndFlyout({ canReadSlos, canWriteSlos }: Props) { flyoutState.isOpen && flyoutState.indicatorType ? slo?.getCreateSLOFormFlyout({ initialValues: { + ...(serviceName && { name: `APM SLO for ${serviceName}` }), indicator: { type: flyoutState.indicatorType, params: { + ...(serviceName && { service: serviceName }), environment: sloEnvironment, }, }, diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_main_template/actions_menu.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_main_template/actions_menu.tsx new file mode 100644 index 0000000000000..683a0f6ef05df --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_main_template/actions_menu.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ALL_VALUE } from '@kbn/slo-schema'; +import { ApmRuleType } from '@kbn/rule-data-utils'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import React, { useCallback, useMemo, useState } from 'react'; +import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; +import type { ApmIndicatorType } from '../../../../../common/slo_indicator_types'; +import { APM_SLO_INDICATOR_TYPES } from '../../../../../common/slo_indicator_types'; +import type { ApmPluginStartDeps } from '../../../../plugin'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useManageSlosUrl } from '../../../../hooks/use_manage_slos_url'; +import { useServiceName } from '../../../../hooks/use_service_name'; +import { getAlertingCapabilities } from '../../../alerting/utils/get_alerting_capabilities'; +import { AlertingFlyout } from '../../../alerting/ui_components/alerting_flyout'; +import type { ActionGroups } from '../../../shared/actions_context_menu'; +import { ActionsContextMenu } from '../../../shared/actions_context_menu'; + +const actionsLabel = i18n.translate('xpack.apm.home.actionsMenu.actions', { + defaultMessage: 'Actions', +}); + +export function ActionsMenu() { + const { slo: sloPlugin } = useKibana().services; + const { core, plugins } = useApmPluginContext(); + const { capabilities } = core.application; + const { query } = useApmParams('/*'); + + const [ruleType, setRuleType] = useState(null); + const [sloFlyoutState, setSloFlyoutState] = useState<{ + isOpen: boolean; + indicatorType: ApmIndicatorType | null; + }>({ + isOpen: false, + indicatorType: null, + }); + + const canReadMlJobs = !!capabilities.ml?.canGetJobs; + const { isAlertingAvailable, canSaveAlerts } = getAlertingCapabilities(plugins, capabilities); + const canSaveApmAlerts = !!capabilities.apm.save && canSaveAlerts; + const canReadSlos = !!capabilities.slo?.read; + const canWriteSlos = !!capabilities.slo?.write; + + const serviceName = useServiceName(); + const apmEnvironment = ('environment' in query && query.environment) || ENVIRONMENT_ALL.value; + const sloEnvironment = apmEnvironment === ENVIRONMENT_ALL.value ? ALL_VALUE : apmEnvironment; + const manageSlosUrl = useManageSlosUrl(); + + const openSloFlyout = useCallback((indicatorType: ApmIndicatorType) => { + setSloFlyoutState({ isOpen: true, indicatorType }); + }, []); + + const closeSloFlyout = useCallback(() => { + setSloFlyoutState({ isOpen: false, indicatorType: null }); + }, []); + + const actionGroups: ActionGroups = useMemo(() => { + const groups: ActionGroups = []; + + if (isAlertingAvailable && canSaveApmAlerts) { + groups.push({ + id: 'alerts', + groupLabel: i18n.translate('xpack.apm.home.actionsMenu.alertsGroup', { + defaultMessage: 'Alerts', + }), + actions: [ + { + id: 'createThresholdRule', + name: i18n.translate('xpack.apm.home.actionsMenu.createThresholdRule', { + defaultMessage: 'Create threshold rule', + }), + items: [ + { + id: 'createLatencyRule', + name: i18n.translate('xpack.apm.home.actionsMenu.latency', { + defaultMessage: 'Latency', + }), + onClick: () => setRuleType(ApmRuleType.TransactionDuration), + }, + { + id: 'createFailedTransactionRateRule', + name: i18n.translate('xpack.apm.home.actionsMenu.failedTransactionRate', { + defaultMessage: 'Failed transaction rate', + }), + onClick: () => setRuleType(ApmRuleType.TransactionErrorRate), + }, + ], + }, + ...(canReadMlJobs + ? [ + { + id: 'createAnomalyRule', + name: i18n.translate('xpack.apm.home.actionsMenu.createAnomalyRule', { + defaultMessage: 'Create anomaly rule', + }), + onClick: () => setRuleType(ApmRuleType.Anomaly), + }, + ] + : []), + { + id: 'createErrorCountRule', + name: i18n.translate('xpack.apm.home.actionsMenu.createErrorCountRule', { + defaultMessage: 'Create error count rule', + }), + onClick: () => setRuleType(ApmRuleType.ErrorCount), + }, + ], + }); + } + + if (canWriteSlos || canReadSlos) { + groups.push({ + id: 'slos', + groupLabel: i18n.translate('xpack.apm.home.actionsMenu.slosGroup', { + defaultMessage: 'SLOs', + }), + actions: [ + ...(canWriteSlos + ? [ + { + id: 'createLatencySlo', + name: i18n.translate('xpack.apm.home.actionsMenu.createLatencySlo', { + defaultMessage: 'Create APM latency SLO', + }), + onClick: () => openSloFlyout('sli.apm.transactionDuration'), + }, + { + id: 'createAvailabilitySlo', + name: i18n.translate('xpack.apm.home.actionsMenu.createAvailabilitySlo', { + defaultMessage: 'Create APM availability SLO', + }), + onClick: () => openSloFlyout('sli.apm.transactionErrorRate'), + }, + ] + : []), + ...(canReadSlos + ? [ + { + id: 'manageSlos', + name: i18n.translate('xpack.apm.home.actionsMenu.manageSlos', { + defaultMessage: 'Manage SLOs', + }), + href: manageSlosUrl, + icon: 'tableOfContents', + }, + ] + : []), + ], + }); + } + + return groups; + }, [ + isAlertingAvailable, + canSaveApmAlerts, + canReadMlJobs, + canWriteSlos, + canReadSlos, + manageSlosUrl, + openSloFlyout, + ]); + + if (actionGroups.length === 0) { + return null; + } + + const CreateSloFlyout = + sloFlyoutState.isOpen && sloFlyoutState.indicatorType + ? sloPlugin?.getCreateSLOFormFlyout({ + initialValues: { + ...(serviceName && { name: `APM SLO for ${serviceName}` }), + indicator: { + type: sloFlyoutState.indicatorType, + params: { + ...(serviceName && { service: serviceName }), + environment: sloEnvironment, + }, + }, + }, + onClose: closeSloFlyout, + formSettings: { + allowedIndicatorTypes: [...APM_SLO_INDICATOR_TYPES], + }, + }) + : null; + + return ( + <> + + {actionsLabel} + + } + /> + { + if (!visible) { + setRuleType(null); + } + }} + /> + {CreateSloFlyout} + + ); +} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_main_template/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_main_template/index.tsx index 3ca645a7c824f..d8424d12b884f 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_main_template/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_main_template/index.tsx @@ -21,6 +21,7 @@ import type { ApmPluginStartDeps } from '../../../../plugin'; import { ServiceGroupSaveButton } from '../../../app/service_groups'; import { ServiceGroupsButtonGroup } from '../../../app/service_groups/service_groups_button_group'; import { ApmEnvironmentFilter } from '../../../shared/environment_filter'; +import { ActionsMenu } from './actions_menu'; import { getNoDataConfig } from '../no_data_config'; // Paths that must skip the no data screen @@ -41,6 +42,7 @@ export function ApmMainTemplate({ pageHeader, children, environmentFilter = true, + showActionsMenu = false, showServiceGroupSaveButton = false, showServiceGroupsNav = false, selectedNavButton, @@ -50,6 +52,7 @@ export function ApmMainTemplate({ pageHeader?: EuiPageHeaderProps; children: React.ReactNode; environmentFilter?: boolean; + showActionsMenu?: boolean; showServiceGroupSaveButton?: boolean; showServiceGroupsNav?: boolean; selectedNavButton?: 'serviceGroups' | 'allServices'; @@ -116,6 +119,7 @@ export function ApmMainTemplate({ const rightSideItems = [ ...(showServiceGroupSaveButton ? [] : []), + ...(showActionsMenu ? [] : []), ...(environmentFilter ? [] : []), ]; diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index f75f7a67f8e0a..64e4494ad598b 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -6,11 +6,18 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiLoadingLogo, EuiSpacer, EuiTitle } from '@elastic/eui'; -import React from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import type { AgentName } from '@kbn/elastic-agent-utils'; +import { i18n } from '@kbn/i18n'; +import { + OBSERVABILITY_AGENT_ID, + OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, +} from '@kbn/observability-agent-builder-plugin/public'; import { isMobileAgentName } from '../../../../../common/agent_name'; import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { ServiceSloContextProvider } from '../../../../context/service_slo/service_slo_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'; @@ -20,10 +27,13 @@ import { useTimeRange } from '../../../../hooks/use_time_range'; import { replace } from '../../../shared/links/url_helpers'; import { SearchBar } from '../../../shared/search_bar/search_bar'; import { ServiceIcons } from '../../../shared/service_icons'; +import { SloOverviewFlyout } from '../../../shared/slo_overview_flyout'; import { ApmMainTemplate } from '../apm_main_template'; import { AnalyzeDataButton } from './analyze_data_button'; +import { ServiceHeaderBadges } from './service_header_badges'; import type { Tab } from './use_tabs'; import { useTabs } from './use_tabs'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; interface Props { title: string; @@ -48,6 +58,7 @@ function TemplateWithContext({ title, children, selectedTab, searchBarOptions }: } = useApmParams('/services/{serviceName}/*'); const history = useHistory(); const location = useLocation(); + const { agentBuilder } = useApmPluginContext(); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -59,6 +70,24 @@ function TemplateWithContext({ title, children, selectedTab, searchBarOptions }: const isPendingServiceAgent = !agentName && isPending(serviceAgentStatus); + const [sloOverviewFlyout, setSloOverviewFlyout] = useState<{ + serviceName: string; + agentName?: string; + } | null>(null); + + const openSloOverviewFlyout = useCallback(() => { + setSloOverviewFlyout({ serviceName, agentName }); + }, [serviceName, agentName]); + + const closeSloOverviewFlyout = useCallback(() => { + setSloOverviewFlyout(null); + }, []); + + const alertsTabHref = router.link('/services/{serviceName}/alerts' as const, { + path: { serviceName }, + query, + }); + useBreadcrumb( () => ({ title, @@ -70,6 +99,36 @@ function TemplateWithContext({ title, children, selectedTab, searchBarOptions }: [query, router, selectedTab, serviceName, title] ); + // Configure agent builder global flyout with the service attachment + useEffect(() => { + if (!agentBuilder || !serviceName) { + return; + } + + agentBuilder.setConversationFlyoutActiveConfig({ + agentId: OBSERVABILITY_AGENT_ID, + attachments: [ + { + type: OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, + data: { + serviceName, + environment, + start, + end, + attachmentLabel: i18n.translate('xpack.apm.serviceDetails.serviceAttachmentLabel', { + defaultMessage: '{serviceName} service', + values: { serviceName }, + }), + }, + }, + ], + }); + + return () => { + agentBuilder.clearConversationFlyoutActiveConfig(); + }; + }, [agentBuilder, serviceName, environment, start, end]); + if (isMobileAgentName(agentName)) { replace(history, { pathname: location.pathname.replace('/services/', '/mobile-services/'), @@ -77,51 +136,72 @@ function TemplateWithContext({ title, children, selectedTab, searchBarOptions }: } return ( - - - - - -

{serviceName}

-
+ + + + + + + +

{serviceName}

+
+
+ + + +
+ - +
-
- + + + + ), + }} + > + {isPendingServiceAgent ? ( + - + + - ), - }} - > - {isPendingServiceAgent ? ( - - - - - - - ) : ( - <> - - - {children} - - - )} -
+ ) : ( + <> + + + {children} + + + )} + {sloOverviewFlyout && ( + + )} + + ); } diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/service_header_badges.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/service_header_badges.test.tsx new file mode 100644 index 0000000000000..9f223417764e2 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/service_header_badges.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { ServiceHeaderBadges } from './service_header_badges'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; + +const mockUseServiceSloContext = jest.fn(); +jest.mock('../../../../context/service_slo/use_service_slo_context', () => ({ + useServiceSloContext: () => mockUseServiceSloContext(), +})); + +const mockUseApmPluginContext = jest.fn(); +jest.mock('../../../../context/apm_plugin/use_apm_plugin_context', () => ({ + useApmPluginContext: () => mockUseApmPluginContext(), +})); + +const mockUseFetcher = jest.fn(); +jest.mock('../../../../hooks/use_fetcher', () => ({ + useFetcher: () => mockUseFetcher(), + FETCH_STATUS: { + LOADING: 'loading', + SUCCESS: 'success', + FAILURE: 'failure', + NOT_INITIATED: 'not_initiated', + }, +})); + +const defaultProps = { + serviceName: 'test-service', + environment: 'production', + start: '2026-01-01T00:00:00.000Z', + end: '2026-01-02T00:00:00.000Z', + onSloClick: jest.fn(), + alertsTabHref: '/services/test-service/alerts', +}; + +function renderBadges(props = defaultProps) { + return render( + + + + ); +} + +function setupMocks({ + isAlertingAvailable = true, + canReadAlerts = true, + canReadSlos = true, + alertsCount = 0, + sloFetchStatus = FETCH_STATUS.SUCCESS as string, + mostCriticalSloStatus = { status: 'healthy' as const, count: 0 }, +}: { + isAlertingAvailable?: boolean; + canReadAlerts?: boolean; + canReadSlos?: boolean; + alertsCount?: number; + sloFetchStatus?: string; + mostCriticalSloStatus?: { status: string; count: number }; +} = {}) { + mockUseApmPluginContext.mockReturnValue({ + core: { + application: { + capabilities: { + slo: { read: canReadSlos }, + apm: { + 'alerting:show': canReadAlerts, + 'alerting:save': canReadAlerts, + }, + }, + }, + }, + plugins: { + alerting: isAlertingAvailable ? {} : undefined, + }, + }); + + mockUseServiceSloContext.mockReturnValue({ + mostCriticalSloStatus, + sloFetchStatus, + }); + + mockUseFetcher.mockReturnValue({ + data: { alertsCount }, + status: FETCH_STATUS.SUCCESS, + }); +} + +describe('ServiceHeaderBadges', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows alerts badge when there are active alerts', () => { + setupMocks({ alertsCount: 5 }); + renderBadges(); + + const badge = screen.getByTestId('serviceHeaderAlertsBadge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('5'); + }); + + it('shows alerts badge with correct href', () => { + setupMocks({ alertsCount: 3 }); + renderBadges(); + + const badge = screen.getByTestId('serviceHeaderAlertsBadge'); + expect(badge).toHaveAttribute('href', '/services/test-service/alerts'); + }); + + it('hides alerts badge when alertsCount is 0', () => { + setupMocks({ alertsCount: 0, mostCriticalSloStatus: { status: 'healthy', count: 1 } }); + renderBadges(); + + expect(screen.queryByTestId('serviceHeaderAlertsBadge')).not.toBeInTheDocument(); + }); + + it('hides alerts badge when alerting is unavailable', () => { + setupMocks({ isAlertingAvailable: false, alertsCount: 5 }); + renderBadges(); + + expect(screen.queryByTestId('serviceHeaderAlertsBadge')).not.toBeInTheDocument(); + }); + + it('shows violated SLO badge when SLOs are violated', () => { + setupMocks({ mostCriticalSloStatus: { status: 'violated', count: 2 } }); + renderBadges(); + + expect(screen.getByTestId('serviceInventorySloViolatedBadge')).toBeInTheDocument(); + }); + + it('shows healthy SLO badge', () => { + setupMocks({ mostCriticalSloStatus: { status: 'healthy', count: 3 } }); + renderBadges(); + + expect(screen.getByTestId('serviceInventorySloHealthyBadge')).toBeInTheDocument(); + }); + + it('shows degrading SLO badge', () => { + setupMocks({ mostCriticalSloStatus: { status: 'degrading', count: 1 } }); + renderBadges(); + + expect(screen.getByTestId('serviceInventorySloDegradingBadge')).toBeInTheDocument(); + }); + + it('hides SLO badge when SLO data is still loading', () => { + setupMocks({ alertsCount: 1, sloFetchStatus: FETCH_STATUS.LOADING }); + renderBadges(); + + expect(screen.queryByTestId('serviceInventorySloHealthyBadge')).not.toBeInTheDocument(); + expect(screen.queryByTestId('serviceInventorySloViolatedBadge')).not.toBeInTheDocument(); + }); + + it('hides SLO badge when user cannot read SLOs', () => { + setupMocks({ + canReadSlos: false, + alertsCount: 1, + mostCriticalSloStatus: { status: 'violated', count: 2 }, + }); + renderBadges(); + + expect(screen.queryByTestId('serviceInventorySloViolatedBadge')).not.toBeInTheDocument(); + }); + + it('returns null when no badges should be shown', () => { + setupMocks({ + alertsCount: 0, + mostCriticalSloStatus: { status: 'noSLOs', count: 0 }, + sloFetchStatus: FETCH_STATUS.NOT_INITIATED, + }); + const { container } = renderBadges(); + + expect(container.firstChild).toBeNull(); + }); + + it('shows both badges when alerts and SLO data exist', () => { + setupMocks({ + alertsCount: 3, + mostCriticalSloStatus: { status: 'violated', count: 1 }, + }); + renderBadges(); + + expect(screen.getByTestId('serviceHeaderAlertsBadge')).toBeInTheDocument(); + expect(screen.getByTestId('serviceInventorySloViolatedBadge')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/service_header_badges.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/service_header_badges.tsx new file mode 100644 index 0000000000000..19898ead3dcab --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/service_header_badges.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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { useServiceSloContext } from '../../../../context/service_slo/use_service_slo_context'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { getAlertingCapabilities } from '../../../alerting/utils/get_alerting_capabilities'; +import { SloStatusBadge } from '../../../shared/slo_status_badge'; + +interface ServiceHeaderBadgesProps { + serviceName: string; + environment: string; + start: string; + end: string; + onSloClick: () => void; + alertsTabHref: string; +} + +export function ServiceHeaderBadges({ + serviceName, + environment, + start, + end, + onSloClick, + alertsTabHref, +}: ServiceHeaderBadgesProps) { + const { core, plugins } = useApmPluginContext(); + const { capabilities } = core.application; + const { isAlertingAvailable, canReadAlerts } = getAlertingCapabilities(plugins, capabilities); + const canReadSlos = !!capabilities.slo?.read; + + const { mostCriticalSloStatus, sloFetchStatus } = useServiceSloContext(); + + const { data: alertsData, status: alertsStatus } = useFetcher( + (callApmApi) => { + if (!(isAlertingAvailable && canReadAlerts)) { + return; + } + return callApmApi('GET /internal/apm/services/{serviceName}/alerts_count', { + params: { + path: { serviceName }, + query: { start, end, environment }, + }, + }); + }, + [serviceName, start, end, environment, isAlertingAvailable, canReadAlerts] + ); + + const alertsCount = alertsData?.alertsCount ?? 0; + + const showAlertsBadge = + isAlertingAvailable && + canReadAlerts && + alertsStatus === FETCH_STATUS.SUCCESS && + alertsCount > 0; + const showSloBadge = canReadSlos && sloFetchStatus === FETCH_STATUS.SUCCESS; + + if (!showAlertsBadge && !showSloBadge) { + return null; + } + + return ( + + {showAlertsBadge && ( + + + + {alertsCount} + + + + )} + {showSloBadge && ( + + + + )} + + ); +} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/use_tabs.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/use_tabs.tsx index 68485c51e6b19..4b2e45d4a11be 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/use_tabs.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/templates/apm_service_template/use_tabs.tsx @@ -6,7 +6,6 @@ */ import type { EuiPageHeaderProps } from '@elastic/eui'; -import { EuiBadge, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { keyBy, omit } from 'lodash'; import React from 'react'; @@ -24,9 +23,7 @@ import { useApmServiceContext } from '../../../../context/apm_service/use_apm_se import { useApmFeatureFlag } from '../../../../hooks/use_apm_feature_flag'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useApmRouter } from '../../../../hooks/use_apm_router'; -import { useFetcher } from '../../../../hooks/use_fetcher'; import { useProfilingPluginSetting } from '../../../../hooks/use_profiling_integration_setting'; -import { useTimeRange } from '../../../../hooks/use_time_range'; import { getAlertingCapabilities } from '../../../alerting/utils/get_alerting_capabilities'; import { BetaBadge } from '../../../shared/beta_badge'; import { TechnicalPreviewBadge } from '../../../shared/technical_preview_badge'; @@ -106,27 +103,6 @@ export function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { } = useApmParams(`/services/{serviceName}/${selectedTab}` as const); const query = omit(queryFromUrl, 'page', 'pageSize', 'sortField', 'sortDirection'); - const { rangeFrom, rangeTo, environment } = queryFromUrl; - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - - const { data: serviceAlertsCount = { alertsCount: 0 } } = useFetcher( - (callApmApi) => { - return callApmApi('GET /internal/apm/services/{serviceName}/alerts_count', { - params: { - path: { - serviceName, - }, - query: { - start, - end, - environment, - }, - }, - }); - }, - [serviceName, start, end, environment] - ); - const allTabsDefinitions: Tab[] = [ { key: 'overview', @@ -228,22 +204,6 @@ export function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { path: { serviceName }, query, }), - append: - serviceAlertsCount.alertsCount > 0 ? ( - - - {serviceAlertsCount.alertsCount} - - - ) : null, label: i18n.translate('xpack.apm.home.alertsTabLabel', { defaultMessage: 'Alerts', }), diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/actions_context_menu/actions_context_menu.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/actions_context_menu/actions_context_menu.test.tsx new file mode 100644 index 0000000000000..515b3d5ca3bd5 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/actions_context_menu/actions_context_menu.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, waitFor } from '@testing-library/react'; +import { ActionsContextMenu, type ActionGroups } from '.'; + +const createActions = (onClickMock: jest.Mock): ActionGroups => [ + { + id: 'alerts', + groupLabel: 'Alerts', + actions: [ + { + id: 'thresholdRule', + name: 'Create threshold rule', + items: [ + { id: 'latency', name: 'Latency', onClick: onClickMock }, + { id: 'errorRate', name: 'Error rate', onClick: onClickMock }, + ], + }, + { id: 'anomalyRule', name: 'Create anomaly rule', onClick: onClickMock }, + ], + }, + { + id: 'slos', + groupLabel: 'SLOs', + actions: [ + { id: 'createSlo', name: 'Create SLO', onClick: onClickMock }, + { id: 'manageSlos', name: 'Manage SLOs', href: '/app/slos' }, + ], + }, +]; + +function renderMenu( + overrides: Partial> = {}, + onClickMock = jest.fn() +) { + const actions = createActions(onClickMock); + const button = ; + + const result = render( + + ); + + return { ...result, onClickMock, actions }; +} + +describe('ActionsContextMenu', () => { + it('renders the trigger button', () => { + renderMenu(); + expect(screen.getByTestId('triggerButton')).toBeInTheDocument(); + }); + + it('opens popover when trigger button is clicked', () => { + renderMenu(); + fireEvent.click(screen.getByTestId('triggerButton')); + expect(screen.getByTestId('testMenuGroup-alerts')).toBeInTheDocument(); + }); + + it('closes popover when an action with onClick is clicked', async () => { + renderMenu(); + + fireEvent.click(screen.getByTestId('triggerButton')); + expect(screen.getByTestId('testMenuItem-anomalyRule')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('testMenuItem-anomalyRule')); + await waitFor(() => { + expect(screen.queryByTestId('testMenuItem-anomalyRule')).not.toBeInTheDocument(); + }); + }); + + it('renders group labels', () => { + renderMenu(); + fireEvent.click(screen.getByTestId('triggerButton')); + + expect(screen.getByTestId('testMenuGroup-alerts')).toHaveTextContent('Alerts'); + expect(screen.getByTestId('testMenuGroup-slos')).toHaveTextContent('SLOs'); + }); + + it('does not render group label when groupLabel is undefined', () => { + const actions: ActionGroups = [ + { + id: 'noLabel', + actions: [{ id: 'action1', name: 'Action 1' }], + }, + ]; + + render( + Open} + dataTestSubjPrefix="testMenu" + /> + ); + + fireEvent.click(screen.getByTestId('triggerButton')); + expect(screen.queryByTestId('testMenuGroup-noLabel')).not.toBeInTheDocument(); + expect(screen.getByTestId('testMenuItem-action1')).toBeInTheDocument(); + }); + + it('renders all action items in the main panel', () => { + renderMenu(); + fireEvent.click(screen.getByTestId('triggerButton')); + + expect(screen.getByTestId('testMenuItem-thresholdRule')).toBeInTheDocument(); + expect(screen.getByTestId('testMenuItem-anomalyRule')).toBeInTheDocument(); + expect(screen.getByTestId('testMenuItem-createSlo')).toBeInTheDocument(); + expect(screen.getByTestId('testMenuItem-manageSlos')).toBeInTheDocument(); + }); + + it('calls onClick and closes popover when an action is clicked', async () => { + const { onClickMock } = renderMenu(); + fireEvent.click(screen.getByTestId('triggerButton')); + + fireEvent.click(screen.getByTestId('testMenuItem-anomalyRule')); + + expect(onClickMock).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.queryByTestId('testMenuItem-anomalyRule')).not.toBeInTheDocument(); + }); + }); + + it('renders href actions as links', () => { + renderMenu(); + fireEvent.click(screen.getByTestId('triggerButton')); + + const manageSlosItem = screen.getByTestId('testMenuItem-manageSlos'); + expect(manageSlosItem.closest('a')).toHaveAttribute('href', '/app/slos'); + }); + + it('opens sub-panel when action with items is clicked', async () => { + renderMenu(); + fireEvent.click(screen.getByTestId('triggerButton')); + + fireEvent.click(screen.getByTestId('testMenuItem-thresholdRule')); + + await waitFor(() => { + expect(screen.getByTestId('testMenuItem-latency')).toBeInTheDocument(); + expect(screen.getByTestId('testMenuItem-errorRate')).toBeInTheDocument(); + }); + }); + + it('calls onClick and closes popover when sub-item is clicked', async () => { + const { onClickMock } = renderMenu(); + fireEvent.click(screen.getByTestId('triggerButton')); + + fireEvent.click(screen.getByTestId('testMenuItem-thresholdRule')); + + await waitFor(() => { + expect(screen.getByTestId('testMenuItem-latency')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('testMenuItem-latency')); + + expect(onClickMock).toHaveBeenCalledTimes(1); + }); + + it('uses default prefix when dataTestSubjPrefix is not specified', () => { + const actions: ActionGroups = [ + { id: 'g', groupLabel: 'Group', actions: [{ id: 'a', name: 'A' }] }, + ]; + render( + Open} /> + ); + + fireEvent.click(screen.getByTestId('btn')); + expect(screen.getByTestId('actionsContextMenuGroup-g')).toBeInTheDocument(); + expect(screen.getByTestId('actionsContextMenuItem-a')).toBeInTheDocument(); + }); + + it('renders empty context menu when no actions are provided', () => { + render( + Open} + /> + ); + + fireEvent.click(screen.getByTestId('triggerButton')); + expect(screen.queryByTestId('testMenuGroup-alerts')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/actions_context_menu/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/actions_context_menu/index.tsx new file mode 100644 index 0000000000000..1a3c69a3897c6 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/actions_context_menu/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiContextMenu, EuiPopover, useEuiTheme } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; + +export interface ActionSubItem { + id: string; + name: string; + onClick?: () => void; + href?: string; + icon?: string; +} + +export interface Action { + id: string; + name: string; + onClick?: () => void; + href?: string; + icon?: string; + items?: ActionSubItem[]; +} + +export interface ActionGroup { + id: string; + groupLabel?: string; + actions: Action[]; +} + +export type ActionGroups = ActionGroup[]; + +interface ActionsContextMenuProps { + actions: ActionGroups; + button: React.ReactElement; + id?: string; + dataTestSubjPrefix?: string; +} + +export function ActionsContextMenu({ + actions, + button, + id = 'actions-context-menu', + dataTestSubjPrefix = 'actionsContextMenu', +}: ActionsContextMenuProps) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { euiTheme } = useEuiTheme(); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((prev) => !prev); + }, []); + + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const buttonWithToggle = useMemo( + () => React.cloneElement(button, { onClick: togglePopover }), + [button, togglePopover] + ); + + const panels: EuiContextMenuPanelDescriptor[] = useMemo(() => { + const mainPanelItems: EuiContextMenuPanelDescriptor['items'] = []; + const subPanels: EuiContextMenuPanelDescriptor[] = []; + let subPanelId = 1; + + for (const [groupIndex, group] of actions.entries()) { + if (group.groupLabel) { + mainPanelItems.push({ + name: group.groupLabel, + disabled: true, + css: { + fontWeight: 700, + color: euiTheme.colors.text, + borderBottom: euiTheme.border.thin, + marginTop: groupIndex > 0 ? euiTheme.size.m : 0, + }, + 'data-test-subj': `${dataTestSubjPrefix}Group-${group.id}`, + }); + } + + for (const action of group.actions) { + const hasSubItems = action.items && action.items.length > 0; + + if (hasSubItems) { + const panelId = subPanelId++; + + mainPanelItems.push({ + name: action.name, + icon: action.icon, + panel: panelId, + 'data-test-subj': `${dataTestSubjPrefix}Item-${action.id}`, + }); + + subPanels.push({ + id: panelId, + title: action.name, + items: action.items!.map((subItem) => ({ + name: subItem.name, + icon: subItem.icon, + ...(subItem.href + ? { href: subItem.href, target: '_self' } + : { + onClick: () => { + subItem.onClick?.(); + closePopover(); + }, + }), + 'data-test-subj': `${dataTestSubjPrefix}Item-${subItem.id}`, + })), + }); + } else { + mainPanelItems.push({ + name: action.name, + icon: action.icon, + ...(action.href + ? { href: action.href, target: '_self' } + : { + onClick: action.onClick + ? () => { + action.onClick!(); + closePopover(); + } + : undefined, + }), + 'data-test-subj': `${dataTestSubjPrefix}Item-${action.id}`, + }); + } + } + } + + return [{ id: 0, items: mainPanelItems }, ...subPanels]; + }, [actions, closePopover, euiTheme, dataTestSubjPrefix]); + + return ( + + + + ); +} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/managed_table/index.tsx index 6926238da4f38..e57b8e211c4f4 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/managed_table/index.tsx @@ -6,16 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import type { EuiBasicTableColumn, EuiContextMenuPanelDescriptor } from '@elastic/eui'; -import { - EuiBasicTable, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiButtonIcon, - EuiContextMenu, - useEuiTheme, -} from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui'; import { isEmpty, merge, orderBy } from 'lodash'; import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -28,6 +20,8 @@ import { getItemsFilteredBySearchQuery, TableSearchBar, } from '../table_search_bar/table_search_bar'; +import type { ActionGroups } from '../actions_context_menu'; +import { ActionsContextMenu } from '../actions_context_menu'; type SortDirection = 'asc' | 'desc'; @@ -90,6 +84,27 @@ export interface TableActionGroup { export type TableActions = Array>; +function resolveTableActions(actions: TableActions, item: T): ActionGroups { + return actions.map((group) => ({ + id: group.id, + groupLabel: group.groupLabel, + actions: group.actions.map((action) => ({ + id: action.id, + name: action.name, + icon: action.icon, + onClick: action.onClick ? () => action.onClick!(item) : undefined, + href: action.href ? action.href(item) : undefined, + items: action.items?.map((subItem) => ({ + id: subItem.id, + name: subItem.name, + icon: subItem.icon, + onClick: subItem.onClick ? () => subItem.onClick!(item) : undefined, + href: subItem.href ? subItem.href(item) : undefined, + })), + })), + })); +} + function ActionsCell({ item, actions, @@ -99,96 +114,13 @@ function ActionsCell({ actions: TableActions; disabled?: boolean; }) { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [activePanelId, setActivePanelId] = useState(0); - const { euiTheme } = useEuiTheme(); - - const togglePopover = useCallback(() => { - setIsPopoverOpen((prev) => !prev); - }, []); - - const closePopover = useCallback(() => { - setIsPopoverOpen(false); - setActivePanelId(0); - }, []); - - const panels: EuiContextMenuPanelDescriptor[] = useMemo(() => { - const mainPanelItems: EuiContextMenuPanelDescriptor['items'] = []; - const subPanels: EuiContextMenuPanelDescriptor[] = []; - let subPanelId = 1; - - for (const [groupIndex, group] of actions.entries()) { - // Add group header if it exists - if (group.groupLabel) { - mainPanelItems.push({ - name: group.groupLabel, - disabled: true, - css: { - fontWeight: 700, - color: euiTheme.colors.text, - borderBottom: euiTheme.border.thin, - marginTop: groupIndex > 0 ? euiTheme.size.m : 0, - }, - 'data-test-subj': `apmManagedTableActionsMenuGroup-${group.id}`, - }); - } - - // Add action items - for (const action of group.actions) { - const hasSubItems = action.items && action.items.length > 0; - - if (hasSubItems) { - const panelId = subPanelId++; - - mainPanelItems.push({ - name: action.name, - icon: action.icon, - panel: panelId, - 'data-test-subj': `apmManagedTableActionsMenuItem-${action.id}`, - }); - - subPanels.push({ - id: panelId, - title: action.name, - items: action.items!.map((subItem) => ({ - name: subItem.name, - icon: subItem.icon, - ...(subItem.href - ? { href: subItem.href(item), target: '_self' } - : { - onClick: () => { - subItem.onClick?.(item); - closePopover(); - }, - }), - 'data-test-subj': `apmManagedTableActionsMenuItem-${subItem.id}`, - })), - }); - } else { - mainPanelItems.push({ - name: action.name, - icon: action.icon, - ...(action.href - ? { href: action.href(item), target: '_self' } - : { - onClick: action.onClick - ? () => { - action.onClick!(item); - closePopover(); - } - : undefined, - }), - 'data-test-subj': `apmManagedTableActionsMenuItem-${action.id}`, - }); - } - } - } - - return [{ id: 0, items: mainPanelItems }, ...subPanels]; - }, [actions, item, closePopover, euiTheme]); + const resolvedActions = useMemo(() => resolveTableActions(actions, item), [actions, item]); return ( - ({ defaultMessage: 'Actions', })} iconType="boxesVertical" - onClick={togglePopover} color="text" isDisabled={disabled} /> } - isOpen={isPopoverOpen} - closePopover={closePopover} - panelPaddingSize="none" - anchorPosition="downRight" - > - - + /> ); } diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_callout/assets/illustration_slo_callout.svg b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_callout/assets/illustration_slo_callout.svg new file mode 100644 index 0000000000000..cedb768ab4d73 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_callout/assets/illustration_slo_callout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_callout/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_callout/index.tsx index 4f5c48e5599f4..a91b14f5908e5 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_callout/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_callout/index.tsx @@ -4,104 +4,141 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiButton, EuiButtonEmpty, EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { sloEditLocatorID } from '@kbn/deeplinks-observability'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, useState } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { ApmPluginStartDeps } from '../../../plugin'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import { + APM_SLO_INDICATOR_TYPES, + type ApmIndicatorType, +} from '../../../../common/slo_indicator_types'; +import illustrationSrc from './assets/illustration_slo_callout.svg'; interface Props { dismissCallout: () => void; serviceName: string; environment: string; - transactionType?: string; - transactionName?: string; } -export function SloCallout({ - dismissCallout, - serviceName, - environment, - transactionType, - transactionName, -}: Props) { +export function SloCallout({ dismissCallout, serviceName, environment }: Props) { const { - plugins: { - share: { - url: { locators }, - }, - }, + core: { docLinks }, } = useApmPluginContext(); + const { slo: sloPlugin } = useKibana().services; - const locator = locators.get(sloEditLocatorID); + const [createSloFlyoutOpen, setCreateSloFlyoutOpen] = useState(false); - const handleClick = () => { - locator?.navigate( - { - indicator: { - type: 'sli.apm.transactionErrorRate', - params: { - service: serviceName, - environment: environment === ENVIRONMENT_ALL.value ? '*' : environment, - transactionName, - transactionType, + const openCreateSloFlyout = useCallback(() => { + setCreateSloFlyoutOpen(true); + }, []); + + const closeCreateSloFlyout = useCallback(() => { + setCreateSloFlyoutOpen(false); + dismissCallout(); + }, [dismissCallout]); + + const defaultIndicatorType: ApmIndicatorType = 'sli.apm.transactionDuration'; + + const CreateSloFlyout = createSloFlyoutOpen + ? sloPlugin?.getCreateSLOFormFlyout({ + initialValues: { + name: `APM SLO for ${serviceName}`, + indicator: { + type: defaultIndicatorType, + params: { + service: serviceName, + environment: environment === ENVIRONMENT_ALL.value ? '*' : environment, + }, }, }, - }, - { - replace: false, - } - ); - }; + onClose: closeCreateSloFlyout, + formSettings: { + allowedIndicatorTypes: [...APM_SLO_INDICATOR_TYPES], + }, + }) + : null; return ( - - - -

- + + + + -

-
- - - - { - handleClick(); - dismissCallout(); - }} - > - {i18n.translate('xpack.apm.slo.callout.createButton', { - defaultMessage: 'Create SLO', - })} - - - - { - dismissCallout(); - }} - > - {i18n.translate('xpack.apm.slo.callout.dimissButton', { - defaultMessage: 'Hide this', - })} - - - - -
-
+
+ + + + +

+ {i18n.translate('xpack.apm.slo.callout.title', { + defaultMessage: "You don't have any SLOs set up for this service yet", + })} +

+
+
+ + +

+ {i18n.translate('xpack.apm.slo.callout.description', { + defaultMessage: + 'Define SLOs to start tracking reliability and performance over time.', + })} +

+
+
+
+ + + + + {i18n.translate('xpack.apm.slo.callout.createButton', { + defaultMessage: 'Create SLO', + })} + + + + + {i18n.translate('xpack.apm.slo.callout.viewDocumentation', { + defaultMessage: 'View documentation', + })} + + + +
+ + + {CreateSloFlyout} + ); } diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/index.tsx index 0e8761b237614..bbd47c23ef17c 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/index.tsx @@ -9,6 +9,8 @@ import type { EuiBasicTableColumn, EuiSelectableOption } from '@elastic/eui'; import { EuiBadge, EuiBasicTable, + EuiButton, + EuiEmptyPrompt, EuiFilterGroup, EuiFilterButton, EuiPopover, @@ -40,15 +42,19 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { SLOWithSummaryResponse } from '@kbn/slo-schema'; import { ALL_VALUE } from '@kbn/slo-schema'; import { AgentIcon } from '@kbn/custom-icons'; -import type { SloTabId, SloListLocatorParams } from '@kbn/deeplinks-observability'; -import { ALERTS_TAB_ID, sloListLocatorID } from '@kbn/deeplinks-observability'; +import type { SloTabId } from '@kbn/deeplinks-observability'; +import { ALERTS_TAB_ID } from '@kbn/deeplinks-observability'; import type { AgentName } from '@kbn/elastic-agent-utils'; import type { ApmPluginStartDeps } from '../../../plugin'; import { useApmRouter } from '../../../hooks/use_apm_router'; -import { useApmParams } from '../../../hooks/use_apm_params'; +import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; import { useFetcher, isPending } from '../../../hooks/use_fetcher'; -import { APM_SLO_INDICATOR_TYPES } from '../../../../common/slo_indicator_types'; -import { SERVICE_NAME } from '../../../../common/es_fields/apm'; +import { useManageSlosUrl } from '../../../hooks/use_manage_slos_url'; +import { + APM_SLO_INDICATOR_TYPES, + type ApmIndicatorType, +} from '../../../../common/slo_indicator_types'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; type SloStatusFilter = 'VIOLATED' | 'DEGRADING' | 'HEALTHY' | 'NO_DATA'; @@ -105,9 +111,9 @@ const ITEMS_PER_PAGE_OPTIONS = [10, 25, 50]; export function SloOverviewFlyout({ serviceName, agentName, onClose }: Props) { const flyoutTitleId = useGeneratedHtmlId({ prefix: 'sloOverviewFlyout' }); const { euiTheme } = useEuiTheme(); - const { uiSettings, slo: sloPlugin, share } = useKibana().services; + const { uiSettings, slo: sloPlugin } = useKibana().services; const { link } = useApmRouter(); - const { query } = useApmParams('/services'); + const { query } = useAnyOfApmParams('/services', '/services/{serviceName}'); const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); useDebounce( @@ -133,7 +139,11 @@ export function SloOverviewFlyout({ serviceName, agentName, onClose }: Props) { return trimmed || undefined; }, [debouncedSearchQuery]); - const { data, status: fetchStatus } = useFetcher( + const { + data, + status: fetchStatus, + refetch, + } = useFetcher( (callApmApi) => { return callApmApi('GET /internal/apm/services/{serviceName}/slos', { params: { @@ -215,41 +225,7 @@ export function SloOverviewFlyout({ serviceName, agentName, onClose }: Props) { }); }, [sloData, activeAlerts]); - const sloAppUrl = useMemo(() => { - const sloListLocator = share?.url.locators.get(sloListLocatorID); - if (!sloListLocator) return undefined; - - return sloListLocator.getRedirectUrl({ - filters: [ - { - meta: { - alias: null, - disabled: false, - key: SERVICE_NAME, - negate: false, - params: { query: serviceName }, - type: 'phrase', - }, - query: { - match_phrase: { [SERVICE_NAME]: serviceName }, - }, - }, - { - meta: { - alias: null, - disabled: false, - key: 'slo.indicator.type', - negate: false, - params: [...APM_SLO_INDICATOR_TYPES], - type: 'phrases', - }, - query: { - terms: { 'slo.indicator.type': [...APM_SLO_INDICATOR_TYPES] }, - }, - }, - ], - }); - }, [share?.url.locators, serviceName]); + const sloAppUrl = useManageSlosUrl({ serviceName }); const serviceOverviewUrl = useMemo(() => { return link('/services/{serviceName}/overview', { @@ -292,6 +268,38 @@ export function SloOverviewFlyout({ serviceName, agentName, onClose }: Props) { setSelectedSloTabId(undefined); }, []); + const [createSloFlyoutOpen, setCreateSloFlyoutOpen] = useState(false); + + const openCreateSloFlyout = useCallback(() => { + setCreateSloFlyoutOpen(true); + }, []); + + const closeCreateSloFlyout = useCallback(() => { + setCreateSloFlyoutOpen(false); + refetch(); + }, [refetch]); + + const defaultIndicatorType: ApmIndicatorType = 'sli.apm.transactionDuration'; + + const CreateSloFlyout = createSloFlyoutOpen + ? sloPlugin?.getCreateSLOFormFlyout({ + initialValues: { + name: `APM SLO for ${serviceName}`, + indicator: { + type: defaultIndicatorType, + params: { + service: serviceName, + environment: environment === ENVIRONMENT_ALL.value ? '*' : environment, + }, + }, + }, + onClose: closeCreateSloFlyout, + formSettings: { + allowedIndicatorTypes: [...APM_SLO_INDICATOR_TYPES], + }, + }) + : null; + const handlePageChange = useCallback((newPage: number) => { setPage(newPage); }, []); @@ -401,23 +409,28 @@ export function SloOverviewFlyout({ serviceName, agentName, onClose }: Props) { const activeFiltersCount = selectedStatuses.length; + const flyoutTitle = i18n.translate('xpack.apm.sloOverviewFlyout.title', { + defaultMessage: 'SLOs', + }); + return (

- {i18n.translate('xpack.apm.sloOverviewFlyout.title', { - defaultMessage: 'SLOs', - })} + {flyoutTitle}

@@ -585,13 +598,42 @@ export function SloOverviewFlyout({ serviceName, agentName, onClose }: Props) { })} rowHeader="name" noItemsMessage={ - isLoading - ? i18n.translate('xpack.apm.sloOverviewFlyout.loading', { - defaultMessage: 'Loading SLOs...', - }) - : i18n.translate('xpack.apm.sloOverviewFlyout.noSlos', { - defaultMessage: 'No SLOs found for this service', - }) + isLoading ? ( + i18n.translate('xpack.apm.sloOverviewFlyout.loading', { + defaultMessage: 'Loading SLOs...', + }) + ) : ( + + {i18n.translate('xpack.apm.sloOverviewFlyout.emptyState.title', { + defaultMessage: 'No SLOs (APM)', + })} +

+ } + titleSize="xxs" + body={ + + {i18n.translate('xpack.apm.sloOverviewFlyout.emptyState.description', { + defaultMessage: + 'Create an SLO to track your application reliability and performance over time. Define a metric, set a target, and monitor how your service meets expectations', + })} + + } + actions={ + + {i18n.translate('xpack.apm.sloOverviewFlyout.emptyState.createSlo', { + defaultMessage: 'Create SLO', + })} + + } + data-test-subj="sloOverviewFlyoutEmptyState" + /> + ) } data-test-subj="sloOverviewFlyoutTable" /> @@ -621,6 +663,7 @@ export function SloOverviewFlyout({ serviceName, agentName, onClose }: Props) { initialTabId={selectedSloTabId} /> )} + {CreateSloFlyout} ); } diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/slo_overview_flyout.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/slo_overview_flyout.test.tsx index ca50912b27bb3..ab8eddb85454a 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/slo_overview_flyout.test.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_overview_flyout/slo_overview_flyout.test.tsx @@ -11,7 +11,7 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { SloOverviewFlyout } from '.'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useApmRouter } from '../../../hooks/use_apm_router'; -import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmParams, useAnyOfApmParams } from '../../../hooks/use_apm_params'; import type { SLOWithSummaryResponse } from '@kbn/slo-schema'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; @@ -25,6 +25,11 @@ jest.mock('../../../hooks/use_apm_router', () => ({ jest.mock('../../../hooks/use_apm_params', () => ({ useApmParams: jest.fn(), + useAnyOfApmParams: jest.fn(), +})); + +jest.mock('../../../hooks/use_manage_slos_url', () => ({ + useManageSlosUrl: () => '/app/slo', })); const mockUseFetcher = jest.fn(); @@ -50,6 +55,7 @@ jest.mock('@elastic/eui', () => { const mockUseKibana = useKibana as jest.Mock; const mockUseApmRouter = useApmRouter as jest.Mock; const mockUseApmParams = useApmParams as jest.Mock; +const mockUseAnyOfApmParams = useAnyOfApmParams as jest.Mock; const createMockSlo = (overrides: Partial = {}): SLOWithSummaryResponse => ({ @@ -114,6 +120,14 @@ describe('SloOverviewFlyout', () => { }, }); + mockUseAnyOfApmParams.mockReturnValue({ + query: { + environment: 'production', + rangeFrom: 'now-15m', + rangeTo: 'now', + }, + }); + mockUseFetcher.mockReturnValue({ data: { results: [], @@ -205,7 +219,7 @@ describe('SloOverviewFlyout', () => { expect(screen.getByText('Error Rate SLO')).toBeInTheDocument(); }); - it('displays "No SLOs found" when no data', async () => { + it('displays empty state when no SLOs exist', async () => { mockUseFetcher.mockReturnValue({ data: { results: [], @@ -221,7 +235,9 @@ describe('SloOverviewFlyout', () => { renderWithIntl(); - expect(screen.getByText('No SLOs found for this service')).toBeInTheDocument(); + expect(screen.getByTestId('sloOverviewFlyoutEmptyState')).toBeInTheDocument(); + expect(screen.getByText('No SLOs (APM)')).toBeInTheDocument(); + expect(screen.getByTestId('sloOverviewFlyoutCreateSloButton')).toBeInTheDocument(); }); it('displays status stats panel', async () => { @@ -415,6 +431,6 @@ describe('SloOverviewFlyout', () => { renderWithIntl(); - expect(screen.getByText('No SLOs found for this service')).toBeInTheDocument(); + expect(screen.getByTestId('sloOverviewFlyoutEmptyState')).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_status_badge/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_status_badge/index.tsx index 40df61ea89ab8..d7fae647765bc 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_status_badge/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/slo_status_badge/index.tsx @@ -7,7 +7,7 @@ import type { MouseEventHandler } from 'react'; import React from 'react'; -import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import { EuiBadge, EuiToolTip, EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { SloStatus } from '../../../../common/service_inventory'; @@ -22,7 +22,7 @@ interface SloStatusConfig { export const SLO_COUNT_CAP = 50; -const SLO_STATUS_CONFIG: Record = { +const SLO_STATUS_CONFIG: Record = { violated: { id: 'Violated', color: 'danger', @@ -93,6 +93,23 @@ const SLO_STATUS_CONFIG: Record = { defaultMessage: 'Healthy', }), }, + noSLOs: { + id: 'NoSLOs', + color: 'hollow', + showCount: false, + tooltipContent: i18n.translate('xpack.apm.servicesTable.tooltip.noSLOs', { + defaultMessage: 'No SLOs are defined for this service. Click to create a new SLO.', + }), + ariaLabel: (serviceName: string) => + i18n.translate('xpack.apm.servicesTable.noSLOsAriaLabel', { + defaultMessage: 'Create a new SLO for {serviceName}', + values: { serviceName }, + }), + badgeLabel: () => + i18n.translate('xpack.apm.servicesTable.noSLOs', { + defaultMessage: 'No SLOs', + }), + }, }; export function SloStatusBadge({ @@ -101,7 +118,7 @@ export function SloStatusBadge({ serviceName, onClick, }: { - sloStatus: SloStatus; + sloStatus: SloStatus | 'noSLOs'; sloCount?: number; serviceName: string; onClick: MouseEventHandler; @@ -122,7 +139,14 @@ export function SloStatusBadge({ onClick={onClick} onClickAriaLabel={config.ariaLabel(serviceName)} > - {config.badgeLabel(cappedCount)} + + + + + + {config.badgeLabel(cappedCount)} + +
); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.test.tsx index 9665f1c6a10e9..76e63996a0d30 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.test.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.test.tsx @@ -444,4 +444,39 @@ describe('BarDetails', () => { expect(queryByText('cold start')).not.toBeInTheDocument(); }); }); + + describe('in case of service name badge', () => { + it('renders service name badge when serviceName is present', () => { + const mockItemWithServiceName = { + ...mockItem, + serviceName: 'my-service', + } as unknown as TraceWaterfallItem; + + const { getByTestId, getByText } = render( + + ); + expect(getByTestId('apmBarDetailsServiceNameBadge')).toBeInTheDocument(); + expect(getByText('my-service')).toBeInTheDocument(); + }); + + it('does not render service name badge when serviceName is empty', () => { + const mockItemWithoutServiceName = { + ...mockItem, + serviceName: '', + } as unknown as TraceWaterfallItem; + + const { queryByTestId } = render(); + expect(queryByTestId('apmBarDetailsServiceNameBadge')).not.toBeInTheDocument(); + }); + + it('does not render service name badge when serviceName is undefined', () => { + const mockItemWithoutServiceName = { + ...mockItem, + serviceName: undefined, + } as unknown as TraceWaterfallItem; + + const { queryByTestId } = render(); + expect(queryByTestId('apmBarDetailsServiceNameBadge')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.tsx index ddf928f79d0f5..847a12c1beb22 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/bar_details.tsx @@ -83,7 +83,7 @@ export function BarDetails({ item, left }: { item: TraceWaterfallItem; left: num > {item.icon && ( - + )} + {item.serviceName && ( + + + {item.serviceName} + + + )} - + {asDuration(item.duration)} - +
{item.status && itemStatusIsFailureOrError && ( @@ -148,6 +164,7 @@ export function BarDetails({ item, left }: { item: TraceWaterfallItem; left: num color={theme.euiTheme.colors.danger} size="s" data-test-subj="apmBarDetailsErrorIcon" + aria-hidden={true} /> )} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/index.tsx index 468a17793baa3..5228d8d262800 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/trace_waterfall/index.tsx @@ -111,45 +111,73 @@ function TraceWaterfallComponent() { return [...agentMarks, ...errorMarks]; }, [agentMarks, errorMarks]); + const stickyTop = isEmbeddable + ? '0px' + : 'var(--kbnAppHeadersOffset, var(--euiFixedHeadersOffset, 0))'; + return ( - + {showCriticalPathControl && ( )} - {showLegend && serviceName && ( - - - - )}
-
- {showAccordion && ( - + {showLegend && serviceName && ( + + + )} - -
+ + + {showAccordion && ( + + + + )} + + + + + + ({ + sloFetchStatus: FETCH_STATUS.NOT_INITIATED, + hasSlos: false, + mostCriticalSloStatus: { status: 'noSLOs', count: 0 }, +}); + +export function ServiceSloContextProvider({ + serviceName, + environment, + children, +}: { + serviceName: string; + environment: string; + children: ReactNode; +}) { + const { core } = useApmPluginContext(); + const canReadSlos = !!core.application.capabilities.slo?.read; + + const { data, status } = useFetcher( + (callApmApi) => { + if (!canReadSlos) { + return; + } + return callApmApi('GET /internal/apm/services/{serviceName}/slos', { + params: { + path: { serviceName }, + query: { + environment, + page: 0, + perPage: 1, + }, + }, + }); + }, + [serviceName, environment, canReadSlos] + ); + + const sloTotal = data?.total ?? 0; + const hasSlos = sloTotal > 0; + + const mostCriticalSloStatus = useMemo(() => { + const statusCounts = data?.statusCounts; + if (hasSlos && statusCounts) { + for (const priority of SLO_STATUS_PRIORITY) { + const count = statusCounts[priority] ?? 0; + if (count > 0) { + return { status: priority, count }; + } + } + } + return { status: 'noSLOs', count: 0 }; + }, [data?.statusCounts, hasSlos]); + + return ( + + ); +} diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/i18n.ts b/x-pack/solutions/observability/plugins/apm/public/context/service_slo/use_service_slo_context.ts similarity index 58% rename from x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/i18n.ts rename to x-pack/solutions/observability/plugins/apm/public/context/service_slo/use_service_slo_context.ts index 1a65751d3f9b5..48273ef4be1a6 100644 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/i18n.ts +++ b/x-pack/solutions/observability/plugins/apm/public/context/service_slo/use_service_slo_context.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import { useContext } from 'react'; +import { ServiceSloContext } from './service_slo_context'; -export const txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { - defaultMessage: 'Go to Dashboard', -}); +export function useServiceSloContext() { + return useContext(ServiceSloContext); +} diff --git a/x-pack/solutions/observability/plugins/apm/public/hooks/use_manage_slos_url.ts b/x-pack/solutions/observability/plugins/apm/public/hooks/use_manage_slos_url.ts new file mode 100644 index 0000000000000..47d9b7427fcf1 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/hooks/use_manage_slos_url.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 { useMemo } from 'react'; +import type { Filter } from '@kbn/es-query'; +import type { LocatorPublic } from '@kbn/share-plugin/common'; +import type { SloListLocatorParams } from '@kbn/deeplinks-observability'; +import { sloListLocatorID } from '@kbn/deeplinks-observability'; +import { APM_SLO_INDICATOR_TYPES } from '../../common/slo_indicator_types'; +import { ENVIRONMENT_ALL } from '../../common/environment_filter_values'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; +import { useApmParams } from './use_apm_params'; +import { useServiceName } from './use_service_name'; + +interface ManageSlosUrlParams { + serviceName?: string; + environment?: string; +} + +export function getManageSlosUrl( + sloListLocator: LocatorPublic | undefined, + params?: ManageSlosUrlParams +): string | undefined { + if (!sloListLocator) return undefined; + + const filters: Filter[] = [ + { + meta: { + alias: null, + disabled: false, + key: 'slo.indicator.type', + negate: false, + params: [...APM_SLO_INDICATOR_TYPES], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: APM_SLO_INDICATOR_TYPES.map((type) => ({ + match_phrase: { 'slo.indicator.type': type }, + })), + }, + }, + }, + ]; + + if (params?.serviceName) { + filters.push({ + meta: { + alias: null, + disabled: false, + key: 'service.name', + negate: false, + params: { query: params.serviceName }, + type: 'phrase', + }, + query: { + match_phrase: { 'service.name': params.serviceName }, + }, + }); + } + + if (params?.environment && params.environment !== ENVIRONMENT_ALL.value) { + filters.push({ + meta: { + alias: null, + disabled: false, + key: 'service.environment', + negate: false, + params: { query: params.environment }, + type: 'phrase', + }, + query: { + match_phrase: { 'service.environment': params.environment }, + }, + }); + } + + return sloListLocator.getRedirectUrl({ filters }); +} + +export function useManageSlosUrl(overrides?: ManageSlosUrlParams): string | undefined { + const { share } = useApmPluginContext(); + const sloListLocator = share.url.locators.get(sloListLocatorID); + + const routeServiceName = useServiceName(); + const { query } = useApmParams('/*'); + const routeEnvironment = 'environment' in query ? query.environment : undefined; + + const serviceName = overrides?.serviceName ?? routeServiceName; + const environment = overrides?.environment ?? routeEnvironment; + + return useMemo( + () => getManageSlosUrl(sloListLocator, { serviceName, environment }), + [sloListLocator, serviceName, environment] + ); +} diff --git a/x-pack/solutions/observability/plugins/apm/public/plugin.ts b/x-pack/solutions/observability/plugins/apm/public/plugin.ts index 3ee3cd18f37fd..9bbc58990b994 100644 --- a/x-pack/solutions/observability/plugins/apm/public/plugin.ts +++ b/x-pack/solutions/observability/plugins/apm/public/plugin.ts @@ -86,8 +86,6 @@ import type { import type { KqlPluginSetup, KqlPluginStart } from '@kbn/kql/public'; import type { SLOPublicStart } from '@kbn/slo-plugin/public'; import type { ConfigSchema } from '.'; -import { registerApmRuleTypes } from './components/alerting/rule_types/register_apm_rule_types'; -import { registerEmbeddables } from './embeddable/register_embeddables'; import { getApmEnrollmentFlyoutData, LazyApmCustomAssetsExtension, @@ -95,8 +93,8 @@ import { import { getLazyApmAgentsTabExtension } from './components/fleet_integration/lazy_apm_agents_tab_extension'; import { getLazyAPMPolicyCreateExtension } from './components/fleet_integration/lazy_apm_policy_create_extension'; import { getLazyAPMPolicyEditExtension } from './components/fleet_integration/lazy_apm_policy_edit_extension'; -import { featureCatalogueEntry } from './feature_catalogue_entry'; import { APMServiceDetailLocator } from './locator/service_detail_locator'; +import { featureCatalogueEntry } from './feature_catalogue_entry'; import type { ITelemetryClient } from './services/telemetry'; import { TelemetryService } from './services/telemetry'; import { createLazyFocusedTraceWaterfallRenderer } from './components/shared/focused_trace_waterfall/lazy_create_focused_trace_waterfall_renderer'; @@ -495,13 +493,19 @@ export class ApmPlugin implements Plugin { }, }); - registerApmRuleTypes(observabilityRuleTypeRegistry); - registerEmbeddables({ - coreSetup: core, - pluginsSetup: plugins, - config, - kibanaEnvironment, - observabilityRuleTypeRegistry, + import('./components/alerting/rule_types/register_apm_rule_types').then( + ({ registerApmRuleTypes }) => { + registerApmRuleTypes(observabilityRuleTypeRegistry); + } + ); + import('./embeddable/register_embeddables').then(({ registerEmbeddables }) => { + registerEmbeddables({ + coreSetup: core, + pluginsSetup: plugins, + config, + kibanaEnvironment, + observabilityRuleTypeRegistry, + }); }); const locator = plugins.share.url.locators.create(new APMServiceDetailLocator(core.uiSettings)); diff --git a/x-pack/solutions/observability/plugins/apm/server/agent_builder/data_provider/get_apm_downstream_dependencies/index.ts b/x-pack/solutions/observability/plugins/apm/server/agent_builder/data_provider/get_apm_downstream_dependencies/index.ts deleted file mode 100644 index 530b12a7b250d..0000000000000 --- a/x-pack/solutions/observability/plugins/apm/server/agent_builder/data_provider/get_apm_downstream_dependencies/index.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import datemath from '@elastic/datemath'; -import * as t from 'io-ts'; -import { termQuery } from '@kbn/observability-plugin/server'; -import type { RandomSampler } from '../../../lib/helpers/get_random_sampler'; -import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; -import { SERVICE_NAME } from '../../../../common/es_fields/apm'; -import { environmentQuery } from '../../../../common/utils/environment_query'; -import { getConnectionStats } from '../../../lib/connections/get_connection_stats'; -import type { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; -import { NodeType } from '../../../../common/connections'; - -export const downstreamDependenciesRouteRt = t.intersection([ - t.type({ - serviceName: t.string, - start: t.string, - end: t.string, - }), - t.partial({ - serviceEnvironment: t.string, - }), -]); - -export interface APMDownstreamDependency { - 'service.name'?: string; - 'span.destination.service.resource': string; - 'span.type'?: string; - 'span.subtype'?: string; - errorRate?: number; - latencyMs?: number; - throughputPerMin?: number; -} - -export async function getApmDownstreamDependencies({ - arguments: args, - apmEventClient, - randomSampler, -}: { - arguments: t.TypeOf; - apmEventClient: APMEventClient; - randomSampler: RandomSampler; -}): Promise { - const start = datemath.parse(args.start)?.valueOf()!; - const end = datemath.parse(args.end)?.valueOf()!; - - const { statsItems } = await getConnectionStats({ - start, - end, - apmEventClient, - filter: [ - ...termQuery(SERVICE_NAME, args.serviceName), - ...environmentQuery(args.serviceEnvironment ?? ENVIRONMENT_ALL.value), - ], - collapseBy: 'downstream', - numBuckets: 1, // not used when withTimeseries: false, but required param - randomSampler, - withTimeseries: false, - }); - return statsItems.map((item) => { - const { location, stats } = item; - - // @ts-expect-error - dependencyName exists when collapsing downstream - const dependencyName = location.dependencyName!; - - const rawThroughput = stats.throughput?.value; - const metrics = { - errorRate: stats.errorRate?.value ?? undefined, - latencyMs: stats.latency?.value ?? undefined, - // Round to 3 decimal places for cleaner LLM output - throughputPerMin: rawThroughput != null ? Math.round(rawThroughput * 1000) / 1000 : undefined, - }; - - if (location.type === NodeType.service) { - return { - 'service.name': location.serviceName, - 'span.destination.service.resource': dependencyName, - ...metrics, - }; - } - - return { - 'span.destination.service.resource': dependencyName, - 'span.type': location.spanType, - 'span.subtype': location.spanSubtype, - ...metrics, - }; - }); -} diff --git a/x-pack/solutions/observability/plugins/apm/server/agent_builder/data_provider/register_data_providers.ts b/x-pack/solutions/observability/plugins/apm/server/agent_builder/data_provider/register_data_providers.ts index 06c9d595459b1..48c4015131dc3 100644 --- a/x-pack/solutions/observability/plugins/apm/server/agent_builder/data_provider/register_data_providers.ts +++ b/x-pack/solutions/observability/plugins/apm/server/agent_builder/data_provider/register_data_providers.ts @@ -7,10 +7,15 @@ import type { CoreSetup, Logger } from '@kbn/core/server'; import { getRollupIntervalForTimeRange } from '@kbn/apm-data-access-plugin/server/utils'; +import type { TraceMetrics } from '@kbn/observability-agent-builder-plugin/server/data_registry/data_registry_types'; +import type { APMConfig } from '../..'; import { getErrorSampleDetails } from '../../routes/errors/get_error_groups/get_error_sample_details'; import { parseDatemath } from '../utils/time'; import { getApmServiceSummary } from './get_apm_service_summary'; -import { getApmDownstreamDependencies } from './get_apm_downstream_dependencies'; +import { getTraceSampleIds } from '../../routes/service_map/get_trace_sample_ids'; +import { fetchExitSpanSamplesFromTraceIds } from '../../routes/service_map/fetch_exit_span_samples'; +import { getConnectionStatsItems } from '../../lib/connections/get_connection_stats/get_connection_stats_items'; +import { getConnectionStats } from '../../lib/connections/get_connection_stats'; import { getServicesItems } from '../../routes/services/get_services/get_services_items'; import { ApmDocumentType } from '../../../common/document_type'; import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; @@ -21,10 +26,12 @@ import type { APMPluginSetupDependencies, APMPluginStartDependencies } from '../ export function registerDataProviders({ core, plugins, + config, logger, }: { core: CoreSetup; plugins: APMPluginSetupDependencies; + config: APMConfig; logger: Logger; }) { const { observabilityAgentBuilder } = plugins; @@ -39,7 +46,6 @@ export function registerDataProviders({ core, plugins, request, - logger, }); return getApmServiceSummary({ @@ -59,33 +65,10 @@ export function registerDataProviders({ } ); - observabilityAgentBuilder.registerDataProvider( - 'apmDownstreamDependencies', - async ({ request, serviceName, serviceEnvironment, start, end }) => { - const { apmEventClient, randomSampler } = await buildApmToolResources({ - core, - plugins, - request, - logger, - }); - - return getApmDownstreamDependencies({ - apmEventClient, - randomSampler, - arguments: { - serviceName, - serviceEnvironment: serviceEnvironment ? serviceEnvironment : ENVIRONMENT_ALL.value, - start, - end, - }, - }); - } - ); - observabilityAgentBuilder.registerDataProvider( 'apmExitSpanChangePoints', async ({ request, serviceName, serviceEnvironment, start, end }) => { - const { apmEventClient } = await buildApmToolResources({ core, plugins, request, logger }); + const { apmEventClient } = await buildApmToolResources({ core, plugins, request }); return getExitSpanChangePoints({ apmEventClient, @@ -108,7 +91,7 @@ export function registerDataProviders({ start, end, }) => { - const { apmEventClient } = await buildApmToolResources({ core, plugins, request, logger }); + const { apmEventClient } = await buildApmToolResources({ core, plugins, request }); return getServiceChangePoints({ apmEventClient, @@ -125,7 +108,7 @@ export function registerDataProviders({ observabilityAgentBuilder.registerDataProvider( 'apmErrorDetails', async ({ request, errorId, serviceName, serviceEnvironment, start, end, kuery = '' }) => { - const { apmEventClient } = await buildApmToolResources({ core, plugins, request, logger }); + const { apmEventClient } = await buildApmToolResources({ core, plugins, request }); return getErrorSampleDetails({ apmEventClient, @@ -142,13 +125,8 @@ export function registerDataProviders({ observabilityAgentBuilder.registerDataProvider( 'servicesItems', async ({ request, environment, kuery, start, end, searchQuery }) => { - const { apmEventClient, randomSampler, mlClient, apmAlertsClient } = - await buildApmToolResources({ - core, - plugins, - request, - logger, - }); + const { apmEventClient, randomSamplerSeed, mlClient, apmAlertsClient } = + await buildApmToolResources({ core, plugins, request }); const startMs = parseDatemath(start); const endMs = parseDatemath(end); @@ -156,7 +134,7 @@ export function registerDataProviders({ return getServicesItems({ apmEventClient, apmAlertsClient, - randomSampler, + randomSampler: { seed: randomSamplerSeed, probability: 1 }, mlClient, logger, environment: environment ?? ENVIRONMENT_ALL.value, @@ -171,4 +149,107 @@ export function registerDataProviders({ }); } ); + + observabilityAgentBuilder.registerDataProvider( + 'apmTraceSampleIds', + async ({ request, serviceName, start, end }) => { + const { apmEventClient } = await buildApmToolResources({ core, plugins, request }); + + return getTraceSampleIds({ + config, + apmEventClient, + serviceName, + environment: ENVIRONMENT_ALL.value, + start, + end, + }); + } + ); + + observabilityAgentBuilder.registerDataProvider( + 'apmExitSpanSamples', + async ({ request, traceIds, start, end }) => { + const { apmEventClient } = await buildApmToolResources({ core, plugins, request }); + + return fetchExitSpanSamplesFromTraceIds({ + apmEventClient, + traceIds, + start, + end, + }); + } + ); + + observabilityAgentBuilder.registerDataProvider( + 'apmConnectionStatsItems', + async ({ request, start, end, filter }) => { + const { apmEventClient } = await buildApmToolResources({ core, plugins, request }); + + const items = await getConnectionStatsItems({ + apmEventClient, + start, + end, + filter, + numBuckets: 1, // not used when withTimeseries: false, but required param + withTimeseries: false, + }); + + return items.map((item) => ({ + from: { serviceName: item.from.serviceName }, + to: { + dependencyName: item.to.dependencyName, + spanType: item.to.spanType, + spanSubtype: item.to.spanSubtype, + }, + value: item.value, + })); + } + ); + + observabilityAgentBuilder.registerDataProvider( + 'apmConnectionStats', + async ({ request, start, end, filter }) => { + const { apmEventClient, randomSamplerSeed } = await buildApmToolResources({ + core, + plugins, + request, + }); + + const { statsItems } = await getConnectionStats({ + apmEventClient, + start, + end, + filter, + collapseBy: 'downstream', + + // getDestinationMap (called by getConnectionStats) computes its own dynamic + // probability internally. probability: 1 here is only used as a fallback + // for small datasets (<20M docs) where sampling is unnecessary. + randomSampler: { seed: randomSamplerSeed, probability: 1 }, + numBuckets: 1, // not used when withTimeseries: false, but required param + withTimeseries: false, + }); + + return statsItems.map((item) => { + const { location, stats } = item; + const metrics: TraceMetrics = { + latencyUs: stats.latency.value, + throughputPerMin: stats.throughput.value, + errorRate: stats.errorRate.value, + }; + + if ('serviceName' in location) { + return { type: 'service' as const, serviceName: location.serviceName, metrics }; + } + + return { + type: 'dependency' as const, + dependencyName: location.dependencyName, + spanType: location.spanType, + spanSubtype: location.spanSubtype, + metrics, + }; + }); + } + ); } diff --git a/x-pack/solutions/observability/plugins/apm/server/agent_builder/utils/build_apm_tool_resources.ts b/x-pack/solutions/observability/plugins/apm/server/agent_builder/utils/build_apm_tool_resources.ts index 6c4987718dd9e..8c641119913bf 100644 --- a/x-pack/solutions/observability/plugins/apm/server/agent_builder/utils/build_apm_tool_resources.ts +++ b/x-pack/solutions/observability/plugins/apm/server/agent_builder/utils/build_apm_tool_resources.ts @@ -5,28 +5,23 @@ * 2.0. */ -import type { - CoreSetup, - KibanaRequest, - Logger, - SavedObjectsClientContract, -} from '@kbn/core/server'; +import type { CoreSetup, KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import type { ApmDataAccessServices } from '@kbn/apm-data-access-plugin/server'; import { firstValueFrom } from 'rxjs'; import type { APMPluginSetupDependencies, APMPluginStartDependencies } from '../../types'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; -import { getRandomSampler } from '../../lib/helpers/get_random_sampler'; import type { MinimalApmPluginRequestHandlerContext } from '../../routes/typings'; import { getMlClient } from '../../lib/helpers/get_ml_client'; import type { MinimalAPMRouteHandlerResources } from '../../routes/apm_routes/register_apm_server_routes'; import { getApmAlertsClient } from '../../lib/helpers/get_apm_alerts_client'; import type { ApmAlertsClient } from '../../lib/helpers/get_apm_alerts_client'; +import { getRandomSamplerSeed } from '../../lib/helpers/get_random_sampler'; export interface ApmToolResources { apmEventClient: Awaited>; apmDataAccessServices: ApmDataAccessServices; - randomSampler: Awaited>; + randomSamplerSeed: number; mlClient: Awaited>; apmAlertsClient: ApmAlertsClient; esClient: IScopedClusterClient; @@ -38,13 +33,11 @@ export async function buildApmToolResources({ plugins, request, esClient, - logger, }: { core: CoreSetup; plugins: APMPluginSetupDependencies; request: KibanaRequest; esClient?: IScopedClusterClient; - logger: Logger; }): Promise { const [coreStart, pluginStart] = await core.getStartServices(); const esScoped = esClient ?? coreStart.elasticsearch.client.asScoped(request); @@ -91,11 +84,7 @@ export async function buildApmToolResources({ }, }); - const randomSamplerPromise = getRandomSampler({ - coreStart, - request, - probability: 1, - }); + const randomSamplerSeed = getRandomSamplerSeed(coreStart, request); const mlClientPromise = getMlClient({ plugins: pluginsAdapter, @@ -109,9 +98,8 @@ export async function buildApmToolResources({ request, }); - const [apmEventClient, randomSampler, mlClient, apmAlertsClient] = await Promise.all([ + const [apmEventClient, mlClient, apmAlertsClient] = await Promise.all([ apmEventClientPromise, - randomSamplerPromise, mlClientPromise, apmAlertsClientPromise, ]); @@ -121,7 +109,7 @@ export async function buildApmToolResources({ return { apmEventClient, apmDataAccessServices, - randomSampler, + randomSamplerSeed, mlClient, apmAlertsClient, esClient: esScoped, diff --git a/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/get_stats.ts b/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/get_connection_stats_items.ts similarity index 87% rename from x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/get_stats.ts rename to x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/get_connection_stats_items.ts index 54f07dca595a0..748276b3f63ee 100644 --- a/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/get_stats.ts +++ b/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/get_connection_stats_items.ts @@ -8,6 +8,7 @@ import objectHash from 'object-hash'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { rangeQuery } from '@kbn/observability-plugin/server'; +import { RollupInterval } from '@kbn/apm-data-access-plugin/common'; import type { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms'; import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; @@ -27,13 +28,12 @@ import { getBucketSize } from '../../../../common/utils/get_bucket_size'; import { EventOutcome } from '../../../../common/event_outcome'; import { NodeType } from '../../../../common/connections'; import { ApmDocumentType } from '../../../../common/document_type'; -import { RollupInterval } from '../../../../common/rollup'; import { excludeRumExitSpansQuery } from '../exclude_rum_exit_spans_query'; import type { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; import { getDocumentTypeFilterForServiceDestinationStatistics } from '../../helpers/spans/get_is_using_service_destination_metrics'; const MAX_ITEMS = 1500; -export const getStats = async ({ +export const getConnectionStatsItems = async ({ apmEventClient, start, end, @@ -56,7 +56,7 @@ export const getStats = async ({ offset, }); - const response = await getConnectionStats({ + const response = await getConnectionStatsAggregations({ apmEventClient, startWithOffset, endWithOffset, @@ -104,7 +104,7 @@ export const getStats = async ({ ); }; -async function getConnectionStats({ +async function getConnectionStatsAggregations({ apmEventClient, startWithOffset, endWithOffset, @@ -151,7 +151,7 @@ async function getConnectionStats({ sources: [ { documentType: ApmDocumentType.ServiceDestinationMetric, - rollupInterval: RollupInterval.OneMinute, + rollupInterval: RollupInterval.OneMinute, // TODO: use getRollupIntervalForTimeRange }, ], }, @@ -172,20 +172,8 @@ async function getConnectionStats({ composite: { size: MAX_ITEMS, sources: asMutableArray([ - { - serviceName: { - terms: { - field: SERVICE_NAME, - }, - }, - }, - { - dependencyName: { - terms: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, - }, - }, - }, + { serviceName: { terms: { field: SERVICE_NAME } } }, + { dependencyName: { terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE } } }, ] as const), }, aggs: { @@ -193,18 +181,10 @@ async function getConnectionStats({ top_metrics: { size: 1, metrics: asMutableArray([ - { - field: SERVICE_ENVIRONMENT, - }, - { - field: AGENT_NAME, - }, - { - field: SPAN_TYPE, - }, - { - field: SPAN_SUBTYPE, - }, + { field: SERVICE_ENVIRONMENT }, + { field: AGENT_NAME }, + { field: SPAN_TYPE }, + { field: SPAN_SUBTYPE }, ] as const), sort: { '@timestamp': 'desc', diff --git a/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/index.ts b/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/index.ts index 45da051debb1f..80b18006eb169 100644 --- a/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/index.ts +++ b/x-pack/solutions/observability/plugins/apm/server/lib/connections/get_connection_stats/index.ts @@ -14,7 +14,7 @@ import { withApmSpan } from '../../../utils/with_apm_span'; import type { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; import type { RandomSampler } from '../../helpers/get_random_sampler'; import { getDestinationMap } from './get_destination_map'; -import { getStats } from './get_stats'; +import { getConnectionStatsItems } from './get_connection_stats_items'; export function getConnectionStats({ apmEventClient, @@ -39,7 +39,7 @@ export function getConnectionStats({ }) { return withApmSpan('get_connection_stats_and_map', async () => { const [allMetrics, { nodesBydependencyName: destinationMap, sampled }] = await Promise.all([ - getStats({ + getConnectionStatsItems({ apmEventClient, start, end, diff --git a/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.test.ts b/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.test.ts index 1e0284b7e382a..5460d5969d47c 100644 --- a/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.test.ts +++ b/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.test.ts @@ -11,17 +11,29 @@ import { getErrorName } from './get_error_name'; describe('getErrorName', () => { it('returns log message', () => { - const event = accessKnownApmEventFields({ 'error.log.message': ['bar'] }); + const event = accessKnownApmEventFields({ + 'error.log.message': ['bar'], + 'error.message': ['baz'], + }); const exception = { message: 'foo' }; expect(getErrorName(event, exception)).toEqual('bar'); }); + it('returns exception message', () => { - const event = accessKnownApmEventFields({} as Partial); + const event = accessKnownApmEventFields({ 'error.message': ['baz'] }); const exception = { message: 'foo' }; expect(getErrorName(event, exception)).toEqual('foo'); }); + + it('returns error message', () => { + const event = accessKnownApmEventFields({ 'error.message': ['baz'] }); + const exception = {}; + + expect(getErrorName(event, exception)).toEqual('baz'); + }); + it('returns default message', () => { const event = accessKnownApmEventFields({} as Partial); expect(getErrorName(event, {})).toEqual(NOT_AVAILABLE_LABEL); diff --git a/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.ts b/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.ts index 7f37cc5084ab9..f23799557b796 100644 --- a/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.ts +++ b/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_error_name.ts @@ -9,11 +9,13 @@ import type { Exception } from '@kbn/apm-types'; import type { FlattenedApmEvent } from '@kbn/apm-data-access-plugin/server/utils/utility_types'; import type { ProxiedApmEvent } from '@kbn/apm-data-access-plugin/server/utils/access_known_fields'; import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; -import { ERROR_LOG_MESSAGE } from '../../../common/es_fields/apm'; +import { ERROR_LOG_MESSAGE, ERROR_MESSAGE } from '../../../common/es_fields/apm'; export function getErrorName>>( event: T, exception: Exception ): string { - return event[ERROR_LOG_MESSAGE] || exception.message || NOT_AVAILABLE_LABEL; + return ( + event[ERROR_LOG_MESSAGE] || exception.message || event[ERROR_MESSAGE] || NOT_AVAILABLE_LABEL + ); } diff --git a/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_random_sampler/index.ts b/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_random_sampler/index.ts index 052f7a6eb4c44..6ec34f2005eda 100644 --- a/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_random_sampler/index.ts +++ b/x-pack/solutions/observability/plugins/apm/server/lib/helpers/get_random_sampler/index.ts @@ -10,7 +10,12 @@ import seedrandom from 'seedrandom'; export type RandomSampler = Awaited>; -export async function getRandomSampler({ +export function getRandomSamplerSeed(coreStart: CoreStart, request: KibanaRequest): number { + const username = coreStart.security.authc.getCurrentUser(request)?.username; + return username ? Math.abs(seedrandom(username).int32()) : 1; +} + +export function getRandomSampler({ coreStart, request, probability, @@ -19,13 +24,7 @@ export async function getRandomSampler({ request: KibanaRequest; probability: number; }) { - let seed = 1; - - const username = coreStart.security.authc.getCurrentUser(request)?.username; - - if (username) { - seed = Math.abs(seedrandom(username).int32()); - } + const seed = getRandomSamplerSeed(coreStart, request); return { probability, diff --git a/x-pack/solutions/observability/plugins/apm/server/plugin.ts b/x-pack/solutions/observability/plugins/apm/server/plugin.ts index a7469c5d0b848..2c1db6d0390b1 100644 --- a/x-pack/solutions/observability/plugins/apm/server/plugin.ts +++ b/x-pack/solutions/observability/plugins/apm/server/plugin.ts @@ -252,6 +252,7 @@ export class APMPlugin registerDataProviders({ core, plugins, + config: currentConfig, logger: this.logger!.get('observabilityAgentBuilder'), }); diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts b/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts index 13b5cc3756996..022e0f343d6d3 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts @@ -18,6 +18,8 @@ import { ERROR_GROUP_ID, ERROR_GROUP_NAME, ERROR_LOG_MESSAGE, + ERROR_MESSAGE, + ERROR_TYPE, SERVICE_NAME, TRACE_ID, TRANSACTION_NAME, @@ -105,6 +107,8 @@ export async function getErrorGroupMainStatistics({ ERROR_EXC_MESSAGE, ERROR_EXC_HANDLED, ERROR_EXC_TYPE, + ERROR_MESSAGE, + ERROR_TYPE, ] as const); const response = await apmEventClient.search('get_error_group_main_statistics', { @@ -185,7 +189,7 @@ export async function getErrorGroupMainStatistics({ occurrences: bucket.doc_count, culprit: event[ERROR_CULPRIT], handled: exception.handled, - type: exception.type, + type: exception.type || event[ERROR_TYPE], traceId: event[TRACE_ID], }; }) ?? []; diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_sample_details.ts b/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_sample_details.ts index 82ee35cdc6fe5..822770706c4b0 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_sample_details.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/errors/get_error_groups/get_error_sample_details.ts @@ -36,6 +36,8 @@ import { TRANSACTION_PAGE_URL, USER_AGENT_NAME, USER_AGENT_VERSION, + ERROR_MESSAGE, + ERROR_TYPE, } from '../../../../common/es_fields/apm'; import { environmentQuery } from '../../../../common/utils/environment_query'; import { ApmDocumentType } from '../../../../common/document_type'; @@ -99,6 +101,8 @@ export async function getErrorSampleDetails({ ERROR_EXC_HANDLED, ERROR_EXC_TYPE, ERROR_ID, + ERROR_MESSAGE, + ERROR_TYPE, URL_FULL, HTTP_REQUEST_METHOD, HTTP_RESPONSE_STATUS_CODE, diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/get_infrastructure_data.ts b/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/get_infrastructure_data.ts index 270f53b88833e..465b123293875 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/get_infrastructure_data.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/get_infrastructure_data.ts @@ -7,73 +7,125 @@ import { rangeQuery, kqlQuery } from '@kbn/observability-plugin/server'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { termQuery, termsQuery } from '@kbn/es-query'; import { environmentQuery } from '../../../common/utils/environment_query'; import { SERVICE_NAME, CONTAINER_ID, KUBERNETES_POD_NAME, + KUBERNETES_POD_NAME_OTEL, HOST_NAME, } from '../../../common/es_fields/apm'; import type { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { hasOpenTelemetryPrefix } from '../../../common/agent_name'; export const getInfrastructureData = async ({ kuery, serviceName, + agentName, environment, apmEventClient, start, end, }: { kuery: string; + agentName: string | undefined; serviceName: string; environment: string; apmEventClient: APMEventClient; start: number; end: number; }) => { - const response = await apmEventClient.search('get_service_infrastructure', { - apm: { - events: [ProcessorEvent.metric], - }, - track_total_hits: false, - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - ...rangeQuery(start, end), - ...environmentQuery(environment), - ...kqlQuery(kuery), - ], + const isOtel = Boolean(agentName && hasOpenTelemetryPrefix(agentName)); + const k8sFilterField = isOtel ? KUBERNETES_POD_NAME_OTEL : KUBERNETES_POD_NAME; + + const response = await apmEventClient.search( + 'get_service_infrastructure', + { + apm: { + events: [ProcessorEvent.metric], }, - }, - aggs: { - containerIds: { - terms: { - field: CONTAINER_ID, - size: 500, + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], }, }, - hostNames: { - terms: { - field: HOST_NAME, - size: 500, + aggs: { + containerIds: { + terms: { + field: CONTAINER_ID, + size: 500, + }, }, - }, - podNames: { - terms: { - field: KUBERNETES_POD_NAME, - size: 500, + hostNames: { + terms: { + field: HOST_NAME, + size: 500, + }, + }, + podNames: { + terms: { + field: k8sFilterField, + size: 500, + }, }, }, }, - }); + { skipProcessorEventFilter: true } + ); + + let containerIds: string[] = + response.aggregations?.containerIds?.buckets.map((bucket) => bucket.key as string) ?? []; + const hostNames = + response.aggregations?.hostNames?.buckets.map((bucket) => bucket.key as string) ?? []; + const podNames = + response.aggregations?.podNames?.buckets.map((bucket) => bucket.key as string) ?? []; + + if (podNames.length > 0 && isOtel) { + const containersByPodResponse = await apmEventClient.search( + 'get_container_ids_by_pod_names', + { + apm: { + events: [ProcessorEvent.metric], + }, + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...rangeQuery(start, end), + ...kqlQuery(kuery), + ...termsQuery(k8sFilterField, podNames), + ], + }, + }, + aggs: { + containerIds: { + terms: { + field: CONTAINER_ID, + size: 500, + }, + }, + }, + }, + { skipProcessorEventFilter: true } + ); + containerIds = + containersByPodResponse.aggregations?.containerIds?.buckets.map( + (bucket) => bucket.key as string + ) ?? []; + } return { - containerIds: - response.aggregations?.containerIds?.buckets.map((bucket) => bucket.key as string) ?? [], - hostNames: - response.aggregations?.hostNames?.buckets.map((bucket) => bucket.key as string) ?? [], - podNames: response.aggregations?.podNames?.buckets.map((bucket) => bucket.key as string) ?? [], + containerIds, + hostNames, + podNames, }; }; diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/route.ts b/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/route.ts index 3a0f4efeab42c..e05cb260b0de0 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/route.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/infrastructure/route.ts @@ -18,7 +18,7 @@ const infrastructureRoute = createApmServerRoute({ path: t.type({ serviceName: t.string, }), - query: t.intersection([kueryRt, rangeRt, environmentRt]), + query: t.intersection([kueryRt, rangeRt, environmentRt, t.partial({ agentName: t.string })]), }), security: { authz: { requiredPrivileges: ['apm'] } }, handler: async ( @@ -34,13 +34,14 @@ const infrastructureRoute = createApmServerRoute({ const { path: { serviceName }, - query: { environment, kuery, start, end }, + query: { environment, kuery, start, end, agentName }, } = params; const infrastructureData = await getInfrastructureData({ apmEventClient, serviceName, environment, + agentName, kuery, start, end, diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/service_map/fetch_exit_span_samples.ts b/x-pack/solutions/observability/plugins/apm/server/routes/service_map/fetch_exit_span_samples.ts index 6743a65a332a7..b70b089653d68 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/service_map/fetch_exit_span_samples.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/service_map/fetch_exit_span_samples.ts @@ -38,6 +38,13 @@ const SPAN_LINK_IDS_LIMIT = 128; const MAX_EXIT_SPANS = 10000; const MAX_SPAN_LINKS = 1000; +// Safety limits to prevent runaway queries on clusters with large number of +// traces or traces with a large number of spans (common instrumentation bugs). +// terminate_after: caps per-shard document scanning +// timeout: cancels the query if it exceeds the time limit (returning partial results) +const TERMINATE_AFTER = 1_000_000; +const QUERY_TIMEOUT = '60s'; + type IncomingSpanLink = ServiceMapService & { transactionName: string }; export async function fetchExitSpanSamplesFromTraceIds({ @@ -104,6 +111,8 @@ async function fetchExitSpanIdsFromTraceIds({ }, track_total_hits: false, + terminate_after: TERMINATE_AFTER, + timeout: QUERY_TIMEOUT, size: 0, query: { bool: { @@ -216,6 +225,8 @@ async function fetchSpanLinksFromTraceIds({ events: [ProcessorEvent.span, ProcessorEvent.transaction], }, track_total_hits: false, + terminate_after: TERMINATE_AFTER, + timeout: QUERY_TIMEOUT, size: 0, query: { bool: { @@ -404,6 +415,8 @@ async function fetchTransactionsFromExitSpans({ events: [ProcessorEvent.transaction], }, track_total_hits: false, + terminate_after: TERMINATE_AFTER, + timeout: QUERY_TIMEOUT, query: { bool: { filter: [...rangeQuery(start, end), ...termsQuery(PARENT_ID, ...exitSpansSample.keys())], @@ -456,6 +469,8 @@ async function fetchSpansFromSpanLinks({ events: [ProcessorEvent.span], }, track_total_hits: false, + terminate_after: TERMINATE_AFTER, + timeout: QUERY_TIMEOUT, size: 0, query: { bool: { diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/services/get_service_slos/index.ts b/x-pack/solutions/observability/plugins/apm/server/routes/services/get_service_slos/index.ts index 6a5f6b9b00bc9..8dd1ee63e6418 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/services/get_service_slos/index.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/services/get_service_slos/index.ts @@ -77,7 +77,16 @@ export async function getServiceSlos({ ]; if (environment && environment !== ENVIRONMENT_ALL.value) { - filters.push({ term: { [SERVICE_ENVIRONMENT]: environment } }); + filters.push({ + bool: { + should: [ + { term: { [SERVICE_ENVIRONMENT]: environment } }, + { term: { [SERVICE_ENVIRONMENT]: ALL_VALUE } }, + { bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } } }, + ], + minimum_should_match: 1, + }, + }); } if (statusFilters && statusFilters.length > 0) { diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/services/get_services/get_service_alerts.test.ts b/x-pack/solutions/observability/plugins/apm/server/routes/services/get_services/get_service_alerts.test.ts new file mode 100644 index 0000000000000..be3e2cf7e12b0 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/server/routes/services/get_services/get_service_alerts.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ALERT_RULE_TYPE_ID, + ALERT_STATUS, + ALERT_STATUS_ACTIVE, + ALERT_UUID, + SLO_BURN_RATE_RULE_TYPE_ID, +} from '@kbn/rule-data-utils'; +import { ALL_VALUE } from '@kbn/slo-schema'; +import { getServicesAlerts } from './get_service_alerts'; +import { SERVICE_ENVIRONMENT, SERVICE_NAME } from '../../../../common/es_fields/apm'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import type { ApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client'; + +interface QueryFilter { + bool?: { + should?: QueryFilter[]; + filter?: QueryFilter[]; + must_not?: QueryFilter | QueryFilter[]; + minimum_should_match?: number; + }; + term?: Record; + range?: Record; + wildcard?: Record; + exists?: { field: string }; +} + +const createMockAlertsClient = () => ({ + search: jest.fn().mockResolvedValue({ + aggregations: { + services: { + buckets: [], + }, + }, + }), +}); + +function getFilters(client: ReturnType): QueryFilter[] { + return client.search.mock.calls[0][0].query.bool.filter; +} + +function isSloRuleFilter(ff: QueryFilter) { + return ff.term?.[ALERT_RULE_TYPE_ID] === SLO_BURN_RATE_RULE_TYPE_ID; +} + +function hasSloClause(s: QueryFilter) { + return s.bool?.filter?.some(isSloRuleFilter); +} + +function hasSloEnvFilter(filters: QueryFilter[]): boolean { + return filters.some((f) => f.bool?.should?.some(hasSloClause)); +} + +describe('getServicesAlerts', () => { + it('filters by active status and time range', async () => { + const client = createMockAlertsClient(); + const start = Date.now() - 3600000; + const end = Date.now(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + start, + end, + }); + + const filters = getFilters(client); + + expect(filters).toEqual( + expect.arrayContaining([{ term: { [ALERT_STATUS]: ALERT_STATUS_ACTIVE } }]) + ); + expect(filters.find((f) => f.range)).toBeDefined(); + }); + + it('filters by serviceName when provided', async () => { + const client = createMockAlertsClient(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + serviceName: 'my-service', + start: Date.now() - 3600000, + end: Date.now(), + }); + + const filters = getFilters(client); + + const serviceNameFilter = [{ term: { [SERVICE_NAME]: 'my-service' } }]; + expect(filters).toEqual(expect.arrayContaining(serviceNameFilter)); + }); + + it('does not add a serviceName filter when serviceName is undefined', async () => { + const client = createMockAlertsClient(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + start: Date.now() - 3600000, + end: Date.now(), + }); + + const filters = getFilters(client); + const hasServiceFilter = filters.some((f) => f.term?.[SERVICE_NAME] !== undefined); + + expect(hasServiceFilter).toBe(false); + }); + + it('adds searchQuery as a wildcard filter', async () => { + const client = createMockAlertsClient(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + searchQuery: 'front*', + start: Date.now() - 3600000, + end: Date.now(), + }); + + const filters = getFilters(client); + + expect(filters.find((f) => f.wildcard?.[SERVICE_NAME])).toBeDefined(); + }); + + it('includes SLO burn rate alerts with wildcard/missing env when filtering by a specific environment', async () => { + const client = createMockAlertsClient(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + environment: 'production', + start: Date.now() - 3600000, + end: Date.now(), + }); + + const filters = getFilters(client); + const envFilter = filters.find((f) => f.bool?.should?.some(hasSloClause)); + + expect(envFilter).toBeDefined(); + + const sloClause = envFilter!.bool!.should!.find(hasSloClause); + expect(sloClause!.bool!.should).toEqual( + expect.arrayContaining([ + { term: { [SERVICE_ENVIRONMENT]: ALL_VALUE } }, + { bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } } }, + ]) + ); + expect(sloClause!.bool!.minimum_should_match).toBe(1); + + const standardEnvClause = envFilter!.bool!.should!.find( + (s) => s.term?.[SERVICE_ENVIRONMENT] === 'production' + ); + expect(standardEnvClause).toBeDefined(); + }); + + it('does not add SLO-specific environment filter when environment is ENVIRONMENT_ALL', async () => { + const client = createMockAlertsClient(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + environment: ENVIRONMENT_ALL.value, + start: Date.now() - 3600000, + end: Date.now(), + }); + + expect(hasSloEnvFilter(getFilters(client))).toBe(false); + }); + + it('falls back to standard environmentQuery when environment is undefined', async () => { + const client = createMockAlertsClient(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + environment: undefined, + start: Date.now() - 3600000, + end: Date.now(), + }); + + expect(hasSloEnvFilter(getFilters(client))).toBe(false); + }); + + it('aggregates alerts per service using cardinality on ALERT_UUID', async () => { + const client = createMockAlertsClient(); + + await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + start: Date.now() - 3600000, + end: Date.now(), + }); + + const aggs = client.search.mock.calls[0][0].aggs; + + expect(aggs.services.terms.field).toBe(SERVICE_NAME); + expect(aggs.services.aggs.alerts_count.cardinality.field).toBe(ALERT_UUID); + }); + + it('returns aggregated alert counts per service', async () => { + const client = createMockAlertsClient(); + client.search.mockResolvedValue({ + aggregations: { + services: { + buckets: [ + { key: 'service-a', alerts_count: { value: 3 } }, + { key: 'service-b', alerts_count: { value: 1 } }, + ], + }, + }, + }); + + const result = await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + start: Date.now() - 3600000, + end: Date.now(), + }); + + expect(result).toEqual([ + { serviceName: 'service-a', alertsCount: 3 }, + { serviceName: 'service-b', alertsCount: 1 }, + ]); + }); + + it('returns empty array when there are no aggregation buckets', async () => { + const client = createMockAlertsClient(); + client.search.mockResolvedValue({ aggregations: undefined }); + + const result = await getServicesAlerts({ + apmAlertsClient: client as unknown as ApmAlertsClient, + start: Date.now() - 3600000, + end: Date.now(), + }); + + expect(result).toEqual([]); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/services/get_services/get_service_alerts.ts b/x-pack/solutions/observability/plugins/apm/server/routes/services/get_services/get_service_alerts.ts index 3a2451c52197d..3a5a85b1ffb27 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/services/get_services/get_service_alerts.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/services/get_services/get_service_alerts.ts @@ -5,12 +5,21 @@ * 2.0. */ +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { kqlQuery, termQuery, rangeQuery, wildcardQuery } from '@kbn/observability-plugin/server'; -import { ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_UUID } from '@kbn/rule-data-utils'; -import { SERVICE_NAME } from '../../../../common/es_fields/apm'; +import { + ALERT_RULE_TYPE_ID, + ALERT_STATUS, + ALERT_STATUS_ACTIVE, + ALERT_UUID, + SLO_BURN_RATE_RULE_TYPE_ID, +} from '@kbn/rule-data-utils'; +import { ALL_VALUE } from '@kbn/slo-schema'; +import { SERVICE_ENVIRONMENT, SERVICE_NAME } from '../../../../common/es_fields/apm'; import type { ServiceGroup } from '../../../../common/service_groups'; import type { ApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client'; import { environmentQuery } from '../../../../common/utils/environment_query'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { MAX_NUMBER_OF_SERVICES } from './get_services_items'; import { serviceGroupWithOverflowQuery } from '../../../lib/service_group_query_with_overflow'; @@ -52,7 +61,7 @@ export async function getServicesAlerts({ ...serviceGroupWithOverflowQuery(serviceGroup), ...termQuery(SERVICE_NAME, serviceName), ...wildcardQuery(SERVICE_NAME, searchQuery), - ...environmentQuery(environment), + ...alertsEnvironmentQuery(environment), ], }, }, @@ -87,3 +96,35 @@ export async function getServicesAlerts({ return servicesAlertsCount; } + +/** + * Extends the standard environmentQuery to also include SLO burn rate alerts + * from SLOs created with the wildcard (*) environment. Those alerts have + * service.environment set to '*' (temp summary) or missing (real summary). + */ +function alertsEnvironmentQuery(environment?: string): QueryDslQueryContainer[] { + if (!environment || environment === ENVIRONMENT_ALL.value) { + return environmentQuery(environment); + } + + return [ + { + bool: { + should: [ + ...environmentQuery(environment), + { + bool: { + filter: [{ term: { [ALERT_RULE_TYPE_ID]: SLO_BURN_RATE_RULE_TYPE_ID } }], + should: [ + { term: { [SERVICE_ENVIRONMENT]: ALL_VALUE } }, + { bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } } }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ]; +} diff --git a/x-pack/solutions/observability/plugins/apm/test/scout/.meta/ui/parallel.json b/x-pack/solutions/observability/plugins/apm/test/scout/.meta/ui/parallel.json index b4374b4dacff9..29a23f347216b 100644 --- a/x-pack/solutions/observability/plugins/apm/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/observability/plugins/apm/test/scout/.meta/ui/parallel.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-10T11:18:11.867Z", - "sha1": "af556655fce897a109fca2469fca8f2961d51eb8", + "sha1": "4e645a9e1ebd8d0615712aa4407b0f5968c7ed2f", "tests": [ { "id": "5e91b4cbd08e25e-f2cd2d3cdc77643", @@ -19,8 +18,8 @@ } }, { - "id": "93959537d8145bf-c60ed75f55e6428", - "title": "Alerts Can create, trigger and view an 'Error count' alert from service inventory", + "id": "93959537d8145bf-66043be86cebc43", + "title": "Alerts Can create an error count rule from service inventory", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -30,8 +29,36 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/alerts/error_count.spec.ts", - "line": 30, - "column": 9 + "line": 144, + "column": 7 + } + }, + { + "id": "93959537d8145bf-19460eecd1a223c", + "title": "Alerts Stateful - Displays an alert in the service details alerts tab", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/alerts/error_count.spec.ts", + "line": 187, + "column": 7 + } + }, + { + "id": "93959537d8145bf-a22699904fcf1be", + "title": "Alerts Serverless - Displays an alert in the service details alerts tab", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/alerts/error_count.spec.ts", + "line": 193, + "column": 7 } }, { @@ -546,6 +573,246 @@ "column": 9 } }, + { + "id": "027a724476bf822-7a5ac6225930c1c", + "title": "Service map - accessibility axe-core automated accessibility checks pass", + "expectedStatus": "skipped", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts", + "line": 29, + "column": 9 + } + }, + { + "id": "027a724476bf822-3e58cea4efef368", + "title": "Service map - accessibility keyboard navigation works correctly", + "expectedStatus": "skipped", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts", + "line": 67, + "column": 9 + } + }, + { + "id": "027a724476bf822-d188045f3c69eae", + "title": "Service map - accessibility focus management and visible indicators work correctly", + "expectedStatus": "skipped", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts", + "line": 114, + "column": 9 + } + }, + { + "id": "027a724476bf822-c5ee877afe0ab0a", + "title": "Service map - accessibility ARIA attributes are properly set for screen readers", + "expectedStatus": "skipped", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts", + "line": 141, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-f8b7c44dd1a9b2b", + "title": "Service map - nodes, edges and popovers renders service map with controls", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 32, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-bd179c28cf90db2", + "title": "Service map - nodes, edges and popovers shows popover when clicking on a service node", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 63, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-0c21e23b58b1030", + "title": "Service map - nodes, edges and popovers dismisses popover when clicking outside", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 78, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-c5a4af62fc020af", + "title": "Service map - nodes, edges and popovers shows popover when clicking on an edge", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 91, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-6472bd63f825f5f", + "title": "Service map - nodes, edges and popovers shows popover when clicking on a dependency node", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 102, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-39e157c4c45a87f", + "title": "Service map - nodes, edges and popovers navigates to Service Details from popover", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 117, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-cfea2cecad5e86a", + "title": "Service map - nodes, edges and popovers navigates to Focus Map from popover", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 136, + "column": 9 + } + }, + { + "id": "6a80f754a8758b3-c0cdd2e5496e521", + "title": "Service map - nodes, edges and popovers navigates to Dependency Details from popover", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_detailed.spec.ts", + "line": 154, + "column": 9 + } + }, + { + "id": "431bbf1927933f1-820ffa8139e87c1", + "title": "Service map renders page with selected date range", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map.spec.ts", + "line": 20, + "column": 9 + } + }, + { + "id": "431bbf1927933f1-9d4b191090eb231", + "title": "Service map shows a detailed service map", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map.spec.ts", + "line": 32, + "column": 9 + } + }, + { + "id": "431bbf1927933f1-bebd132799161c0", + "title": "Service map shows empty state when there is no data", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map.spec.ts", + "line": 45, + "column": 9 + } + }, { "id": "d7c92033c6be650-e93404c0871603d", "title": "Service overview alerts tab Is accessible from the default tab", @@ -1674,54 +1941,6 @@ "column": 7 } }, - { - "id": "bb77c5fe685c994-820ffa8139e87c1", - "title": "Service map renders page with selected date range", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map.spec.ts", - "line": 20, - "column": 9 - } - }, - { - "id": "bb77c5fe685c994-9d4b191090eb231", - "title": "Service map shows a detailed service map", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map.spec.ts", - "line": 32, - "column": 9 - } - }, - { - "id": "bb77c5fe685c994-bebd132799161c0", - "title": "Service map shows empty state when there is no data", - "expectedStatus": "passed", - "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-observability_complete", - "@cloud-serverless-observability_complete" - ], - "location": { - "file": "x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map.spec.ts", - "line": 45, - "column": 9 - } - }, { "id": "b823835fc8ed203-c0d7ecffab644e9", "title": "Storage Explorer - Admin User user navigates and explores storage explorer functionality", diff --git a/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts b/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts index 36d7cdc15f992..9a2c4ba5d9575 100644 --- a/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts +++ b/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/service_map/service_map_a11y.spec.ts @@ -8,14 +8,9 @@ import { expect } from '@kbn/scout-oblt/ui'; import { tags } from '@kbn/scout-oblt'; import { test, testData } from '../../fixtures'; -import { - SERVICE_MAP_KUERY_OPBEANS_JAVA, - SERVICE_OPBEANS_JAVA, - SERVICE_OPBEANS_NODE, -} from '../../fixtures/constants'; +import { SERVICE_OPBEANS_JAVA } from '../../fixtures/constants'; -// FLAKY: https://github.com/elastic/kibana/issues/253809 -test.describe.skip( +test.describe( 'Service map - accessibility', { tag: [...tags.stateful.classic, ...tags.serverless.observability.complete] }, () => { @@ -26,10 +21,7 @@ test.describe.skip( await serviceMapPage.dismissPopoverIfOpen(); }); - test('axe-core automated accessibility checks pass', async ({ - page, - pageObjects: { serviceMapPage }, - }) => { + test('axe-core automated accessibility checks pass', async ({ page }) => { await test.step('service map container has no accessibility violations', async () => { await page.testSubj.locator('serviceMapGraph').waitFor({ state: 'visible' }); const { violations } = await page.checkA11y({ @@ -44,71 +36,6 @@ test.describe.skip( }); expect(violations).toHaveLength(0); }); - - await test.step('service node popover has no accessibility violations', async () => { - // Navigate with kuery so the map loads pre-filtered (no re-fetch after opening popover) - await serviceMapPage.gotoWithDateSelected(testData.START_DATE, testData.END_DATE, { - kuery: SERVICE_MAP_KUERY_OPBEANS_JAVA, - }); - await serviceMapPage.waitForMapToLoad(); - await serviceMapPage.dismissPopoverIfOpen(); - await serviceMapPage.clickFitView(); - await serviceMapPage.waitForServiceNodeToLoad(SERVICE_OPBEANS_JAVA); - await serviceMapPage.clickServiceNode(SERVICE_OPBEANS_JAVA); - await serviceMapPage.waitForPopoverToBeVisible(); - await expect(serviceMapPage.serviceMapPopoverContent).toBeVisible(); - const { violations } = await page.checkA11y({ - include: ['[data-test-subj="serviceMapPopoverContent"]'], - }); - expect(violations).toHaveLength(0); - }); - }); - - test('keyboard navigation works correctly', async ({ - page, - pageObjects: { serviceMapPage }, - }) => { - await test.step('service map nodes are focusable with Tab key', async () => { - await serviceMapPage.waitForServiceNodeToLoad(SERVICE_OPBEANS_JAVA); - const serviceMap = page.testSubj.locator('serviceMapGraph'); - await serviceMap.focus(); - await page.keyboard.press('Tab'); - const focusedElement = await page.evaluate(() => document.activeElement?.tagName); - expect(focusedElement).toBeTruthy(); - }); - - await test.step('arrow keys navigate between nodes', async () => { - await serviceMapPage.waitForServiceNodeToLoad(SERVICE_OPBEANS_NODE); - await serviceMapPage.focusServiceNodeAndWaitForFocus(SERVICE_OPBEANS_JAVA); - const originalFocusedId = await page.evaluate(() => { - const el = document.activeElement?.closest('[data-id]'); - return el?.getAttribute('data-id'); - }); - await page.keyboard.press('ArrowRight'); - const newFocusedId = await page.evaluate(() => { - const el = document.activeElement?.closest('[data-id]'); - return el?.getAttribute('data-id'); - }); - expect(newFocusedId || originalFocusedId).toBeTruthy(); - }); - - await test.step('pressing Enter on a focused node opens the popover', async () => { - await serviceMapPage.typeInTheSearchBar(SERVICE_MAP_KUERY_OPBEANS_JAVA); - await serviceMapPage.waitForServiceNodeToLoad(SERVICE_OPBEANS_JAVA); - await serviceMapPage.openPopoverWithKeyboardForService(SERVICE_OPBEANS_JAVA, 'Enter'); - }); - - await test.step('pressing Escape closes the popover', async () => { - await page.keyboard.press('Escape'); - await serviceMapPage.waitForPopoverToBeHidden(); - await expect(serviceMapPage.serviceMapPopoverContent).toBeHidden(); - }); - - await test.step('pressing Space on a focused node opens the popover', async () => { - await serviceMapPage.openPopoverWithKeyboardForService(SERVICE_OPBEANS_JAVA, ' '); - await page.keyboard.press('Escape'); - await serviceMapPage.waitForPopoverToBeHidden(); - }); }); test('focus management and visible indicators work correctly', async ({ diff --git a/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/transaction_details/transaction_details.spec.ts b/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/transaction_details/transaction_details.spec.ts index 4b0c94d0a8a47..b92bc87572b07 100644 --- a/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/transaction_details/transaction_details.spec.ts +++ b/x-pack/solutions/observability/plugins/apm/test/scout/ui/parallel_tests/transaction_details/transaction_details.spec.ts @@ -33,10 +33,6 @@ test.describe( ).toBeVisible(); }); - await test.step('Renders SLOs callout', async () => { - await expect(page.getByTestId('apmSloCalloutCreateSloButton')).toBeVisible(); - }); - await test.step('Renders transaction charts', async () => { await expect(page.getByTestId('latencyChart')).toBeVisible(); await expect(page.getByTestId('throughput')).toBeVisible(); diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/README.md b/x-pack/solutions/observability/plugins/exploratory_view/e2e/README.md deleted file mode 100644 index 58dd5d0f8957f..0000000000000 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/README.md +++ /dev/null @@ -1,15 +0,0 @@ -## How to run these tests - -These tests rely on the Kibana functional test runner. There is a Kibana config in this directory, and a dedicated -script for standing up the test server. - -### Start the server - -From `~/x-pack/solutions/observability/plugins/exploratory_view/scripts`, run `node e2e.js --server`. Wait for the server to startup. It will provide you -with an example run command when it finishes. - -### Run the tests - -From this directory, `~/x-pack/solutions/observability/plugins/exploratory_view/e2e`, you can now run `node ../../../../../scripts/functional_test_runner --config synthetics_run.ts`. - -In addition to the usual flags like `--grep`, you can also specify `--no-headless` in order to view your tests as you debug/develop. diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/exploratory_view.ts b/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/exploratory_view.ts deleted file mode 100644 index 99324d0fa921d..0000000000000 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/exploratory_view.ts +++ /dev/null @@ -1,108 +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 { journey, step, before } from '@elastic/synthetics'; -import { recordVideo } from '@kbn/observability-synthetics-test-data'; -import { createExploratoryViewUrl } from '../../public/components/shared/exploratory_view/configurations/exploratory_view_url'; -import { loginToKibana, TIMEOUT_60_SEC, waitForLoadingToFinish } from '../utils'; - -journey('Exploratory view', async ({ page, params }) => { - recordVideo(page); - - before(async () => { - await waitForLoadingToFinish({ page }); - }); - - const expUrl = createExploratoryViewUrl({ - reportType: 'kpi-over-time', - allSeries: [ - { - name: 'Elastic page views', - time: { - from: '2021-01-18T12:20:01.682Z', - to: '2021-01-18T12:25:27.484Z', - }, - selectedMetricField: '___records___', - reportDefinitions: { 'service.name': [] }, - dataType: 'ux', - }, - ], - }); - - const baseUrl = `${params.kibanaUrl}${expUrl}`; - - step('Go to Exploratory view', async () => { - await page.goto(baseUrl, { - waitUntil: 'networkidle', - }); - await loginToKibana({ - page, - user: { username: 'elastic', password: 'changeme' }, - }); - }); - - step('renders as expected', async () => { - await Promise.all([page.waitForNavigation(TIMEOUT_60_SEC), page.click('text=Explore data')]); - await page.click('text=User experience (RUM)'); - await page.click('[aria-label="Toggle series information"] >> text=Page views', TIMEOUT_60_SEC); - await page.click('[aria-label="Edit series"]', TIMEOUT_60_SEC); - await page.click('button:has-text("No breakdown")'); - await page.click('button[role="option"]:has-text("Operating system")', TIMEOUT_60_SEC); - await page.click('button:has-text("Apply changes")'); - - await page.click('text=Chrome OS'); - await page.click('text=iOS'); - await page.click('text=iOS'); - await page.click('text=Chrome OS'); - await page.click('text=Ubuntu'); - await page.click('text=Android'); - await page.click('text=Linux'); - await page.click('text=Mac OS X'); - await page.click('text=Windows'); - await page.click('h1:has-text("Explore data")'); - }); - - step('Edit and change the series to distribution', async () => { - await page.click('[aria-label="View series actions"]'); - await page.click('[aria-label="Remove series"]'); - await page.click('button:has-text("KPI over time")'); - await page.click('button[role="option"]:has-text("Performance distribution")'); - await page.click('button:has-text("Add series")'); - await page.click('button:has-text("Select data type")'); - await page.click('button:has-text("User experience (RUM)")'); - await page.click('button:has-text("Select report metric")'); - await page.click('button:has-text("Page load time")'); - await page.click('.euiComboBox__inputWrap'); - await page.click('[aria-label="Date quick select"]'); - await page.click('text=Last 1 year'); - await page.click('[aria-label="Date quick select"]'); - await page.click('[aria-label="Time value"]'); - await page.fill('[aria-label="Time value"]', '010'); - await page.selectOption('[aria-label="Time unit"]', 'y'); - - await page.click('div[role="dialog"] button:has-text("Apply")'); - await page.click('.euiComboBox__inputWrap'); - await page.click('button[role="option"]:has-text("elastic-co-frontend")'); - await page.click('button:has-text("Apply changes")'); - await page.click('text=ux-series-1'); - await page.click('text=User experience (RUM)'); - await page.click('text=Page load time'); - await page.click('text=Pages loaded'); - await page.click('button:has-text("95th")'); - await page.click('button:has-text("90th")'); - await page.click('button:has-text("99th")'); - await page.click('[aria-label="Edit series"]'); - await page.click('button:has-text("No breakdown")'); - await page.click('button[role="option"]:has-text("Browser family")'); - await page.click('button:has-text("Apply changes")'); - await page.click('text=Edge'); - await page.click('text=Opera'); - await page.click('text=Safari'); - await page.click('text=HeadlessChrome'); - await page.click('[aria-label="Firefox; Activate to hide series in graph"]'); - }); -}); diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/single_metric.journey.ts b/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/single_metric.journey.ts deleted file mode 100644 index 23f847784308f..0000000000000 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/single_metric.journey.ts +++ /dev/null @@ -1,65 +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 { journey, step, before } from '@elastic/synthetics'; -import { recordVideo } from '@kbn/observability-synthetics-test-data'; -import { createExploratoryViewUrl } from '../../public/components/shared/exploratory_view/configurations/exploratory_view_url'; -import { loginToKibana, TIMEOUT_60_SEC, waitForLoadingToFinish } from '../utils'; - -journey('SingleMetric', async ({ page, params }) => { - recordVideo(page); - - before(async () => { - await waitForLoadingToFinish({ page }); - }); - - const expUrl = createExploratoryViewUrl({ - reportType: 'single-metric', - allSeries: [ - { - dataType: 'synthetics', - time: { - from: 'now-1y/d', - to: 'now', - }, - name: 'synthetics-series-1', - selectedMetricField: 'monitor_availability', - reportDefinitions: { - 'monitor.name': ['test-monitor - inline'], - 'url.full': ['https://www.elastic.co/'], - }, - }, - ], - }); - - const baseUrl = `${params.kibanaUrl}${expUrl}`; - - step('Go to Exploratory view', async () => { - await page.goto(baseUrl, { - waitUntil: 'networkidle', - }); - await loginToKibana({ - page, - user: { username: 'elastic', password: 'changeme' }, - }); - }); - - step('Open exploratory view with single metric', async () => { - await Promise.all([ - page.waitForNavigation(TIMEOUT_60_SEC), - page.click('text=Explore data', TIMEOUT_60_SEC), - ]); - - await waitForLoadingToFinish({ page }); - - await page.click('text=0.0%', TIMEOUT_60_SEC); - await page.click('text=0.0%Availability'); - await page.click( - 'text=Explore data Last Updated: a few seconds agoRefreshHide chart0.0%AvailabilityRep' - ); - }); -}); diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/step_duration.journey.ts b/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/step_duration.journey.ts deleted file mode 100644 index a0ff1a5e3cde6..0000000000000 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/step_duration.journey.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { journey, step } from '@elastic/synthetics'; -import { recordVideo } from '@kbn/observability-synthetics-test-data'; -import moment from 'moment'; -import { createExploratoryViewUrl } from '../../public/components/shared/exploratory_view/configurations/exploratory_view_url'; -import { loginToKibana, TIMEOUT_60_SEC, waitForLoadingToFinish } from '../utils'; - -journey('Step Duration series', async ({ page, params }) => { - recordVideo(page); - - page.setDefaultTimeout(TIMEOUT_60_SEC.timeout); - - const expUrl = createExploratoryViewUrl({ - reportType: 'kpi-over-time', - allSeries: [ - { - dataType: 'uptime', - time: { - from: moment().subtract(10, 'y').toISOString(), - to: moment().toISOString(), - }, - name: 'synthetics-series-1', - breakdown: 'monitor.type', - selectedMetricField: 'monitor.duration.us', - reportDefinitions: { - 'url.full': ['ALL_VALUES'], - }, - }, - ], - }); - - const baseUrl = `${params.kibanaUrl}${expUrl}`; - - step('Go to Exploratory view', async () => { - await page.goto(baseUrl, { - waitUntil: 'networkidle', - }); - await loginToKibana({ - page, - user: { username: 'elastic', password: 'changeme' }, - }); - }); - - step('build series with monitor duration', async () => { - await page.waitForNavigation(TIMEOUT_60_SEC); - - await waitForLoadingToFinish({ page }); - await page.click('text=browser'); - await page.click('text=http'); - await page.click('[aria-label="Remove report metric"]'); - await page.click('button:has-text("Select report metric")'); - await page.click('button:has-text("Step duration")'); - await page.waitForSelector('[data-test-subj=seriesBreakdown]'); - await page.getByTestId('seriesBreakdown').click(); - await page.click('button[role="option"]:has-text("Step name")'); - await page.click('.euiComboBox__inputWrap'); - await page.click('[role="combobox"][placeholder="Search Monitor name"]'); - await page.click('button[role="option"]:has-text("test-monitor - inline")'); - await page.click('button:has-text("Apply changes")'); - }); - - step('Verify that changes are applied', async () => { - await waitForLoadingToFinish({ page }); - await page.click('svg[aria-label="series color: #16c5c0"]'); - await page.click('svg[aria-label="series color: #a6edea"]'); - await page.click('svg[aria-label="series color: #61a2ff"]'); - await page.click('svg[aria-label="series color: #bfdbff"]'); - await page.click('svg[aria-label="series color: #ee72a6"]'); - await page.click('svg[aria-label="series color: #ffc7db"]'); - await page.click('text=load homepage'); - await page.click('text=load homepage'); - await page.click('text=load github'); - await page.click('text=load github'); - await page.click('text=load google'); - await page.click('text=load google'); - await page.click('text=hover over products menu'); - await page.click('text=hover over products menu'); - await page.click('text=load homepage 1'); - await page.click('text=load homepage 1'); - await page.click('text=load homepage 2'); - await page.click('text=load homepage 2'); - }); -}); diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/synthetics_run.ts b/x-pack/solutions/observability/plugins/exploratory_view/e2e/synthetics_run.ts deleted file mode 100644 index 95835285a40a4..0000000000000 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/synthetics_run.ts +++ /dev/null @@ -1,44 +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 { FtrConfigProviderContext } from '@kbn/test'; -import path from 'path'; -import { REPO_ROOT } from '@kbn/repo-info'; -import { SyntheticsRunner, argv } from '@kbn/observability-synthetics-test-data'; - -const { headless, grep, bail: pauseOnError } = argv; - -async function runE2ETests({ readConfigFile }: FtrConfigProviderContext) { - const kibanaConfig = await readConfigFile(require.resolve('@kbn/synthetics-e2e/config')); - - return { - ...kibanaConfig.getAll(), - testRunner: async ({ getService }: any) => { - const syntheticsRunner = new SyntheticsRunner(getService, { - headless, - match: grep, - pauseOnError, - }); - - await syntheticsRunner.setup(); - await syntheticsRunner.loadTestData( - `${REPO_ROOT}/x-pack/solutions/observability/plugins/ux/e2e/fixtures/`, - ['rum_8.0.0', 'rum_test_data'] - ); - await syntheticsRunner.loadTestData( - `${REPO_ROOT}/x-pack/solutions/observability/plugins/synthetics/e2e/fixtures/es_archiver/`, - ['full_heartbeat', 'browser'] - ); - await syntheticsRunner.loadTestFiles(async () => { - require(path.join(__dirname, './journeys')); - }); - await syntheticsRunner.run(); - }, - }; -} - -// eslint-disable-next-line import/no-default-export -export default runE2ETests; diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/tsconfig.json b/x-pack/solutions/observability/plugins/exploratory_view/e2e/tsconfig.json deleted file mode 100644 index 0438e00c45039..0000000000000 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@kbn/tsconfig-base/tsconfig.json", - "exclude": ["tmp", "target/**/*"], - "include": ["./**/*"], - "compilerOptions": { - "outDir": "target/types", - "types": ["node"] - }, - "kbn_references": [ - "@kbn/test", - "@kbn/repo-info", - "@kbn/observability-synthetics-test-data", - ] -} diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/utils.ts b/x-pack/solutions/observability/plugins/exploratory_view/e2e/utils.ts deleted file mode 100644 index fe6b88da406c3..0000000000000 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/utils.ts +++ /dev/null @@ -1,59 +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 { Page } from '@elastic/synthetics'; -import { expect } from '@elastic/synthetics'; - -export async function waitForLoadingToFinish({ page }: { page: Page }) { - while (true) { - if (!(await page.isVisible(byTestId('kbnLoadingMessage'), { timeout: 5000 }))) break; - await page.waitForTimeout(1000); - } -} - -export async function loginToKibana({ - page, - user, -}: { - page: Page; - user?: { username: string; password: string }; -}) { - await page.fill('[data-test-subj=loginUsername]', user?.username ?? 'elastic', { - timeout: 60 * 1000, - }); - - await page.fill('[data-test-subj=loginPassword]', user?.password ?? 'changeme'); - - await page.click('[data-test-subj=loginSubmit]'); - - await waitForLoadingToFinish({ page }); -} - -export const byTestId = (testId: string) => { - return `[data-test-subj=${testId}]`; -}; - -export const assertText = async ({ page, text }: { page: Page; text: string }) => { - const element = await page.waitForSelector(`text=${text}`); - expect(await element.isVisible()).toBeTruthy(); -}; - -export const assertNotText = async ({ page, text }: { page: Page; text: string }) => { - expect(await page.$(`text=${text}`)).toBeFalsy(); -}; - -export const getQuerystring = (params: object) => { - return Object.entries(params) - .map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value)) - .join('&'); -}; - -export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export const TIMEOUT_60_SEC = { - timeout: 60 * 1000, -}; diff --git a/x-pack/solutions/observability/plugins/exploratory_view/moon.yml b/x-pack/solutions/observability/plugins/exploratory_view/moon.yml index 7b14667eaf521..6fbcf8b176a90 100644 --- a/x-pack/solutions/observability/plugins/exploratory_view/moon.yml +++ b/x-pack/solutions/observability/plugins/exploratory_view/moon.yml @@ -59,6 +59,7 @@ dependsOn: - '@kbn/ebt-tools' - '@kbn/config-schema' - '@kbn/chart-expressions-common' + - '@kbn/scout-oblt' tags: - plugin - prod @@ -71,6 +72,7 @@ fileGroups: - public/**/* - server/**/* - public/**/*.json + - test/scout/**/* - '!target/**/*' tasks: jest: diff --git a/x-pack/solutions/observability/plugins/exploratory_view/test/scout/.meta/ui/standard.json b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/.meta/ui/standard.json new file mode 100644 index 0000000000000..dd54a1dfc2245 --- /dev/null +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/.meta/ui/standard.json @@ -0,0 +1,34 @@ +{ + "lastModified": "2026-02-16T09:53:31.181Z", + "sha1": "", + "tests": [ + { + "id": "1b513f723098bcd-67df9619ed61daf", + "title": "Ingest Exploratory View test data", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/global.setup.ts", + "line": 11, + "column": 16 + } + }, + { + "id": "e0b56225d6d2301-f5763f6ded5f5d6", + "title": "Step Duration series builds series with step duration metric", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic" + ], + "location": { + "file": "x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/step_duration.spec.ts", + "line": 18, + "column": 7 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/constants.ts b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/constants.ts new file mode 100644 index 0000000000000..3080eff633462 --- /dev/null +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ES_ARCHIVES = { + RUM_8_0_0: 'x-pack/solutions/observability/plugins/ux/e2e/fixtures/rum_8.0.0', + RUM_TEST_DATA: 'x-pack/solutions/observability/plugins/ux/e2e/fixtures/rum_test_data', + FULL_HEARTBEAT: + 'x-pack/solutions/observability/plugins/synthetics/e2e/fixtures/es_archiver/full_heartbeat', + BROWSER: 'x-pack/solutions/observability/plugins/synthetics/e2e/fixtures/es_archiver/browser', +} as const; diff --git a/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/index.ts b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/index.ts new file mode 100644 index 0000000000000..de39c84b038a4 --- /dev/null +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/index.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + ObltTestFixtures, + ObltWorkerFixtures, + ObltParallelTestFixtures, + ObltParallelWorkerFixtures, +} from '@kbn/scout-oblt'; +import { + test as baseTest, + spaceTest as spaceBaseTest, + createLazyPageObject, +} from '@kbn/scout-oblt'; +import { ExploratoryViewPage } from './page_objects'; + +export interface ExploratoryViewTestFixtures { + pageObjects: ObltTestFixtures['pageObjects'] & { + exploratoryView: ExploratoryViewPage; + }; +} + +export const test = baseTest.extend({ + pageObjects: async ( + { + pageObjects, + page, + kbnUrl, + }: { + pageObjects: ObltTestFixtures['pageObjects']; + page: ObltTestFixtures['page']; + kbnUrl: ObltWorkerFixtures['kbnUrl']; + }, + use: (pageObjects: ExploratoryViewTestFixtures['pageObjects']) => Promise + ) => { + const extendedPageObjects: ExploratoryViewTestFixtures['pageObjects'] = { + ...pageObjects, + exploratoryView: createLazyPageObject(ExploratoryViewPage, page, kbnUrl), + }; + + await use(extendedPageObjects); + }, +}); + +export interface ExploratoryViewParallelTestFixtures { + pageObjects: ObltParallelTestFixtures['pageObjects'] & { + exploratoryView: ExploratoryViewPage; + }; +} + +export const spaceTest = spaceBaseTest.extend< + ExploratoryViewParallelTestFixtures, + ObltParallelWorkerFixtures +>({ + pageObjects: async ( + { + pageObjects, + page, + kbnUrl, + }: { + pageObjects: ObltParallelTestFixtures['pageObjects']; + page: ObltParallelTestFixtures['page']; + kbnUrl: ObltParallelWorkerFixtures['kbnUrl']; + }, + use: (pageObjects: ExploratoryViewParallelTestFixtures['pageObjects']) => Promise + ) => { + const extendedPageObjects: ExploratoryViewParallelTestFixtures['pageObjects'] = { + ...pageObjects, + exploratoryView: createLazyPageObject(ExploratoryViewPage, page, kbnUrl), + }; + + await use(extendedPageObjects); + }, +}); + +export * as testData from './constants'; diff --git a/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/page_objects/exploratory_view.ts b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/page_objects/exploratory_view.ts new file mode 100644 index 0000000000000..e9c8dc64939e7 --- /dev/null +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/page_objects/exploratory_view.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 { type KibanaUrl, type ScoutPage } from '@kbn/scout-oblt'; +import { expect } from '@kbn/scout-oblt/ui'; + +export class ExploratoryViewPage { + public readonly echLegendItemLocator; + + constructor(private readonly page: ScoutPage, private readonly kbnUrl: KibanaUrl) { + this.echLegendItemLocator = this.page.locator('[data-testid="echLegendItemLabel"]'); + } + + async goto(urlPath: string): Promise { + await this.page.goto(this.kbnUrl.get(urlPath)); + } + + async waitForLoadingToFinish(): Promise { + await expect(this.page.testSubj.locator('kbnLoadingMessage')).toBeHidden(); + await this.page.testSubj.locator('exploratoryViewMainContainer').waitFor(); + } + + async changeReportMetric(value: string): Promise { + await this.page.click('[aria-label="Remove report metric"]'); + await this.page.testSubj.click('o11yReportMetricOptionsButton'); + await this.page.click(`button:has-text("${value}")`); + } + + async selectSeriesBreakdown(value: string): Promise { + await this.page.testSubj.locator('seriesBreakdown').click(); + await this.page.click(`button[role="option"]:has-text("${value}")`); + } + + async applySeriesChanges(): Promise { + await this.page.testSubj.click('seriesChangesApplyButton'); + } +} diff --git a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/index.ts b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/page_objects/index.ts similarity index 66% rename from x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/index.ts rename to x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/page_objects/index.ts index eae0b7c0aefc1..0277e97df2680 100644 --- a/x-pack/solutions/observability/plugins/exploratory_view/e2e/journeys/index.ts +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/fixtures/page_objects/index.ts @@ -5,6 +5,4 @@ * 2.0. */ -// export * from './exploratory_view'; -export type * from './step_duration.journey'; -// export * from './single_metric.journey'; +export { ExploratoryViewPage } from './exploratory_view'; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/context_menu_actions_module.ts b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/playwright.config.ts similarity index 63% rename from x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/context_menu_actions_module.ts rename to x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/playwright.config.ts index 69c01dc8f1ab6..bbf2c16848a87 100644 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/actions/context_menu_actions_module.ts +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/playwright.config.ts @@ -5,5 +5,9 @@ * 2.0. */ -export { flyoutCreateDrilldownAction } from './flyout_create_drilldown'; -export { flyoutEditDrilldownAction } from './flyout_edit_drilldown'; +import { createPlaywrightConfig } from '@kbn/scout-oblt'; + +export default createPlaywrightConfig({ + testDir: './tests', + runGlobalSetup: true, +}); diff --git a/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/global.setup.ts b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/global.setup.ts new file mode 100644 index 0000000000000..37b127d2e237f --- /dev/null +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/global.setup.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { globalSetupHook, tags } from '@kbn/scout-oblt'; +import { testData } from '../fixtures'; + +globalSetupHook( + 'Ingest Exploratory View test data', + { tag: tags.stateful.classic }, + async ({ esArchiver, log }) => { + const archives = [ + testData.ES_ARCHIVES.RUM_8_0_0, + testData.ES_ARCHIVES.RUM_TEST_DATA, + testData.ES_ARCHIVES.FULL_HEARTBEAT, + testData.ES_ARCHIVES.BROWSER, + ]; + + log.debug('[setup] loading test data (only if indexes do not exist)...'); + for (const archive of archives) { + await esArchiver.loadIfNeeded(archive); + } + } +); diff --git a/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/step_duration.spec.ts b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/step_duration.spec.ts new file mode 100644 index 0000000000000..96eab4c0b6f2c --- /dev/null +++ b/x-pack/solutions/observability/plugins/exploratory_view/test/scout/ui/tests/step_duration.spec.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable playwright/no-nth-methods */ + +import { tags } from '@kbn/scout-oblt'; +import { expect } from '@kbn/scout-oblt/ui'; +import moment from 'moment'; +import { createExploratoryViewUrl } from '../../../../public/components/shared/exploratory_view/configurations/exploratory_view_url'; +import { test } from '../fixtures'; + +test.describe('Step Duration series', { tag: tags.stateful.classic }, () => { + test('builds series with step duration metric', async ({ pageObjects, page, browserAuth }) => { + await test.step('Go to Exploratory view', async () => { + await browserAuth.loginAsAdmin(); + + const testUrl = createExploratoryViewUrl({ + reportType: 'kpi-over-time', + allSeries: [ + { + dataType: 'uptime', + time: { + from: moment().subtract(10, 'y').toISOString(), + to: moment().toISOString(), + }, + name: 'synthetics-series-1', + breakdown: 'monitor.type', + selectedMetricField: 'monitor.duration.us', + reportDefinitions: { + 'monitor.name': ['test-monitor - inline'], + 'url.full': ['ALL_VALUES'], + }, + }, + ], + }); + + await pageObjects.exploratoryView.goto(testUrl); + await pageObjects.exploratoryView.waitForLoadingToFinish(); + }); + + await test.step('build series with monitor duration', async () => { + const legendItems = pageObjects.exploratoryView.echLegendItemLocator; + await expect(legendItems).toHaveCount(1); + await expect(legendItems.nth(0)).toHaveText('browser'); + + await pageObjects.exploratoryView.changeReportMetric('Step duration'); + await pageObjects.exploratoryView.selectSeriesBreakdown('Step name'); + + await pageObjects.exploratoryView.applySeriesChanges(); + await pageObjects.exploratoryView.waitForLoadingToFinish(); + }); + + await test.step('Verify that changes are applied', async () => { + const legendItems = pageObjects.exploratoryView.echLegendItemLocator; + await expect(legendItems).toHaveCount(6); + await expect(legendItems.nth(0)).toHaveText('load homepage'); + await expect(legendItems.nth(1)).toHaveText('load github'); + await expect(legendItems.nth(2)).toHaveText('load google'); + await expect(legendItems.nth(3)).toHaveText('hover over products menu'); + await expect(legendItems.nth(4)).toHaveText('load homepage 1'); + await expect(legendItems.nth(5)).toHaveText('load homepage 2'); + }); + + await test.step('Hide series', async () => { + const legendItems = pageObjects.exploratoryView.echLegendItemLocator; + await expect(legendItems).toHaveCount(6); + await legendItems.nth(0).click(); + await expect(page.locator('[title="series hidden"]')).toHaveCount(5); + await legendItems.nth(1).click(); + await expect(page.locator('[title="series hidden"]')).toHaveCount(4); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/exploratory_view/tsconfig.json b/x-pack/solutions/observability/plugins/exploratory_view/tsconfig.json index b0551414beb84..d6099dcf2bfba 100644 --- a/x-pack/solutions/observability/plugins/exploratory_view/tsconfig.json +++ b/x-pack/solutions/observability/plugins/exploratory_view/tsconfig.json @@ -3,7 +3,14 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["common/**/*", "public/**/*", "server/**/*", "public/**/*.json", "../../../../../typings/**/*"], + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "public/**/*.json", + "test/scout/**/*", + "../../../../../typings/**/*" + ], "kbn_references": [ "@kbn/core", "@kbn/data-plugin", @@ -46,6 +53,7 @@ "@kbn/ebt-tools", "@kbn/config-schema", "@kbn/chart-expressions-common", + "@kbn/scout-oblt", ], "exclude": ["target/**/*"] } diff --git a/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/parallel.json b/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/parallel.json index 83df03a67d6cd..c32f14b967ad9 100644 --- a/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/parallel.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-06T14:35:27.904Z", - "sha1": "c60f77790accb19b65ed3714d3bf3dbc694f5f1f", + "sha1": "f2c5d662e7cd675081be2eb284e927eae5d08507", "tests": [ { "id": "50b3a018a3d8e65-f2cd2d3cdc77643", @@ -268,7 +267,7 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/infra/test/scout/ui/parallel_tests/inventory/host_asset_details_flyout.spec.ts", - "line": 344, + "line": 348, "column": 9 } }, @@ -284,7 +283,7 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/infra/test/scout/ui/parallel_tests/inventory/host_asset_details_flyout.spec.ts", - "line": 378, + "line": 382, "column": 9 } }, diff --git a/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/standard.json b/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/standard.json index d26cb2e93512b..c485c18831ba9 100644 --- a/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/standard.json +++ b/x-pack/solutions/observability/plugins/infra/test/scout/.meta/ui/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-06T14:35:30.298Z", - "sha1": "c60f77790accb19b65ed3714d3bf3dbc694f5f1f", + "sha1": "f2c5d662e7cd675081be2eb284e927eae5d08507", "tests": [ { "id": "a01812d3d8d03d1-6edec65fe06717b", diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/.storybook/main.js b/x-pack/solutions/observability/plugins/metrics_data_access/.storybook/main.js similarity index 100% rename from x-pack/platform/plugins/shared/dashboard_enhanced/.storybook/main.js rename to x-pack/solutions/observability/plugins/metrics_data_access/.storybook/main.js diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/.storybook/preview.js b/x-pack/solutions/observability/plugins/metrics_data_access/.storybook/preview.js new file mode 100644 index 0000000000000..4b44e3a873962 --- /dev/null +++ b/x-pack/solutions/observability/plugins/metrics_data_access/.storybook/preview.js @@ -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 React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { BehaviorSubject } from 'rxjs'; +import { CoreProviders } from '../public/apps/common_providers'; +import { createStartServicesAccessorMock } from '../public/components/infrastructure_node_metrics_tables/test_helpers'; + +export const parameters = { + docs: { + source: { + type: 'code', // without this, stories in mdx documents freeze the browser + }, + }, +}; + +// Add a global decorator that wraps all stories with Router context and CoreProviders +// This ensures hooks like useLocation have the necessary context +export const decorators = [ + (StoryFn) => { + const { coreProvidersPropsMock } = createStartServicesAccessorMock(); + + // Ensure the CoreStart mock includes proper Observable mocks for RxJS subscriptions + coreProvidersPropsMock.core.application.currentAppId$ = new BehaviorSubject('metrics'); + + // Ensure the CoreStart mock includes a share.locators.get implementation + const MOCK_HREF = '/app/r?l=ASSET_DETAILS_LOCATOR&v=8.15.0&lz=MoCkLoCaToRvAlUe'; + const mockLocator = { + getRedirectUrl: () => MOCK_HREF, + navigate: () => {}, + }; + + if (!coreProvidersPropsMock.core.share) { + coreProvidersPropsMock.core.share = {}; + } + if (!coreProvidersPropsMock.core.share.url) { + coreProvidersPropsMock.core.share.url = {}; + } + coreProvidersPropsMock.core.share.url.locators = { + get: () => mockLocator, + }; + + return ( + + + + + + ); + }, +]; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/moon.yml b/x-pack/solutions/observability/plugins/metrics_data_access/moon.yml index 322b4fc71d4c4..e7d78846ee1d4 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/moon.yml +++ b/x-pack/solutions/observability/plugins/metrics_data_access/moon.yml @@ -50,6 +50,7 @@ dependsOn: - '@kbn/router-utils' - '@kbn/chart-expressions-common' - '@kbn/observability-utils-common' + - '@kbn/esql-language' tags: - plugin - prod diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_configs.test.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_configs.test.ts new file mode 100644 index 0000000000000..1c79161d3c18e --- /dev/null +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_configs.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, + ECS_CONTAINER_MEMORY_USAGE_BYTES, + SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION, + SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT, + SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, +} from '../shared/constants'; +import { getOptionsForSchema } from './container_metrics_configs'; + +describe('container_metrics_configs', () => { + describe('getOptionsForSchema', () => { + it('returns ECS options when isOtel is false', () => { + const { options } = getOptionsForSchema(false); + + expect(options.groupBy).toBe('container.id'); + expect(options.kuery).toBe(`event.dataset: "kubernetes.container"`); + expect(options.metrics).toEqual( + expect.arrayContaining([ + { field: ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, aggregation: 'avg' }, + { field: ECS_CONTAINER_MEMORY_USAGE_BYTES, aggregation: 'avg' }, + ]) + ); + expect(options.metrics).toHaveLength(2); + }); + + it('returns SemConv Docker options when isOtel is true and isK8sContainer is false', () => { + const { options } = getOptionsForSchema(true, false); + + expect(options.groupBy).toBe('container.id'); + expect(options.kuery).toBe('event.dataset: "dockerstatsreceiver.otel"'); + expect(options.metrics).toEqual( + expect.arrayContaining([ + { field: SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION, aggregation: 'avg' }, + { field: SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT, aggregation: 'avg' }, + ]) + ); + expect(options.metrics).toHaveLength(2); + }); + + it('returns SemConv Docker options when isOtel is true and isK8sContainer is undefined', () => { + const { options } = getOptionsForSchema(true); + + expect(options.groupBy).toBe('container.id'); + expect(options.kuery).toBe('event.dataset: "dockerstatsreceiver.otel"'); + expect(options.metrics).toEqual( + expect.arrayContaining([ + { field: SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION, aggregation: 'avg' }, + { field: SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT, aggregation: 'avg' }, + ]) + ); + expect(options.metrics).toHaveLength(2); + }); + + it('returns SemConv K8s options when isOtel is true and isK8sContainer is true', () => { + const { options } = getOptionsForSchema(true, true); + + expect(options.groupBy).toBe('container.id'); + expect(options.kuery).toBe('event.dataset: "kubeletstatsreceiver.otel"'); + expect(options.metrics).toEqual( + expect.arrayContaining([ + { field: SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION, aggregation: 'avg' }, + { field: SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, aggregation: 'avg' }, + ]) + ); + expect(options.metrics).toHaveLength(2); + }); + + it('combines kuery with ECS source filter when isOtel is false and kuery is provided', () => { + const kuery = 'container.id: "abc-123"'; + const { options } = getOptionsForSchema(false, undefined, kuery); + + expect(options.kuery).toBe(`event.dataset: "kubernetes.container" AND (${kuery})`); + }); + + it('combines kuery with SemConv Docker when isOtel is true, isK8sContainer false, and kuery is provided', () => { + const kuery = 'container.id: "abc-123"'; + const { options } = getOptionsForSchema(true, false, kuery); + + expect(options.kuery).toBe(`event.dataset: "dockerstatsreceiver.otel" AND (${kuery})`); + }); + + it('combines kuery with SemConv K8s when isOtel is true, isK8sContainer true, and kuery is provided', () => { + const kuery = 'container.id: "abc-123"'; + const { options } = getOptionsForSchema(true, true, kuery); + + expect(options.kuery).toBe(`event.dataset: "kubeletstatsreceiver.otel" AND (${kuery})`); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_configs.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_configs.ts new file mode 100644 index 0000000000000..547a461d27fca --- /dev/null +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_configs.ts @@ -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 type { MetricsQueryOptions } from '../shared'; +import { createMetricByFieldLookup, makeUnpackMetric, metricsToApiOptions } from '../shared'; +import { + ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, + ECS_CONTAINER_MEMORY_USAGE_BYTES, + SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT, + SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION, + SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, +} from '../shared/constants'; + +// --- ECS (Elastic Common Schema) --- +type ContainerMetricsFieldEcs = + | typeof ECS_CONTAINER_CPU_USAGE_LIMIT_PCT + | typeof ECS_CONTAINER_MEMORY_USAGE_BYTES; + +const containerMetricsQueryConfigEcs: MetricsQueryOptions = { + sourceFilter: `event.dataset: "kubernetes.container"`, + groupByField: 'container.id', + metricsMap: { + [ECS_CONTAINER_CPU_USAGE_LIMIT_PCT]: { + aggregation: 'avg', + field: ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, + }, + [ECS_CONTAINER_MEMORY_USAGE_BYTES]: { + aggregation: 'avg', + field: ECS_CONTAINER_MEMORY_USAGE_BYTES, + }, + }, +}; + +// --- SemConv Docker (generic container metrics) --- +type ContainerMetricsFieldSemconvDocker = + | typeof SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION + | typeof SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT; + +const containerMetricsQueryConfigSemconvDocker: MetricsQueryOptions = + { + sourceFilter: 'event.dataset: "dockerstatsreceiver.otel"', + groupByField: 'container.id', + metricsMap: { + [SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION]: { + aggregation: 'avg', + field: SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION, + }, + [SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT]: { + aggregation: 'avg', + field: SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT, + }, + }, + }; + +// --- SemConv K8s (Kubernetes container metrics) --- +type ContainerMetricsFieldSemconvK8s = + | typeof SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION + | typeof SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION; + +const containerMetricsQueryConfigSemconvK8s: MetricsQueryOptions = + { + sourceFilter: 'event.dataset: "kubeletstatsreceiver.otel"', + groupByField: 'container.id', + metricsMap: { + [SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION]: { + aggregation: 'avg', + field: SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + }, + [SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION]: { + aggregation: 'avg', + field: SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, + }, + }, + }; + +export const metricByFieldEcs = createMetricByFieldLookup( + containerMetricsQueryConfigEcs.metricsMap +); +export const unpackMetricEcs = makeUnpackMetric(metricByFieldEcs); + +const metricByFieldSemconvDocker = createMetricByFieldLookup( + containerMetricsQueryConfigSemconvDocker.metricsMap +); +export const unpackMetricSemconvDocker = makeUnpackMetric(metricByFieldSemconvDocker); + +const metricByFieldSemconvK8s = createMetricByFieldLookup( + containerMetricsQueryConfigSemconvK8s.metricsMap +); +export const unpackMetricSemconvK8s = makeUnpackMetric(metricByFieldSemconvK8s); + +/** @deprecated Use metricByFieldEcs for ECS; use unpack from getUnpackMetricsForSchema for transform */ +export const metricByField = metricByFieldEcs; + +export function getOptionsForSchema(isOtel: boolean, isK8sContainer?: boolean, kuery?: string) { + if (isOtel) { + return isK8sContainer + ? metricsToApiOptions(containerMetricsQueryConfigSemconvK8s, kuery) + : metricsToApiOptions(containerMetricsQueryConfigSemconvDocker, kuery); + } + return metricsToApiOptions(containerMetricsQueryConfigEcs, kuery); +} diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx index 8e71679ac0c09..210f199bcea72 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx @@ -17,12 +17,18 @@ import type { ContainerNodeMetricsRow } from './use_container_metrics_table'; const mockServices = { application: { + currentAppId$: { + subscribe: (callback: (appId: string) => void) => { + callback('mock-app-id'); + return { unsubscribe: () => {} }; + }, + }, getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, }, }; export default { - title: 'infra/Node Metrics Tables/Container', + title: 'metrics_data_access/Node Metrics Tables/Container', decorators: [ (wrappedStory) => {wrappedStory()}, (wrappedStory) => ( @@ -60,28 +66,28 @@ export default { const loadedContainers: ContainerNodeMetricsRow[] = [ { id: 'gke-edge-oblt-pool-1-9a60016d-lgg1', - averageCpuUsagePercent: 99, - averageMemoryUsageMegabytes: 34, + averageCpuUsage: 99, + averageMemoryUsage: 34, }, { id: 'gke-edge-oblt-pool-1-9a60016d-lgg2', - averageCpuUsagePercent: 72, - averageMemoryUsageMegabytes: 68, + averageCpuUsage: 72, + averageMemoryUsage: 68, }, { id: 'gke-edge-oblt-pool-1-9a60016d-lgg3', - averageCpuUsagePercent: 54, - averageMemoryUsageMegabytes: 132, + averageCpuUsage: 54, + averageMemoryUsage: 132, }, { id: 'gke-edge-oblt-pool-1-9a60016d-lgg4', - averageCpuUsagePercent: 34, - averageMemoryUsageMegabytes: 264, + averageCpuUsage: 34, + averageMemoryUsage: 264, }, { id: 'gke-edge-oblt-pool-1-9a60016d-lgg5', - averageCpuUsagePercent: 13, - averageMemoryUsageMegabytes: 512, + averageCpuUsage: 13, + averageMemoryUsage: 512, }, ]; @@ -165,3 +171,26 @@ export const FailedToLoadMetrics = { }, }, }; + +export const BasicWithSemconv = { + render: Template, + + args: { + data: { + state: 'data', + currentPageIndex: 1, + pageCount: 10, + rows: loadedContainers, + }, + isOtel: true, + }, +}; + +export const LoadingWithSemconv = { + render: Template, + + args: { + isLoading: true, + isOtel: true, + }, +}; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx index 977c99e852a22..4b56a7dfc5466 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx @@ -13,6 +13,10 @@ import { createStartServicesAccessorMock, createMetricsClientMock } from '../tes import { ContainerMetricsTable } from './container_metrics_table'; import { createLazyContainerMetricsTable } from './create_lazy_container_metrics_table'; import IntegratedContainerMetricsTable from './integrated_container_metrics_table'; +import { + ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, + ECS_CONTAINER_MEMORY_USAGE_BYTES, +} from '../shared/constants'; import { metricByField } from './use_container_metrics_table'; jest.mock('../../../pages/link_to/use_asset_details_redirect', () => ({ @@ -146,8 +150,8 @@ function createContainer( id: name, rows: [ { - [metricByField['kubernetes.container.cpu.usage.limit.pct']]: cpuUsagePct, - [metricByField['kubernetes.container.memory.usage.bytes']]: memoryUsageBytes, + [metricByField[ECS_CONTAINER_CPU_USAGE_LIMIT_PCT]]: cpuUsagePct, + [metricByField[ECS_CONTAINER_MEMORY_USAGE_BYTES]]: memoryUsageBytes, } as MetricsExplorerSeries['rows'][number], ], }; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx index 89b96ae4cf8b8..1c2ad91fb404e 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx @@ -23,6 +23,7 @@ import { NumberCell, StepwisePagination, } from '../shared'; + import type { ContainerNodeMetricsRow } from './use_container_metrics_table'; export interface ContainerMetricsTableProps { @@ -35,12 +36,28 @@ export interface ContainerMetricsTableProps { from: string; to: string; }; + isOtel?: boolean; + metricsIndices?: string; + isK8sContainer?: boolean; } export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => { - const { data, isLoading, setCurrentPageIndex, setSortState, sortState, timerange } = props; + const { + data, + isLoading, + setCurrentPageIndex, + setSortState, + sortState, + timerange, + isOtel, + metricsIndices, + isK8sContainer, + } = props; - const columns = useMemo(() => containerNodeColumns(timerange), [timerange]); + const columns = useMemo( + () => containerNodeColumns({ timerange, isOtel, metricsIndices, isK8sContainer }), + [timerange, isOtel, metricsIndices, isK8sContainer] + ); const sortSettings: EuiTableSortingType = { enableAllColumns: true, @@ -112,9 +129,16 @@ export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => { } }; -function containerNodeColumns( - timerange: ContainerMetricsTableProps['timerange'] -): Array> { +function containerNodeColumns({ + timerange, + isOtel, + metricsIndices, + isK8sContainer, +}: Pick< + ContainerMetricsTableProps, + 'timerange' | 'isOtel' | 'metricsIndices' | 'isK8sContainer' +>): Array> { + const memoryUnit = isOtel ? '%' : ' MB'; return [ { name: i18n.translate('xpack.metricsData.metricsTable.container.idColumnHeader', { @@ -125,34 +149,51 @@ function containerNodeColumns( textOnly: true, render: (id: string) => { return ( - + ); }, }, { - name: i18n.translate( - 'xpack.metricsData.metricsTable.container.averageCpuUsagePercentColumnHeader', - { - defaultMessage: 'CPU usage (avg.)', - } + name: ( + + + {i18n.translate( + 'xpack.metricsData.metricsTable.container.averageCpuUsagePercentColumnHeader', + { + defaultMessage: 'CPU usage (avg.)', + } + )} + + ), - field: 'averageCpuUsagePercent', + field: 'averageCpuUsage', align: 'right', - render: (averageCpuUsagePercent: number) => ( - - ), + render: (averageCpuUsage: number) => , }, { - name: i18n.translate( - 'xpack.metricsData.metricsTable.container.averageMemoryUsageMegabytesColumnHeader', - { - defaultMessage: 'Memory usage(avg.)', - } + name: ( + + + {i18n.translate( + 'xpack.metricsData.metricsTable.container.averageMemoryUsageColumnHeader', + { + defaultMessage: 'Memory usage(avg.)', + } + )} + + ), - field: 'averageMemoryUsageMegabytes', + field: 'averageMemoryUsage', align: 'right', - render: (averageMemoryUsageMegabytes: number) => ( - + render: (averageMemoryUsage: number) => ( + ), }, ]; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx index 01b9657eb6699..09d0cf9311478 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx @@ -9,13 +9,20 @@ import type { CoreStart } from '@kbn/core/public'; import React, { lazy, Suspense } from 'react'; import type { MetricsDataClient } from '../../../lib/metrics_client'; import type { NodeMetricsTableProps } from '../shared'; - const LazyIntegratedContainerMetricsTable = lazy( () => import('./integrated_container_metrics_table') ); export function createLazyContainerMetricsTable(core: CoreStart, metricsClient: MetricsDataClient) { - return ({ timerange, kuery, sourceId }: NodeMetricsTableProps) => { + return ({ + timerange, + kuery, + sourceId, + isOtel, + isK8sContainer, + }: NodeMetricsTableProps & { + isK8sContainer?: boolean; + }) => { return ( ); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx index 55444935bb4e7..ae0bdf9d89280 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx @@ -7,36 +7,59 @@ import React from 'react'; import { CoreProviders } from '../../../apps/common_providers'; -import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; +import type { IntegratedNodeMetricsTableProps } from '../shared'; import { ContainerMetricsTable } from './container_metrics_table'; import { useContainerMetricsTable } from './use_container_metrics_table'; +type ContainerIntegratedProps = IntegratedNodeMetricsTableProps & { + isK8sContainer?: boolean; +}; + +type HookedContainerMetricsTableProps = Pick< + ContainerIntegratedProps, + 'timerange' | 'kuery' | 'isOtel' | 'isK8sContainer' | 'metricsClient' +>; function HookedContainerMetricsTable({ timerange, kuery, + isOtel, + isK8sContainer, metricsClient, -}: UseNodeMetricsTableOptions) { +}: HookedContainerMetricsTableProps) { const containerMetricsTableProps = useContainerMetricsTable({ timerange, kuery, + isOtel, + isK8sContainer, metricsClient, }); - return ; + return ( + + ); } function ContainerMetricsTableWithProviders({ timerange, kuery, sourceId, + isOtel, + isK8sContainer, metricsClient, ...coreProvidersProps -}: IntegratedNodeMetricsTableProps) { +}: ContainerIntegratedProps) { return ( ); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.test.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.test.ts index 4e1bdab841d73..82d6d7bc43e81 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.test.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.test.ts @@ -5,6 +5,14 @@ * 2.0. */ +import { + ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, + ECS_CONTAINER_MEMORY_USAGE_BYTES, + SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION, + SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT, + SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, +} from '../shared/constants'; import { useContainerMetricsTable } from './use_container_metrics_table'; import { useInfrastructureNodeMetrics } from '../shared'; import { renderHook } from '@testing-library/react'; @@ -27,6 +35,7 @@ describe('useContainerMetricsTable hook', () => { useInfrastructureNodeMetricsMock.mockReturnValue({ isLoading: true, data: { state: 'empty-indices' }, + metricIndices: 'test-index', }); renderHook(() => @@ -47,4 +56,108 @@ describe('useContainerMetricsTable hook', () => { }) ); }); + + it('should call useInfrastructureNodeMetrics with SemConv Docker metrics when isOtel is true (default)', () => { + const kuery = 'container.id: "gke-edge-oblt-pool-1-9a60016d-lgg9"'; + + useInfrastructureNodeMetricsMock.mockReturnValue({ + isLoading: true, + data: { state: 'empty-indices' }, + metricIndices: 'test-index', + }); + + renderHook(() => + useContainerMetricsTable({ + timerange: { from: 'now-30d', to: 'now' }, + kuery, + metricsClient: createMetricsClientMock({}), + isOtel: true, + }) + ); + + expect(useInfrastructureNodeMetricsMock).toHaveBeenCalledWith( + expect.objectContaining({ + metricsExplorerOptions: expect.objectContaining({ + kuery: `event.dataset: "dockerstatsreceiver.otel" AND (${kuery})`, + metrics: expect.arrayContaining([ + expect.objectContaining({ field: SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION }), + expect.objectContaining({ field: SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT }), + ]), + }), + }) + ); + }); + + it('should call useInfrastructureNodeMetrics with SemConv K8s metrics when isOtel is true and isK8s is true', () => { + const kuery = 'container.id: "some-k8s-container"'; + + useInfrastructureNodeMetricsMock.mockReturnValue({ + isLoading: true, + data: { state: 'empty-indices' }, + metricIndices: 'test-index', + }); + + renderHook(() => + useContainerMetricsTable({ + timerange: { from: 'now-30d', to: 'now' }, + kuery, + metricsClient: createMetricsClientMock({}), + isOtel: true, + isK8sContainer: true, + }) + ); + + expect(useInfrastructureNodeMetricsMock).toHaveBeenCalledWith( + expect.objectContaining({ + metricsExplorerOptions: expect.objectContaining({ + kuery: `event.dataset: "kubeletstatsreceiver.otel" AND (${kuery})`, + metrics: expect.arrayContaining([ + expect.objectContaining({ + field: SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + }), + expect.objectContaining({ + field: SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, + }), + ]), + }), + }) + ); + }); + + it('should call useInfrastructureNodeMetrics with ECS metrics when isOtel is false', () => { + const kuery = 'container.id: "gke-edge-oblt-pool-1-9a60016d-lgg9"'; + + useInfrastructureNodeMetricsMock.mockReturnValue({ + isLoading: true, + data: { state: 'empty-indices' }, + metricIndices: 'test-index', + }); + + renderHook(() => + useContainerMetricsTable({ + timerange: { from: 'now-30d', to: 'now' }, + kuery, + metricsClient: createMetricsClientMock({}), + isOtel: false, + }) + ); + + const kueryWithEventDatasetFilter = `event.dataset: "kubernetes.container" AND (${kuery})`; + + expect(useInfrastructureNodeMetricsMock).toHaveBeenCalledWith( + expect.objectContaining({ + metricsExplorerOptions: expect.objectContaining({ + kuery: kueryWithEventDatasetFilter, + metrics: expect.arrayContaining([ + expect.objectContaining({ + field: ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, + }), + expect.objectContaining({ + field: ECS_CONTAINER_MEMORY_USAGE_BYTES, + }), + ]), + }), + }) + ); + }); }); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts index 667c5b017e5c0..48f9db5e4130b 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts @@ -10,72 +10,115 @@ import type { MetricsExplorerRow, MetricsExplorerSeries, } from '../../../../common/http_api/metrics_explorer'; -import type { MetricsQueryOptions, SortState, UseNodeMetricsTableOptions } from '../shared'; +import type { SortState, UseNodeMetricsTableOptions } from '../shared'; +import { averageOfValues, scaleUpPercentage, useInfrastructureNodeMetrics } from '../shared'; import { - averageOfValues, - createMetricByFieldLookup, - makeUnpackMetric, - metricsToApiOptions, - scaleUpPercentage, - useInfrastructureNodeMetrics, -} from '../shared'; - -type ContainerMetricsField = - | 'kubernetes.container.cpu.usage.limit.pct' - | 'kubernetes.container.memory.usage.bytes'; - -const containerMetricsQueryConfig: MetricsQueryOptions = { - sourceFilter: `event.dataset: "kubernetes.container"`, - groupByField: 'container.id', - metricsMap: { - 'kubernetes.container.cpu.usage.limit.pct': { - aggregation: 'avg', - field: 'kubernetes.container.cpu.usage.limit.pct', - }, - 'kubernetes.container.memory.usage.bytes': { - aggregation: 'avg', - field: 'kubernetes.container.memory.usage.bytes', - }, - }, -}; - -export const metricByField = createMetricByFieldLookup(containerMetricsQueryConfig.metricsMap); -const unpackMetric = makeUnpackMetric(metricByField); + getOptionsForSchema, + unpackMetricEcs, + unpackMetricSemconvDocker, + unpackMetricSemconvK8s, +} from './container_metrics_configs'; +import { + ECS_CONTAINER_CPU_USAGE_LIMIT_PCT, + ECS_CONTAINER_MEMORY_USAGE_BYTES, + SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION, + SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT, + SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, +} from '../shared/constants'; + +export { metricByFieldEcs, metricByField } from './container_metrics_configs'; + +export interface UseContainerMetricsTableOptions extends UseNodeMetricsTableOptions { + isK8sContainer?: boolean; +} export interface ContainerNodeMetricsRow { id: string; - averageCpuUsagePercent: number | null; - averageMemoryUsageMegabytes: number | null; + averageCpuUsage: number | null; + averageMemoryUsage: number | null; +} + +type UnpackMetricsFn = (row: MetricsExplorerRow) => Omit; + +const semconvDockerUnpackMetrics = (row: MetricsExplorerRow) => { + // semconv docker unpack metrics + const cpuUtilization = unpackMetricSemconvDocker(row, SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION); + const memoryUtilization = unpackMetricSemconvDocker(row, SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT); + return { + averageCpuUsage: cpuUtilization !== null ? scaleUpPercentage(cpuUtilization) : null, + averageMemoryUsage: memoryUtilization !== null ? memoryUtilization : null, + }; +}; + +const semconvK8sUnpackMetrics = (row: MetricsExplorerRow) => { + // semconv k8s unpack metrics + const cpuUtilization = unpackMetricSemconvK8s(row, SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION); + const memoryUsage = unpackMetricSemconvK8s(row, SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION); + return { + averageCpuUsage: cpuUtilization !== null ? scaleUpPercentage(cpuUtilization) : null, + averageMemoryUsage: memoryUsage !== null ? scaleUpPercentage(memoryUsage) : null, + }; +}; + +const ecsUnpackMetrics = (row: MetricsExplorerRow) => { + const rawCpu = unpackMetricEcs(row, ECS_CONTAINER_CPU_USAGE_LIMIT_PCT); + const memoryBytes = unpackMetricEcs(row, ECS_CONTAINER_MEMORY_USAGE_BYTES); + return { + averageCpuUsage: rawCpu !== null ? scaleUpPercentage(rawCpu) : null, + averageMemoryUsage: memoryBytes !== null ? Math.floor(memoryBytes / 1_000_000) : null, + }; +}; + +function getUnpackMetricsForSchema(isOtel: boolean, isK8sContainer?: boolean): UnpackMetricsFn { + if (isOtel) { + if (isK8sContainer) { + // semconv k8s metrics unpacking + return semconvK8sUnpackMetrics; + } + // semconv docker metrics unpacking + return semconvDockerUnpackMetrics; + } + // ecs metrics unpacking + return ecsUnpackMetrics; } export function useContainerMetricsTable({ timerange, kuery, metricsClient, -}: UseNodeMetricsTableOptions) { + isOtel, + isK8sContainer, +}: UseContainerMetricsTableOptions) { const [currentPageIndex, setCurrentPageIndex] = useState(0); const [sortState, setSortState] = useState>({ - field: 'averageCpuUsagePercent', + field: 'averageCpuUsage', direction: 'desc', }); - const { options: containerMetricsOptions } = useMemo( - () => metricsToApiOptions(containerMetricsQueryConfig, kuery), - [kuery] + const metricsExplorerOptions = useMemo( + () => getOptionsForSchema(isOtel ?? false, isK8sContainer, kuery).options, + [isOtel, isK8sContainer, kuery] ); - const { data, isLoading } = useInfrastructureNodeMetrics({ - metricsExplorerOptions: containerMetricsOptions, + const transform = useMemo(() => { + const unpackMetrics = getUnpackMetricsForSchema(isOtel ?? false, isK8sContainer); + return (series: MetricsExplorerSeries): ContainerNodeMetricsRow => + seriesToContainerNodeMetricsRow(series, unpackMetrics); + }, [isOtel, isK8sContainer]); + + const { data, isLoading, metricIndices } = useInfrastructureNodeMetrics({ + metricsExplorerOptions, timerange, - transform: seriesToContainerNodeMetricsRow, + transform, sortState, currentPageIndex, metricsClient, }); - return { data, isLoading, + metricIndices, setCurrentPageIndex, setSortState, sortState, @@ -83,72 +126,67 @@ export function useContainerMetricsTable({ }; } -function seriesToContainerNodeMetricsRow(series: MetricsExplorerSeries): ContainerNodeMetricsRow { +function seriesToContainerNodeMetricsRow( + series: MetricsExplorerSeries, + unpackMetrics: UnpackMetricsFn +): ContainerNodeMetricsRow { if (series.rows.length === 0) { return rowWithoutMetrics(series.id); } return { id: series.id, - ...calculateMetricAverages(series.rows), + ...calculateMetricAverages(series.rows, unpackMetrics), }; } -function rowWithoutMetrics(id: string) { +function rowWithoutMetrics(id: string): ContainerNodeMetricsRow { return { id, - averageCpuUsagePercent: null, - averageMemoryUsageMegabytes: null, + averageCpuUsage: null, + averageMemoryUsage: null, }; } -function calculateMetricAverages(rows: MetricsExplorerRow[]) { - const { averageCpuUsagePercentValues, averageMemoryUsageMegabytesValues } = - collectMetricValues(rows); +function calculateMetricAverages(rows: MetricsExplorerRow[], unpackMetrics: UnpackMetricsFn) { + const { averageCpuUsageValues, averageMemoryUsageValues } = collectMetricValues( + rows, + unpackMetrics + ); - let averageCpuUsagePercent = null; - if (averageCpuUsagePercentValues.length !== 0) { - averageCpuUsagePercent = scaleUpPercentage(averageOfValues(averageCpuUsagePercentValues)); + let averageCpuUsage: number | null = null; + if (averageCpuUsageValues.length !== 0) { + averageCpuUsage = averageOfValues(averageCpuUsageValues); } - let averageMemoryUsageMegabytes = null; - if (averageMemoryUsageMegabytesValues.length !== 0) { - const averageInBytes = averageOfValues(averageMemoryUsageMegabytesValues); - const bytesPerMegabyte = 1000000; - averageMemoryUsageMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + let averageMemoryUsage: number | null = null; + if (averageMemoryUsageValues.length !== 0) { + averageMemoryUsage = Math.floor(averageOfValues(averageMemoryUsageValues)); } return { - averageCpuUsagePercent, - averageMemoryUsageMegabytes, + averageCpuUsage, + averageMemoryUsage, }; } -function collectMetricValues(rows: MetricsExplorerRow[]) { - const averageCpuUsagePercentValues: number[] = []; - const averageMemoryUsageMegabytesValues: number[] = []; - +function collectMetricValues(rows: MetricsExplorerRow[], unpackMetrics: UnpackMetricsFn) { + const averageCpuUsageValues: number[] = []; + const averageMemoryUsageValues: number[] = []; rows.forEach((row) => { - const { averageCpuUsagePercent, averageMemoryUsageMegabytes } = unpackMetrics(row); + const { averageCpuUsage, averageMemoryUsage } = unpackMetrics(row); - if (averageCpuUsagePercent !== null) { - averageCpuUsagePercentValues.push(averageCpuUsagePercent); + if (averageCpuUsage !== null) { + averageCpuUsageValues.push(averageCpuUsage); } - if (averageMemoryUsageMegabytes !== null) { - averageMemoryUsageMegabytesValues.push(averageMemoryUsageMegabytes); + if (averageMemoryUsage !== null) { + averageMemoryUsageValues.push(averageMemoryUsage); } }); return { - averageCpuUsagePercentValues, - averageMemoryUsageMegabytesValues, - }; -} - -function unpackMetrics(row: MetricsExplorerRow): Omit { - return { - averageCpuUsagePercent: unpackMetric(row, 'kubernetes.container.cpu.usage.limit.pct'), - averageMemoryUsageMegabytes: unpackMetric(row, 'kubernetes.container.memory.usage.bytes'), + averageCpuUsageValues, + averageMemoryUsageValues, }; } diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx index 91b858641ff9c..a60c7d6b3a61f 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx @@ -13,13 +13,14 @@ import type { NodeMetricsTableProps } from '../shared'; const LazyIntegratedHostMetricsTable = lazy(() => import('./integrated_host_metrics_table')); export function createLazyHostMetricsTable(core: CoreStart, metricsClient: MetricsDataClient) { - return ({ timerange, kuery, sourceId }: NodeMetricsTableProps) => { + return ({ timerange, kuery, sourceId, isOtel }: NodeMetricsTableProps) => { return ( diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx index 7e507e4cfa779..0cf9253b6955c 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx @@ -17,12 +17,18 @@ import type { HostNodeMetricsRow } from './use_host_metrics_table'; const mockServices = { application: { + currentAppId$: { + subscribe: (callback: (appId: string) => void) => { + callback('mock-app-id'); + return { unsubscribe: () => {} }; + }, + }, getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, }, }; export default { - title: 'infra/Node Metrics Tables/Host', + title: 'metrics_data_access/Node Metrics Tables/Host', decorators: [ (wrappedStory) => {wrappedStory()}, (wrappedStory) => ( @@ -175,3 +181,26 @@ export const FailedToLoadMetrics = { }, }, }; + +export const BasicWithSemconv = { + render: Template, + + args: { + data: { + state: 'data', + currentPageIndex: 1, + pageCount: 10, + rows: loadedHosts, + }, + isOtel: true, + }, +}; + +export const LoadingWithSemconv = { + render: Template, + + args: { + isLoading: true, + isOtel: true, + }, +}; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx index 706e3a31cea08..bf3ade7f3bd77 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx @@ -13,6 +13,12 @@ import { createStartServicesAccessorMock, createMetricsClientMock } from '../tes import { createLazyHostMetricsTable } from './create_lazy_host_metrics_table'; import { HostMetricsTable } from './host_metrics_table'; import IntegratedHostMetricsTable from './integrated_host_metrics_table'; +import { + SYSTEM_CPU_CORES, + SYSTEM_CPU_TOTAL_NORM_PCT, + SYSTEM_MEMORY_TOTAL, + SYSTEM_MEMORY_USED_PCT, +} from '../shared/constants'; import { metricByField } from './use_host_metrics_table'; jest.mock('../../../pages/link_to/use_asset_details_redirect', () => ({ @@ -145,10 +151,10 @@ function createHost( id: name, rows: [ { - [metricByField['system.cpu.cores']]: coreCount, - [metricByField['system.cpu.total.norm.pct']]: cpuUsagePct, - [metricByField['system.memory.total']]: memoryBytes, - [metricByField['system.memory.used.pct']]: memoryUsagePct, + [metricByField[SYSTEM_CPU_CORES]]: coreCount, + [metricByField[SYSTEM_CPU_TOTAL_NORM_PCT]]: cpuUsagePct, + [metricByField[SYSTEM_MEMORY_TOTAL]]: memoryBytes, + [metricByField[SYSTEM_MEMORY_USED_PCT]]: memoryUsagePct, } as MetricsExplorerSeries['rows'][number], ], }; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx index 072a291957ebc..db817883d4082 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx @@ -35,12 +35,26 @@ export interface HostMetricsTableProps { from: string; to: string; }; + isOtel?: boolean; + metricIndices?: string; } export const HostMetricsTable = (props: HostMetricsTableProps) => { - const { data, isLoading, setCurrentPageIndex, setSortState, sortState, timerange } = props; + const { + data, + isLoading, + setCurrentPageIndex, + setSortState, + sortState, + timerange, + isOtel, + metricIndices, + } = props; - const columns = useMemo(() => hostMetricsColumns(timerange), [timerange]); + const columns = useMemo( + () => hostMetricsColumns(timerange, isOtel, metricIndices), + [timerange, isOtel, metricIndices] + ); const sortSettings: EuiTableSortingType = { enableAllColumns: true, @@ -110,7 +124,9 @@ export const HostMetricsTable = (props: HostMetricsTableProps) => { }; function hostMetricsColumns( - timerange: HostMetricsTableProps['timerange'] + timerange: HostMetricsTableProps['timerange'], + isOtel?: HostMetricsTableProps['isOtel'], + metricIndices?: HostMetricsTableProps['metricIndices'] ): Array> { return [ { @@ -121,7 +137,14 @@ function hostMetricsColumns( truncateText: true, textOnly: true, render: (name: string) => ( - + ), }, { diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx index 404574e709dc2..458f228088db8 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx @@ -7,13 +7,29 @@ import React from 'react'; import { CoreProviders } from '../../../apps/common_providers'; -import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; import { HostMetricsTable } from './host_metrics_table'; import { useHostMetricsTable } from './use_host_metrics_table'; +import type { IntegratedNodeMetricsTableProps } from '../shared'; -function HookedHostMetricsTable({ timerange, kuery, metricsClient }: UseNodeMetricsTableOptions) { - const hostMetricsTableProps = useHostMetricsTable({ timerange, kuery, metricsClient }); - return ; +type HookedHostMetricsTableProps = Pick< + IntegratedNodeMetricsTableProps, + 'timerange' | 'kuery' | 'isOtel' | 'metricsClient' +>; + +function HookedHostMetricsTable({ + timerange, + kuery, + metricsClient, + isOtel, +}: HookedHostMetricsTableProps) { + const hostMetricsTableProps = useHostMetricsTable({ timerange, kuery, metricsClient, isOtel }); + return ( + + ); } function HostMetricsTableWithProviders({ @@ -21,11 +37,17 @@ function HostMetricsTableWithProviders({ kuery, sourceId, metricsClient, + isOtel, ...coreProvidersProps }: IntegratedNodeMetricsTableProps) { return ( - + ); } diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.test.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.test.ts index a05b266c31a54..4140cd8a4c669 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.test.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.test.ts @@ -5,6 +5,16 @@ * 2.0. */ +import { + SEMCONV_SYSTEM_CPU_LOGICAL_COUNT, + SEMCONV_SYSTEM_CPU_UTILIZATION, + SEMCONV_SYSTEM_MEMORY_LIMIT, + SEMCONV_SYSTEM_MEMORY_UTILIZATION, + SYSTEM_CPU_CORES, + SYSTEM_CPU_TOTAL_NORM_PCT, + SYSTEM_MEMORY_TOTAL, + SYSTEM_MEMORY_USED_PCT, +} from '../shared/constants'; import { useHostMetricsTable } from './use_host_metrics_table'; import { useInfrastructureNodeMetrics } from '../shared'; import { renderHook } from '@testing-library/react'; @@ -27,6 +37,7 @@ describe('useHostMetricsTable hook', () => { useInfrastructureNodeMetricsMock.mockReturnValue({ isLoading: true, data: { state: 'empty-indices' }, + metricIndices: 'test-index', }); renderHook(() => @@ -47,4 +58,74 @@ describe('useHostMetricsTable hook', () => { }) ); }); + + it('should call useInfrastructureNodeMetrics with OTEL/semconv metrics when isOtel is true', () => { + const kuery = `host.name: "gke-edge-oblt-pool-1-9a60016d-lgg9"`; + + // include this to prevent rendering error in test + useInfrastructureNodeMetricsMock.mockReturnValue({ + isLoading: true, + data: { state: 'empty-indices' }, + metricIndices: 'test-index', + }); + + renderHook(() => + useHostMetricsTable({ + timerange: { from: 'now-30d', to: 'now' }, + kuery, + metricsClient: createMetricsClientMock({}), + isOtel: true, + }) + ); + + expect(useInfrastructureNodeMetricsMock).toHaveBeenCalledWith( + expect.objectContaining({ + metricsExplorerOptions: expect.objectContaining({ + kuery: `event.dataset: "hostmetricsreceiver.otel" AND (${kuery})`, + metrics: expect.arrayContaining([ + expect.objectContaining({ field: SEMCONV_SYSTEM_CPU_LOGICAL_COUNT }), + expect.objectContaining({ field: SEMCONV_SYSTEM_CPU_UTILIZATION }), + expect.objectContaining({ field: SEMCONV_SYSTEM_MEMORY_LIMIT }), + expect.objectContaining({ field: SEMCONV_SYSTEM_MEMORY_UTILIZATION }), + ]), + }), + }) + ); + }); + + it('should call useInfrastructureNodeMetrics with ECS metrics when isOtel is false', () => { + const kuery = `host.name: "gke-edge-oblt-pool-1-9a60016d-lgg9"`; + + // include this to prevent rendering error in test + useInfrastructureNodeMetricsMock.mockReturnValue({ + isLoading: true, + data: { state: 'empty-indices' }, + metricIndices: 'test-index', + }); + + renderHook(() => + useHostMetricsTable({ + timerange: { from: 'now-30d', to: 'now' }, + kuery, + metricsClient: createMetricsClientMock({}), + isOtel: false, + }) + ); + + const kueryWithEventModuleFilter = `event.module: "system" AND (${kuery})`; + + expect(useInfrastructureNodeMetricsMock).toHaveBeenCalledWith( + expect.objectContaining({ + metricsExplorerOptions: expect.objectContaining({ + kuery: kueryWithEventModuleFilter, + metrics: expect.arrayContaining([ + expect.objectContaining({ field: SYSTEM_CPU_CORES }), + expect.objectContaining({ field: SYSTEM_CPU_TOTAL_NORM_PCT }), + expect.objectContaining({ field: SYSTEM_MEMORY_TOTAL }), + expect.objectContaining({ field: SYSTEM_MEMORY_USED_PCT }), + ]), + }), + }) + ); + }); }); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts index b7392f31bc6c3..c5a426c7ac570 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts @@ -19,30 +19,68 @@ import { scaleUpPercentage, useInfrastructureNodeMetrics, } from '../shared'; +import { + SEMCONV_SYSTEM_CPU_LOGICAL_COUNT, + SEMCONV_SYSTEM_CPU_UTILIZATION, + SEMCONV_SYSTEM_MEMORY_LIMIT, + SEMCONV_SYSTEM_MEMORY_UTILIZATION, + SYSTEM_CPU_CORES, + SYSTEM_CPU_TOTAL_NORM_PCT, + SYSTEM_MEMORY_TOTAL, + SYSTEM_MEMORY_USED_PCT, +} from '../shared/constants'; type HostMetricsField = - | 'system.cpu.cores' - | 'system.cpu.total.norm.pct' - | 'system.memory.total' - | 'system.memory.used.pct'; + | typeof SYSTEM_CPU_CORES + | typeof SYSTEM_CPU_TOTAL_NORM_PCT + | typeof SYSTEM_MEMORY_TOTAL + | typeof SYSTEM_MEMORY_USED_PCT; const hostsMetricsQueryConfig: MetricsQueryOptions = { - sourceFilter: `event.module: "system"`, + sourceFilter: 'event.module: "system"', groupByField: 'host.name', metricsMap: { - 'system.cpu.cores': { aggregation: 'max', field: 'system.cpu.cores' }, - 'system.cpu.total.norm.pct': { + [SYSTEM_CPU_CORES]: { aggregation: 'max', field: SYSTEM_CPU_CORES }, + [SYSTEM_CPU_TOTAL_NORM_PCT]: { aggregation: 'avg', - field: 'system.cpu.total.norm.pct', + field: SYSTEM_CPU_TOTAL_NORM_PCT, }, - 'system.memory.total': { aggregation: 'max', field: 'system.memory.total' }, - 'system.memory.used.pct': { + [SYSTEM_MEMORY_TOTAL]: { aggregation: 'max', field: SYSTEM_MEMORY_TOTAL }, + [SYSTEM_MEMORY_USED_PCT]: { aggregation: 'avg', - field: 'system.memory.used.pct', + field: SYSTEM_MEMORY_USED_PCT, }, }, }; +type HostMetricsFieldsOtel = + | typeof SEMCONV_SYSTEM_CPU_LOGICAL_COUNT + | typeof SEMCONV_SYSTEM_CPU_UTILIZATION + | typeof SEMCONV_SYSTEM_MEMORY_LIMIT + | typeof SEMCONV_SYSTEM_MEMORY_UTILIZATION; + +const hostsMetricsQueryConfigOtel: MetricsQueryOptions = { + sourceFilter: 'event.dataset: "hostmetricsreceiver.otel"', + groupByField: 'host.name', + metricsMap: { + [SEMCONV_SYSTEM_CPU_LOGICAL_COUNT]: { + aggregation: 'max', + field: SEMCONV_SYSTEM_CPU_LOGICAL_COUNT, + }, + [SEMCONV_SYSTEM_CPU_UTILIZATION]: { + aggregation: 'avg', + field: SEMCONV_SYSTEM_CPU_UTILIZATION, + }, + [SEMCONV_SYSTEM_MEMORY_LIMIT]: { + aggregation: 'max', + field: SEMCONV_SYSTEM_MEMORY_LIMIT, + }, + [SEMCONV_SYSTEM_MEMORY_UTILIZATION]: { + aggregation: 'avg', + field: SEMCONV_SYSTEM_MEMORY_UTILIZATION, + }, + }, +}; export const metricByField = createMetricByFieldLookup(hostsMetricsQueryConfig.metricsMap); const unpackMetric = makeUnpackMetric(metricByField); @@ -58,6 +96,7 @@ export function useHostMetricsTable({ timerange, kuery, metricsClient, + isOtel, }: UseNodeMetricsTableOptions) { const [currentPageIndex, setCurrentPageIndex] = useState(0); const [sortState, setSortState] = useState>({ @@ -70,8 +109,12 @@ export function useHostMetricsTable({ [kuery] ); - const { data, isLoading } = useInfrastructureNodeMetrics({ - metricsExplorerOptions: hostMetricsOptions, + const { options: hostMetricsOptionsOtel } = useMemo( + () => metricsToApiOptions(hostsMetricsQueryConfigOtel, kuery), + [kuery] + ); + const { data, isLoading, metricIndices } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: isOtel ? hostMetricsOptionsOtel : hostMetricsOptions, timerange, transform: seriesToHostNodeMetricsRow, sortState, @@ -82,6 +125,7 @@ export function useHostMetricsTable({ return { data, isLoading, + metricIndices, setCurrentPageIndex, setSortState, sortState, @@ -185,9 +229,9 @@ function collectMetricValues(rows: MetricsExplorerRow[]) { function unpackMetrics(row: MetricsExplorerRow): Omit { return { - cpuCount: unpackMetric(row, 'system.cpu.cores'), - averageCpuUsagePercent: unpackMetric(row, 'system.cpu.total.norm.pct'), - totalMemoryMegabytes: unpackMetric(row, 'system.memory.total'), - averageMemoryUsagePercent: unpackMetric(row, 'system.memory.used.pct'), + cpuCount: unpackMetric(row, SYSTEM_CPU_CORES), + averageCpuUsagePercent: unpackMetric(row, SYSTEM_CPU_TOTAL_NORM_PCT), + totalMemoryMegabytes: unpackMetric(row, SYSTEM_MEMORY_TOTAL), + averageMemoryUsagePercent: unpackMetric(row, SYSTEM_MEMORY_USED_PCT), }; } diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx index 6354ffdaa4291..e42757da7e146 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx @@ -13,7 +13,7 @@ import type { NodeMetricsTableProps } from '../shared'; const LazyIntegratedPodMetricsTable = lazy(() => import('./integrated_pod_metrics_table')); export function createLazyPodMetricsTable(core: CoreStart, metricsClient: MetricsDataClient) { - return ({ timerange, kuery, sourceId }: NodeMetricsTableProps) => { + return ({ timerange, kuery, sourceId, isOtel }: NodeMetricsTableProps) => { return ( ); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx index 3dfe9c7079569..b3a9bf25b7483 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx @@ -11,9 +11,20 @@ import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from import { PodMetricsTable } from './pod_metrics_table'; import { usePodMetricsTable } from './use_pod_metrics_table'; -function HookedPodMetricsTable({ timerange, kuery, metricsClient }: UseNodeMetricsTableOptions) { - const podMetricsTableProps = usePodMetricsTable({ timerange, kuery, metricsClient }); - return ; +function HookedPodMetricsTable({ + timerange, + kuery, + metricsClient, + isOtel, +}: UseNodeMetricsTableOptions) { + const podMetricsTableProps = usePodMetricsTable({ timerange, kuery, metricsClient, isOtel }); + return ( + + ); } function PodMetricsTableWithProviders({ @@ -21,11 +32,17 @@ function PodMetricsTableWithProviders({ kuery, sourceId, metricsClient, + isOtel, ...coreProvidersProps }: IntegratedNodeMetricsTableProps) { return ( - + ); } diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx index 0e18bfb87d1cf..4bf8c5980ce90 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx @@ -17,12 +17,18 @@ import type { PodNodeMetricsRow } from './use_pod_metrics_table'; const mockServices = { application: { + currentAppId$: { + subscribe: (callback: (appId: string) => void) => { + callback('mock-app-id'); + return { unsubscribe: () => {} }; + }, + }, getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, }, }; export default { - title: 'infra/Node Metrics Tables/Pod', + title: 'metrics_data_access/Node Metrics Tables/Pod', decorators: [ (wrappedStory) => {wrappedStory()}, (wrappedStory) => ( @@ -62,31 +68,31 @@ const loadedPods: PodNodeMetricsRow[] = [ id: '358d96e3-026f-4440-a487-f6c2301884c0', name: 'gke-edge-oblt-pool-1-9a60016d-lgg1', averageCpuUsagePercent: 99, - averageMemoryUsageMegabytes: 34, + averageMemoryUsagePercent: 34, }, { id: '358d96e3-026f-4440-a487-f6c2301884c1', name: 'gke-edge-oblt-pool-1-9a60016d-lgg2', averageCpuUsagePercent: 72, - averageMemoryUsageMegabytes: 68, + averageMemoryUsagePercent: 68, }, { id: '358d96e3-026f-4440-a487-f6c2301884c0', name: 'gke-edge-oblt-pool-1-9a60016d-lgg3', averageCpuUsagePercent: 54, - averageMemoryUsageMegabytes: 132, + averageMemoryUsagePercent: 132, }, { id: '358d96e3-026f-4440-a487-f6c2301884c0', name: 'gke-edge-oblt-pool-1-9a60016d-lgg4', averageCpuUsagePercent: 34, - averageMemoryUsageMegabytes: 264, + averageMemoryUsagePercent: 264, }, { id: '358d96e3-026f-4440-a487-f6c2301884c0', name: 'gke-edge-oblt-pool-1-9a60016d-lgg5', averageCpuUsagePercent: 13, - averageMemoryUsageMegabytes: 512, + averageMemoryUsagePercent: 512, }, ]; @@ -101,7 +107,7 @@ export const Basic = { data: { state: 'data', currentPageIndex: 1, - pageCount: 10, + pageCount: 1, rows: loadedPods, }, }, @@ -170,3 +176,26 @@ export const FailedToLoadMetrics = { }, }, }; + +export const BasicWithSemconv = { + render: Template, + + args: { + data: { + state: 'data', + currentPageIndex: 1, + pageCount: 10, + rows: loadedPods, + }, + isOtel: true, + }, +}; + +export const LoadingWithSemconv = { + render: Template, + + args: { + isLoading: true, + isOtel: true, + }, +}; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx index 607fb7a5f5d5d..9cfd18d335b52 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx @@ -13,6 +13,7 @@ import { createStartServicesAccessorMock, createMetricsClientMock } from '../tes import { createLazyPodMetricsTable } from './create_lazy_pod_metrics_table'; import IntegratedPodMetricsTable from './integrated_pod_metrics_table'; import { PodMetricsTable } from './pod_metrics_table'; +import { ECS_POD_CPU_USAGE_LIMIT_PCT, MEMORY_LIMIT_UTILIZATION } from '../shared/constants'; import { metricByField } from './use_pod_metrics_table'; jest.mock('../../../pages/link_to/use_asset_details_redirect', () => ({ @@ -145,8 +146,8 @@ function createPod( keys: [id, name], rows: [ { - [metricByField['kubernetes.pod.cpu.usage.limit.pct']]: cpuUsagePct, - [metricByField['kubernetes.pod.memory.usage.bytes']]: memoryUsageBytes, + [metricByField[ECS_POD_CPU_USAGE_LIMIT_PCT]]: cpuUsagePct, + [metricByField[MEMORY_LIMIT_UTILIZATION]]: memoryUsageBytes, } as MetricsExplorerSeries['rows'][number], ], }; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx index 152652c91bc96..5741b10665ffc 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx @@ -10,7 +10,7 @@ import type { EuiBasicTableColumn, EuiTableSortingType, } from '@elastic/eui'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import type { SortState, NodeMetricsTableData } from '../shared'; @@ -23,6 +23,10 @@ import { NumberCell, StepwisePagination, } from '../shared'; +import { + SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION, + SEMCONV_K8S_POD_MEMORY_LIMIT_UTILIZATION, +} from '../shared/constants'; import type { PodNodeMetricsRow } from './use_pod_metrics_table'; export interface PodMetricsTableProps { @@ -35,12 +39,26 @@ export interface PodMetricsTableProps { from: string; to: string; }; + isOtel?: boolean; + metricIndices?: string; } export const PodMetricsTable = (props: PodMetricsTableProps) => { - const { data, isLoading, setCurrentPageIndex, setSortState, sortState, timerange } = props; + const { + data, + isLoading, + setCurrentPageIndex, + setSortState, + sortState, + timerange, + isOtel, + metricIndices, + } = props; - const columns = useMemo(() => podNodeColumns(timerange), [timerange]); + const columns = useMemo( + () => podNodeColumns(timerange, isOtel, metricIndices), + [timerange, isOtel, metricIndices] + ); const sorting: EuiTableSortingType = { enableAllColumns: true, @@ -108,7 +126,9 @@ export const PodMetricsTable = (props: PodMetricsTableProps) => { }; function podNodeColumns( - timerange: PodMetricsTableProps['timerange'] + timerange: PodMetricsTableProps['timerange'], + isOtel?: PodMetricsTableProps['isOtel'], + metricIndices?: PodMetricsTableProps['metricIndices'] ): Array> { return [ { @@ -120,16 +140,45 @@ function podNodeColumns( textOnly: true, render: (_, { id, name }) => { return ( - + ); }, }, { - name: i18n.translate( - 'xpack.metricsData.metricsTable.pod.averageCpuUsagePercentColumnHeader', - { - defaultMessage: 'CPU usage (avg.)', - } + name: ( + + + {i18n.translate( + 'xpack.metricsData.metricsTable.pod.averageCpuUsagePercentColumnHeader', + { + defaultMessage: 'CPU usage (avg.)', + } + )} + + {isOtel ? ( + + + + ) : null} + ), field: 'averageCpuUsagePercent', align: 'right', @@ -138,16 +187,38 @@ function podNodeColumns( ), }, { - name: i18n.translate( - 'xpack.metricsData.metricsTable.pod.averageMemoryUsageMegabytesColumnHeader', - { - defaultMessage: 'Memory usage (avg.)', - } + name: ( + + + {i18n.translate( + 'xpack.metricsData.metricsTable.pod.averageMemoryUsagePercentColumnHeader', + { + defaultMessage: 'Memory usage (avg.)', + } + )} + + {isOtel ? ( + + + + ) : null} + ), - field: 'averageMemoryUsageMegabytes', + field: 'averageMemoryUsagePercent', align: 'right', - render: (averageMemoryUsageMegabytes: number) => ( - + render: (averageMemoryUsagePercent: number) => ( + ), }, ]; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.test.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.test.ts index 6c960f93070bc..7ce4b12a4d9e8 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.test.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.test.ts @@ -5,6 +5,11 @@ * 2.0. */ +import { + ECS_POD_CPU_USAGE_LIMIT_PCT, + MEMORY_LIMIT_UTILIZATION, + SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION, +} from '../shared/constants'; import { usePodMetricsTable } from './use_pod_metrics_table'; import { useInfrastructureNodeMetrics } from '../shared'; import { renderHook } from '@testing-library/react'; @@ -27,6 +32,7 @@ describe('usePodMetricsTable hook', () => { useInfrastructureNodeMetricsMock.mockReturnValue({ isLoading: true, data: { state: 'empty-indices' }, + metricIndices: 'test-index', }); renderHook(() => @@ -47,4 +53,70 @@ describe('usePodMetricsTable hook', () => { }) ); }); + + it('should call useInfrastructureNodeMetrics with OTEL/semconv metrics when isOtel is true', () => { + const kuery = 'container.id: "gke-edge-oblt-pool-1-9a60016d-lgg9"'; + + // include this to prevent rendering error in test + useInfrastructureNodeMetricsMock.mockReturnValue({ + isLoading: true, + data: { state: 'empty-indices' }, + metricIndices: 'test-index', + }); + + renderHook(() => + usePodMetricsTable({ + timerange: { from: 'now-30d', to: 'now' }, + kuery, + metricsClient: createMetricsClientMock({}), + isOtel: true, + }) + ); + + expect(useInfrastructureNodeMetricsMock).toHaveBeenCalledWith( + expect.objectContaining({ + metricsExplorerOptions: expect.objectContaining({ + kuery: `event.dataset: "kubeletstatsreceiver.otel" AND (${kuery})`, + metrics: expect.arrayContaining([ + expect.objectContaining({ field: SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION }), + expect.objectContaining({ field: MEMORY_LIMIT_UTILIZATION }), + ]), + }), + }) + ); + }); + + it('should call useInfrastructureNodeMetrics with ECS metrics when isOtel is false', () => { + const kuery = 'container.id: "gke-edge-oblt-pool-1-9a60016d-lgg9"'; + + // include this to prevent rendering error in test + useInfrastructureNodeMetricsMock.mockReturnValue({ + isLoading: true, + data: { state: 'empty-indices' }, + metricIndices: 'test-index', + }); + + renderHook(() => + usePodMetricsTable({ + timerange: { from: 'now-30d', to: 'now' }, + kuery: `(${kuery})`, + metricsClient: createMetricsClientMock({}), + isOtel: false, + }) + ); + + const kueryWithEventDatasetFilter = `event.dataset: "kubernetes.pod" AND (${kuery})`; + + expect(useInfrastructureNodeMetricsMock).toHaveBeenCalledWith( + expect.objectContaining({ + metricsExplorerOptions: expect.objectContaining({ + kuery: kueryWithEventDatasetFilter, + metrics: expect.arrayContaining([ + expect.objectContaining({ field: ECS_POD_CPU_USAGE_LIMIT_PCT }), + expect.objectContaining({ field: MEMORY_LIMIT_UTILIZATION }), + ]), + }), + }) + ); + }); }); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts index d661bb9aece3a..db1b5f6493649 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts @@ -19,20 +19,70 @@ import { scaleUpPercentage, useInfrastructureNodeMetrics, } from '../shared'; +import { + ECS_POD_CPU_USAGE_LIMIT_PCT, + KUBERNETES_NODE_MEMORY_ALLOCATABLE_BYTES, + KUBERNETES_NODE_MEMORY_USAGE_BYTES, + MEMORY_LIMIT_UTILIZATION, + SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION, + SEMCONV_K8S_POD_MEMORY_LIMIT_UTILIZATION, +} from '../shared/constants'; -type PodMetricsField = 'kubernetes.pod.cpu.usage.limit.pct' | 'kubernetes.pod.memory.usage.bytes'; +type PodMetricsField = typeof ECS_POD_CPU_USAGE_LIMIT_PCT | typeof MEMORY_LIMIT_UTILIZATION; const podMetricsQueryConfig: MetricsQueryOptions = { sourceFilter: `event.dataset: "kubernetes.pod"`, groupByField: ['kubernetes.pod.uid', 'kubernetes.pod.name'], metricsMap: { - 'kubernetes.pod.cpu.usage.limit.pct': { + [ECS_POD_CPU_USAGE_LIMIT_PCT]: { aggregation: 'avg', - field: 'kubernetes.pod.cpu.usage.limit.pct', + field: ECS_POD_CPU_USAGE_LIMIT_PCT, + }, + [MEMORY_LIMIT_UTILIZATION]: { + aggregation: 'custom', + field: MEMORY_LIMIT_UTILIZATION, + custom_metrics: [ + { + name: 'A', + aggregation: 'max', + field: KUBERNETES_NODE_MEMORY_ALLOCATABLE_BYTES, + }, + { + name: 'B', + aggregation: 'avg', + field: KUBERNETES_NODE_MEMORY_USAGE_BYTES, + }, + ], + equation: 'B / A', }, - 'kubernetes.pod.memory.usage.bytes': { + }, +}; + +type PodMetricsFieldsOtel = + | typeof SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION + | typeof MEMORY_LIMIT_UTILIZATION; + +const podMetricsQueryConfigOtel: MetricsQueryOptions = { + sourceFilter: 'event.dataset: "kubeletstatsreceiver.otel"', + groupByField: ['k8s.pod.uid', 'k8s.pod.name'], + metricsMap: { + // this is an optional field and wont populate unless specifically enabled in kubeletstatreceiver. + // There are not pod metrics that can derive this value. + [SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION]: { aggregation: 'avg', - field: 'kubernetes.pod.memory.usage.bytes', + field: SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION, // this is an opt-in field. + }, + [MEMORY_LIMIT_UTILIZATION]: { + field: MEMORY_LIMIT_UTILIZATION, + aggregation: 'custom', + custom_metrics: [ + { + name: 'A', + aggregation: 'avg', + field: SEMCONV_K8S_POD_MEMORY_LIMIT_UTILIZATION, + }, + ], + equation: 'A', }, }, }; @@ -44,13 +94,14 @@ export interface PodNodeMetricsRow { id: string; name: string; averageCpuUsagePercent: number | null; - averageMemoryUsageMegabytes: number | null; + averageMemoryUsagePercent: number | null; } export function usePodMetricsTable({ timerange, kuery, metricsClient, + isOtel, }: UseNodeMetricsTableOptions) { const [currentPageIndex, setCurrentPageIndex] = useState(0); const [sortState, setSortState] = useState>({ @@ -63,8 +114,13 @@ export function usePodMetricsTable({ [kuery] ); - const { data, isLoading } = useInfrastructureNodeMetrics({ - metricsExplorerOptions: podMetricsOptions, + const { options: podMetricsOptionsOtel } = useMemo( + () => metricsToApiOptions(podMetricsQueryConfigOtel, kuery), + [kuery] + ); + + const { data, isLoading, metricIndices } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: isOtel ? podMetricsOptionsOtel : podMetricsOptions, timerange, transform: seriesToPodNodeMetricsRow, sortState, @@ -76,6 +132,7 @@ export function usePodMetricsTable({ currentPageIndex, data, isLoading, + metricIndices, setCurrentPageIndex, setSortState, sortState, @@ -101,12 +158,12 @@ function rowWithoutMetrics(id: string, name: string) { id, name, averageCpuUsagePercent: null, - averageMemoryUsageMegabytes: null, + averageMemoryUsagePercent: null, }; } function calculateMetricAverages(rows: MetricsExplorerRow[]) { - const { averageCpuUsagePercentValues, averageMemoryUsageMegabytesValues } = + const { averageCpuUsagePercentValues, averageMemoryUsagePercentValues } = collectMetricValues(rows); let averageCpuUsagePercent = null; @@ -114,44 +171,41 @@ function calculateMetricAverages(rows: MetricsExplorerRow[]) { averageCpuUsagePercent = scaleUpPercentage(averageOfValues(averageCpuUsagePercentValues)); } - let averageMemoryUsageMegabytes = null; - if (averageMemoryUsageMegabytesValues.length !== 0) { - const averageInBytes = averageOfValues(averageMemoryUsageMegabytesValues); - const bytesPerMegabyte = 1000000; - averageMemoryUsageMegabytes = Math.floor(averageInBytes / bytesPerMegabyte); + let averageMemoryUsagePercent = null; + if (averageMemoryUsagePercentValues.length !== 0) { + averageMemoryUsagePercent = scaleUpPercentage(averageOfValues(averageMemoryUsagePercentValues)); } - return { averageCpuUsagePercent, - averageMemoryUsageMegabytes, + averageMemoryUsagePercent, }; } function collectMetricValues(rows: MetricsExplorerRow[]) { const averageCpuUsagePercentValues: number[] = []; - const averageMemoryUsageMegabytesValues: number[] = []; + const averageMemoryUsagePercentValues: number[] = []; rows.forEach((row) => { - const { averageCpuUsagePercent, averageMemoryUsageMegabytes } = unpackMetrics(row); + const { averageCpuUsagePercent, averageMemoryUsagePercent } = unpackMetrics(row); if (averageCpuUsagePercent !== null) { averageCpuUsagePercentValues.push(averageCpuUsagePercent); } - if (averageMemoryUsageMegabytes !== null) { - averageMemoryUsageMegabytesValues.push(averageMemoryUsageMegabytes); + if (averageMemoryUsagePercent !== null) { + averageMemoryUsagePercentValues.push(averageMemoryUsagePercent); } }); return { averageCpuUsagePercentValues, - averageMemoryUsageMegabytesValues, + averageMemoryUsagePercentValues, }; } function unpackMetrics(row: MetricsExplorerRow): Omit { return { - averageCpuUsagePercent: unpackMetric(row, 'kubernetes.pod.cpu.usage.limit.pct'), - averageMemoryUsageMegabytes: unpackMetric(row, 'kubernetes.pod.memory.usage.bytes'), + averageCpuUsagePercent: unpackMetric(row, ECS_POD_CPU_USAGE_LIMIT_PCT), + averageMemoryUsagePercent: unpackMetric(row, MEMORY_LIMIT_UTILIZATION), }; } diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.test.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.test.tsx new file mode 100644 index 0000000000000..0f6c7fc7bc0ef --- /dev/null +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.test.tsx @@ -0,0 +1,289 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { coreMock } from '@kbn/core/public/mocks'; +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { createKibanaContextForPlugin } from '../../../../hooks/use_kibana'; +import { MetricsNodeDetailsLink } from './metrics_node_details_link'; + +const DISCOVER_APP_LOCATOR_ID = 'DISCOVER_APP_LOCATOR'; +const ASSET_HREF = '/app/metrics/link-to/asset-detail'; +const DISCOVER_HREF = '/app/discover#/'; + +const mockGetAssetDetailUrl = jest.fn(() => ({ + href: ASSET_HREF, + onClick: jest.fn(), +})); + +jest.mock('../../../../pages/link_to/use_asset_details_redirect', () => ({ + useAssetDetailsRedirect: () => ({ + getAssetDetailUrl: mockGetAssetDetailUrl, + }), +})); + +function createDiscoverLocatorMock() { + const getRedirectUrl = jest.fn((params: { query?: { esql?: string }; timeRange?: unknown }) => { + return params?.query?.esql ? DISCOVER_HREF : '#'; + }); + const navigate = jest.fn(); + return { getRedirectUrl, navigate }; +} + +function createWrapper(discoverLocator = createDiscoverLocatorMock()) { + const core = coreMock.createStart(); + core.i18n.Context.mockImplementation(I18nProvider as () => JSX.Element); + + const share = { + url: { + locators: { + get: (id: string) => (id === DISCOVER_APP_LOCATOR_ID ? discoverLocator : undefined), + }, + }, + }; + + const services = { ...core, share: share as any }; + const { Provider: KibanaContextProviderForPlugin } = createKibanaContextForPlugin(services); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + }; +} + +const defaultProps = { + id: 'my-container-id', + label: 'my-container', + nodeType: 'container' as const, + timerange: { from: 'now-15m', to: 'now' }, +}; + +describe('MetricsNodeDetailsLink', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when isOtel is true and nodeType is not host', () => { + it('navigates to Discover with ES|QL query for container', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(discoverLocator.getRedirectUrl).toHaveBeenCalledWith( + expect.objectContaining({ + query: { esql: 'TS "metrics-*" | WHERE container.id == "abc-123"' }, + timeRange: { from: 'now-15m', to: 'now' }, + }) + ); + expect(mockGetAssetDetailUrl).not.toHaveBeenCalled(); + }); + + it('navigates to Discover with ES|QL query for pod', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(discoverLocator.getRedirectUrl).toHaveBeenCalledWith( + expect.objectContaining({ + query: { esql: 'TS "metrics-*" | WHERE k8s.pod.uid == "pod-uid-456"' }, + timeRange: { from: 'now-15m', to: 'now' }, + }) + ); + expect(mockGetAssetDetailUrl).not.toHaveBeenCalled(); + }); + + it('uses metricIndices in ES|QL when provided', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(discoverLocator.getRedirectUrl).toHaveBeenCalledWith( + expect.objectContaining({ + query: { esql: 'TS "my-metrics-*" | WHERE container.id == "abc-123"' }, + timeRange: { from: 'now-15m', to: 'now' }, + }) + ); + }); + + it('falls back to metrics-* when metricIndices is not provided', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(discoverLocator.getRedirectUrl).toHaveBeenCalledWith( + expect.objectContaining({ + query: { esql: 'TS "metrics-*" | WHERE container.id == "x"' }, + timeRange: { from: 'now-15m', to: 'now' }, + }) + ); + }); + + it('escapes double quotes in entity id in ES|QL', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(discoverLocator.getRedirectUrl).toHaveBeenCalledWith( + expect.objectContaining({ + query: { + esql: 'TS "metrics-*" | WHERE container.id == "id-with-\\"quote\\""', + }, + timeRange: { from: 'now-15m', to: 'now' }, + }) + ); + }); + }); + + describe('when isOtel is true and nodeType is host', () => { + it('uses asset details redirect, not Discover', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(mockGetAssetDetailUrl).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'host', + entityId: 'host-01', + preferredSchema: 'semconv', + }) + ); + expect(discoverLocator.getRedirectUrl).not.toHaveBeenCalled(); + }); + }); + + describe('when isOtel is false or undefined', () => { + it('uses asset details redirect for container when isOtel is false', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(mockGetAssetDetailUrl).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'container', + entityId: 'abc-123', + preferredSchema: 'ecs', + search: expect.objectContaining({ name: 'my-container' }), + }) + ); + expect(discoverLocator.getRedirectUrl).not.toHaveBeenCalled(); + }); + + it('uses asset details redirect when isOtel is undefined', () => { + const discoverLocator = createDiscoverLocatorMock(); + const Wrapper = createWrapper(discoverLocator); + + render( + , + { wrapper: Wrapper } + ); + + expect(mockGetAssetDetailUrl).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'pod', + entityId: 'pod-1', + }) + ); + expect(discoverLocator.getRedirectUrl).not.toHaveBeenCalled(); + }); + }); + + describe('render', () => { + it('renders link with label', () => { + const Wrapper = createWrapper(); + + render(, { + wrapper: Wrapper, + }); + + const link = screen.getByTestId('infraMetricsNodeDetailsLinkLink'); + expect(link).toHaveTextContent('My Container Label'); + }); + + it('uses Discover href when redirecting to Discover', () => { + const Wrapper = createWrapper(); + + render(, { + wrapper: Wrapper, + }); + + const link = screen.getByRole('link', { name: defaultProps.label }); + expect(link).toHaveAttribute('href', DISCOVER_HREF); + }); + + it('uses asset details href when not redirecting to Discover', () => { + const Wrapper = createWrapper(); + + render(, { wrapper: Wrapper }); + + const link = screen.getByRole('link', { name: defaultProps.label }); + expect(link).toHaveAttribute('href', ASSET_HREF); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx index 351b86c9968d1..cef39fe69d921 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx @@ -5,19 +5,47 @@ * 2.0. */ +import React, { useMemo } from 'react'; import { parse } from '@kbn/datemath'; import { EuiLink } from '@elastic/eui'; -import React from 'react'; +import { type ComposerQuery, esql } from '@kbn/esql-language'; import type { InventoryItemType } from '../../../../../common/inventory_models/types'; +import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; import { useAssetDetailsRedirect } from '../../../../pages/link_to/use_asset_details_redirect'; +const DISCOVER_APP_LOCATOR_ID = 'DISCOVER_APP_LOCATOR'; + +/** Fallback index pattern when infrastructure metric indices are not available. */ +const DEFAULT_METRICS_INDEX = 'metrics-*'; + type ExtractStrict = Extract; +type NodeTypeForLink = ExtractStrict; interface MetricsNodeDetailsLinkProps { id: string; label: string; - nodeType: ExtractStrict; + nodeType: NodeTypeForLink; timerange: { from: string; to: string }; + isOtel?: boolean; + /** Infrastructure/metrics index pattern from settings; used for Discover ES|QL when isOtel is true. */ + metricsIndices?: string; +} + +/** Build an ES|QL query that filters by the given node type and entity id. */ +function getDiscoverEsqlQueryForNode( + nodeType: NodeTypeForLink, + entityId: string, + indexPattern: string +): ComposerQuery { + const from = indexPattern || DEFAULT_METRICS_INDEX; + switch (nodeType) { + case 'container': + return esql`TS ${from} | WHERE container.id == ${entityId}`; + case 'pod': + return esql`TS ${from} | WHERE k8s.pod.uid == ${entityId}`; + default: + return esql`TS ${from}`; + } } export const MetricsNodeDetailsLink = ({ @@ -25,20 +53,71 @@ export const MetricsNodeDetailsLink = ({ label, nodeType, timerange, + isOtel, + metricsIndices, }: MetricsNodeDetailsLinkProps) => { + const { share } = useKibanaContextForPlugin().services; const { getAssetDetailUrl } = useAssetDetailsRedirect(); - const linkProps = getAssetDetailUrl({ - entityType: nodeType, - entityId: id, - search: { - name: label, - from: parse(timerange.from)?.valueOf(), - to: parse(timerange.to)?.valueOf(), - }, - }); + + const redirectToDiscover = isOtel && nodeType !== 'host'; + + const linkProps = useMemo(() => { + if (redirectToDiscover) { + const discoverLocator = share?.url?.locators?.get(DISCOVER_APP_LOCATOR_ID); + const esqlQuery = getDiscoverEsqlQueryForNode( + nodeType, + id, + metricsIndices ?? DEFAULT_METRICS_INDEX + ); + const discoverParams = { + timeRange: { from: timerange.from, to: timerange.to }, + query: { + esql: esqlQuery.toRequest().query, + }, + }; + const href = discoverLocator?.getRedirectUrl(discoverParams) ?? '#'; + return { + href, + onClick: (e: React.MouseEvent) => { + if (discoverLocator) { + e.preventDefault(); + discoverLocator.navigate(discoverParams); + } + }, + }; + } + + const assetDetails = getAssetDetailUrl({ + entityType: nodeType, + entityId: id, + search: { + name: label, + from: parse(timerange.from)?.valueOf(), + to: parse(timerange.to)?.valueOf(), + }, + preferredSchema: isOtel ? 'semconv' : 'ecs', + }); + return { href: assetDetails.href, onClick: assetDetails.onClick }; + }, [ + redirectToDiscover, + share?.url?.locators, + timerange.from, + timerange.to, + nodeType, + id, + label, + isOtel, + metricsIndices, + getAssetDetailUrl, + ]); return ( - + // eslint-disable-next-line @elastic/eui/href-or-on-click -- onClick for programmatic navigation; href for "Open in new tab" and fallback + {label} ); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/constants.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/constants.ts new file mode 100644 index 0000000000000..216311342bc79 --- /dev/null +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/constants.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// --- Container metric field names --- + +/** ECS (Elastic Common Schema) container metric field names */ +export const ECS_CONTAINER_CPU_USAGE_LIMIT_PCT = 'kubernetes.container.cpu.usage.limit.pct'; +export const ECS_CONTAINER_MEMORY_USAGE_BYTES = 'kubernetes.container.memory.usage.bytes'; + +/** SemConv K8s (Kubernetes container) metric field names */ +export const SEMCONV_K8S_CONTAINER_CPU_LIMIT_UTILIZATION = + 'metrics.k8s.container.cpu_limit_utilization'; +export const SEMCONV_K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION = + 'metrics.k8s.container.memory_limit_utilization'; + +/** SemConv container metric names used in UI tooltips (generic / display) */ +export const SEMCONV_DOCKER_CONTAINER_CPU_UTILIZATION = 'metrics.container.cpu.utilization'; +export const SEMCONV_DOCKER_CONTAINER_MEMORY_PERCENT = 'metrics.container.memory.percent'; + +// --- Host metric field names --- + +/** ECS / system host metric field names */ +export const SYSTEM_CPU_CORES = 'system.cpu.cores'; +export const SYSTEM_CPU_TOTAL_NORM_PCT = 'system.cpu.total.norm.pct'; +export const SYSTEM_MEMORY_TOTAL = 'system.memory.total'; +export const SYSTEM_MEMORY_USED_PCT = 'system.memory.used.pct'; + +/** SemConv (OpenTelemetry) host metric field names */ +export const SEMCONV_SYSTEM_CPU_LOGICAL_COUNT = 'metrics.system.cpu.logical.count'; +export const SEMCONV_SYSTEM_CPU_UTILIZATION = 'metrics.system.cpu.utilization'; +export const SEMCONV_SYSTEM_MEMORY_LIMIT = 'metrics.system.memory.limit'; +export const SEMCONV_SYSTEM_MEMORY_UTILIZATION = 'metrics.system.memory.utilization'; + +// --- Pod metric field names --- + +/** ECS (Elastic Common Schema) pod metric field names */ +export const ECS_POD_CPU_USAGE_LIMIT_PCT = 'kubernetes.pod.cpu.usage.limit.pct'; + +/** Derived/custom metric name used in both ECS and SemConv */ +export const MEMORY_LIMIT_UTILIZATION = 'memory_limit_utilization'; + +/** ECS custom metric sub-fields (node memory for equation) */ +export const KUBERNETES_NODE_MEMORY_ALLOCATABLE_BYTES = 'kubernetes.node.memory.allocatable.bytes'; +export const KUBERNETES_NODE_MEMORY_USAGE_BYTES = 'kubernetes.node.memory.usage.bytes'; + +/** SemConv K8s pod metric field names */ +export const SEMCONV_K8S_POD_CPU_LIMIT_UTILIZATION = 'metrics.k8s.pod.cpu_limit_utilization'; +export const SEMCONV_K8S_POD_MEMORY_LIMIT_UTILIZATION = 'metrics.k8s.pod.memory_limit_utilization'; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts index 3e90068338bde..5298fe7bb5d47 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts @@ -88,7 +88,7 @@ export function metricsToApiOptions( } function buildFilterKuery(sourceFilter: string, kuery?: string): string { - return !!kuery ? `${sourceFilter} AND (${kuery})` : sourceFilter; + return !!kuery ? `${sourceFilter ? `${sourceFilter} AND ` : ''}(${kuery})` : sourceFilter; } export function createMetricByFieldLookup(metricMap: MetricsMap) { diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts index 771b6bb854b73..05e715c91c5ae 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts @@ -177,6 +177,7 @@ export const useInfrastructureNodeMetrics = ( return { isLoading, data, + metricIndices, }; }; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/types.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/types.ts index 6974c3ccc3955..c46c343f241c6 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/types.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/components/infrastructure_node_metrics_tables/shared/types.ts @@ -12,6 +12,7 @@ export interface UseNodeMetricsTableOptions { timerange: { from: string; to: string }; kuery?: string; metricsClient: MetricsDataClient; + isOtel?: boolean; } export interface SourceProviderProps { diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/public/types.ts b/x-pack/solutions/observability/plugins/metrics_data_access/public/types.ts index dad9df317c4c0..a412669259335 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/public/types.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/public/types.ts @@ -8,7 +8,6 @@ import type { Search } from 'history'; import type { Plugin as PluginClass } from '@kbn/core/public'; import type { MetricsDataClient } from './lib/metrics_client'; import type { NodeMetricsTableProps } from './components/infrastructure_node_metrics_tables/shared'; - export interface MetricsDataPluginSetup { metricsClient: MetricsDataClient; } @@ -17,7 +16,9 @@ export interface MetricsDataPluginStart { metricsClient: MetricsDataClient; HostMetricsTable: (props: NodeMetricsTableProps) => JSX.Element; PodMetricsTable: (props: NodeMetricsTableProps) => JSX.Element; - ContainerMetricsTable: (props: NodeMetricsTableProps) => JSX.Element; + ContainerMetricsTable: ( + props: NodeMetricsTableProps & { isK8sContainer?: boolean } + ) => JSX.Element; } export type MetricsDataPluginClass = PluginClass; diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/server/routes/metrics_explorer/index.ts b/x-pack/solutions/observability/plugins/metrics_data_access/server/routes/metrics_explorer/index.ts index 04eb4739369ad..d7a58478e8c25 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/server/routes/metrics_explorer/index.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/server/routes/metrics_explorer/index.ts @@ -23,6 +23,7 @@ import type { KibanaFramework } from '../../lib/adapters/framework/kibana_framew export const initMetricExplorerRoute = (framework: KibanaFramework) => { const validateBody = createRouteValidationFunction(metricsExplorerRequestBodyRT); + framework.registerRoute( { method: 'post', diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/tsconfig.json b/x-pack/solutions/observability/plugins/metrics_data_access/tsconfig.json index 84e8713c51b58..40aa174b3ed91 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/tsconfig.json +++ b/x-pack/solutions/observability/plugins/metrics_data_access/tsconfig.json @@ -37,6 +37,7 @@ "@kbn/react-kibana-context-theme", "@kbn/router-utils", "@kbn/chart-expressions-common", - "@kbn/observability-utils-common" + "@kbn/observability-utils-common", + "@kbn/esql-language" ] } diff --git a/x-pack/solutions/observability/plugins/observability/public/navigation_tree.ts b/x-pack/solutions/observability/plugins/observability/public/navigation_tree.ts index 813c103cac142..c7ab3b3b792b2 100644 --- a/x-pack/solutions/observability/plugins/observability/public/navigation_tree.ts +++ b/x-pack/solutions/observability/plugins/observability/public/navigation_tree.ts @@ -14,7 +14,6 @@ import { AIChatExperience } from '@kbn/ai-assistant-common'; import { AI_CHAT_EXPERIENCE_TYPE } from '@kbn/management-settings-ids'; import type { Location } from 'history'; import type { ObservabilityPublicPluginsStart } from './plugin'; - const title = i18n.translate( 'xpack.observability.obltNav.headerSolutionSwitcher.obltSolutionTitle', { diff --git a/x-pack/solutions/observability/plugins/observability/server/lib/esql_extensions/set_esql_recommended_queries.ts b/x-pack/solutions/observability/plugins/observability/server/lib/esql_extensions/set_esql_recommended_queries.ts index 160935ba46775..8dcd70ba06bb3 100644 --- a/x-pack/solutions/observability/plugins/observability/server/lib/esql_extensions/set_esql_recommended_queries.ts +++ b/x-pack/solutions/observability/plugins/observability/server/lib/esql_extensions/set_esql_recommended_queries.ts @@ -108,14 +108,33 @@ const LOGS_AND_METRICS_ESQL_RECOMMENDED_QUERIES = [ }, ]; +const SEARCH_ALL_METRICS_ESQL_RECOMMENDED_QUERY = { + name: i18n.translate('xpack.observability.esqlQueries.searchAllMetrics.name', { + defaultMessage: 'Search all metrics', + }), + query: `TS ${METRICS_INDEX_PATTERN}`, + description: i18n.translate('xpack.observability.esqlQueries.searchAllMetrics.description', { + defaultMessage: 'Searches all available metrics', + }), +}; + export function setEsqlRecommendedQueries(esqlPlugin: ESQLSetup) { const esqlExtensionsRegistry = esqlPlugin.getExtensionsRegistry(); + const observabilityRecommendedQueries = [ + ...TRACES_ESQL_RECOMMENDED_QUERIES, + ...LOGS_AND_METRICS_ESQL_RECOMMENDED_QUERIES, + SEARCH_ALL_METRICS_ESQL_RECOMMENDED_QUERY, + ]; + + // Register full observability-specific recommendations for observability solution view. + esqlExtensionsRegistry.setRecommendedQueries(observabilityRecommendedQueries, 'oblt'); - // Register recommended queries + // Register only the "Search all metrics" recommendation for security and search solution views. esqlExtensionsRegistry.setRecommendedQueries( - [...TRACES_ESQL_RECOMMENDED_QUERIES, ...LOGS_AND_METRICS_ESQL_RECOMMENDED_QUERIES], - 'oblt' + [SEARCH_ALL_METRICS_ESQL_RECOMMENDED_QUERY], + 'security' ); + esqlExtensionsRegistry.setRecommendedQueries([SEARCH_ALL_METRICS_ESQL_RECOMMENDED_QUERY], 'es'); // Register recommended fields esqlExtensionsRegistry.setRecommendedFields(ALL_RECOMMENDED_FIELDS_FOR_ESQL, 'oblt'); diff --git a/x-pack/solutions/observability/plugins/observability/test/scout/.meta/ui/parallel.json b/x-pack/solutions/observability/plugins/observability/test/scout/.meta/ui/parallel.json index f23f0d10f00b6..d34abc786f7d3 100644 --- a/x-pack/solutions/observability/plugins/observability/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/observability/plugins/observability/test/scout/.meta/ui/parallel.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-06T14:35:32.876Z", - "sha1": "b1913fd60fafdaaba55379bc242987d75b64aa6c", + "sha1": "29d7b2f68e770aba1831cbed98c558f36f6f0fa8", "tests": [ { "id": "f3ddbbb53c28ebe-f2cd2d3cdc77643", @@ -21,7 +20,7 @@ { "id": "a9b59e66421b951-c63c54ed2add824", "title": "Alert Details Page should show an error when the alert does not exist", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -30,14 +29,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 64, + "line": 65, "column": 9 } }, { "id": "a9b59e66421b951-d4dadee02e92d57", "title": "Alert Details Page should show a tabbed view", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -46,14 +45,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 69, + "line": 70, "column": 9 } }, { "id": "a9b59e66421b951-dd4dba6c9c632ce", "title": "Alert Details Page should show a Threshold Alert Overview section", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -62,14 +61,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 80, + "line": 81, "column": 9 } }, { "id": "a9b59e66421b951-aef937b4a8d10ab", "title": "Alert Details Page should show an Alerts History section", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -78,14 +77,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 87, + "line": 88, "column": 9 } }, { "id": "a9b59e66421b951-56bb30050edffc8", "title": "Alert Details Page should show Metadata tab panel when Metadata tab is clicked", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -94,14 +93,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 94, + "line": 95, "column": 9 } }, { "id": "a9b59e66421b951-8d6ba1a0e3b98d2", "title": "Alert Details Page should show an empty prompt in the Investigation Guide tab when no guide is set", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -110,14 +109,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 105, + "line": 106, "column": 9 } }, { "id": "a9b59e66421b951-c97a3632a71b56d", "title": "Alert Details Page should show a Related Alerts table when Related Alerts tab is clicked", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -126,14 +125,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 116, + "line": 117, "column": 9 } }, { "id": "a9b59e66421b951-bcfc6f52ba8e770", "title": "Alert Details Page should show a Related Dashboard component when Related Dashboards tab is clicked", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -142,7 +141,7 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/alert_details_page.spec.ts", - "line": 127, + "line": 128, "column": 9 } }, @@ -245,7 +244,7 @@ { "id": "d727dcfffd97d68-e91ca78d2096478", "title": "Custom Threshold Rule - Ad-hoc Data View should create a custom threshold rule with an ad-hoc data view", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -254,14 +253,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/custom_threshold_rule/ad_hoc_data_view.spec.ts", - "line": 56, + "line": 57, "column": 9 } }, { "id": "19ff7375d931f4e-920679a76f69971", "title": "Custom threshold preview chart should render the empty chart only once at bootstrap", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -270,14 +269,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/custom_threshold_rule/custom_threshold_preview_chart.spec.ts", - "line": 27, + "line": 28, "column": 9 } }, { "id": "19ff7375d931f4e-30615e975f2f139", "title": "Custom threshold preview chart should handle the error message correctly", - "expectedStatus": "passed", + "expectedStatus": "skipped", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -286,7 +285,7 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/custom_threshold_rule/custom_threshold_preview_chart.spec.ts", - "line": 32, + "line": 33, "column": 9 } }, @@ -309,7 +308,7 @@ { "id": "06878172d9c56e6-ece821f6c3bbd60", "title": "Rule Details Page - Admin should navigate from rules table to rule details and display page correctly", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -318,14 +317,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 34, + "line": 45, "column": 9 } }, { "id": "06878172d9c56e6-e5a9cc15ae46b2a", "title": "Rule Details Page - Admin should load rule details page directly by URL", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -334,14 +333,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 61, + "line": 72, "column": 9 } }, { "id": "06878172d9c56e6-a10166571f2daf4", "title": "Rule Details Page - Admin should display alert summary widget on the page", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -350,14 +349,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 70, + "line": 81, "column": 9 } }, { "id": "06878172d9c56e6-daabe00867abf81", "title": "Rule Details Page - Admin should navigate to alerts tab with active filter when clicking active alerts", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -366,14 +365,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 78, + "line": 89, "column": 9 } }, { "id": "06878172d9c56e6-553570ada722d2a", "title": "Rule Details Page - Admin should navigate to alerts tab with all statuses when clicking total alerts", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -382,14 +381,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 96, + "line": 107, "column": 9 } }, { "id": "06878172d9c56e6-b977afb55e35f29", "title": "Rule Details Page - Admin should show edit and delete actions for admin user", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -398,14 +397,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 115, + "line": 126, "column": 9 } }, { "id": "06878172d9c56e6-cce1d74e8b769f6", "title": "Rule Details Page - Admin should close actions popover when clicking actions button again", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -414,14 +413,14 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 131, + "line": 142, "column": 9 } }, { "id": "06878172d9c56e6-c7422a09250b93c", "title": "Rule Details Page - Admin should display dashboard options in related dashboards dropdown when editing rule", - "expectedStatus": "skipped", + "expectedStatus": "passed", "tags": [ "@local-stateful-classic", "@cloud-stateful-classic", @@ -430,7 +429,7 @@ ], "location": { "file": "x-pack/solutions/observability/plugins/observability/test/scout/ui/parallel_tests/rules/rule_details_page/rule_details_page.admin.spec.ts", - "line": 151, + "line": 162, "column": 9 } }, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/common/constants.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/common/constants.ts index 737de9d268d39..4c74f1b9854f6 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/common/constants.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/common/constants.ts @@ -13,3 +13,5 @@ export const OBSERVABILITY_AI_INSIGHT_ATTACHMENT_TYPE_ID = 'observability.ai_ins export const OBSERVABILITY_ERROR_ATTACHMENT_TYPE_ID = 'observability.error'; export const OBSERVABILITY_ALERT_ATTACHMENT_TYPE_ID = 'observability.alert'; export const OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID = 'observability.log'; +export const OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID = 'observability.service'; +export const OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID = 'observability.slo'; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/common/index.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/common/index.ts index 34f6df7ee33ec..b395c7c04342c 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/common/index.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/common/index.ts @@ -11,4 +11,8 @@ export { OBSERVABILITY_ALERT_ATTACHMENT_TYPE_ID, OBSERVABILITY_ERROR_ATTACHMENT_TYPE_ID, OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, } from './constants'; + +export type { ConnectorInfo } from './types'; diff --git a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/index.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/common/types.ts similarity index 61% rename from x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/index.ts rename to x-pack/solutions/observability/plugins/observability_agent_builder/common/types.ts index 9215f067fc851..746f5d0fa3058 100644 --- a/x-pack/platform/plugins/shared/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/index.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/common/types.ts @@ -5,5 +5,11 @@ * 2.0. */ -export type { DashboardDrilldownCollectConfigProps } from './collect_config_container'; -export { CollectConfigContainer } from './collect_config_container'; +export interface ConnectorInfo { + connectorId: string; + name: string; + type: string; + modelFamily: string; + modelProvider: string; + modelId: string; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml b/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml index 3765cb00aba1d..15e8ee0134428 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml @@ -57,7 +57,9 @@ dependsOn: - '@kbn/sse-utils-server' - '@kbn/kibana-utils-plugin' - '@kbn/server-route-repository-client' + - '@kbn/core-analytics-browser' - '@kbn/apm-types' + - '@kbn/slo-schema' - '@kbn/apm-types-shared' - '@kbn/observability-utils-server' tags: diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/index.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/index.ts new file mode 100644 index 0000000000000..7a266cf89f99f --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser'; +import { + insightFailedEventSchema, + insightFeedbackEventSchema, + insightResponseGeneratedEventSchema, +} from './schemas/ai_insight_events'; +import type { + InsightFailedEvent, + InsightFeedbackEvent, + InsightResponseGeneratedEvent, +} from './schemas/ai_insight_events'; +import type { ObservabilityAgentBuilderTelemetryEventType } from './telemetry_event_type'; + +export const registerTelemetryEventTypes = (analytics: AnalyticsServiceSetup) => { + analytics.registerEventType(insightResponseGeneratedEventSchema); + analytics.registerEventType(insightFailedEventSchema); + analytics.registerEventType(insightFeedbackEventSchema); +}; + +export type TelemetryEvent = + | { + type: ObservabilityAgentBuilderTelemetryEventType.AiInsightResponseGenerated; + payload: InsightResponseGeneratedEvent; + } + | { + type: ObservabilityAgentBuilderTelemetryEventType.AiInsightFailed; + payload: InsightFailedEvent; + } + | { + type: ObservabilityAgentBuilderTelemetryEventType.AiInsightFeedback; + payload: InsightFeedbackEvent; + }; + +export function reportTelemetryEvent( + analytics: AnalyticsServiceStart, + event: TelemetryEvent +): void { + try { + analytics.reportEvent(event.type, event.payload); + } catch { + // do nothing + } +} + +export { ObservabilityAgentBuilderTelemetryEventType } from './telemetry_event_type'; +export type { + ConnectorInfo, + InsightFailedEvent, + InsightFeedbackEvent, + InsightResponseGeneratedEvent, + InsightType, +} from './schemas/ai_insight_events'; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/schemas/ai_insight_events.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/schemas/ai_insight_events.ts new file mode 100644 index 0000000000000..658c7772144fd --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/schemas/ai_insight_events.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { EventTypeOpts } from '@kbn/core/public'; +import type { ConnectorInfo } from '../../../common'; +import type { Feedback } from '../../components/ai_insight/feedback_buttons'; +import { ObservabilityAgentBuilderTelemetryEventType } from '../telemetry_event_type'; + +export type InsightType = 'log' | 'alert' | 'error'; + +export type { ConnectorInfo }; + +export interface InsightResponseGeneratedEvent { + insightType: InsightType; + connector: ConnectorInfo; +} + +export interface InsightFeedbackEvent { + insightType: InsightType; + feedback: Feedback; + connector: ConnectorInfo; +} + +export interface InsightFailedEvent { + insightType: InsightType; + errorMessage: string; + connector?: ConnectorInfo; +} + +const insightTypeSchema = { + type: 'keyword' as const, + _meta: { + description: 'Type of AI insight: log, alert, or error', + }, +}; + +const connectorSchema = { + properties: { + connectorId: { + type: 'keyword' as const, + _meta: { + description: 'The ID of the connector used', + }, + }, + name: { + type: 'keyword' as const, + _meta: { + description: 'The name of the connector used', + }, + }, + type: { + type: 'keyword' as const, + _meta: { + description: 'The action type of the connector used', + }, + }, + modelFamily: { + type: 'keyword' as const, + _meta: { + description: 'The model family of the connector used', + }, + }, + modelProvider: { + type: 'keyword' as const, + _meta: { + description: 'The model provider of the connector used', + }, + }, + modelId: { + type: 'keyword' as const, + _meta: { + description: 'The specific model ID of the connector used', + }, + }, + }, + _meta: { + description: 'Information about the connector used for the insight', + }, +}; + +export const insightResponseGeneratedEventSchema: EventTypeOpts = { + eventType: ObservabilityAgentBuilderTelemetryEventType.AiInsightResponseGenerated, + schema: { + insightType: insightTypeSchema, + connector: connectorSchema, + }, +}; + +export const insightFailedEventSchema: EventTypeOpts = { + eventType: ObservabilityAgentBuilderTelemetryEventType.AiInsightFailed, + schema: { + insightType: insightTypeSchema, + errorMessage: { + type: 'text', + _meta: { + description: 'The error message from the failed insight generation', + }, + }, + connector: { + ...connectorSchema, + _meta: { + ...connectorSchema._meta, + optional: true, + }, + }, + }, +}; + +export const insightFeedbackEventSchema: EventTypeOpts = { + eventType: ObservabilityAgentBuilderTelemetryEventType.AiInsightFeedback, + schema: { + insightType: insightTypeSchema, + feedback: { + type: 'keyword', + _meta: { + description: 'Whether the user found the insight helpful: positive or negative', + }, + }, + connector: connectorSchema, + }, +}; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/telemetry_event_type.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/telemetry_event_type.ts new file mode 100644 index 0000000000000..0ad29f41f7fe7 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/analytics/telemetry_event_type.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum ObservabilityAgentBuilderTelemetryEventType { + AiInsightResponseGenerated = 'observability_agent_builder_ai_insight_response_generated', + AiInsightFailed = 'observability_agent_builder_ai_insight_failed', + AiInsightFeedback = 'observability_agent_builder_ai_insight_feedback', +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.test.ts index c2d526a00d974..3e26f108055b4 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.test.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.test.ts @@ -11,6 +11,8 @@ import { OBSERVABILITY_ALERT_ATTACHMENT_TYPE_ID, OBSERVABILITY_ERROR_ATTACHMENT_TYPE_ID, OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, } from '../../common/constants'; import { registerAttachmentUiDefinitions } from '.'; @@ -24,10 +26,10 @@ describe('registerAttachmentUiDefinitions', () => { jest.clearAllMocks(); }); - it('registers all four attachment types', () => { + it('registers all six attachment types', () => { registerAttachmentUiDefinitions({ attachments: mockAttachments }); - expect(mockAddAttachmentType).toHaveBeenCalledTimes(4); + expect(mockAddAttachmentType).toHaveBeenCalledTimes(6); }); it('registers AI Insight attachment type with correct config', () => { @@ -129,4 +131,30 @@ describe('registerAttachmentUiDefinitions', () => { }; expect(config.getLabel(attachment)).toBe('Observability alert'); }); + + it('registers SLO attachment type with correct config', () => { + registerAttachmentUiDefinitions({ attachments: mockAttachments }); + + const sloCall = mockAddAttachmentType.mock.calls.find( + (call) => call[0] === OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID + ); + expect(sloCall).toBeDefined(); + + const config = sloCall![1]; + expect(config.getIcon()).toBe('chartGauge'); + expect(config.getLabel({ id: 'test', type: 'test', data: {} })).toBe('SLO'); + }); + + it('registers service attachment type with correct config', () => { + registerAttachmentUiDefinitions({ attachments: mockAttachments }); + + const serviceCall = mockAddAttachmentType.mock.calls.find( + (call) => call[0] === OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID + ); + expect(serviceCall).toBeDefined(); + + const config = serviceCall![1]; + expect(config.getIcon()).toBe('gear'); + expect(config.getLabel({ id: 'test', type: 'test', data: {} })).toBe('Service'); + }); }); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.ts index a84292830471a..2c1f6740115d3 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/attachment_types/index.ts @@ -13,6 +13,8 @@ import { OBSERVABILITY_ALERT_ATTACHMENT_TYPE_ID, OBSERVABILITY_ERROR_ATTACHMENT_TYPE_ID, OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, } from '../../common/constants'; type UnknownAttachmentWithLabel = Attachment< @@ -55,6 +57,20 @@ const ATTACHMENT_TYPE_CONFIGS: AttachmentTypeConfig[] = [ }), icon: 'logPatternAnalysis', }, + { + type: OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, + label: i18n.translate('xpack.observabilityAgentBuilder.attachments.slo.label', { + defaultMessage: 'SLO', + }), + icon: 'chartGauge', + }, + { + type: OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, + label: i18n.translate('xpack.observabilityAgentBuilder.attachments.service.label', { + defaultMessage: 'Service', + }), + icon: 'gear', + }, ]; const createAttachmentTypeConfig = (defaultLabel: string, icon: string) => ({ diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.test.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.test.tsx index d276bfcb3add8..6bef1dc31fc1f 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.test.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.test.tsx @@ -34,24 +34,24 @@ const mockUseStreamingAiInsight = useStreamingAiInsight as jest.Mock; const mockCreateStream = jest.fn(); const AiInsightTest = AiInsight as React.ComponentType; -const renderComponent = () => - render( - - - - ); - const mockOpenConversationFlyout = jest.fn(); +const mockReportEvent = jest.fn(); + +const mockConnectorInfo = { + connectorId: 'connector-1', + name: 'Test Connector', + type: '.gen-ai', + modelFamily: 'GPT', + modelProvider: 'OpenAI', + modelId: 'gpt-4', +}; const baseStreamingState = () => ({ isLoading: false, error: undefined as string | undefined, summary: '', context: '', + connectorInfo: mockConnectorInfo, wasStopped: false, fetch: jest.fn(), stop: jest.fn(), @@ -63,6 +63,25 @@ const createStreamingState = (overrides: Partial> = {}) => + render( + + + + ); + +const openAccordion = (container: HTMLElement) => { + const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); + expect(toggle).toBeTruthy(); + fireEvent.click(toggle!); +}; + describe('AiInsight', () => { beforeEach(() => { jest.clearAllMocks(); @@ -78,6 +97,17 @@ describe('AiInsight', () => { agentBuilder: { show: true }, }, }, + analytics: { + reportEvent: mockReportEvent, + }, + notifications: { + feedback: { + isEnabled: jest.fn().mockReturnValue(true), + }, + toasts: { + addSuccess: jest.fn(), + }, + }, }, }); mockUseLicense.mockReturnValue({ @@ -96,44 +126,41 @@ describe('AiInsight', () => { mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ fetch })); const { container, unmount } = renderComponent(); - const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); - - expect(toggle).toBeTruthy(); - fireEvent.click(toggle!); + openAccordion(container); expect(fetch).toHaveBeenCalledTimes(1); unmount(); }); describe('when an error occurs', () => { - it('displays an error banner with error message', () => { - mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ error: 'Boom' })); + const errorMessage = 'Something went wrong'; - const { container, getByText, unmount } = renderComponent(); - const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); - fireEvent.click(toggle!); + beforeEach(() => { + mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ error: errorMessage })); + }); - const errorBanner = container.querySelector('[data-test-subj="AiInsightErrorBanner"]'); - expect(errorBanner).toBeTruthy(); + it('displays an error banner with error message', () => { + const { container, getByText, unmount } = renderComponent(); + openAccordion(container); + expect(container.querySelector('[data-test-subj="AiInsightErrorBanner"]')).toBeTruthy(); expect(getByText('Failed to generate AI insight')).toBeTruthy(); - expect(getByText('The AI insight could not be generated: Boom')).toBeTruthy(); - - const retryButton = container.querySelector( - '[data-test-subj="AiInsightErrorBannerRetryButton"]' - ); - expect(retryButton).toBeTruthy(); + expect(getByText(`The AI insight could not be generated: ${errorMessage}`)).toBeTruthy(); + expect( + container.querySelector('[data-test-subj="AiInsightErrorBannerRetryButton"]') + ).toBeTruthy(); unmount(); }); it('refetches insights when retry button is clicked', () => { const fetch = jest.fn(); - mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ error: 'Boom', fetch })); + mockUseStreamingAiInsight.mockReturnValue( + createStreamingState({ error: errorMessage, fetch }) + ); const { container, unmount } = renderComponent(); - const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); - fireEvent.click(toggle!); + openAccordion(container); const retryButton = container.querySelector( '[data-test-subj="AiInsightErrorBannerRetryButton"]' @@ -141,26 +168,38 @@ describe('AiInsight', () => { fireEvent.click(retryButton!); expect(fetch).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('reports AiInsightFailed telemetry event', () => { + const { container, unmount } = renderComponent(); + openAccordion(container); + + expect(mockReportEvent).toHaveBeenCalledWith( + 'observability_agent_builder_ai_insight_failed', + { insightType: 'log', errorMessage, connector: mockConnectorInfo } + ); unmount(); }); }); describe('when a summary has been generated', () => { - it('displays start conversation button', () => { + beforeEach(() => { mockUseStreamingAiInsight.mockReturnValue( - createStreamingState({ summary: 'Hello world', context: 'context' }) + createStreamingState({ summary: 'Generated insight', context: 'context' }) ); + }); + it('displays start conversation button', () => { const { container, unmount } = renderComponent(); - const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); - fireEvent.click(toggle!); - - const startConversationButton = container.querySelector( - '[data-test-subj="aiAgentStartConversationButton"]' - ); + openAccordion(container); - expect(startConversationButton).toBeTruthy(); + expect( + container.querySelector( + '[data-test-subj="observabilityAgentBuilderLogStartConversationButton"]' + ) + ).toBeTruthy(); unmount(); }); @@ -171,21 +210,11 @@ describe('AiInsight', () => { createStreamingState({ summary: 'Hello world', context: 'context' }) ); - const { container, unmount } = render( - - - - ); - - const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); - fireEvent.click(toggle!); + const { container, unmount } = renderComponent({ buildAttachments }); + openAccordion(container); const startConversationButton = container.querySelector( - '[data-test-subj="aiAgentStartConversationButton"]' + '[data-test-subj="observabilityAgentBuilderLogStartConversationButton"]' ); fireEvent.click(startConversationButton!); @@ -198,6 +227,41 @@ describe('AiInsight', () => { unmount(); }); + + it('reports AiInsightResponseGenerated telemetry event', () => { + const { container, unmount } = renderComponent(); + openAccordion(container); + + expect(mockReportEvent).toHaveBeenCalledWith( + 'observability_agent_builder_ai_insight_response_generated', + { insightType: 'log', connector: mockConnectorInfo } + ); + + unmount(); + }); + + it.each([ + ['positive', 'observabilityAgentBuilderFeedbackPositiveButton'], + ['negative', 'observabilityAgentBuilderFeedbackNegativeButton'], + ] as const)( + 'reports AiInsightFeedback telemetry event for %s feedback', + (feedback, testSubj) => { + const { container, unmount } = renderComponent(); + openAccordion(container); + + mockReportEvent.mockClear(); + + const feedbackButton = container.querySelector(`[data-test-subj="${testSubj}"]`); + fireEvent.click(feedbackButton!); + + expect(mockReportEvent).toHaveBeenCalledWith( + 'observability_agent_builder_ai_insight_feedback', + { insightType: 'log', feedback, connector: mockConnectorInfo } + ); + + unmount(); + } + ); }); it('shows regenerate button after stream is stopped', () => { @@ -207,9 +271,7 @@ describe('AiInsight', () => { ); const { container, unmount } = renderComponent(); - - const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); - fireEvent.click(toggle!); + openAccordion(container); const regenerateButton = container.querySelector( '[data-test-subj="observabilityAgentBuilderRegenerateButton"]' diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx index c1a9f150e092b..f75077411398d 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { EuiIcon, EuiAccordion, @@ -34,7 +34,14 @@ import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; import { StartConversationButton } from './start_conversation_button'; import { AiInsightErrorBanner } from './ai_insight_error_banner'; import { LoadingCursor } from './loading_cursor'; +import { FeedbackButtons, type Feedback } from './feedback_buttons'; import { OBSERVABILITY_AGENT_ID } from '../../../common/constants'; +import { + ObservabilityAgentBuilderTelemetryEventType, + reportTelemetryEvent, + type InsightType, + type InsightFailedEvent, +} from '../../analytics'; export interface AiInsightResponse { summary: string; @@ -49,16 +56,17 @@ export interface AiInsightAttachment { export interface AiInsightProps { title: string; + insightType: InsightType; createStream: (signal: AbortSignal) => Observable; buildAttachments: (summary: string, context: string) => AiInsightAttachment[]; } -export function AiInsight({ title, createStream, buildAttachments }: AiInsightProps) { +export function AiInsight({ title, insightType, createStream, buildAttachments }: AiInsightProps) { const { euiTheme } = useEuiTheme(); const [isOpen, setIsOpen] = useState(false); const { - services: { agentBuilder, application }, + services: { agentBuilder, application, analytics }, } = useKibana(); const { getLicense } = useLicense(); @@ -72,9 +80,22 @@ export function AiInsight({ title, createStream, buildAttachments }: AiInsightPr const hasEnterpriseLicense = license?.hasAtLeast('enterprise'); const hasAgentBuilderAccess = application?.capabilities.agentBuilder?.show === true; - const { isLoading, error, summary, context, wasStopped, fetch, stop, regenerate } = + const { isLoading, error, summary, context, connectorInfo, wasStopped, fetch, stop, regenerate } = useStreamingAiInsight(createStream); + // Report the response generated event when the stream finishes (completely or stopped) + useEffect(() => { + const hasContent = Boolean(summary && summary.trim()); + const hasGeneratedInsight = !isLoading && hasContent && !error && connectorInfo; + + if (hasGeneratedInsight) { + reportTelemetryEvent(analytics, { + type: ObservabilityAgentBuilderTelemetryEventType.AiInsightResponseGenerated, + payload: { insightType, connector: connectorInfo }, + }); + } + }, [analytics, connectorInfo, error, insightType, isLoading, summary]); + const handleStartConversation = useCallback(() => { if (!agentBuilder?.openConversationFlyout) return; @@ -85,6 +106,32 @@ export function AiInsight({ title, createStream, buildAttachments }: AiInsightPr }); }, [agentBuilder, buildAttachments, summary, context]); + const handleFeedback = useCallback( + (feedback: Feedback) => { + if (!connectorInfo) return; + reportTelemetryEvent(analytics, { + type: ObservabilityAgentBuilderTelemetryEventType.AiInsightFeedback, + payload: { feedback, insightType, connector: connectorInfo }, + }); + }, + [analytics, connectorInfo, insightType] + ); + + useEffect(() => { + if (!error) return; + + const payload: InsightFailedEvent = { + insightType, + errorMessage: error, + ...(connectorInfo ? { connector: connectorInfo } : {}), + }; + + reportTelemetryEvent(analytics, { + type: ObservabilityAgentBuilderTelemetryEventType.AiInsightFailed, + payload, + }); + }, [analytics, connectorInfo, error, insightType]); + if ( !hasConnectors || !agentBuilder || @@ -195,7 +242,10 @@ export function AiInsight({ title, createStream, buildAttachments }: AiInsightPr {Boolean(summary && summary.trim()) && ( - + )} @@ -205,9 +255,15 @@ export function AiInsight({ title, createStream, buildAttachments }: AiInsightPr - + + + + - + diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/feedback_buttons.test.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/feedback_buttons.test.tsx new file mode 100644 index 0000000000000..a66bd024b6d6f --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/feedback_buttons.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { EuiThemeProvider } from '@elastic/eui'; +import { FeedbackButtons, type Feedback } from './feedback_buttons'; +import { useKibana } from '../../hooks/use_kibana'; + +jest.mock('../../hooks/use_kibana'); + +const mockUseKibana = useKibana as jest.Mock; +const mockAddSuccess = jest.fn(); +const mockIsEnabled = jest.fn(); + +const POSITIVE_BUTTON_SELECTOR = + '[data-test-subj="observabilityAgentBuilderFeedbackPositiveButton"]'; +const NEGATIVE_BUTTON_SELECTOR = + '[data-test-subj="observabilityAgentBuilderFeedbackNegativeButton"]'; + +const renderComponent = (onClickFeedback: jest.Mock) => + render( + + + + ); + +const getButtons = (container: HTMLElement) => ({ + positiveButton: container.querySelector(POSITIVE_BUTTON_SELECTOR) as HTMLButtonElement, + negativeButton: container.querySelector(NEGATIVE_BUTTON_SELECTOR) as HTMLButtonElement, +}); + +describe('FeedbackButtons', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockIsEnabled.mockReturnValue(true); + mockUseKibana.mockReturnValue({ + services: { + notifications: { + toasts: { + addSuccess: mockAddSuccess, + }, + feedback: { + isEnabled: mockIsEnabled, + }, + }, + }, + }); + }); + + it('does not render when feedback is disabled', () => { + mockIsEnabled.mockReturnValue(false); + + const onClickFeedback = jest.fn(); + const { container, unmount } = renderComponent(onClickFeedback); + + const { positiveButton, negativeButton } = getButtons(container); + + expect(positiveButton).toBeNull(); + expect(negativeButton).toBeNull(); + + unmount(); + }); + + it('renders the feedback buttons when feedback is enabled', () => { + const onClickFeedback = jest.fn(); + const { getByText, container, unmount } = renderComponent(onClickFeedback); + + expect(getByText('Was this helpful?')).toBeTruthy(); + + const { positiveButton, negativeButton } = getButtons(container); + + expect(positiveButton).toBeTruthy(); + expect(negativeButton).toBeTruthy(); + expect(getByText('Yes')).toBeTruthy(); + expect(getByText('No')).toBeTruthy(); + + unmount(); + }); + + it.each<{ feedback: Feedback; buttonSelector: string }>([ + { feedback: 'positive', buttonSelector: POSITIVE_BUTTON_SELECTOR }, + { feedback: 'negative', buttonSelector: NEGATIVE_BUTTON_SELECTOR }, + ])('handles $feedback feedback button click correctly', ({ feedback, buttonSelector }) => { + const onClickFeedback = jest.fn(); + const { container, unmount } = renderComponent(onClickFeedback); + + const { positiveButton, negativeButton } = getButtons(container); + const clickedButton = container.querySelector(buttonSelector) as HTMLButtonElement; + + // Buttons should be enabled initially + expect(positiveButton.disabled).toBe(false); + expect(negativeButton.disabled).toBe(false); + + fireEvent.click(clickedButton); + + // Should invoke callback with correct feedback value + expect(onClickFeedback).toHaveBeenCalledTimes(1); + expect(onClickFeedback).toHaveBeenCalledWith(feedback); + + // Should display toast notification + expect(mockAddSuccess).toHaveBeenCalledTimes(1); + expect(mockAddSuccess).toHaveBeenCalledWith('Thanks for your feedback'); + + // Should disable both buttons after click + expect(positiveButton.disabled).toBe(true); + expect(negativeButton.disabled).toBe(true); + + unmount(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/feedback_buttons.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/feedback_buttons.tsx new file mode 100644 index 0000000000000..fb56fcd13f3c7 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/feedback_buttons.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, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { useKibana } from '../../hooks/use_kibana'; + +export type Feedback = 'positive' | 'negative'; + +interface FeedbackButtonsProps { + onClickFeedback: (feedback: Feedback) => void; +} + +const THANK_YOU_MESSAGE = i18n.translate( + 'xpack.observabilityAgentBuilder.feedbackButtons.notificationLabel', + { defaultMessage: 'Thanks for your feedback' } +); + +export function FeedbackButtons({ onClickFeedback }: FeedbackButtonsProps) { + const { notifications } = useKibana().services; + const isFeedbackEnabled = notifications.feedback.isEnabled(); + + const [hasBeenClicked, setHasBeenClicked] = useState(false); + + const handleClick = (feedback: Feedback) => { + setHasBeenClicked(true); + notifications.toasts.addSuccess(THANK_YOU_MESSAGE); + onClickFeedback(feedback); + }; + + if (!isFeedbackEnabled) { + return null; + } + + return ( + + + + + {i18n.translate('xpack.observabilityAgentBuilder.aiInsight.feedbackButtons.title', { + defaultMessage: 'Was this helpful?', + })} + + + + + + + + handleClick('positive')} + > + {i18n.translate( + 'xpack.observabilityAgentBuilder.aiInsight.feedbackButtons.yesLabel', + { + defaultMessage: 'Yes', + } + )} + + + + + handleClick('negative')} + > + {i18n.translate('xpack.observabilityAgentBuilder.aiInsight.feedbackButtons.noLabel', { + defaultMessage: 'No', + })} + + + + + + ); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/start_conversation_button.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/start_conversation_button.tsx index 91f8f765c6161..e178e04771d48 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/start_conversation_button.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/start_conversation_button.tsx @@ -6,13 +6,19 @@ */ import React from 'react'; +import { upperFirst } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiButton } from '@elastic/eui'; +import type { InsightType } from '../../analytics'; -export function StartConversationButton(props: React.ComponentProps) { +type StartConversationButtonProps = React.ComponentProps & { + insightType: InsightType; +}; + +export function StartConversationButton({ insightType, ...props }: StartConversationButtonProps) { return ( diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx index c89d7475589cd..0893cf46df857 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx @@ -86,6 +86,7 @@ export function ErrorSampleAiInsight({ title={i18n.translate('xpack.observabilityAgentBuilder.errorAiInsight.titleLabel', { defaultMessage: "What's this error?", })} + insightType="error" createStream={createStream} buildAttachments={buildAttachments} /> diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx index e853fa952d297..40f328b01ca4f 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx @@ -94,6 +94,7 @@ export function LogAiInsight({ doc }: LogAiInsightProps) { <> diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.ts index 012aae5cf0b03..2f29aecc4fd8b 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.ts @@ -8,12 +8,20 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { scan, takeUntil, finalize, Observable } from 'rxjs'; import { AbortError } from '@kbn/kibana-utils-plugin/common'; +import type { ConnectorInfo } from '../../common'; + +export type { ConnectorInfo }; interface ContextEvent { type: 'context'; context: string; } +interface ConnectorInfoEvent { + type: 'connectorInfo'; + connector: ConnectorInfo; +} + interface ChatCompletionChunkEvent { type: 'chatCompletionChunk'; content: string; @@ -26,12 +34,14 @@ interface ChatCompletionMessageEvent { export type InsightStreamEvent = | ContextEvent + | ConnectorInfoEvent | ChatCompletionChunkEvent | ChatCompletionMessageEvent; export interface InsightResponse { summary: string; context: string; + connectorInfo?: ConnectorInfo; } const handleStreamError = (err: unknown, setError: (error: string | undefined) => void): void => { @@ -48,6 +58,7 @@ export function useStreamingAiInsight( const [error, setError] = useState(undefined); const [summary, setSummary] = useState(''); const [context, setContext] = useState(''); + const [connectorInfo, setConnectorInfo] = useState(undefined); const [wasStopped, setWasStopped] = useState(false); const abortControllerRef = useRef(null); const cleanupRef = useRef<() => void>(); @@ -67,6 +78,7 @@ export function useStreamingAiInsight( setWasStopped(false); setSummary(''); setContext(''); + setConnectorInfo(undefined); const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -92,6 +104,9 @@ export function useStreamingAiInsight( if (event.type === 'context') { return { ...acc, context: event.context }; } + if (event.type === 'connectorInfo') { + return { ...acc, connectorInfo: event.connector }; + } if (event.type === 'chatCompletionChunk') { return { ...acc, summary: acc.summary + event.content }; } @@ -112,6 +127,9 @@ export function useStreamingAiInsight( next: (state: InsightResponse) => { setSummary(state.summary); setContext(state.context); + if (state.connectorInfo) { + setConnectorInfo(state.connectorInfo); + } }, error: (err: unknown) => handleStreamError(err, setError), }); @@ -133,6 +151,7 @@ export function useStreamingAiInsight( error, summary, context, + connectorInfo, wasStopped, fetch, stop, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/index.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/index.ts index 183e00e389ae2..e9460dbcbd51d 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/index.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/index.ts @@ -14,6 +14,8 @@ export { OBSERVABILITY_ALERT_ATTACHMENT_TYPE_ID, OBSERVABILITY_ERROR_ATTACHMENT_TYPE_ID, OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, + OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, } from '../common/constants'; export type { diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/plugin.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/plugin.tsx index 1d97cd6afdb5a..830b418e86db0 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/plugin.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/plugin.tsx @@ -19,6 +19,7 @@ import { createLogsAIInsightRenderer, } from './components/insights'; import { registerAttachmentUiDefinitions } from './attachment_types'; +import { registerTelemetryEventTypes } from './analytics'; export class ObservabilityAgentBuilderPlugin implements @@ -38,6 +39,7 @@ export class ObservabilityAgentBuilderPlugin >, plugins: ObservabilityAgentBuilderPluginSetupDependencies ): ObservabilityAgentBuilderPluginPublicSetup { + registerTelemetryEventTypes(core.analytics); return {}; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/ai_insight.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/ai_insight.ts index 80af8d52a132d..8e292a24f08ad 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/ai_insight.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/ai_insight.ts @@ -10,11 +10,11 @@ import dedent from 'dedent'; import type { Attachment } from '@kbn/agent-builder-common/attachments'; import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; import { OBSERVABILITY_AI_INSIGHT_ATTACHMENT_TYPE_ID } from '../../common'; +import { observabilityAttachmentDataSchema } from './observability_attachment_data_schema'; -const aiInsightAttachmentDataSchema = z.object({ +const aiInsightAttachmentDataSchema = observabilityAttachmentDataSchema.extend({ summary: z.string(), context: z.string(), - attachmentLabel: z.string().optional(), }); type AiInsightAttachmentData = z.infer; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/alert.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/alert.ts index c19ef42c652ea..ada81fe0154c3 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/alert.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/alert.ts @@ -13,10 +13,12 @@ import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; import dedent from 'dedent'; import { OBSERVABILITY_ALERT_ATTACHMENT_TYPE_ID } from '../../common/constants'; import type { ObservabilityAgentBuilderCoreSetup } from '../types'; +import { observabilityAttachmentDataSchema } from './observability_attachment_data_schema'; -const alertDataSchema = z.object({ +const GET_ALERT_DETAILS_TOOL_ID = 'get_alert_details'; + +const alertDataSchema = observabilityAttachmentDataSchema.extend({ alertId: z.string(), - attachmentLabel: z.string().optional(), }); export type AlertAttachmentData = z.infer; @@ -43,11 +45,11 @@ export function createAlertAttachmentType({ return { getRepresentation: () => ({ type: 'text', - value: `Observability Alert ID: ${alertId}. Use the get_alert_details tool to fetch full alert information.`, + value: `Observability Alert ID: ${alertId}. Use the ${GET_ALERT_DETAILS_TOOL_ID} tool to fetch full alert information.`, }), getBoundedTools: () => [ { - id: `get_alert_details`, + id: GET_ALERT_DETAILS_TOOL_ID, type: ToolType.builtin, description: `Fetch full details for alert ${alertId} including rule info, status, reason, and related entities.`, schema: z.object({}), @@ -60,6 +62,19 @@ export function createAlertAttachmentType({ const alertDoc = await alertsClient.get({ id: alertId }); + if (!alertDoc) { + return { + results: [ + { + type: ToolResultType.error, + data: { + message: `Alert document not found for ${alertId}`, + }, + }, + ], + }; + } + return { results: [ { @@ -95,7 +110,7 @@ export function createAlertAttachmentType({ getTools: () => [], getAgentDescription: () => dedent( - `An Observability alert attachment. The alert ID is provided - use the get_alert_details tool to fetch full alert information including rule name, status, reason, and related entities.` + `An Observability alert attachment. The alert ID is provided - use the ${GET_ALERT_DETAILS_TOOL_ID} tool to fetch full alert information including rule name, status, reason, and related entities.` ), }; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/error.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/error.ts index 6187cb1f40e54..da518cad6db65 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/error.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/error.ts @@ -12,12 +12,12 @@ import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachm import { ToolType } from '@kbn/agent-builder-common'; import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; import { OBSERVABILITY_ERROR_ATTACHMENT_TYPE_ID } from '../../common'; -import type { ObservabilityAgentBuilderCoreSetup } from '../types'; import type { ObservabilityAgentBuilderDataRegistry } from '../data_registry/data_registry'; +import { observabilityAttachmentDataSchema } from './observability_attachment_data_schema'; const GET_ERROR_DETAILS_TOOL_ID = 'get_error_details'; -const errorDataSchema = z.object({ +const errorDataSchema = observabilityAttachmentDataSchema.extend({ errorId: z.string(), serviceName: z.string().optional(), environment: z.string().nullable().optional(), @@ -28,11 +28,9 @@ const errorDataSchema = z.object({ export type ErrorAttachmentData = z.infer; export function createErrorAttachmentType({ - core, logger, dataRegistry, }: { - core: ObservabilityAgentBuilderCoreSetup; logger: Logger; dataRegistry: ObservabilityAgentBuilderDataRegistry; }): AttachmentTypeDefinition { @@ -70,6 +68,19 @@ export function createErrorAttachmentType({ serviceEnvironment: environment ?? '', }); + if (!errorDetails) { + return { + results: [ + { + type: ToolResultType.error, + data: { + message: `Error details not found for ${errorId}`, + }, + }, + ], + }; + } + return { results: [ { diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/log.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/log.ts index fc98d8333a45e..c1db6fe2ac036 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/log.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/log.ts @@ -12,13 +12,13 @@ import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachm import { ToolType } from '@kbn/agent-builder-common'; import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; import type { ObservabilityAgentBuilderCoreSetup } from '../types'; -import type { ObservabilityAgentBuilderDataRegistry } from '../data_registry/data_registry'; import { OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID } from '../../common'; import { getLogDocumentById } from '../routes/ai_insights/get_log_document_by_id'; +import { observabilityAttachmentDataSchema } from './observability_attachment_data_schema'; const GET_LOG_DOCUMENT_TOOL_ID = 'get_log_document'; -const logDataSchema = z.object({ +const logDataSchema = observabilityAttachmentDataSchema.extend({ id: z.string(), index: z.string(), }); @@ -28,11 +28,9 @@ export type LogAttachmentData = z.infer; export function createLogAttachmentType({ core, logger, - dataRegistry, }: { core: ObservabilityAgentBuilderCoreSetup; logger: Logger; - dataRegistry: ObservabilityAgentBuilderDataRegistry; }): AttachmentTypeDefinition { return { id: OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/observability_attachment_data_schema.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/observability_attachment_data_schema.ts new file mode 100644 index 0000000000000..ecdff1dd4ab06 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/observability_attachment_data_schema.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 { z } from '@kbn/zod'; + +/** + * Base schema for all Observability agent-builder attachments. + * Each attachment can override its UI label by providing an `attachmentLabel`, + */ +export const observabilityAttachmentDataSchema = z.object({ + attachmentLabel: z.string().optional(), +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/register_attachments.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/register_attachments.ts index 724d6fc39bc25..1aababf95c901 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/register_attachments.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/register_attachments.ts @@ -16,6 +16,8 @@ import type { ObservabilityAgentBuilderPluginSetupDependencies, } from '../types'; import type { ObservabilityAgentBuilderDataRegistry } from '../data_registry/data_registry'; +import { createServiceAttachmentType } from './service'; +import { createSloAttachmentType } from './slo'; export async function registerAttachments({ core, @@ -30,9 +32,11 @@ export async function registerAttachments({ }) { const attachmentTypes: AttachmentTypeDefinition[] = [ createAiInsightAttachmentType(), - createErrorAttachmentType({ core, logger, dataRegistry }), + createErrorAttachmentType({ logger, dataRegistry }), createAlertAttachmentType({ core, logger }), - createLogAttachmentType({ core, logger, dataRegistry }), + createLogAttachmentType({ core, logger }), + createServiceAttachmentType({ logger, dataRegistry }), + createSloAttachmentType({ logger, dataRegistry }), ]; for (const attachment of attachmentTypes) { diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/service.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/service.ts new file mode 100644 index 0000000000000..41b72b40be525 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/service.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import dedent from 'dedent'; +import { z } from '@kbn/zod'; +import type { Logger } from '@kbn/core/server'; +import { ToolResultType, ToolType } from '@kbn/agent-builder-common'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import { OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID } from '../../common'; +import type { ObservabilityAgentBuilderDataRegistry } from '../data_registry/data_registry'; +import { observabilityAttachmentDataSchema } from './observability_attachment_data_schema'; + +const GET_SERVICE_DETAILS_TOOL_ID = 'get_service_details'; + +const serviceDataSchema = observabilityAttachmentDataSchema.extend({ + serviceName: z.string(), + environment: z.string(), + start: z.string(), + end: z.string(), +}); + +export type ServiceAttachmentData = z.infer; + +export function createServiceAttachmentType({ + logger, + dataRegistry, +}: { + logger: Logger; + dataRegistry: ObservabilityAgentBuilderDataRegistry; +}): AttachmentTypeDefinition< + typeof OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, + ServiceAttachmentData +> { + return { + id: OBSERVABILITY_SERVICE_ATTACHMENT_TYPE_ID, + validate: (input) => { + const parsed = serviceDataSchema.safeParse(input); + if (parsed.success) { + return { valid: true, data: parsed.data }; + } + return { valid: false, error: parsed.error.message }; + }, + format: (attachment) => { + const { serviceName, environment, start, end } = attachment.data; + + return { + getRepresentation: () => ({ + type: 'text', + value: `Observability Service Name: ${serviceName}. Use the ${GET_SERVICE_DETAILS_TOOL_ID} tool to fetch the full service details.`, + }), + getBoundedTools: () => [ + { + id: GET_SERVICE_DETAILS_TOOL_ID, + type: ToolType.builtin, + description: `Fetch the full service details for service ${serviceName}.`, + schema: z.object({}), + handler: async (_args, context) => { + try { + const serviceDetails = await dataRegistry.getData('apmServiceSummary', { + request: context.request, + serviceName, + serviceEnvironment: environment, + start, + end, + }); + + if (!serviceDetails) { + return { + results: [ + { + type: ToolResultType.error, + data: { + message: `Service details not found for ${serviceName}`, + }, + }, + ], + }; + } + + return { + results: [ + { + type: ToolResultType.other, + data: serviceDetails, + }, + ], + }; + } catch (error) { + logger.error(`Failed to fetch service details for attachment: ${error?.message}`); + logger.debug(error); + + return { + results: [ + { + type: ToolResultType.error, + data: { + message: `Failed to fetch service details: ${error.message}`, + stack: error.stack, + }, + }, + ], + }; + } + }, + }, + ], + }; + }, + getTools: () => [], + getAgentDescription: () => + dedent( + `An Observability service attachment. The service name, environment and the time range is provided - use the ${GET_SERVICE_DETAILS_TOOL_ID} tool to fetch the full service details.` + ), + }; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/slo.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/slo.ts new file mode 100644 index 0000000000000..6532fa4f1fa2d --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/attachments/slo.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import dedent from 'dedent'; +import type { Logger } from '@kbn/core/server'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import { ToolType } from '@kbn/agent-builder-common'; +import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; +import { OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID } from '../../common'; +import type { ObservabilityAgentBuilderDataRegistry } from '../data_registry/data_registry'; +import { observabilityAttachmentDataSchema } from './observability_attachment_data_schema'; + +const GET_SLO_DETAILS_TOOL_ID = 'get_slo_details'; + +const sloDataSchema = observabilityAttachmentDataSchema.extend({ + sloId: z.string(), + sloInstanceId: z.string().optional(), + remoteName: z.string().optional(), +}); + +export type SloAttachmentData = z.infer; + +export function createSloAttachmentType({ + logger, + dataRegistry, +}: { + logger: Logger; + dataRegistry: ObservabilityAgentBuilderDataRegistry; +}): AttachmentTypeDefinition { + return { + id: OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, + validate: (input) => { + const parsed = sloDataSchema.safeParse(input); + if (parsed.success) { + return { valid: true, data: parsed.data }; + } + return { valid: false, error: parsed.error.message }; + }, + format: (attachment) => { + const { sloId, sloInstanceId, remoteName } = attachment.data; + + return { + getRepresentation: () => ({ + type: 'text', + value: `Observability SLO ID: ${sloId}. Use the ${GET_SLO_DETAILS_TOOL_ID} tool to fetch full SLO information.`, + }), + getBoundedTools: () => [ + { + id: GET_SLO_DETAILS_TOOL_ID, + type: ToolType.builtin, + description: `Fetch the full details for SLO ${sloId}.`, + schema: z.object({}), + handler: async (_args, context) => { + try { + const sloDetails = await dataRegistry.getData('sloDetails', { + request: context.request, + sloId, + sloInstanceId, + remoteName, + }); + + if (!sloDetails) { + return { + results: [ + { + type: ToolResultType.error, + data: { + message: `SLO details not found for ${sloId}`, + }, + }, + ], + }; + } + + return { + results: [ + { + type: ToolResultType.other, + data: sloDetails, + }, + ], + }; + } catch (error) { + logger.error(`Failed to fetch SLO details for attachment: ${error?.message}`); + logger.debug(error); + + return { + results: [ + { + type: ToolResultType.error, + data: { + message: `Failed to fetch SLO details: ${error.message}`, + stack: error.stack, + }, + }, + ], + }; + } + }, + }, + ], + }; + }, + getTools: () => [], + getAgentDescription: () => + dedent( + `An Observability SLO attachment. The SLO ID is provided - use the ${GET_SLO_DETAILS_TOOL_ID} tool to fetch the full SLO information.` + ), + }; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/data_registry/data_registry_types.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/data_registry/data_registry_types.ts index 89e8887de1b71..2a59fdf7833d9 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/data_registry/data_registry_types.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/data_registry/data_registry_types.ts @@ -7,6 +7,8 @@ import type { KibanaRequest } from '@kbn/core/server'; import type { ChangePointType } from '@kbn/es-types/src'; +import type { GetSLOParams, GetSLOResponse } from '@kbn/slo-schema'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; type ServiceHealthStatus = 'healthy' | 'warning' | 'critical' | 'unknown'; @@ -38,16 +40,6 @@ interface ServiceSummary { deployments: Array<{ '@timestamp': string }>; } -export interface APMDownstreamDependency { - 'service.name'?: string; - 'span.destination.service.resource': string; - 'span.type'?: string; - 'span.subtype'?: string; - errorRate?: number; - latencyMs?: number; - throughputPerMin?: number; -} - interface APMErrorSample { processor?: { event?: string; @@ -132,6 +124,47 @@ interface InfraHostsResponse { nodes: InfraEntityMetricsItem[]; } +export interface ExitSpanSample { + serviceName: string; + spanDestinationServiceResource: string; + spanType: string; + spanSubtype: string; + destinationService?: { + serviceName: string; + }; +} + +export interface ConnectionStatsItem { + from: { serviceName: string }; + to: { + dependencyName: string; + spanType: string; + spanSubtype: string; + }; + value: { + latency_count: number; + latency_sum: number; + error_count: number; + success_count: number; + }; +} + +export interface TraceMetrics { + latencyUs: number | null; + throughputPerMin: number | null; + errorRate: number | null; +} + +export type ApmConnectionStatsEntry = + | { type: 'service'; serviceName: string; metrics: TraceMetrics } + | { + type: 'dependency'; + dependencyName: string; + spanType: string; + spanSubtype: string; + metrics: TraceMetrics; + }; + export interface ObservabilityAgentBuilderDataRegistryTypes { apmErrorDetails: (params: { request: KibanaRequest; @@ -152,14 +185,6 @@ export interface ObservabilityAgentBuilderDataRegistryTypes { transactionType?: string; }) => Promise; - apmDownstreamDependencies: (params: { - request: KibanaRequest; - serviceName: string; - serviceEnvironment: string; - start: string; - end: string; - }) => Promise; - apmExitSpanChangePoints: (params: { request: KibanaRequest; serviceName: string; @@ -195,4 +220,39 @@ export interface ObservabilityAgentBuilderDataRegistryTypes { query: Record | undefined; hostNames?: string[]; }) => Promise; + + sloDetails: (params: { + request: KibanaRequest; + sloId: string; + sloInstanceId?: GetSLOParams['instanceId']; + remoteName?: GetSLOParams['remoteName']; + }) => Promise; + + apmTraceSampleIds: (params: { + request: KibanaRequest; + serviceName: string; + start: number; + end: number; + }) => Promise<{ traceIds: string[] }>; + + apmExitSpanSamples: (params: { + request: KibanaRequest; + traceIds: string[]; + start: number; + end: number; + }) => Promise; + + apmConnectionStatsItems: (params: { + request: KibanaRequest; + start: number; + end: number; + filter: QueryDslQueryContainer[]; + }) => Promise; + + apmConnectionStats: (params: { + request: KibanaRequest; + start: number; + end: number; + filter: QueryDslQueryContainer[]; + }) => Promise; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/alert_ai_insights/generate_alert_ai_insight.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/alert_ai_insights/generate_alert_ai_insight.ts new file mode 100644 index 0000000000000..a6dd1c6ed4af3 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/alert_ai_insights/generate_alert_ai_insight.ts @@ -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 type { KibanaRequest, Logger } from '@kbn/core/server'; +import type { + InferenceClient, + ChatCompletionEvent, + InferenceConnector, +} from '@kbn/inference-common'; +import { MessageRole } from '@kbn/inference-common'; +import dedent from 'dedent'; +import moment from 'moment'; +import type { Observable } from 'rxjs'; +import type { ObservabilityAgentBuilderDataRegistry } from '../../../data_registry/data_registry'; +import { createAiInsightResult, type AiInsightResult } from '../types'; +import type { + ObservabilityAgentBuilderCoreSetup, + ObservabilityAgentBuilderPluginSetupDependencies, +} from '../../../types'; +import { getEntityLinkingInstructions } from '../../../agent/register_observability_agent'; +import { runSignalFetchers, formatSignalResults } from './signal_fetchers'; + +/** + * These types are derived from the generated alerts-as-data schemas: + * - `AlertSchema` in `kbn-alerts-as-data-utils` (see `alert_schema.ts`) which defines + * `kibana.alert.start` and other technical fields. + * - `ObservabilityApmAlertSchema` in `observability_apm_schema.ts`, which adds + * the APM-specific fields like `service.*` and `transaction.*`. + * + * We only rely on these well-known keys; all other properties are treated as + * opaque via the index signature below so this type can safely represent any + * Observability alert document. + */ +export interface AlertDocForInsight { + 'service.name'?: string; + 'service.environment'?: string; + 'transaction.type'?: string; + 'transaction.name'?: string; + 'host.name'?: string; + 'kibana.alert.start'?: string | number; + [key: string]: unknown; +} + +interface GetAlertAiInsightParams { + core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; + alertDoc: AlertDocForInsight; + inferenceClient: InferenceClient; + connectorId: string; + connector: InferenceConnector; + dataRegistry: ObservabilityAgentBuilderDataRegistry; + request: KibanaRequest; + logger: Logger; +} + +export async function getAlertAiInsight({ + core, + plugins, + alertDoc, + inferenceClient, + connectorId, + connector, + dataRegistry, + request, + logger, +}: GetAlertAiInsightParams): Promise { + const urlPrefix = core.http.basePath.get(request); + + const { context, signalDescriptions } = await fetchAlertContext({ + core, + plugins, + alertDoc, + dataRegistry, + request, + logger, + }); + + const events$: Observable = generateAlertSummary({ + inferenceClient, + connectorId, + alertDoc, + urlPrefix, + context, + signalDescriptions, + }); + + return createAiInsightResult(context, connector, events$); +} + +async function fetchAlertContext({ + core, + plugins, + alertDoc, + dataRegistry, + request, + logger, +}: Pick< + GetAlertAiInsightParams, + 'core' | 'plugins' | 'alertDoc' | 'dataRegistry' | 'request' | 'logger' +>): Promise<{ context: string; signalDescriptions: string[] }> { + const serviceName = alertDoc?.['service.name'] ?? ''; + const serviceEnvironment = alertDoc?.['service.environment'] ?? ''; + const transactionType = alertDoc?.['transaction.type']; + const transactionName = alertDoc?.['transaction.name']; + const hostName = alertDoc?.['host.name'] ?? ''; + + if (!serviceName && !hostName) { + return { context: 'No related signals available.', signalDescriptions: [] }; + } + + const alertStart = moment(alertDoc?.['kibana.alert.start']).toISOString(); + + const [coreStart] = await core.getStartServices(); + const esClient = coreStart.elasticsearch.client.asScoped(request); + + const results = await runSignalFetchers( + { + core, + plugins, + dataRegistry, + esClient, + request, + logger, + serviceName, + serviceEnvironment, + transactionType, + transactionName, + hostName, + }, + alertStart + ); + + return formatSignalResults(results); +} + +function generateAlertSummary({ + inferenceClient, + urlPrefix, + connectorId, + alertDoc, + context, + signalDescriptions, +}: { + inferenceClient: InferenceClient; + urlPrefix: string; + connectorId: string; + alertDoc: AlertDocForInsight; + context: string; + signalDescriptions: string[]; +}): Observable { + const systemPrompt = dedent(` + You are an SRE assistant. Help an SRE quickly understand likely cause, impact, and next actions for this alert using the provided context. + + Output format (use **bold** titles, prose paragraphs, minimal bullets): + + **Summary** + 1–2 sentences: What is likely happening and why it matters. If recovered, reduce urgency. If no strong signals, say "Inconclusive" and briefly note why. + + **Assessment** + Most plausible explanation in prose, citing the signals that support it (e.g., "downstream metrics show...", "logs indicate..."). Say "Inconclusive" if signals do not support a clear cause. + + **Next Steps** + 2–3 numbered actions an SRE can take now. + + Guardrails: + - Do not repeat the alert reason verbatim. + - Only give a non-inconclusive Assessment when supported by on-topic signals; otherwise say "Inconclusive" and don't speculate. + - Keep it concise (~100–150 words total). + + Available signals (use what's relevant and available): + ${signalDescriptions.length > 0 ? signalDescriptions.join('\n ') : 'None available.'} + + Note: Numeric values on a 0-1 scale represent percentages (e.g., 0.95 = 95%, 0.3 = 30%). + + ${getEntityLinkingInstructions({ urlPrefix })} + `); + + const alertDetails = `\`\`\`json\n${JSON.stringify(alertDoc, null, 2)}\n\`\`\``; + + const userPrompt = dedent(` + + ${alertDetails} + + + ${context} + + Task: + Summarize likely cause, impact, and immediate next checks for this alert using the format above. Tie related signals to the alert scope; ignore unrelated noise. If signals are weak or conflicting, mark Assessment "Inconclusive" and propose the safest next diagnostic step. + `); + + return inferenceClient.chatComplete({ + connectorId, + system: systemPrompt, + messages: [{ role: MessageRole.User, content: userPrompt }], + stream: true, + }); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/alert_ai_insights/signal_fetchers.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/alert_ai_insights/signal_fetchers.ts new file mode 100644 index 0000000000000..b114f40e2e829 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/alert_ai_insights/signal_fetchers.ts @@ -0,0 +1,283 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IScopedClusterClient, KibanaRequest, Logger } from '@kbn/core/server'; +import { compact, isEmpty } from 'lodash'; +import moment from 'moment'; +import type { ObservabilityAgentBuilderDataRegistry } from '../../../data_registry/data_registry'; +import type { + ObservabilityAgentBuilderCoreSetup, + ObservabilityAgentBuilderPluginSetupDependencies, +} from '../../../types'; +import { getToolHandler as getLogGroups } from '../../../tools/get_log_groups/handler'; +import { getToolHandler as getRuntimeMetrics } from '../../../tools/get_runtime_metrics/handler'; +import { getToolHandler as getHosts } from '../../../tools/get_hosts/handler'; +import { getToolHandler as getServices } from '../../../tools/get_services/handler'; +import { getServiceTopology } from '../../../tools/get_service_topology/get_service_topology'; + +export interface SignalFetcherDeps { + core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; + dataRegistry: ObservabilityAgentBuilderDataRegistry; + esClient: IScopedClusterClient; + request: KibanaRequest; + logger: Logger; + serviceName: string; + serviceEnvironment: string; + transactionType?: string; + transactionName?: string; + hostName: string; +} + +export interface SignalFetcher { + key: string; + description: string; + startOffsetMinutes: number; + fetch: (deps: SignalFetcherDeps, start: string, end: string) => Promise; +} + +export interface SignalResult { + key: string; + description: string; + start: string; + end: string; + data: unknown; +} + +export const SIGNAL_FETCHERS: SignalFetcher[] = [ + { + key: 'apmServiceSummary', + description: 'instance counts, versions, anomalies, and metadata', + startOffsetMinutes: 5, + async fetch( + { dataRegistry, request, serviceName, serviceEnvironment, transactionType }, + start, + end + ) { + if (!serviceName) return null; + const data = await dataRegistry.getData('apmServiceSummary', { + request, + serviceName, + serviceEnvironment, + transactionType, + start, + end, + }); + return isEmpty(data) ? null : data; + }, + }, + { + key: 'apmServiceChangePoints', + description: 'sudden shifts in throughput/latency/failure rate — shows when problems started', + startOffsetMinutes: 6 * 60, + async fetch( + { dataRegistry, request, serviceName, serviceEnvironment, transactionType, transactionName }, + start, + end + ) { + if (!serviceName) return null; + const data = await dataRegistry.getData('apmServiceChangePoints', { + request, + serviceName, + serviceEnvironment, + transactionType, + transactionName, + start, + end, + }); + return isEmpty(data) ? null : data; + }, + }, + { + key: 'apmExitSpanChangePoints', + description: + 'sudden shifts in throughput/latency/failure rate of downstream dependencies — shows when problems started', + startOffsetMinutes: 6 * 60, + async fetch({ dataRegistry, request, serviceName, serviceEnvironment }, start, end) { + if (!serviceName) return null; + const data = await dataRegistry.getData('apmExitSpanChangePoints', { + request, + serviceName, + serviceEnvironment, + start, + end, + }); + return isEmpty(data) ? null : data; + }, + }, + { + key: 'apmServiceTopology', + description: + 'Shows downstream dependencies (services and external dependencies), including metrics for latency/througput/error rate. Useful for understanding if the service is a victim of cascading failures.', + startOffsetMinutes: 24 * 60, + async fetch({ core, plugins, dataRegistry, request, logger, serviceName }, start, end) { + if (!serviceName) return null; + const data = await getServiceTopology({ + core, + plugins, + dataRegistry, + request, + logger, + serviceName, + direction: 'downstream', + depth: 1, + start, + end, + }); + return isEmpty(data) ? null : data; + }, + }, + { + key: 'logGroups', + description: 'error messages and exception patterns', + startOffsetMinutes: 15, + async fetch({ core, plugins, request, logger, esClient, serviceName, hostName }, start, end) { + let kqlFilter: string; + if (serviceName) { + kqlFilter = `service.name: "${serviceName}"`; + } else if (hostName) { + kqlFilter = `host.name: "${hostName}"`; + } else { + return null; + } + + const result = await getLogGroups({ + core, + plugins, + request, + logger, + esClient, + start, + end, + kqlFilter, + fields: [], + includeStackTrace: false, + includeFirstSeen: false, + size: 10, + }); + return result.length > 0 ? result : null; + }, + }, + { + key: 'runtimeMetrics', + description: 'CPU, memory, GC duration, thread count — indicates internal resource pressure', + startOffsetMinutes: 15, + async fetch({ core, plugins, request, logger, serviceName, serviceEnvironment }, start, end) { + if (!serviceName) return null; + const result = await getRuntimeMetrics({ + core, + plugins, + request, + logger, + serviceName, + serviceEnvironment, + start, + end, + }); + return result.nodes.length > 0 ? result.nodes : null; + }, + }, + { + key: 'infraHosts', + description: 'CPU, memory, disk, network usage — indicates host-level resource pressure', + startOffsetMinutes: 15, + async fetch({ request, dataRegistry, serviceName, hostName }, start, end) { + const kqlFilter = hostName + ? `host.name: "${hostName}"` + : serviceName + ? `service.name: "${serviceName}"` + : null; + + if (!kqlFilter) return null; + + const result = await getHosts({ + request, + dataRegistry, + start, + end, + limit: 10, + kqlFilter, + }); + + return result.hosts.length > 0 ? result.hosts : null; + }, + }, + { + key: 'servicesOnHost', + description: + 'for infrastructure alerts, shows services running on the affected host — helps identify which service may be causing resource pressure', + startOffsetMinutes: 15, + async fetch( + { core, plugins, request, esClient, dataRegistry, logger, serviceName, hostName }, + start, + end + ) { + if (!hostName || serviceName) return null; + + const result = await getServices({ + core, + plugins, + request, + esClient, + dataRegistry, + logger, + start, + end, + kqlFilter: `host.name: "${hostName}"`, + }); + + return result.services.length > 0 ? result.services : null; + }, + }, +]; + +export async function runSignalFetchers( + deps: SignalFetcherDeps, + alertStart: string +): Promise { + const results = await Promise.all( + SIGNAL_FETCHERS.map(async ({ key, description, startOffsetMinutes, fetch }) => { + try { + const start = moment(alertStart) + .clone() + .subtract(startOffsetMinutes, 'minutes') + .toISOString(); + + const end = alertStart; + + const data = await fetch(deps, start, end); + return data != null ? { key, description, start, end: alertStart, data } : null; + } catch (err) { + deps.logger.debug(`AI insight: ${key} failed: ${err}`); + return null; + } + }) + ); + + return compact(results); +} + +export function formatSignalResults(results: SignalResult[]): { + context: string; + signalDescriptions: string[]; +} { + const contextParts = results.map( + ({ key, start, end, data }) => + `<${key}>\nTime window: ${start} to ${end}\n\`\`\`json\n${JSON.stringify( + data, + null, + 2 + )}\n\`\`\`\n` + ); + + const signalDescriptions = results.map(({ key, description }) => `- ${key}: ${description}`); + + return { + context: contextParts.length > 0 ? contextParts.join('\n\n') : 'No related signals available.', + signalDescriptions, + }; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/fetch_apm_error_context.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/fetch_apm_error_context.ts index 2f87afc321aa5..89b230b70f2ec 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/fetch_apm_error_context.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/fetch_apm_error_context.ts @@ -15,7 +15,8 @@ import type { } from '../../../types'; import { getApmIndices } from '../../../utils/get_apm_indices'; import { parseDatemath } from '../../../utils/time'; -import { fetchDistributedTrace } from './fetch_distributed_trace'; +import { getServiceTopology } from '../../../tools/get_service_topology/get_service_topology'; +import { getTraceDocuments } from '../../../tools/get_traces/get_trace_documents'; export interface FetchApmErrorContextParams { core: ObservabilityAgentBuilderCoreSetup; @@ -84,10 +85,14 @@ export async function fetchApmErrorContext({ start, end, handler: () => - dataRegistry.getData('apmDownstreamDependencies', { + getServiceTopology({ + core, + plugins, + dataRegistry, request, + logger, serviceName, - serviceEnvironment: environment ?? '', + direction: 'downstream', start, end, }), @@ -97,13 +102,15 @@ export async function fetchApmErrorContext({ if (traceId) { const traceContextPromise = (async () => { const apmIndices = await getApmIndices({ core, plugins, logger }); - return fetchDistributedTrace({ + return getTraceDocuments({ esClient, - apmIndices, - traceId, - start: parsedStart, - end: parsedEnd, - logger, + traceIds: [traceId], + index: [apmIndices.transaction, apmIndices.span, apmIndices.error].flatMap((pattern) => + pattern.split(',') + ), + size: 100, + startTime: parsedStart, + endTime: parsedEnd, }); })(); @@ -112,10 +119,10 @@ export async function fetchApmErrorContext({ start, end, handler: async () => { - const { traceDocuments, isPartialTrace } = await traceContextPromise; + const [trace] = await traceContextPromise; return { - isPartialTrace, - documents: traceDocuments, + isPartialTrace: trace.isTruncated, + documents: trace.items, }; }, }); @@ -124,7 +131,10 @@ export async function fetchApmErrorContext({ name: 'TraceServices', start, end, - handler: async () => (await traceContextPromise).services, + handler: async () => { + const [trace] = await traceContextPromise; + return trace.services; + }, }); } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/fetch_distributed_trace.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/fetch_distributed_trace.ts deleted file mode 100644 index 782ad9f409a56..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/fetch_distributed_trace.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { IScopedClusterClient, Logger } from '@kbn/core/server'; -import type { APMIndices } from '@kbn/apm-sources-access-plugin/server'; -import { accessKnownApmEventFields } from '@kbn/apm-data-access-plugin/server/utils'; -import type { UnflattenedApmEvent } from '@kbn/apm-data-access-plugin/server/utils/utility_types'; -import { getTypedSearch } from '../../../utils/get_typed_search'; -import { termFilter, timeRangeFilter } from '../../../utils/dsl_filters'; - -interface ServiceAggregate { - serviceName: string; - count: number; - errorCount: number; -} - -export interface DistributedTrace { - traceDocuments: Array; - isPartialTrace: boolean; - services: ServiceAggregate[]; -} - -export async function fetchDistributedTrace({ - esClient, - apmIndices, - traceId, - start, - end, - logger, -}: { - esClient: IScopedClusterClient; - apmIndices: APMIndices; - traceId: string; - start: number; - end: number; - logger: Logger; -}): Promise { - const search = getTypedSearch(esClient.asCurrentUser); - const indices = [apmIndices.transaction, apmIndices.span, apmIndices.error].join(','); - - const size = 100; - const traceResponse = await search({ - index: indices, - size, - track_total_hits: size + 1, - query: { - bool: { - filter: [ - ...termFilter('trace.id', traceId), - ...timeRangeFilter('@timestamp', { start, end }), - ], - }, - }, - aggs: { - services: { - terms: { - field: 'service.name', - size: 100, - }, - aggs: { - error_count: { - filter: { term: { 'event.outcome': 'failure' } }, - }, - }, - }, - }, - fields: [ - '@timestamp', - 'service.name', - 'trace.id', - 'event.outcome', - 'parent.id', - 'processor.event', - 'transaction.id', - 'transaction.name', - 'transaction.type', - 'transaction.duration.us', - 'span.id', - 'span.name', - 'span.type', - 'span.subtype', - 'span.duration.us', - 'span.destination.service.resource', - 'error.id', - 'error.grouping_key', - 'error.culprit', - 'error.log.message', - 'error.exception.message', - 'error.exception.handled', - 'error.exception.type', - ], - _source: false, - sort: [{ '@timestamp': 'asc' }], - }); - - const traceDocuments = traceResponse.hits.hits.map( - (hit) => accessKnownApmEventFields(hit.fields ?? {}).unflatten() as UnflattenedApmEvent - ); - - const total = traceResponse.hits.total; - const isPartialTrace = total.relation === 'gte'; - - const serviceAggs = traceResponse.aggregations?.services.buckets ?? []; - const services = serviceAggs - .map((bucket) => ({ - serviceName: bucket.key as string, - count: bucket.doc_count, - errorCount: bucket.error_count?.doc_count ?? 0, - })) - .sort((a, b) => b.count - a.count); - - logger.debug( - `Fetched distributed trace for ${traceId}: ${traceDocuments.length} documents, ${services.length} services, partial: ${isPartialTrace}` - ); - - return { - traceDocuments, - isPartialTrace, - services, - }; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts index 657ebf5f54ae3..980ea24236fea 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts @@ -8,7 +8,7 @@ import type { KibanaRequest } from '@kbn/core-http-server'; import type { Logger } from '@kbn/logging'; import { MessageRole } from '@kbn/inference-common'; -import type { BoundInferenceClient } from '@kbn/inference-common'; +import type { BoundInferenceClient, InferenceConnector } from '@kbn/inference-common'; import dedent from 'dedent'; import type { ObservabilityAgentBuilderDataRegistry } from '../../../data_registry/data_registry'; import type { @@ -75,6 +75,7 @@ export interface GenerateErrorAiInsightParams { logger: Logger; request: KibanaRequest; inferenceClient: BoundInferenceClient; + connector: InferenceConnector; dataRegistry: ObservabilityAgentBuilderDataRegistry; } @@ -89,6 +90,7 @@ export async function generateErrorAiInsight({ logger, request, inferenceClient, + connector, dataRegistry, }: GenerateErrorAiInsightParams): Promise { const urlPrefix = core.http.basePath.get(request); @@ -119,5 +121,5 @@ export async function generateErrorAiInsight({ stream: true, }); - return createAiInsightResult(errorContext, events$); + return createAiInsightResult(errorContext, connector, events$); } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_alert_ai_insights.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_alert_ai_insights.ts deleted file mode 100644 index 9daf90e84ffca..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_alert_ai_insights.ts +++ /dev/null @@ -1,382 +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 { KibanaRequest, Logger } from '@kbn/core/server'; -import type { InferenceClient, ChatCompletionEvent } from '@kbn/inference-common'; -import { MessageRole } from '@kbn/inference-common'; -import dedent from 'dedent'; -import { compact, isEmpty } from 'lodash'; -import moment from 'moment'; -import type { Observable } from 'rxjs'; -import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; -import { createAiInsightResult, type AiInsightResult } from './types'; -import type { - ObservabilityAgentBuilderCoreSetup, - ObservabilityAgentBuilderPluginSetupDependencies, -} from '../../types'; -import { getToolHandler as getLogGroups } from '../../tools/get_log_groups/handler'; -import { getToolHandler as getRuntimeMetrics } from '../../tools/get_runtime_metrics/handler'; -import { getToolHandler as getHosts } from '../../tools/get_hosts/handler'; -import { getToolHandler as getServices } from '../../tools/get_services/handler'; -import { getEntityLinkingInstructions } from '../../agent/register_observability_agent'; - -/** - * These types are derived from the generated alerts-as-data schemas: - * - `AlertSchema` in `kbn-alerts-as-data-utils` (see `alert_schema.ts`) which defines - * `kibana.alert.start` and other technical fields. - * - `ObservabilityApmAlertSchema` in `observability_apm_schema.ts`, which adds - * the APM-specific fields like `service.*` and `transaction.*`. - * - * We only rely on these well-known keys; all other properties are treated as - * opaque via the index signature below so this type can safely represent any - * Observability alert document. - */ -export interface AlertDocForInsight { - 'service.name'?: string; - 'service.environment'?: string; - 'transaction.type'?: string; - 'transaction.name'?: string; - 'host.name'?: string; - 'kibana.alert.start'?: string | number; - [key: string]: unknown; -} - -interface GetAlertAiInsightParams { - core: ObservabilityAgentBuilderCoreSetup; - plugins: ObservabilityAgentBuilderPluginSetupDependencies; - alertDoc: AlertDocForInsight; - inferenceClient: InferenceClient; - connectorId: string; - dataRegistry: ObservabilityAgentBuilderDataRegistry; - request: KibanaRequest; - logger: Logger; -} - -export async function getAlertAiInsight({ - core, - plugins, - alertDoc, - inferenceClient, - connectorId, - dataRegistry, - request, - logger, -}: GetAlertAiInsightParams): Promise { - const urlPrefix = core.http.basePath.get(request); - - const relatedContext = await fetchAlertContext({ - core, - plugins, - alertDoc, - dataRegistry, - request, - logger, - }); - const events$: Observable = generateAlertSummary({ - inferenceClient, - connectorId, - alertDoc, - urlPrefix, - context: relatedContext, - }); - - return createAiInsightResult(relatedContext, events$); -} - -// Time window offsets in minutes before alert start -const START_TIME_OFFSETS = { - serviceSummary: 5, - downstream: 24 * 60, // 24 hours - logs: 15, - changePoints: 6 * 60, // 6 hours - runtimeMetrics: 15, - infraHosts: 15, - servicesOnHost: 15, -} as const; - -async function fetchAlertContext({ - core, - plugins, - alertDoc, - dataRegistry, - request, - logger, -}: Pick< - GetAlertAiInsightParams, - 'core' | 'plugins' | 'alertDoc' | 'dataRegistry' | 'request' | 'logger' ->): Promise { - const serviceName = alertDoc?.['service.name'] ?? ''; - const serviceEnvironment = alertDoc?.['service.environment'] ?? ''; - const transactionType = alertDoc?.['transaction.type']; - const transactionName = alertDoc?.['transaction.name']; - const hostName = alertDoc?.['host.name'] ?? ''; - - // Need at least a service name or host name to fetch context - if (!serviceName && !hostName) { - return 'No related signals available.'; - } - - const alertTime = moment(alertDoc?.['kibana.alert.start']); - const alertStart = alertTime.toISOString(); - - const getStart = (minutesBefore: number) => - alertTime.clone().subtract(minutesBefore, 'minutes').toISOString(); - - // Config-driven fetch definitions - const fetchConfigs = [ - { - key: 'apmServiceSummary' as const, - startOffset: START_TIME_OFFSETS.serviceSummary, - params: { serviceName, serviceEnvironment, transactionType }, - }, - { - key: 'apmDownstreamDependencies' as const, - startOffset: START_TIME_OFFSETS.downstream, - params: { serviceName, serviceEnvironment }, - }, - { - key: 'apmServiceChangePoints' as const, - startOffset: START_TIME_OFFSETS.changePoints, - params: { serviceName, serviceEnvironment, transactionType, transactionName }, - }, - { - key: 'apmExitSpanChangePoints' as const, - startOffset: START_TIME_OFFSETS.changePoints, - params: { serviceName, serviceEnvironment }, - }, - ]; - - const [coreStart] = await core.getStartServices(); - const esClient = coreStart.elasticsearch.client.asScoped(request); - - async function fetchLogGroups() { - // Build filter based on available identifiers - let filter: string; - if (serviceName) { - filter = `service.name: "${serviceName}"`; - } else if (hostName) { - filter = `host.name: "${hostName}"`; - } else { - return null; - } - - try { - const start = getStart(START_TIME_OFFSETS.logs); - const end = alertStart; - const result = await getLogGroups({ - core, - plugins, - request, - logger, - esClient, - start, - end, - kqlFilter: filter, - fields: [], - includeStackTrace: false, - includeFirstSeen: false, - size: 10, - }); - - return result.length > 0 ? { key: 'logGroups' as const, start, end, data: result } : null; - } catch (err) { - logger.debug(`AI insight: logGroups failed: ${err}`); - return null; - } - } - - async function fetchRuntimeMetrics() { - if (!serviceName) return null; - - try { - const start = getStart(START_TIME_OFFSETS.runtimeMetrics); - const end = alertStart; - const result = await getRuntimeMetrics({ - core, - plugins, - request, - logger, - serviceName, - serviceEnvironment, - start, - end, - }); - - return result.nodes.length > 0 - ? { key: 'runtimeMetrics' as const, start, end, data: result.nodes } - : null; - } catch (err) { - logger.debug(`AI insight: runtimeMetrics failed: ${err}`); - return null; - } - } - - async function fetchInfraHosts() { - const kqlFilter = hostName - ? `host.name: "${hostName}"` - : serviceName - ? `service.name: "${serviceName}"` - : null; - - if (!kqlFilter) return null; - - try { - const start = getStart(START_TIME_OFFSETS.infraHosts); - const end = alertStart; - const result = await getHosts({ - request, - dataRegistry, - start, - end, - limit: 10, - kqlFilter, - }); - - return result.hosts.length > 0 - ? { key: 'infraHosts' as const, start, end, data: result.hosts } - : null; - } catch (err) { - logger.debug(`AI insight: infraHosts failed: ${err}`); - return null; - } - } - - // Reverse correlation: find services running on a host (for infra alerts) - async function fetchServicesOnHost() { - // Only fetch if we have a host name but no service name - // (for infra alerts that need to discover which services are affected) - if (!hostName || serviceName) return null; - - try { - const start = getStart(START_TIME_OFFSETS.servicesOnHost); - const end = alertStart; - const result = await getServices({ - core, - plugins, - request, - esClient, - dataRegistry, - logger, - start, - end, - kqlFilter: `host.name: "${hostName}"`, - }); - - return result.services.length > 0 - ? { key: 'servicesOnHost' as const, start, end, data: result.services } - : null; - } catch (err) { - logger.debug(`AI insight: servicesOnHost failed: ${err}`); - return null; - } - } - - // APM-specific fetchers only run when we have a service name - const apmFetchers = serviceName - ? [ - ...fetchConfigs.map(async (config) => { - try { - const start = getStart(config.startOffset); - const end = alertStart; - const data = await dataRegistry.getData(config.key, { - request, - ...config.params, - start, - end, - }); - return isEmpty(data) ? null : { key: config.key, start, end, data }; - } catch (err) { - logger.debug(`AI insight: ${config.key} failed: ${err}`); - return null; - } - }), - fetchRuntimeMetrics(), - ] - : []; - - // These fetchers work with either service.name or host.name - const allFetchers = [...apmFetchers, fetchLogGroups(), fetchInfraHosts(), fetchServicesOnHost()]; - - const results = await Promise.all(allFetchers); - const contextParts = compact(results).map( - ({ key, start, end, data }) => - `<${key}>\nTime window: ${start} to ${end}\n\`\`\`json\n${JSON.stringify( - data, - null, - 2 - )}\n\`\`\`\n` - ); - - return contextParts.length > 0 ? contextParts.join('\n\n') : 'No related signals available.'; -} - -function generateAlertSummary({ - inferenceClient, - urlPrefix, - connectorId, - alertDoc, - context, -}: { - inferenceClient: InferenceClient; - urlPrefix: string; - connectorId: string; - alertDoc: AlertDocForInsight; - context: string; -}): Observable { - const systemPrompt = dedent(` - You are an SRE assistant. Help an SRE quickly understand likely cause, impact, and next actions for this alert using the provided context. - - Output format (use **bold** titles, prose paragraphs, minimal bullets): - - **Summary** - 1–2 sentences: What is likely happening and why it matters. If recovered, reduce urgency. If no strong signals, say "Inconclusive" and briefly note why. - - **Assessment** - Most plausible explanation in prose, citing the signals that support it (e.g., "downstream metrics show...", "logs indicate..."). Say "Inconclusive" if signals do not support a clear cause. - - **Next Steps** - 2–3 numbered actions an SRE can take now. - - Guardrails: - - Do not repeat the alert reason verbatim. - - Only give a non-inconclusive Assessment when supported by on-topic signals; otherwise say "Inconclusive" and don't speculate. - - Keep it concise (~100–150 words total). - - Available signals (use what's relevant and available): - - Runtime metrics: CPU, memory, GC duration, thread count — indicates internal resource pressure - - Downstream dependencies: latency/errors in called services — indicates external issues - - Change points: sudden shifts in throughput/latency/failure rate — shows when problems started - - Log categories: error messages and exception patterns - - Service summary: instance counts, versions, anomalies, and metadata - - Host infrastructure: CPU, memory, disk, network usage — indicates host-level resource pressure - - Services on host: for infrastructure alerts, shows services running on the affected host — helps identify which service may be causing resource pressure - - Note: Numeric values on a 0-1 scale represent percentages (e.g., 0.95 = 95%, 0.3 = 30%). - - ${getEntityLinkingInstructions({ urlPrefix })} - `); - - const alertDetails = `\`\`\`json\n${JSON.stringify(alertDoc, null, 2)}\n\`\`\``; - - const userPrompt = dedent(` - - ${alertDetails} - - - ${context} - - Task: - Summarize likely cause, impact, and immediate next checks for this alert using the format above. Tie related signals to the alert scope; ignore unrelated noise. If signals are weak or conflicting, mark Assessment "Inconclusive" and propose the safest next diagnostic step. - `); - - return inferenceClient.chatComplete({ - connectorId, - system: systemPrompt, - messages: [{ role: MessageRole.User, content: userPrompt }], - stream: true, - }); -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts index a850740c2673e..68daa1a81d847 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts @@ -7,23 +7,32 @@ import moment from 'moment'; import type { Observable } from 'rxjs'; -import type { ChatCompletionEvent, InferenceClient } from '@kbn/inference-common'; +import type { + ChatCompletionEvent, + InferenceClient, + InferenceConnector, +} from '@kbn/inference-common'; import { MessageRole } from '@kbn/inference-common'; import type { IScopedClusterClient, KibanaRequest, Logger } from '@kbn/core/server'; import dedent from 'dedent'; -import type { ObservabilityAgentBuilderCoreSetup } from '../../types'; +import type { + ObservabilityAgentBuilderCoreSetup, + ObservabilityAgentBuilderPluginSetupDependencies, +} from '../../types'; import { getLogDocumentById, type LogDocument } from './get_log_document_by_id'; -import { getCorrelatedLogsForLogEntry } from '../../tools/get_correlated_logs/handler'; +import { getToolHandler as getTraces } from '../../tools/get_traces/handler'; import { isWarningOrAbove } from '../../utils/warning_and_above_log_filter'; import { getEntityLinkingInstructions } from '../../agent/register_observability_agent'; import { createAiInsightResult, type AiInsightResult } from './types'; export interface GetLogAiInsightsParams { core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; index: string; id: string; inferenceClient: InferenceClient; connectorId: string; + connector: InferenceConnector; request: KibanaRequest; esClient: IScopedClusterClient; logger: Logger; @@ -31,11 +40,13 @@ export interface GetLogAiInsightsParams { export async function getLogAiInsights({ core, + plugins, index, id, esClient, inferenceClient, connectorId, + connector, request, logger, }: GetLogAiInsightsParams): Promise { @@ -51,6 +62,7 @@ export async function getLogAiInsights({ const context = await fetchLogContext({ core, + plugins, logger, esClient, index, @@ -67,11 +79,12 @@ export async function getLogAiInsights({ request, }); - return createAiInsightResult(context, events$); + return createAiInsightResult(context, connector, events$); } async function fetchLogContext({ core, + plugins, logger, esClient, index, @@ -79,6 +92,7 @@ async function fetchLogContext({ logEntry, }: { core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; logger: Logger; esClient: IScopedClusterClient; index: string; @@ -104,31 +118,32 @@ async function fetchLogContext({ `); - let correlatedLogsResult; try { - const { sequences } = await getCorrelatedLogsForLogEntry({ + const { traces } = await getTraces({ core, + plugins, logger, esClient, index, start: windowStart, end: windowEnd, - logId: id, + kqlFilter: `_id: ${id}`, + maxTraces: 10, + maxDocsPerTrace: 100, }); - correlatedLogsResult = sequences[0]; - } catch (error) { - logger.debug(`Failed to fetch correlated logs: ${error.message}`); - } - - if (correlatedLogsResult?.logs?.length) { - context += dedent(` - + const trace = traces[0]; + if (trace) { + context += dedent(` + Time window: ${windowStart} to ${windowEnd} \`\`\`json - ${JSON.stringify(correlatedLogsResult, null, 2)} + ${JSON.stringify(trace, null, 2)} \`\`\` - + `); + } + } catch (error) { + logger.debug(`Failed to fetch traces: ${error.message}`); } return context; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts index 59e8d7b3140c9..d9eb69b227b78 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts @@ -13,7 +13,10 @@ import { getRequestAbortedSignal } from '@kbn/inference-plugin/server/routes/get import { generateErrorAiInsight } from './apm_error/generate_error_ai_insight'; import { createObservabilityAgentBuilderServerRoute } from '../create_observability_agent_builder_server_route'; import { getLogAiInsights } from './get_log_ai_insights'; -import { getAlertAiInsight, type AlertDocForInsight } from './get_alert_ai_insights'; +import { + getAlertAiInsight, + type AlertDocForInsight, +} from './alert_ai_insights/generate_alert_ai_insight'; import { getDefaultConnectorId } from '../../utils/get_default_connector_id'; export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerRouteRepository { @@ -40,6 +43,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerR const connectorId = await getDefaultConnectorId({ coreStart, inference, request, logger }); const inferenceClient = inference.getClient({ request }); + const connector = await inference.getConnectorById(connectorId, request); const alertsClient = await ruleRegistry.getRacClientWithRequest(request); const alertDoc = (await alertsClient.get({ id: alertId })) as AlertDocForInsight; @@ -50,6 +54,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerR alertDoc, inferenceClient, connectorId, + connector, dataRegistry, request, logger, @@ -91,6 +96,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerR const connectorId = await getDefaultConnectorId({ coreStart, inference, request, logger }); const inferenceClient = inference.getClient({ request, bindTo: { connectorId } }); + const connector = await inference.getConnectorById(connectorId, request); const result = await generateErrorAiInsight({ core, @@ -100,6 +106,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerR start, end, environment, + connector, dataRegistry, request, inferenceClient, @@ -131,7 +138,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerR id: t.string, }), }), - handler: async ({ request, core, dataRegistry, params, response, logger, plugins }) => { + handler: async ({ request, core, params, response, logger, plugins }) => { const { index, id } = params.body; const [coreStart, startDeps] = await core.getStartServices(); @@ -139,14 +146,17 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerR const connectorId = await getDefaultConnectorId({ coreStart, inference, request }); const inferenceClient = inference.getClient({ request }); + const connector = await inference.getConnectorById(connectorId, request); const esClient = coreStart.elasticsearch.client.asScoped(request); const result = await getLogAiInsights({ core, + plugins, index, id, inferenceClient, connectorId, + connector, request, esClient, logger, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/types.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/types.ts index 656d73ff4ebe9..071995134b2a5 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/types.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/types.ts @@ -7,7 +7,11 @@ import type { Observable } from 'rxjs'; import { concat, of } from 'rxjs'; -import type { ChatCompletionEvent } from '@kbn/inference-common'; +import type { ChatCompletionEvent, InferenceConnector } from '@kbn/inference-common'; +import { getConnectorFamily, getConnectorProvider, getConnectorModel } from '@kbn/inference-common'; +import type { ConnectorInfo } from '../../../common'; + +export type { ConnectorInfo }; export interface ContextEvent { type: 'context'; @@ -15,20 +19,49 @@ export interface ContextEvent { [key: string]: unknown; } +export interface ConnectorInfoEvent { + type: 'connectorInfo'; + connector: ConnectorInfo; + [key: string]: unknown; +} + +export type AiInsightEvent = ChatCompletionEvent | ContextEvent | ConnectorInfoEvent; + export interface AiInsightResult { - events$: Observable; + events$: Observable; context: string; } /** - * Creates an AiInsightResult by prepending a context event to the chat completion stream. + * Builds ConnectorInfo from an InferenceConnector + */ +export function buildConnectorInfo(connector: InferenceConnector): ConnectorInfo { + return { + connectorId: connector.connectorId, + name: connector.name, + type: connector.type, + modelFamily: getConnectorFamily(connector), + modelProvider: getConnectorProvider(connector), + modelId: getConnectorModel(connector) ?? 'unknown', + }; +} + +/** + * Creates an AiInsightResult by prepending context and connector info events to the chat completion stream. */ export function createAiInsightResult( context: string, + connector: InferenceConnector, events$: Observable ): AiInsightResult { + const connectorInfo = buildConnectorInfo(connector); + return { - events$: concat(of({ type: 'context', context }), events$), + events$: concat( + of({ type: 'context', context }), + of({ type: 'connectorInfo', connector: connectorInfo }), + events$ + ), context, }; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/AGENTS.md b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/AGENTS.md index 181abcca42a74..16b86cdf67359 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/AGENTS.md +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/AGENTS.md @@ -19,7 +19,7 @@ | **Detection** | `get_alerts`, `get_services` | | **Scope** | `get_services`, `get_hosts`, `get_trace_metrics` | | **Timeline** | `get_trace_metrics` (time series), `run_log_rate_analysis` | -| **Correlation** | `get_correlated_logs`, `get_downstream_dependencies` | +| **Correlation** | `get_traces`, `get_service_topology` | | **Root Cause** | `get_log_groups`, `get_trace_metrics` (grouped by dimension) | --- @@ -279,6 +279,21 @@ All new tools **must** be added to the Agent Builder allow list: x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts ``` +### Cleaning Observability Data + +Delete all observability data streams (APM, OTel, logs, infrastructure metrics, synthetics) to avoid stale data polluting results: + +```bash +curl -s -X DELETE "http://elastic:changeme@localhost:9200/_data_stream/traces-apm*,metrics-apm*,logs-apm*,metrics-*.otel*,traces-*.otel*,logs-*.otel*,logs-*-*,metrics-system*,metrics-kubernetes*,metrics-docker*,metrics-aws*,synthetics-*-*" | jq . +``` + +Verify that all data streams are gone: + +```bash +curl -s "http://elastic:changeme@localhost:9200/_data_stream/*apm*,*otel*,logs-*,metrics-*,synthetics-*" | jq '[.data_streams[] | .name]' +# Expected: [] +``` + --- ## 8. Testing with OpenTelemetry Demo @@ -332,19 +347,7 @@ Full list of available feature flags: https://opentelemetry.io/docs/demo/feature ### Cleaning Data Between Test Runs -Between test runs, delete all APM data streams to avoid data from previous scenarios polluting results: - -```bash -for ds in traces-apm-default \ - logs-apm.error-default \ - metrics-apm.internal-default \ - metrics-apm.transaction.1m-default \ - metrics-apm.service_destination.1m-default \ - metrics-apm.service_transaction.1m-default \ - metrics-apm.service_summary.1m-default; do - curl -s -X DELETE "http://elastic:changeme@localhost:9200/_data_stream/$ds" | jq -r '.acknowledged // .error.type' -done -``` +Between test runs, clean all observability data streams — see [Cleaning Observability Data](#cleaning-observability-data) in section 7. ### Wait for Data Accumulation diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/handler.ts index 106093c276bd3..4790be43691f7 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/handler.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/handler.ts @@ -7,12 +7,14 @@ import type { KibanaRequest, Logger } from '@kbn/core/server'; import type Ml from '@elastic/elasticsearch/lib/api/api/ml'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import type { ObservabilityAgentBuilderCoreSetup, ObservabilityAgentBuilderPluginSetupDependencies, } from '../../types'; +import { kqlToInfluencerQuery } from './kql_to_influencer_query'; type MlSystem = ReturnType; @@ -22,8 +24,13 @@ export async function getToolHandler({ mlClient, request, logger, + group, jobIds = [], jobsLimit, + anomalyRecordsLimit, + minAnomalyScore, + includeExplanation, + influencerFilter, rangeStart, rangeEnd, }: { @@ -32,8 +39,13 @@ export async function getToolHandler({ mlClient: Ml; request: KibanaRequest; logger: Logger; + group?: string; jobIds?: string[]; jobsLimit: number; + anomalyRecordsLimit: number; + minAnomalyScore: number; + includeExplanation: boolean; + influencerFilter?: string; rangeStart: string; rangeEnd: string; }) { @@ -45,7 +57,13 @@ export async function getToolHandler({ throw new Error('Machine Learning plugin is unavailable.'); } - const { jobs = [] } = await mlClient.getJobs({ job_id: jobIds.join(',') }).catch((error) => { + // The ML getJobs API job_id parameter accepts: job identifiers, group names, + // comma-separated lists, or wildcard expressions. When a group name is passed, + // it automatically expands to all jobs belonging to that group. + // See: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-ml-get-jobs + const jobIdParam = [group, ...jobIds].filter(Boolean).join(','); + + const { jobs = [] } = await mlClient.getJobs({ job_id: jobIdParam }).catch((error) => { if (error.statusCode === 404) { return { jobs: [] }; } @@ -55,7 +73,7 @@ export async function getToolHandler({ // Get job stats for state information const { jobs: jobsStats = [] } = await mlClient - .getJobStats({ job_id: jobIds.join(',') }) + .getJobStats({ job_id: jobIdParam }) .catch((error) => { if (error.statusCode === 404) { return { jobs: [] }; @@ -68,12 +86,19 @@ export async function getToolHandler({ return Promise.all( jobs.slice(0, jobsLimit).map(async (job) => { - const topAnomalies = await getTopAnomalyRecords({ - mlSystem, - jobId: job.job_id, - start: rangeStart, - end: rangeEnd, - }); + const topAnomalies = + anomalyRecordsLimit > 0 + ? await getTopAnomalyRecords({ + mlSystem, + jobId: job.job_id, + anomalyRecordsLimit, + minAnomalyScore, + includeExplanation, + influencerFilter, + start: rangeStart, + end: rangeEnd, + }) + : []; const jobStats = jobsStatsMap.get(job.job_id); @@ -101,45 +126,60 @@ export async function getToolHandler({ async function getTopAnomalyRecords({ mlSystem, jobId, + anomalyRecordsLimit, + minAnomalyScore, + includeExplanation, + influencerFilter, start, end, }: { mlSystem: MlSystem; jobId: string; + anomalyRecordsLimit: number; + minAnomalyScore: number; + includeExplanation: boolean; + influencerFilter?: string; start: string; end: string; }) { + const sourceFields = [ + 'timestamp', + 'record_score', + 'by_field_name', + 'by_field_value', + 'partition_field_name', + 'partition_field_value', + 'field_name', + 'typical', + 'actual', + 'influencers', + ...(includeExplanation ? ['anomaly_score_explanation'] : []), + ]; + + const filters: QueryDslQueryContainer[] = [ + { term: { job_id: jobId } }, + { term: { result_type: 'record' } }, + { term: { is_interim: false } }, + { range: { timestamp: { gte: start, lte: end } } }, + { range: { record_score: { gte: minAnomalyScore } } }, + ]; + + const influencerQuery = kqlToInfluencerQuery(influencerFilter); + if (influencerQuery) { + filters.push(influencerQuery); + } + const response = await mlSystem.mlAnomalySearch( { track_total_hits: false, - size: 100, + size: anomalyRecordsLimit, sort: [{ record_score: { order: 'desc' as const } }], query: { bool: { - filter: [ - { term: { job_id: jobId } }, - { term: { result_type: 'record' } }, - { term: { is_interim: false } }, - { - range: { - timestamp: { gte: start, lte: end }, - }, - }, - ], + filter: filters, }, }, - _source: [ - 'timestamp', - 'record_score', - 'by_field_name', - 'by_field_value', - 'partition_field_name', - 'partition_field_value', - 'field_name', - 'anomaly_score_explanation', - 'typical', - 'actual', - ], + _source: sourceFields, }, [jobId] ); @@ -156,8 +196,12 @@ async function getTopAnomalyRecords({ partitionFieldName: record.partition_field_name, partitionFieldValue: record.partition_field_value, fieldName: record.field_name, - anomalyScoreExplanation: record.anomaly_score_explanation, typicalValue: record.typical, actualValue: record.actual, + influencers: record.influencers?.map((inf) => ({ + fieldName: inf.influencer_field_name, + fieldValues: inf.influencer_field_values, + })), + ...(includeExplanation && { anomalyScoreExplanation: record.anomaly_score_explanation }), })); } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/kql_to_influencer_query.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/kql_to_influencer_query.test.ts new file mode 100644 index 0000000000000..56df2bab4daca --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/kql_to_influencer_query.test.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kqlToInfluencerQuery } from './kql_to_influencer_query'; + +function nestedTerm(fieldName: string, fieldValue?: string) { + return { + nested: { + path: 'influencers', + query: { + bool: { + filter: [ + { term: { 'influencers.influencer_field_name': fieldName } }, + ...(fieldValue !== undefined + ? [{ term: { 'influencers.influencer_field_values': fieldValue } }] + : []), + ], + }, + }, + }, + }; +} + +function nestedWildcard(fieldName: string, fieldValue: string) { + return { + nested: { + path: 'influencers', + query: { + bool: { + filter: [ + { term: { 'influencers.influencer_field_name': fieldName } }, + { wildcard: { 'influencers.influencer_field_values': fieldValue } }, + ], + }, + }, + }, + }; +} + +describe('kqlToInfluencerQuery', () => { + describe('returns undefined for empty input', () => { + it.each([undefined, '', ' ', '\t\n'])('when input is %j', (input) => { + expect(kqlToInfluencerQuery(input)).toBeUndefined(); + }); + }); + + describe('simple field:value', () => { + it('handles a quoted value', () => { + const result = kqlToInfluencerQuery('service.name: "frontend"'); + expect(result).toEqual({ + bool: { + should: [nestedTerm('service.name', 'frontend')], + minimum_should_match: 1, + }, + }); + }); + + it('handles an unquoted value', () => { + const result = kqlToInfluencerQuery('service.name: frontend'); + expect(result).toEqual({ + bool: { + should: [nestedTerm('service.name', 'frontend')], + minimum_should_match: 1, + }, + }); + }); + }); + + describe('AND logic', () => { + it('combines two conditions with bool.filter', () => { + const result = kqlToInfluencerQuery('service.name: "frontend" AND host.name: "server-1"'); + + expect(result).toEqual({ + bool: { + filter: [ + { + bool: { + should: [nestedTerm('service.name', 'frontend')], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [nestedTerm('host.name', 'server-1')], + minimum_should_match: 1, + }, + }, + ], + }, + }); + }); + }); + + describe('OR logic', () => { + it('combines two conditions with bool.should', () => { + const result = kqlToInfluencerQuery('service.name: "frontend" OR host.name: "server-1"'); + + expect(result).toEqual({ + bool: { + should: [ + { + bool: { + should: [nestedTerm('service.name', 'frontend')], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [nestedTerm('host.name', 'server-1')], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('handles parenthesized value list shorthand', () => { + const result = kqlToInfluencerQuery('service.name: ("frontend" OR "backend")'); + + expect(result).toEqual({ + bool: { + should: [ + { + bool: { + should: [nestedTerm('service.name', 'frontend')], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [nestedTerm('service.name', 'backend')], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + }); + + describe('NOT logic', () => { + it('wraps the inner query with bool.must_not', () => { + const result = kqlToInfluencerQuery('NOT service.name: "frontend"'); + + expect(result).toEqual({ + bool: { + must_not: [ + { + bool: { + should: [nestedTerm('service.name', 'frontend')], + minimum_should_match: 1, + }, + }, + ], + }, + }); + }); + }); + + describe('exists query', () => { + it('produces a nested query matching the influencer field name only', () => { + const result = kqlToInfluencerQuery('service.name: *'); + + expect(result).toEqual({ + bool: { + should: [nestedTerm('service.name')], + minimum_should_match: 1, + }, + }); + }); + }); + + describe('wildcard value', () => { + it('uses a wildcard query for the influencer field value', () => { + const result = kqlToInfluencerQuery('service.name: front*'); + + expect(result).toEqual({ + bool: { + should: [nestedWildcard('service.name', 'front*')], + minimum_should_match: 1, + }, + }); + }); + }); + + describe('invalid KQL', () => { + it('throws on malformed input', () => { + expect(() => kqlToInfluencerQuery(':::')).toThrow(); + }); + }); + + describe('unsupported KQL patterns (pass-through behavior)', () => { + it('range query: passes through the range DSL unchanged', () => { + const result = kqlToInfluencerQuery('service.name > "abc"'); + + expect(result).toEqual({ + bool: { + should: [{ range: { 'service.name': { gt: 'abc' } } }], + minimum_should_match: 1, + }, + }); + }); + + it('field-less query: passes through multi_match DSL unchanged', () => { + const result = kqlToInfluencerQuery('frontend'); + + expect(result).toEqual({ + multi_match: { + type: 'best_fields', + query: 'frontend', + lenient: true, + }, + }); + }); + + it('match-all (*: *): passes through match_all DSL unchanged', () => { + const result = kqlToInfluencerQuery('*: *'); + + expect(result).toEqual({ match_all: {} }); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/kql_to_influencer_query.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/kql_to_influencer_query.ts new file mode 100644 index 0000000000000..52d57d08dd391 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/kql_to_influencer_query.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import { isEmpty } from 'lodash'; + +/** + * Converts a KQL string into a nested Elasticsearch query targeting + * influencer fields in ML anomaly records. + * + * ML anomaly records store influencer data as nested objects + * (`influencers.influencer_field_name` / `influencers.influencer_field_values`), + * so standard KQL-to-ES conversion doesn't work directly. This function + * uses the standard KQL→DSL pipeline, then rewrites leaf field queries + * into the nested influencer structure while preserving bool logic. + */ +export function kqlToInfluencerQuery( + influencerFilter?: string +): QueryDslQueryContainer | undefined { + if (isEmpty(influencerFilter?.trim())) { + return undefined; + } + + const dsl = toElasticsearchQuery(fromKueryExpression(influencerFilter!)); + return rewriteDslToInfluencerQuery(dsl); +} + +/** + * Walks a DSL query tree produced by `toElasticsearchQuery`. Bool compounds + * are recursed into (preserving structure), and leaf queries (match, + * match_phrase, term, exists, wildcard, query_string) are rewritten to + * target the nested `influencers` path. + */ +function rewriteDslToInfluencerQuery(dsl: QueryDslQueryContainer): QueryDslQueryContainer { + if (dsl.bool) { + const rewritten: QueryDslQueryContainer = { bool: {} }; + if (dsl.bool.filter) { + rewritten.bool!.filter = toArray(dsl.bool.filter).map(rewriteDslToInfluencerQuery); + } + if (dsl.bool.must) { + rewritten.bool!.must = toArray(dsl.bool.must).map(rewriteDslToInfluencerQuery); + } + if (dsl.bool.should) { + rewritten.bool!.should = toArray(dsl.bool.should).map(rewriteDslToInfluencerQuery); + rewritten.bool!.minimum_should_match = dsl.bool.minimum_should_match; + } + if (dsl.bool.must_not) { + rewritten.bool!.must_not = toArray(dsl.bool.must_not).map(rewriteDslToInfluencerQuery); + } + return rewritten; + } + + const extracted = extractFieldAndValue(dsl); + if (!extracted) { + return dsl; + } + + const valueFilter: QueryDslQueryContainer[] = []; + if (extracted.value !== undefined) { + valueFilter.push( + extracted.isWildcard + ? { wildcard: { 'influencers.influencer_field_values': extracted.value } } + : { term: { 'influencers.influencer_field_values': extracted.value } } + ); + } + + return { + nested: { + path: 'influencers', + query: { + bool: { + filter: [ + { term: { 'influencers.influencer_field_name': extracted.field } }, + ...valueFilter, + ], + }, + }, + }, + }; +} + +function extractFieldAndValue( + dsl: QueryDslQueryContainer +): { field: string; value?: string; isWildcard?: boolean } | undefined { + if (dsl.match) { + const [field] = Object.keys(dsl.match); + const clause = (dsl.match as Record)[field]; + return { field, value: String(typeof clause === 'object' ? (clause as any).query : clause) }; + } + if (dsl.match_phrase) { + const [field] = Object.keys(dsl.match_phrase); + const clause = (dsl.match_phrase as Record)[field]; + return { field, value: String(typeof clause === 'object' ? (clause as any).query : clause) }; + } + if (dsl.term) { + const [field] = Object.keys(dsl.term); + const clause = (dsl.term as Record)[field]; + return { field, value: String(typeof clause === 'object' ? (clause as any).value : clause) }; + } + if (dsl.exists) { + return { field: dsl.exists.field as string }; + } + if (dsl.wildcard) { + const [field] = Object.keys(dsl.wildcard); + const clause = (dsl.wildcard as Record)[field]; + return { + field, + value: String(typeof clause === 'object' ? (clause as any).value : clause), + isWildcard: true, + }; + } + if (dsl.query_string) { + const qs = dsl.query_string; + const field = Array.isArray(qs.fields) ? qs.fields[0] : undefined; + if (field && qs.query) { + return { field, value: String(qs.query), isWildcard: true }; + } + } + return undefined; +} + +function toArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/tool.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/tool.ts index 1abe45b681a64..0b4e9f935559e 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/tool.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_anomaly_detection_jobs/tool.ts @@ -23,6 +23,8 @@ export const OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID = 'observability.get_anomaly_detection_jobs'; const DEFAULT_JOBS_LIMIT = 10; +const DEFAULT_ANOMALY_RECORDS_LIMIT = 10; +const DEFAULT_MIN_ANOMALY_SCORE = 50; const DEFAULT_TIME_RANGE = { start: 'now-24h', end: 'now', @@ -38,19 +40,52 @@ export interface GetAnomalyDetectionJobsToolResult { } const getAnomalyDetectionJobsSchema = z.object({ + group: z + .string() + .optional() + .describe('Filter jobs by ML job group name (e.g., "apm", "network").'), jobIds: z .array(z.string().min(1)) .min(1) .max(20) .optional() .describe('Specific ML job IDs to query. Omit to include all jobs in this space.'), - limit: z + jobsLimit: z .number() .int() .min(1) .max(25) .default(DEFAULT_JOBS_LIMIT) .describe('Maximum number of jobs to return.'), + anomalyRecordsLimit: z + .number() + .int() + .min(0) + .max(100) + .default(DEFAULT_ANOMALY_RECORDS_LIMIT) + .describe( + 'Maximum anomaly records to return per job. Set to 0 to skip anomaly records entirely.' + ), + minAnomalyScore: z + .number() + .min(0) + .max(100) + .default(DEFAULT_MIN_ANOMALY_SCORE) + .describe( + 'Minimum anomaly score threshold (0-100). Higher scores indicate more severe anomalies. Default is 50 to filter noise.' + ), + includeExplanation: z + .boolean() + .default(false) + .describe( + 'Include detailed anomaly score explanations. Disabled by default to reduce response size.' + ), + influencerFilter: z + .string() + .optional() + .describe( + 'Filter anomalies by influencer fields using KQL syntax. Influencer fields are entity fields like service.name, host.name, kubernetes.pod.name, etc. Examples: \'service.name: "frontend"\', \'service.name: "frontend" AND host.name: "server-1"\', \'NOT host.name: "server-3"\'.' + ), ...timeRangeSchemaOptional(DEFAULT_TIME_RANGE), }); @@ -86,7 +121,17 @@ When to use: ): Promise<{ results: (GetAnomalyDetectionJobsToolResult | Omit)[]; }> => { - const { jobIds, limit: jobsLimit, start: rangeStart, end: rangeEnd } = toolParams; + const { + group, + jobIds, + jobsLimit, + anomalyRecordsLimit, + minAnomalyScore, + includeExplanation, + influencerFilter, + start: rangeStart, + end: rangeEnd, + } = toolParams; const scopedEsClient = esClient.asCurrentUser; const mlClient = scopedEsClient.ml; @@ -97,8 +142,13 @@ When to use: mlClient, request, logger, + group, jobIds, jobsLimit, + anomalyRecordsLimit, + minAnomalyScore, + includeExplanation, + influencerFilter, rangeStart, rangeEnd, }); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/README.md b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/README.md deleted file mode 100644 index 0cfaf3ab3ab3f..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# get_correlated_logs - -Retrieves log sequences around error events to understand what happened. By default, anchors on error logs (ERROR, WARN, FATAL, HTTP 5xx). Set `errorLogsOnly: false` to anchor on any (non-error) logs. - -## Examples - -### Find correlated logs for errors in the payment-service - -```jsonc -POST kbn://api/agent_builder/tools/_execute -{ - "tool_id": "observability.get_correlated_logs", - "tool_params": { - "kqlFilter": "service.name: payment-service" - } -} -``` - -### Find correlated logs for slow requests (non-errors) - -```jsonc -POST kbn://api/agent_builder/tools/_execute -{ - "tool_id": "observability.get_correlated_logs", - "tool_params": { - "kqlFilter": "service.name: payment-service AND event.duration > 1000000", - "errorLogsOnly": false - } -} -``` - -### Find correlated logs for a specific log ID - -```jsonc -POST kbn://api/agent_builder/tools/_execute -{ - "tool_id": "observability.get_correlated_logs", - "tool_params": { - "logId": "c8f9d2a1-4b3e-4a1c-9d8e-7f6a5b4c3d2e" - } -} -``` - -### Use custom correlation fields - -```jsonc -POST kbn://api/agent_builder/tools/_execute -{ - "tool_id": "observability.get_correlated_logs", - "tool_params": { - "correlationFields": ["my_custom_correlation_id"] - } -} -``` diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/constants.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/constants.ts deleted file mode 100644 index b941b478908f3..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/constants.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// Correlation identifier fields in priority order -export const DEFAULT_CORRELATION_IDENTIFIER_FIELDS = [ - 'trace.id', - 'x-trace-id', - 'request.id', - 'request_id', - 'x_request_id', - 'transaction.id', - 'correlation.id', - 'correlation_id', - 'x-correlation-id', - 'http.request.id', - 'session.id', - 'session_id', - 'event.id', - 'cloud.trace_id', - 'parent.id', - 'span.id', - 'process.pid', -]; - -export const DEFAULT_LOG_SOURCE_FIELDS = [ - '@timestamp', - 'message', - 'log.level', - 'service.*', - 'host.*', - 'container.*', - 'kubernetes.*', - 'cloud.*', - 'error.*', - 'event.*', - 'url.*', - 'user_agent.*', - 'http.request.method', - 'http.response.status_code', - 'client.ip', -]; - -export const DEFAULT_TIME_RANGE = { - start: 'now-1h', - end: 'now', -}; - -export const DEFAULT_ERROR_LOGS_ONLY = true; -export const DEFAULT_MAX_SEQUENCES = 10; -export const DEFAULT_MAX_LOGS_PER_SEQUENCE = 50; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/fetch_anchor_log_by_id.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/fetch_anchor_log_by_id.ts deleted file mode 100644 index 3c09de7084fe0..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/fetch_anchor_log_by_id.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { first, get } from 'lodash'; -import type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; -import type { IScopedClusterClient, Logger } from '@kbn/core/server'; -import { getTypedSearch } from '../../../utils/get_typed_search'; -import type { AnchorLog } from '../types'; - -export async function getAnchorLogById({ - esClient, - logsIndices, - logId, - correlationFields, - logger, -}: { - esClient: IScopedClusterClient; - logsIndices: string[]; - logId: string; - correlationFields: string[]; - logger: Logger; -}): Promise { - const search = getTypedSearch(esClient.asCurrentUser); - const response = await search({ - size: 1, - track_total_hits: false, - _source: false, - fields: ['@timestamp', ...correlationFields], - index: logsIndices, - query: { ids: { values: [logId] } }, - }); - - const hit = first(response.hits.hits); - - if (!hit) { - logger.warn(`Log with ID ${logId} not found in indices: ${logsIndices.join(', ')}`); - return undefined; - } - - return getAnchorLogFromHit(hit, correlationFields); -} - -function getAnchorLogFromHit(hit: SearchHit, correlationFields: string[]): AnchorLog | undefined { - if (!hit) return undefined; - - const correlationIdentifier = correlationFields - .map((correlationField) => { - const timestamp = first(hit.fields?.['@timestamp']) as string; - const value = first(get(hit.fields, correlationField)) as string; - const anchorLogId = hit._id as string; - - return { - '@timestamp': timestamp, - correlation: { field: correlationField, value, anchorLogId }, - }; - }) - .find(({ correlation }) => correlation.value != null); - - return correlationIdentifier; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/fetch_anchor_logs.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/fetch_anchor_logs.ts deleted file mode 100644 index a5e761fbc0b86..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/fetch_anchor_logs.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { IScopedClusterClient, Logger } from '@kbn/core/server'; -import type { AnchorLog } from '../types'; -import { getAnchorLogById } from './fetch_anchor_log_by_id'; -import { getAnchorLogsForTimeRange } from './get_anchor_logs_for_time_range'; - -export async function getAnchorLogs({ - esClient, - logsIndices, - startTime, - endTime, - kqlFilter, - errorLogsOnly, - correlationFields, - logger, - logId, - maxSequences, -}: { - esClient: IScopedClusterClient; - logsIndices: string[]; - startTime: number; - endTime: number; - kqlFilter: string | undefined; - errorLogsOnly: boolean; - correlationFields: string[]; - logger: Logger; - logId?: string; - maxSequences: number; -}): Promise { - if (logId) { - const anchor = await getAnchorLogById({ - esClient, - logsIndices, - logId, - correlationFields, - logger, - }); - return anchor ? [anchor] : []; - } - - return getAnchorLogsForTimeRange({ - esClient, - logsIndices, - startTime, - endTime, - kqlFilter, - errorLogsOnly, - correlationFields, - logger, - maxSequences, - }); -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/get_anchor_logs_for_time_range.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/get_anchor_logs_for_time_range.ts deleted file mode 100644 index b1a2a702824fd..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/get_anchor_logs_for_time_range.ts +++ /dev/null @@ -1,179 +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 { uniqBy } from 'lodash'; -import { createHash } from 'crypto'; -import type { IScopedClusterClient, Logger } from '@kbn/core/server'; -import type { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/types'; -import { warningAndAboveLogFilter } from '../../../utils/warning_and_above_log_filter'; -import { getTypedSearch } from '../../../utils/get_typed_search'; -import { kqlFilter as buildKqlFilter, timeRangeFilter } from '../../../utils/dsl_filters'; -import type { AnchorLog } from '../types'; -import type { CorrelationFieldAggregations } from './types'; - -export async function getAnchorLogsForTimeRange({ - esClient, - logsIndices, - startTime, - endTime, - kqlFilter, - errorLogsOnly, - correlationFields, - logger, - maxSequences, -}: { - esClient: IScopedClusterClient; - logsIndices: string[]; - startTime: number; - endTime: number; - kqlFilter: string | undefined; - errorLogsOnly: boolean; - correlationFields: string[]; - logger: Logger; - maxSequences: number; -}) { - const search = getTypedSearch(esClient.asCurrentUser); - - // Use aggregation-based approach to get diverse samples across all correlation fields. - // This prevents "starvation" where a single sequence with many anchors would prevent other sequences from being discovered. - const searchRequest = { - size: 0, - track_total_hits: false, - index: logsIndices, - query: { - bool: { - filter: [ - ...timeRangeFilter('@timestamp', { start: startTime, end: endTime }), - ...buildKqlFilter(kqlFilter), - - // must have at least one correlation field - { - bool: { - should: correlationFields.map((field) => ({ exists: { field } })), - minimum_should_match: 1, - }, - }, - - // filter by error severity (default) or include all logs - ...(errorLogsOnly ? [warningAndAboveLogFilter()] : []), - ], - }, - }, - aggs: buildDiversifiedSamplerAggregations(correlationFields, maxSequences), - }; - - const response = await search(searchRequest); - - const anchorLogs = parseAnchorLogsFromAggregations( - response.aggregations as CorrelationFieldAggregations | undefined, - correlationFields - ); - - logger.debug(`Found ${anchorLogs.length} unique anchor logs across correlation fields`); - - return anchorLogs.slice(0, maxSequences); -} - -/** - * Generates a unique aggregation key from a field name. - * Combines a human-readable sanitized name with a short hash to guarantee uniqueness. - * e.g., 'trace.id' -> 'field_trace_id_a1b2c3', 'trace_id' -> 'field_trace_id_d4e5f6' - */ -function getAggNameForField(field: string): string { - const sanitized = field.replace(/[^a-zA-Z0-9]/g, '_'); - const hash = createHash('sha256').update(field).digest('hex').slice(0, 6); - return `field_${sanitized}_${hash}`; -} - -/** - * Builds aggregations for diverse sampling of anchor logs across multiple correlation fields. - * - * This uses a layered approach to handle high-cardinality fields efficiently: - * 1. Filter (exists): Skips documents without the field (fast, uses inverted index) - * 2. Diversified Sampler: Limits scope and ensures unique values per field - * 3. Terms with execution_hint 'map': Avoids Global Ordinals memory overhead - * 4. Top Hits: Fetches document metadata in a single pass - */ -function buildDiversifiedSamplerAggregations( - correlationFields: string[], - maxSequences: number -): Record { - return correlationFields.reduce((acc, field) => { - const aggName = getAggNameForField(field); - - const fieldAgg: AggregationsAggregationContainer = { - filter: { exists: { field } }, - aggs: { - diverse_sampler: { - diversified_sampler: { - shard_size: Math.max(100, maxSequences * 10), // Oversample to improve diversity - // shard_size: 1000, // Fixed oversampling to improve diversity - field, - max_docs_per_value: 1, - }, - aggs: { - unique_values: { - terms: { - field, - size: maxSequences, - execution_hint: 'map', // Disables "Global Ordinals". This is critically important for high-cardinality fields - }, - aggs: { - anchor_doc: { - top_hits: { - size: 1, - _source: ['@timestamp'], - }, - }, - }, - }, - }, - }, - }, - }; - - return { ...acc, [aggName]: fieldAgg }; - }, {} as Record); -} - -function parseAnchorLogsFromAggregations( - aggregations: CorrelationFieldAggregations | undefined, - correlationFields: string[] -): AnchorLog[] { - if (!aggregations) return []; - - const allAnchors = correlationFields.flatMap((field) => { - const aggName = getAggNameForField(field); - const filterAgg = aggregations[aggName]; - if (!filterAgg) return []; - - const buckets = filterAgg.diverse_sampler?.unique_values?.buckets ?? []; - - return buckets.map((bucket): AnchorLog => { - const firstHit = bucket.anchor_doc.hits.hits[0]; - - return { - '@timestamp': firstHit?._source?.['@timestamp'] ?? '', - correlation: { - field, - value: String(bucket.key), - anchorLogId: firstHit?._id ?? 'unknown', - }, - }; - }); - }); - - // Dedupe by anchor document ID first (keeps first occurrence = highest priority field). - // When the same document has multiple correlation fields (e.g., trace.id AND request.id), - // we keep only the one from the highest priority field since correlationFields is ordered. - // Then dedupe by field+value to ensure each correlation identity appears only once. - const dedupedByDocId = uniqBy(allAnchors, (anchor) => anchor.correlation.anchorLogId); - return uniqBy( - dedupedByDocId, - (anchor) => `${anchor.correlation.field}_${anchor.correlation.value}` - ); -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/types.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/types.ts deleted file mode 100644 index 967aac7f45359..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/fetch_anchor_logs/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -interface AnchorDocTopHitsAgg { - hits: { - hits: Array<{ - _id: string; - _source?: { - '@timestamp'?: string; - }; - }>; - }; -} - -interface UniqueValuesTermsAgg { - buckets: Array<{ - key: string; - doc_count: number; - anchor_doc: AnchorDocTopHitsAgg; - }>; -} - -interface DiverseSamplerAgg { - doc_count: number; - unique_values?: UniqueValuesTermsAgg; -} - -interface FieldFilterAgg { - doc_count: number; - diverse_sampler: DiverseSamplerAgg; -} - -export type CorrelationFieldAggregations = Record; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/get_correlated_logs_for_anchor.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/get_correlated_logs_for_anchor.ts deleted file mode 100644 index 2515307109a83..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/get_correlated_logs_for_anchor.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import type { IScopedClusterClient, Logger } from '@kbn/core/server'; -import { getTypedSearch } from '../../utils/get_typed_search'; -import { timeRangeFilter } from '../../utils/dsl_filters'; -import { unwrapEsFields } from '../../utils/unwrap_es_fields'; -import type { AnchorLog } from './types'; -import { getTotalHits } from '../../utils/get_total_hits'; - -export async function getCorrelatedLogsForAnchor({ - esClient, - anchorLog, - logsIndices, - logger, - logSourceFields, - maxLogsPerSequence, -}: { - esClient: IScopedClusterClient; - anchorLog: AnchorLog; - logsIndices: string[]; - logger: Logger; - logSourceFields: string[]; - maxLogsPerSequence: number; -}) { - const search = getTypedSearch(esClient.asCurrentUser); - const { correlation, '@timestamp': timestamp } = anchorLog; - - const start = moment(timestamp).subtract(1, 'hour').valueOf(); - const end = moment(timestamp).add(1, 'hour').valueOf(); - logger.debug( - `Fetching correlated logs using ${correlation.field}=${correlation.value} between ${start} - ${end}` - ); - - const res = await search({ - _source: false, - fields: logSourceFields, - track_total_hits: maxLogsPerSequence + 1, // +1 to check if sequence is truncated - size: maxLogsPerSequence, - index: logsIndices, - sort: [{ '@timestamp': { order: 'asc' } }], - query: { - bool: { - filter: [ - ...timeRangeFilter('@timestamp', { start, end }), - { term: { [correlation.field]: correlation.value } }, - ], - }, - }, - }); - - const totalHits = getTotalHits(res); - - return { - logs: res.hits.hits.map((hit) => unwrapEsFields(hit.fields)), - isTruncated: totalHits > maxLogsPerSequence, - }; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/handler.ts deleted file mode 100644 index b51303d3c08f8..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/handler.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; -import type { Logger } from '@kbn/core/server'; -import type { ObservabilityAgentBuilderCoreSetup } from '../../types'; -import { getLogsIndices } from '../../utils/get_logs_indices'; -import { parseDatemath } from '../../utils/time'; -import { - DEFAULT_CORRELATION_IDENTIFIER_FIELDS, - DEFAULT_LOG_SOURCE_FIELDS, - DEFAULT_ERROR_LOGS_ONLY, - DEFAULT_MAX_SEQUENCES, - DEFAULT_MAX_LOGS_PER_SEQUENCE, -} from './constants'; -import { getAnchorLogs } from './fetch_anchor_logs/fetch_anchor_logs'; -import { getCorrelatedLogsForAnchor } from './get_correlated_logs_for_anchor'; - -export function getNoResultsMessage({ - logId, - kqlFilter, - errorLogsOnly, - correlationFields, - start, - end, -}: { - logId: string | undefined; - kqlFilter: string | undefined; - errorLogsOnly: boolean; - correlationFields: string[]; - start: string; - end: string; -}): string { - const isUsingDefaultCorrelationFields = - correlationFields === DEFAULT_CORRELATION_IDENTIFIER_FIELDS; - - const correlationFieldsDescription = isUsingDefaultCorrelationFields - ? 'Matching logs exist but lack the default correlation fields (trace.id, request.id, transaction.id, etc.). Try using `correlationFields` for specifying custom correlation fields.' - : `Matching logs exist but lack the custom correlation fields: ${correlationFields.join(', ')}`; - - if (logId) { - return `The log ID "${logId}" was not found, or the log does not have any of the ${correlationFieldsDescription}.`; - } - - const suggestions = [ - `No matching logs exist in this time range (${start} to ${end})`, - ...(kqlFilter ? ['`kqlFilter` is too restrictive'] : []), - ...(errorLogsOnly - ? [ - 'No error logs found (errorLogsOnly=true filters for ERROR/WARN/FATAL, HTTP 5xx, etc.). Try errorLogsOnly=false to include all log levels.', - ] - : []), - correlationFieldsDescription, - ]; - - return `No log sequences found. Possible reasons: ${suggestions - .map((s, i) => `(${i + 1}) ${s}`) - .join(', ')}.`; -} - -export async function getCorrelatedLogsForLogEntry({ - core, - logger, - esClient, - index, - start, - end, - logId, -}: { - core: ObservabilityAgentBuilderCoreSetup; - logger: Logger; - esClient: IScopedClusterClient; - index: string; - start: string; - end: string; - logId: string; -}) { - return getToolHandler({ - core, - logger, - esClient, - index, - start, - end, - logId, - errorLogsOnly: false, - correlationFields: DEFAULT_CORRELATION_IDENTIFIER_FIELDS, - logSourceFields: DEFAULT_LOG_SOURCE_FIELDS, - maxSequences: DEFAULT_MAX_SEQUENCES, - maxLogsPerSequence: DEFAULT_MAX_LOGS_PER_SEQUENCE, - }); -} - -export async function getToolHandler({ - core, - logger, - esClient, - start, - end, - kqlFilter, - errorLogsOnly = DEFAULT_ERROR_LOGS_ONLY, - index, - correlationFields = DEFAULT_CORRELATION_IDENTIFIER_FIELDS, - logId, - logSourceFields = DEFAULT_LOG_SOURCE_FIELDS, - maxSequences = DEFAULT_MAX_SEQUENCES, - maxLogsPerSequence = DEFAULT_MAX_LOGS_PER_SEQUENCE, -}: { - core: ObservabilityAgentBuilderCoreSetup; - logger: Logger; - esClient: IScopedClusterClient; - start: string; - end: string; - kqlFilter?: string; - errorLogsOnly: boolean; - index?: string; - correlationFields: string[]; - logId?: string; - logSourceFields: string[]; - maxSequences: number; - maxLogsPerSequence: number; -}) { - const logsIndices = index?.split(',') ?? (await getLogsIndices({ core, logger })); - const startTime = parseDatemath(start); - const endTime = parseDatemath(end, { roundUp: true }); - - const anchorLogs = await getAnchorLogs({ - esClient, - logsIndices, - startTime, - endTime, - kqlFilter, - errorLogsOnly, - correlationFields, - logger, - logId, - maxSequences, - }); - - // For each anchor log, find the correlated logs - const sequences = await Promise.all( - anchorLogs.map(async (anchorLog) => { - const { logs, isTruncated } = await getCorrelatedLogsForAnchor({ - esClient, - anchorLog, - logsIndices, - logger, - logSourceFields, - maxLogsPerSequence, - }); - - return { - correlation: anchorLog.correlation, - logs, - isTruncated, - }; - }) - ); - - return { sequences }; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/tool.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/tool.ts deleted file mode 100644 index 7d14ecf5534de..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/tool.ts +++ /dev/null @@ -1,183 +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 { z } from '@kbn/zod'; -import { ToolType } from '@kbn/agent-builder-common'; -import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; -import type { BuiltinToolDefinition, StaticToolRegistration } from '@kbn/agent-builder-server'; -import type { Logger } from '@kbn/core/server'; -import type { ObservabilityAgentBuilderCoreSetup } from '../../types'; -import { indexDescription, timeRangeSchemaOptional } from '../../utils/tool_schemas'; -import { - DEFAULT_CORRELATION_IDENTIFIER_FIELDS, - DEFAULT_TIME_RANGE, - DEFAULT_LOG_SOURCE_FIELDS, - DEFAULT_ERROR_LOGS_ONLY, - DEFAULT_MAX_SEQUENCES, - DEFAULT_MAX_LOGS_PER_SEQUENCE, -} from './constants'; -import { getAgentBuilderResourceAvailability } from '../../utils/get_agent_builder_resource_availability'; -import { getNoResultsMessage, getToolHandler } from './handler'; - -export const OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID = 'observability.get_correlated_logs'; - -const getCorrelatedLogsSchema = z.object({ - ...timeRangeSchemaOptional(DEFAULT_TIME_RANGE), - index: z.string().describe(indexDescription).optional(), - logId: z - .string() - .optional() - .describe( - 'ID of a specific log entry to use as an anchor. When provided, other filter parameters are ignored.' - ), - kqlFilter: z - .string() - .optional() - .describe( - 'KQL filter to narrow down logs. Examples: \'service.name: "payment"\', \'host.name: "web-server-01"\'. Ignored if logId is provided.' - ), - errorLogsOnly: z - .boolean() - .default(DEFAULT_ERROR_LOGS_ONLY) - .describe( - 'When true, only sequences containing error logs (ERROR, WARN, FATAL, HTTP 5xx) are returned. Set to false to return any sequence. You can use `kqlFilter` to apply another filter (e.g., slow requests).' - ), - correlationFields: z - .array(z.string()) - .optional() - .describe( - 'Field names to correlate logs by. Example: ["session_id"]. Overrides the default trace/request ID fields.' - ), - logSourceFields: z - .array(z.string()) - .optional() - .describe( - 'Fields to return for each log entry. For a minimal view: ["@timestamp", "message", "log.level"].' - ), - maxSequences: z - .number() - .default(DEFAULT_MAX_SEQUENCES) - .describe('Maximum number of unique log sequences to return.'), - maxLogsPerSequence: z - .number() - .default(DEFAULT_MAX_LOGS_PER_SEQUENCE) - .describe( - 'Maximum number of logs per sequence. Increase this to see a longer sequence of logs surrounding the anchor log.' - ), -}); - -export function createGetCorrelatedLogsTool({ - core, - logger, -}: { - core: ObservabilityAgentBuilderCoreSetup; - logger: Logger; -}): StaticToolRegistration { - const toolDefinition: BuiltinToolDefinition = { - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - type: ToolType.builtin, - description: `Retrieves complete log sequences around error events to understand what happened. By default, anchors on error logs (ERROR, WARN, FATAL, HTTP 5xx). Set errorLogsOnly=false to anchor on non-error events like slow requests. - -When to use: -- Investigating WHY something failed or behaved unexpectedly -- Understanding the sequence of events leading to an error -- Following a request/transaction across services using correlation IDs (trace.id, request.id, etc.) - -How it works: -1. Finds "anchor" logs (errors by default, or any log if errorLogsOnly=false) -2. Groups logs by correlation ID (trace.id, request.id, etc.) -3. Returns chronologically sorted sequences showing context before and after each anchor - -Do NOT use for: -- High-level overview of log patterns (use get_log_groups) -- Analyzing log volume changes (use run_log_rate_analysis)`, - schema: getCorrelatedLogsSchema, - tags: ['observability', 'logs'], - availability: { - cacheMode: 'space', - handler: async ({ request }) => { - return getAgentBuilderResourceAvailability({ core, request, logger }); - }, - }, - handler: async (toolParams, { esClient }) => { - const { - start, - end, - kqlFilter, - errorLogsOnly, - index, - correlationFields = DEFAULT_CORRELATION_IDENTIFIER_FIELDS, - logId, - logSourceFields = DEFAULT_LOG_SOURCE_FIELDS, - maxSequences, - maxLogsPerSequence, - } = toolParams; - - try { - const { sequences } = await getToolHandler({ - core, - logger, - esClient, - start, - end, - kqlFilter, - errorLogsOnly, - index, - correlationFields, - logId, - logSourceFields, - maxSequences, - maxLogsPerSequence, - }); - - if (sequences.length === 0) { - const message = getNoResultsMessage({ - logId, - kqlFilter, - errorLogsOnly, - correlationFields, - start, - end, - }); - - return { - results: [ - { - type: ToolResultType.other, - data: { - sequences: [], - message, - }, - }, - ], - }; - } - - return { - results: [{ type: ToolResultType.other, data: { sequences } }], - }; - } catch (error) { - logger.error(`Error fetching errors and surrounding logs: ${error.message}`); - logger.debug(error); - - return { - results: [ - { - type: ToolResultType.error, - data: { - message: `Failed to fetch errors and surrounding logs: ${error.message}`, - stack: error.stack, - }, - }, - ], - }; - } - }, - }; - - return toolDefinition; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/types.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/types.ts deleted file mode 100644 index b46f5cc6ecadf..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_correlated_logs/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; - -export interface LogSequence { - correlation: AnchorLog['correlation']; - logs: Record[]; - isTruncated?: boolean; -} - -export interface GetCorrelatedLogsToolResult { - type: ToolResultType.other; - data: { - sequences: LogSequence[]; - message?: string; - }; -} - -export interface AnchorLog { - '@timestamp': string; - correlation: { - field: string; - value: string; - anchorLogId: string; - }; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/README.md b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/README.md deleted file mode 100644 index dc80a8cbef3f7..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# get_downstream_dependencies - -Get downstream dependencies (services or uninstrumented backends) for a given service and time range. - -## Example - -``` -POST kbn://api/agent_builder/tools/_execute -{ - "tool_id": "observability.get_downstream_dependencies", - "tool_params": { - "start": "now-15m", - "end": "now", - "serviceName": "frontend" - } -} -``` diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/handler.ts deleted file mode 100644 index a9fba6448e08a..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { KibanaRequest } from '@kbn/core/server'; -import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; -import type { APMDownstreamDependency } from '../../data_registry/data_registry_types'; - -export async function getToolHandler({ - request, - dataRegistry, - serviceName, - serviceEnvironment, - start, - end, -}: { - request: KibanaRequest; - dataRegistry: ObservabilityAgentBuilderDataRegistry; - serviceName: string; - serviceEnvironment?: string; - start: string; - end: string; -}): Promise<{ dependencies: APMDownstreamDependency[] | undefined }> { - const dependencies = await dataRegistry.getData('apmDownstreamDependencies', { - request, - serviceName, - serviceEnvironment: serviceEnvironment ?? '', - start, - end, - }); - - return { dependencies }; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/tool.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/tool.ts deleted file mode 100644 index bc777f54e606b..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_downstream_dependencies/tool.ts +++ /dev/null @@ -1,114 +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 { z } from '@kbn/zod'; -import type { Logger } from '@kbn/core/server'; -import type { BuiltinToolDefinition, StaticToolRegistration } from '@kbn/agent-builder-server'; -import { ToolType } from '@kbn/agent-builder-common'; -import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; -import type { ObservabilityAgentBuilderCoreSetup } from '../../types'; -import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; -import { timeRangeSchemaOptional } from '../../utils/tool_schemas'; -import { getAgentBuilderResourceAvailability } from '../../utils/get_agent_builder_resource_availability'; -import { getToolHandler } from './handler'; - -export const OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID = - 'observability.get_downstream_dependencies'; - -const DEFAULT_TIME_RANGE = { start: 'now-1h', end: 'now' }; - -const getDownstreamDependenciesToolSchema = z.object({ - ...timeRangeSchemaOptional(DEFAULT_TIME_RANGE), - serviceName: z.string().min(1).describe('The name of the service'), - environment: z - .string() - .optional() - .describe( - 'The environment that the service is running in. Leave empty to query for all environments.' - ), -}); - -export function createDownstreamDependenciesTool({ - core, - dataRegistry, - logger, -}: { - core: ObservabilityAgentBuilderCoreSetup; - dataRegistry: ObservabilityAgentBuilderDataRegistry; - logger: Logger; -}): StaticToolRegistration { - const toolDefinition: BuiltinToolDefinition = { - id: OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID, - type: ToolType.builtin, - description: `Retrieves downstream dependencies (other services, databases, external APIs) that a service calls, including health metrics. - -Returns for each dependency: -- Service name (if resolved) or resource identifier (IP:port, hostname) -- Error rate (0-1, where 1 = 100% failures) -- Latency in milliseconds -- Throughput (calls per minute) - -When to use: -- Identifying which downstream dependencies are failing (high error rate) -- Diagnosing if a service's errors are caused by a downstream outage -- Mapping what external services/databases a service depends on`, - schema: getDownstreamDependenciesToolSchema, - tags: ['observability', 'apm', 'dependencies'], - availability: { - cacheMode: 'space', - handler: async ({ request }) => { - return getAgentBuilderResourceAvailability({ core, request, logger }); - }, - }, - handler: async (toolParams, context) => { - const { serviceName, environment, start, end } = toolParams; - const { request } = context; - - try { - const { dependencies } = await getToolHandler({ - request, - dataRegistry, - serviceName, - serviceEnvironment: environment, - start, - end, - }); - - const total = dependencies?.length ?? 0; - - return { - results: [ - { - type: ToolResultType.other, - data: { - total, - dependencies, - }, - }, - ], - }; - } catch (error) { - logger.error(`Error getting APM downstream dependencies: ${error.message}`); - logger.debug(error); - - return { - results: [ - { - type: ToolResultType.error, - data: { - message: `Failed to fetch downstream dependencies: ${error.message}`, - stack: error.stack, - }, - }, - ], - }; - } - }, - }; - - return toolDefinition; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_change_points/handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_change_points/handler.ts index 645163d62cfce..1f5f23d3e7b54 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_change_points/handler.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_change_points/handler.ts @@ -10,15 +10,11 @@ import type { IScopedClusterClient } from '@kbn/core/server'; import { orderBy } from 'lodash'; import { getTotalHits } from '../../utils/get_total_hits'; import { type Bucket, getChangePoints } from '../../utils/get_change_points'; +import { computeSamplingProbability } from '../../utils/compute_sampling_probability'; import { parseDatemath } from '../../utils/time'; import { timeRangeFilter, kqlFilter } from '../../utils/dsl_filters'; import { getTypedSearch } from '../../utils/get_typed_search'; -function getProbability(totalHits: number): number { - const probability = Math.min(1, 500_000 / totalHits); - return probability > 0.5 ? 1 : probability; -} - async function getLogChangePoint({ index, start, @@ -60,7 +56,7 @@ async function getLogChangePoint({ const aggregations = { sampler: { random_sampler: { - probability: getProbability(totalHits), + probability: computeSamplingProbability({ totalHits, targetSampleSize: 500000 }), }, aggs: { groups: { diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/README.md b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/README.md index 4051f063f278b..b7e02219957c3 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/README.md +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/README.md @@ -86,4 +86,4 @@ POST kbn://api/agent_builder/tools/_execute 1. **Start broad**: Call `get_log_groups()` to see all logs and exceptions 2. **Identify hotspot**: Find the service or pattern with most errors 3. **Drill down**: Call `get_log_groups(kqlFilter='service.name: "..."')` to focus on a specific service -4. **Investigate**: Use `get_correlated_logs` with a trace.id from the sample to understand the sequence of events +4. **Investigate**: Use `get_traces` with a trace.id from the sample to understand the sequence of events diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/get_categorized_logs.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/get_categorized_logs.ts index 64dd40b0ad79c..8a492629e8335 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/get_categorized_logs.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/get_categorized_logs.ts @@ -10,6 +10,7 @@ import type { QueryDslBoolQuery } from '@elastic/elasticsearch/lib/api/types'; import { getTypedSearch } from '../../utils/get_typed_search'; import { unwrapEsFields } from '../../utils/unwrap_es_fields'; import { getTotalHits } from '../../utils/get_total_hits'; +import { computeSamplingProbability } from '../../utils/compute_sampling_probability'; export async function getSamplingProbability({ esClient, @@ -30,12 +31,7 @@ export async function getSamplingProbability({ }); const totalHits = getTotalHits(countResponse); - - // Calculate sampling probability to get ~10,000 samples - const targetSampleSize = 10000; - const rawSamplingProbability = targetSampleSize / totalHits; - // probability must be between 0.0 and 0.5 or exactly 1.0 - const samplingProbability = rawSamplingProbability < 0.5 ? rawSamplingProbability : 1; + const samplingProbability = computeSamplingProbability({ totalHits, targetSampleSize: 10000 }); return { samplingProbability, totalHits }; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/tool.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/tool.ts index 1345282003e99..a8411469f56f4 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/tool.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_log_groups/tool.ts @@ -18,7 +18,7 @@ import type { import { timeRangeSchemaOptional, indexDescription } from '../../utils/tool_schemas'; import { getAgentBuilderResourceAvailability } from '../../utils/get_agent_builder_resource_availability'; import { getToolHandler } from './handler'; -import { OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID } from '../get_correlated_logs/tool'; +import { OBSERVABILITY_GET_TRACES_TOOL_ID } from '../get_traces/tool'; export interface GetLogGroupsToolResult { type: ToolResultType.other; @@ -95,10 +95,10 @@ export function createGetLogGroupsTool({ - Answering "what kinds of things are happening?" rather than "what exactly happened?" After using this tool: - - Use \`${OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID}\` to trace the full sequence of events leading up to a given log sample + - Use \`${OBSERVABILITY_GET_TRACES_TOOL_ID}\` to trace the full sequence of events leading up to a given log sample Do NOT use for: - - Understanding the sequence of events for a specific error (use \`${OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID}\`) + - Understanding the sequence of events for a specific error (use \`${OBSERVABILITY_GET_TRACES_TOOL_ID}\`) - Analyzing changes in log volume over time (use run_log_rate_analysis) `, schema: getLogsSchema, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/AGENTS.md b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/AGENTS.md new file mode 100644 index 0000000000000..4dbe13f79c15d --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/AGENTS.md @@ -0,0 +1,90 @@ +# get_service_topology — Requirements + +## Purpose + +Surface upstream and downstream service dependencies for a given service, enabling SREs to understand blast radius and trace root causes during incidents. + +## Implementation + +Core logic: `x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_service_topology.ts` + +## Direction Semantics + +- **downstream**: Services and dependencies _called by_ the queried service (and its descendants). +- **upstream**: Services that _call_ the queried service (and their ancestors). +- **both**: Union of upstream and downstream. + +## Depth Parameter + +The `depth` parameter limits BFS traversal to a maximum number of hops: + +- `depth=1`: Immediate (single-hop) dependencies only. Replaces the former `get_downstream_dependencies` tool. +- `depth=2`: Up to two hops from the root service. +- Omitted: Unlimited traversal (full multi-hop topology). + +## Graph Traversal Rules + +Given this topology: + +``` + frontend + / \ + checkout recommendation + / | \ | +pg redis kafka pg +``` + +### Downstream (BFS (Breadth-First Search) forward from root) + +Start from `serviceName`, follow outgoing edges. Include multi-hop descendants. + +**Query: `checkout` downstream** + +``` + frontend + / \ + [checkout] recommendation ← sibling branch excluded + / | \ | +[pg][redis][kafka] pg +``` + +Result: `checkout→pg`, `checkout→redis`, `checkout→kafka` +Excluded: `frontend→*`, `recommendation→*` (parent and sibling branches) + +### Upstream (BFS backward from root) + +Start from `serviceName`, follow incoming edges backward. Include multi-hop ancestors. + +**Query: `pg` upstream** + +``` + [frontend] + / \ + [checkout] [recommendation] + / | \ | +[pg] redis kafka [pg] +``` + +Result: `checkout→pg`, `frontend→checkout`, `recommendation→pg` +Excluded: `checkout→redis`, `checkout→kafka` (sibling edges that don't lead to `pg`) + +## Critical Constraints + +### Service identity must come from resolved `service.name`, not from `span.destination.service.resource` + +- `span.destination.service.resource` can be arbitrary (proxy hostname, IP address, load balancer) and has **no guaranteed relationship** to `service.name`. +- Service-to-service edges in the graph must use the resolved `service.name` from linked traces (parent-child join via `span.id` → `parent.id`). +- **DO NOT** use heuristic/fuzzy matching on `span.destination.service.resource` for graph traversal. + +### Upstream must work for both instrumented services and external dependencies + +- For **instrumented services**: the service has its own transactions, so upstream callers can be discovered from those traces. +- For **external dependencies** (e.g., `postgres`, `redis`): the dependency has no transactions. Upstream callers must still be discoverable via exit spans that target the dependency. + +## Test Coverage + +API tests: `x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_service_topology.spec.ts` + +Synthtrace scenario: `src/platform/packages/shared/kbn-synthtrace/src/scenarios/agent_builder/tools/get_service_topology/topology.ts` + +The synthtrace scenario generates **linked traces** (frontend → checkout-service → deps share a `trace.id`) to enable multi-hop testing, and **separate traces** (recommendation-service → postgres) to verify sibling exclusion. diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/build_connections_from_spans.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/build_connections_from_spans.test.ts new file mode 100644 index 0000000000000..80834a8ee1119 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/build_connections_from_spans.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExitSpanSample } from '../../data_registry/data_registry_types'; +import { buildConnectionsFromSpans } from './build_connections_from_spans'; + +function makeSpan(overrides: Partial = {}): ExitSpanSample { + return { + serviceName: 'service-a', + spanDestinationServiceResource: 'service-b:8080', + spanType: 'external', + spanSubtype: 'http', + ...overrides, + }; +} + +describe('buildConnectionsFromSpans', () => { + it('returns empty array for empty input', () => { + expect(buildConnectionsFromSpans([])).toEqual([]); + }); + + it('resolves to service target when destinationService is present', () => { + const [conn] = buildConnectionsFromSpans([ + makeSpan({ + serviceName: 'frontend', + spanDestinationServiceResource: 'backend-host:8080', + destinationService: { serviceName: 'backend' }, + }), + ]); + + expect(conn.source).toEqual({ 'service.name': 'frontend' }); + expect(conn.target).toEqual({ 'service.name': 'backend' }); + }); + + it('resolves to external target when destinationService is absent', () => { + const [conn] = buildConnectionsFromSpans([ + makeSpan({ + serviceName: 'backend', + spanDestinationServiceResource: 'postgres:5432', + spanType: 'db', + spanSubtype: 'postgresql', + }), + ]); + + expect(conn.source).toEqual({ 'service.name': 'backend' }); + expect(conn.target).toEqual({ + 'span.destination.service.resource': 'postgres:5432', + 'span.type': 'db', + 'span.subtype': 'postgresql', + }); + }); + + it('deduplicates connections by source + dependency name', () => { + const connections = buildConnectionsFromSpans([ + makeSpan({ serviceName: 'frontend', spanDestinationServiceResource: 'backend:8080' }), + makeSpan({ serviceName: 'frontend', spanDestinationServiceResource: 'backend:8080' }), + ]); + + expect(connections).toHaveLength(1); + }); + + it('keeps connections with different dependencies separate', () => { + const connections = buildConnectionsFromSpans([ + makeSpan({ serviceName: 'backend', spanDestinationServiceResource: 'postgres:5432' }), + makeSpan({ serviceName: 'backend', spanDestinationServiceResource: 'redis:6379' }), + ]); + + expect(connections).toHaveLength(2); + expect(connections.map((c) => c._dependencyName).sort()).toEqual([ + 'postgres:5432', + 'redis:6379', + ]); + }); + + it('uses raw resource name as _dependencyName even when service.name is resolved', () => { + const [conn] = buildConnectionsFromSpans([ + makeSpan({ + serviceName: 'frontend', + spanDestinationServiceResource: 'backend-loadbalancer:443', + destinationService: { serviceName: 'backend' }, + }), + ]); + + expect(conn._dependencyName).toBe('backend-loadbalancer:443'); + expect(conn.target).toEqual({ 'service.name': 'backend' }); + }); + + it('first span wins when duplicate connections resolve to different targets', () => { + const connections = buildConnectionsFromSpans([ + makeSpan({ + serviceName: 'frontend', + spanDestinationServiceResource: 'backend:8080', + destinationService: { serviceName: 'backend-v1' }, + }), + makeSpan({ + serviceName: 'frontend', + spanDestinationServiceResource: 'backend:8080', + destinationService: { serviceName: 'backend-v2' }, + }), + ]); + + expect(connections).toHaveLength(1); + expect(connections[0].target).toEqual({ 'service.name': 'backend-v1' }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/build_connections_from_spans.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/build_connections_from_spans.ts new file mode 100644 index 0000000000000..7127aa351e826 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/build_connections_from_spans.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_TYPE, + SPAN_SUBTYPE, +} from '@kbn/apm-types'; +import type { ExitSpanSample } from '../../data_registry/data_registry_types'; +import type { ConnectionWithKey } from './types'; + +export const buildConnectionKey = (sourceName: string, dependencyName: string): string => + `${sourceName}::${dependencyName}`; + +export function buildConnectionsFromSpans(spans: ExitSpanSample[]): ConnectionWithKey[] { + const connectionMap = new Map(); + + for (const span of spans) { + const source = { [SERVICE_NAME]: span.serviceName }; + + const target = span.destinationService + ? { [SERVICE_NAME]: span.destinationService.serviceName } + : { + [SPAN_DESTINATION_SERVICE_RESOURCE]: span.spanDestinationServiceResource, + [SPAN_TYPE]: span.spanType, + [SPAN_SUBTYPE]: span.spanSubtype, + }; + + const sourceName = source[SERVICE_NAME]; + const dependencyName = span.spanDestinationServiceResource; + const connectionKey = buildConnectionKey(sourceName, dependencyName); + + if (!connectionMap.has(connectionKey)) { + connectionMap.set(connectionKey, { + source, + target, + metrics: undefined, + + // internal fields, not part of the public API + _key: connectionKey, // deduplication key + _sourceName: sourceName, + _dependencyName: dependencyName, + }); + } + } + + return Array.from(connectionMap.values()); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/filter_connections.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/filter_connections.test.ts new file mode 100644 index 0000000000000..e97590be90c89 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/filter_connections.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { filterDownstreamConnections, filterUpstreamConnections } from './filter_connections'; +import type { ConnectionWithKey } from './types'; + +function makeServiceConnection(source: string, target: string): ConnectionWithKey { + return { + source: { 'service.name': source }, + target: { 'service.name': target }, + metrics: undefined, + _key: `${source}->${target}`, + _sourceName: source, + _dependencyName: target, + }; +} + +function makeExternalConnection( + source: string, + resource: string, + spanType = 'db', + spanSubtype = 'postgresql' +): ConnectionWithKey { + return { + source: { 'service.name': source }, + target: { + 'span.destination.service.resource': resource, + 'span.type': spanType, + 'span.subtype': spanSubtype, + }, + metrics: undefined, + _key: `${source}->${resource}`, + _sourceName: source, + _dependencyName: resource, + }; +} + +function getConnectionKeys(connections: ConnectionWithKey[]) { + return connections.map((c) => c._key).sort(); +} + +describe('filterDownstreamConnections', () => { + it('returns only connections reachable from root', () => { + const connections = [ + makeServiceConnection('A', 'B'), + makeServiceConnection('B', 'C'), + makeServiceConnection('X', 'Y'), + ]; + + const result = filterDownstreamConnections(connections, 'A'); + expect(getConnectionKeys(result)).toEqual(['A->B', 'B->C']); + }); + + it('returns empty when root has no outgoing connections', () => { + const result = filterDownstreamConnections([makeServiceConnection('X', 'Y')], 'A'); + expect(result).toEqual([]); + }); + + it('handles cycles without infinite loop', () => { + const connections = [ + makeServiceConnection('A', 'B'), + makeServiceConnection('B', 'C'), + makeServiceConnection('C', 'A'), + ]; + + expect(getConnectionKeys(filterDownstreamConnections(connections, 'A'))).toEqual([ + 'A->B', + 'B->C', + 'C->A', + ]); + }); + + it('includes external dependency connections', () => { + const connections = [makeServiceConnection('A', 'B'), makeExternalConnection('B', 'postgres')]; + + expect(getConnectionKeys(filterDownstreamConnections(connections, 'A'))).toEqual([ + 'A->B', + 'B->postgres', + ]); + }); + + it('respects maxDepth', () => { + const connections = [ + makeServiceConnection('A', 'B'), + makeServiceConnection('B', 'C'), + makeServiceConnection('C', 'D'), + ]; + + expect(getConnectionKeys(filterDownstreamConnections(connections, 'A', 2))).toEqual([ + 'A->B', + 'B->C', + ]); + }); + + /* + * BFS correctness: when a node is reachable via two paths at different depths, + * BFS discovers it at the minimum depth. This matters when maxDepth is set: + * + * A → B (depth 1) → D (depth 2) → E (depth 3) + * A → C (depth 1) → B (depth 2, redundant) + * + * With DFS (pop), C→B might be processed before A→B, assigning B depth 2. + * Then D would be at depth 3, which exceeds maxDepth=3 — losing D→E. + * BFS (shift) always finds B at depth 1, so D is correctly at depth 2. + */ + it('uses BFS to find nodes at minimum depth when multiple paths exist', () => { + const connections = [ + makeServiceConnection('A', 'B'), + makeServiceConnection('A', 'C'), + makeServiceConnection('C', 'B'), + makeServiceConnection('B', 'D'), + makeServiceConnection('D', 'E'), + ]; + + expect(getConnectionKeys(filterDownstreamConnections(connections, 'A', 3))).toEqual([ + 'A->B', + 'A->C', + 'B->D', + 'C->B', + 'D->E', + ]); + }); +}); + +describe('filterUpstreamConnections', () => { + it('returns only connections leading to root', () => { + const connections = [ + makeServiceConnection('A', 'B'), + makeServiceConnection('B', 'C'), + makeServiceConnection('X', 'Y'), + ]; + + expect(getConnectionKeys(filterUpstreamConnections(connections, 'C'))).toEqual([ + 'A->B', + 'B->C', + ]); + }); + + it('returns empty when nothing points to root', () => { + const result = filterUpstreamConnections([makeServiceConnection('X', 'Y')], 'A'); + expect(result).toEqual([]); + }); + + it('handles cycles without infinite loop', () => { + const connections = [ + makeServiceConnection('A', 'B'), + makeServiceConnection('B', 'C'), + makeServiceConnection('C', 'A'), + ]; + + expect(getConnectionKeys(filterUpstreamConnections(connections, 'A'))).toEqual([ + 'A->B', + 'B->C', + 'C->A', + ]); + }); + + it('finds upstream callers of external dependency by dependency name', () => { + const connections = [makeServiceConnection('A', 'B'), makeExternalConnection('B', 'postgres')]; + + expect(getConnectionKeys(filterUpstreamConnections(connections, 'postgres'))).toEqual([ + 'A->B', + 'B->postgres', + ]); + }); + + it('respects maxDepth', () => { + const connections = [ + makeServiceConnection('A', 'B'), + makeServiceConnection('B', 'C'), + makeServiceConnection('C', 'D'), + ]; + + expect(getConnectionKeys(filterUpstreamConnections(connections, 'D', 2))).toEqual([ + 'B->C', + 'C->D', + ]); + }); + + /* + * Same BFS correctness concern as downstream, but in reverse: + * when a service has multiple callers at different depths, BFS ensures + * the minimum depth is used. + */ + it('uses BFS to find callers at minimum depth when multiple paths exist', () => { + const connections = [ + makeServiceConnection('B', 'A'), + makeServiceConnection('C', 'B'), + makeServiceConnection('D', 'B'), + makeServiceConnection('D', 'C'), + makeServiceConnection('E', 'D'), + ]; + + expect(getConnectionKeys(filterUpstreamConnections(connections, 'A', 3))).toEqual([ + 'B->A', + 'C->B', + 'D->B', + 'D->C', + 'E->D', + ]); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/filter_connections.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/filter_connections.ts new file mode 100644 index 0000000000000..a431dc20eb133 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/filter_connections.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SERVICE_NAME } from '@kbn/apm-types'; +import type { ConnectionWithKey } from './types'; + +/** + * Filters connections to only include those that are downstream (descendants) of the root service. + * This performs a graph traversal starting from the root service, following outgoing edges. + * + * For example, given connections: A→B, B→C, A→D, X→Y + * filterDownstreamConnections(connections, 'A') returns: A→B, B→C, A→D + * (excludes X→Y because X is not reachable from A) + * + * This excludes sibling dependencies - services called by ancestors but not by the queried service + * or its descendants. + */ +export function filterDownstreamConnections( + connections: ConnectionWithKey[], + rootServiceName: string, + maxDepth?: number +): ConnectionWithKey[] { + // Build adjacency list: source -> list of connections from that source + const adjacencyMap = new Map(); + for (const conn of connections) { + const existing = adjacencyMap.get(conn._sourceName) ?? []; + existing.push(conn); + adjacencyMap.set(conn._sourceName, existing); + } + + // BFS traversal starting from rootServiceName + const visitedServices = new Set(); + const results: ConnectionWithKey[] = []; + const queue: Array<{ serviceName: string; depth: number }> = [ + { serviceName: rootServiceName, depth: 0 }, + ]; + + while (queue.length > 0) { + const { serviceName: currentService, depth: currentDepth } = queue.shift()!; + + // Skip already-visited nodes to avoid cycles + if (!visitedServices.has(currentService)) { + visitedServices.add(currentService); + + // Get all connections from this service + const outgoingConnections = adjacencyMap.get(currentService) ?? []; + + for (const conn of outgoingConnections) { + results.push(conn); + + // If target is a service (not external dependency), add to queue for further traversal + const target = conn.target; + if (SERVICE_NAME in target) { + const targetServiceName = target[SERVICE_NAME]; + if ( + !visitedServices.has(targetServiceName) && + (maxDepth === undefined || currentDepth + 1 < maxDepth) + ) { + queue.push({ serviceName: targetServiceName, depth: currentDepth + 1 }); + } + } + } + } + } + + return results; +} + +/** + * Filters connections to only include those that are upstream (ancestors) of the root service. + * This performs a reverse graph traversal starting from the root service, following incoming edges. + * + * For example, given connections: A→B, B→C, C→D, X→Y + * filterUpstreamConnections(connections, 'D') returns: A→B, B→C, C→D + * (excludes X→Y because it doesn't lead to D) + * + * This excludes sibling dependencies - services that don't eventually call the queried service. + * + * IMPORTANT: Graph traversal relies on resolved `target['service.name']` for service-to-service + * edges. We do NOT use heuristic matching on `span.destination.service.resource` because that + * field can contain arbitrary values (proxy hostnames, IP addresses, load balancer names, etc.) + * that don't necessarily relate to the downstream service name. For the root node only, we also + * check exact `_dependencyName` match to handle external dependencies (e.g., "postgres"). + */ +export function filterUpstreamConnections( + connections: ConnectionWithKey[], + rootServiceName: string, + maxDepth?: number +): ConnectionWithKey[] { + // Build reverse adjacency lists: + // 1. By resolved service name (target['service.name']) - reliable for graph traversal + // 2. By exact dependency name (span.destination.service.resource) - only for root node lookup + const reverseAdjacencyByService = new Map(); + const reverseAdjacencyByDep = new Map(); + + for (const conn of connections) { + // Add to service name map if the target is a resolved service + if (SERVICE_NAME in conn.target) { + const targetServiceName = conn.target[SERVICE_NAME]; + const existing = reverseAdjacencyByService.get(targetServiceName) ?? []; + existing.push(conn); + reverseAdjacencyByService.set(targetServiceName, existing); + } + + // Add to dependency name map (exact key) + const depName = conn._dependencyName; + const existingByDep = reverseAdjacencyByDep.get(depName) ?? []; + existingByDep.push(conn); + reverseAdjacencyByDep.set(depName, existingByDep); + } + + // BFS traversal starting from the root service, going backwards through callers + const visitedServices = new Set(); + const visitedEdges = new Set(); + const results: ConnectionWithKey[] = []; + const queue: Array<{ serviceName: string; depth: number }> = [ + { serviceName: rootServiceName, depth: 0 }, + ]; + + while (queue.length > 0) { + const { serviceName: currentService, depth: currentDepth } = queue.shift()!; + + if (!visitedServices.has(currentService)) { + visitedServices.add(currentService); + + // Find connections where target['service.name'] matches (resolved service-to-service edges) + const connsByService = reverseAdjacencyByService.get(currentService) ?? []; + + // For the root node only, also find connections by exact dependency name. + // This handles external dependencies like "postgres" where the user queries by the + // resource name and there is no resolved target['service.name']. + const connsByDep = + currentService === rootServiceName ? reverseAdjacencyByDep.get(currentService) ?? [] : []; + + // Process both sets of connections, deduplicating by edge key + for (const conn of [...connsByService, ...connsByDep]) { + if (!visitedEdges.has(conn._key)) { + visitedEdges.add(conn._key); + results.push(conn); + + const sourceName = conn._sourceName; + if ( + !visitedServices.has(sourceName) && + (maxDepth === undefined || currentDepth + 1 < maxDepth) + ) { + queue.push({ serviceName: sourceName, depth: currentDepth + 1 }); + } + } + } + } + } + + return results; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_connection_metrics.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_connection_metrics.test.ts new file mode 100644 index 0000000000000..2eea978047f3c --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_connection_metrics.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ConnectionWithKey } from './types'; +import { computeConnectionMetrics, finalizeConnections } from './get_connection_metrics'; + +describe('computeConnectionMetrics', () => { + it('converts latency from microseconds to milliseconds', () => { + const { latencyMs } = computeConnectionMetrics({ + latencyUs: 5000, + throughputPerMin: 100, + errorRate: 0.05, + }); + + expect(latencyMs).toBe(5); + }); + + it('rounds throughput to 3 decimal places', () => { + const { throughputPerMin } = computeConnectionMetrics({ + latencyUs: 1000, + throughputPerMin: 33.33333333, + errorRate: 0, + }); + + expect(throughputPerMin).toBe(33.333); + }); + + it('converts null values to undefined', () => { + expect( + computeConnectionMetrics({ latencyUs: null, throughputPerMin: null, errorRate: null }) + ).toEqual({ + latencyMs: undefined, + throughputPerMin: undefined, + errorRate: undefined, + }); + }); +}); + +describe('finalizeConnections', () => { + const makeConnection = (source: string, target: string): ConnectionWithKey => ({ + source: { 'service.name': source }, + target: { 'service.name': target }, + metrics: undefined, + _key: `${source}::${target}`, + _sourceName: source, + _dependencyName: target, + }); + + it('strips internal fields and attaches metrics from metricsMap', () => { + const connections = [makeConnection('A', 'B')]; + const metricsMap = { + 'A::B': { latencyMs: 5, throughputPerMin: 100, errorRate: 0.01 }, + }; + + const [result] = finalizeConnections(connections, metricsMap); + + expect(result).toEqual({ + source: { 'service.name': 'A' }, + target: { 'service.name': 'B' }, + metrics: { latencyMs: 5, throughputPerMin: 100, errorRate: 0.01 }, + }); + expect(result).not.toHaveProperty('_key'); + expect(result).not.toHaveProperty('_sourceName'); + expect(result).not.toHaveProperty('_dependencyName'); + }); + + it('leaves metrics undefined when connection key is missing from metricsMap', () => { + const connections = [makeConnection('A', 'B')]; + const metricsMap = { 'X::Y': { latencyMs: 10, throughputPerMin: 50, errorRate: 0 } }; + + const [result] = finalizeConnections(connections, metricsMap); + expect(result.metrics).toBeUndefined(); + }); + + it('leaves metrics undefined when no metricsMap is provided', () => { + const [result] = finalizeConnections([makeConnection('A', 'B')]); + expect(result.metrics).toBeUndefined(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_connection_metrics.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_connection_metrics.ts new file mode 100644 index 0000000000000..d23305db14f83 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_connection_metrics.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { calculateThroughputWithRange } from '@kbn/apm-data-access-plugin/server/utils'; +import { termQuery } from '@kbn/observability-utils-server/es/queries/term_query'; +import type { KibanaRequest } from '@kbn/core/server'; +import { SERVICE_NAME } from '@kbn/apm-types'; +import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; +import type { TraceMetrics } from '../../data_registry/data_registry_types'; +import { buildConnectionKey } from './build_connections_from_spans'; +import type { ConnectionMetrics, ConnectionWithKey, ServiceTopologyConnection } from './types'; + +interface MetricsMap { + [key: string]: ConnectionMetrics; +} + +export function computeConnectionMetrics(params: TraceMetrics): ConnectionMetrics { + return { + latencyMs: params.latencyUs !== null ? params.latencyUs / 1000 : undefined, + throughputPerMin: + params.throughputPerMin !== null + ? Math.round(params.throughputPerMin * 1000) / 1000 + : undefined, + errorRate: params.errorRate ?? undefined, + }; +} + +function rawCountsToMetrics({ + latencyCount, + latencySum, + errorCount, + successCount, + start, + end, +}: { + latencyCount: number; + latencySum: number; + errorCount: number; + successCount: number; + start: number; + end: number; +}): TraceMetrics { + const totalCount = errorCount + successCount; + return { + latencyUs: latencyCount > 0 ? latencySum / latencyCount : null, + throughputPerMin: + latencyCount > 0 ? calculateThroughputWithRange({ start, end, value: latencyCount }) : null, + errorRate: totalCount > 0 ? errorCount / totalCount : null, + }; +} + +export async function getConnectionMetrics({ + dataRegistry, + request, + start, + end, + serviceNames, +}: { + dataRegistry: ObservabilityAgentBuilderDataRegistry; + request: KibanaRequest; + start: number; + end: number; + serviceNames: string[]; +}): Promise { + if (serviceNames.length === 0) { + return {}; + } + + const statsItems = await dataRegistry.getData('apmConnectionStatsItems', { + request, + start, + end, + filter: + serviceNames.length > 1 + ? [{ terms: { [SERVICE_NAME]: serviceNames } }] + : termQuery(SERVICE_NAME, serviceNames[0]), + }); + + if (!statsItems) { + return {}; + } + + const metricsEntries = statsItems.flatMap((item) => { + const serviceName = item.from.serviceName; + const dependencyName = item.to.dependencyName; + if (!serviceName || !dependencyName) { + return []; + } + + const key = buildConnectionKey(serviceName, dependencyName); + const { error_count, success_count, latency_count, latency_sum } = item.value; + + return [ + [ + key, + computeConnectionMetrics( + rawCountsToMetrics({ + latencyCount: latency_count, + latencySum: latency_sum, + errorCount: error_count, + successCount: success_count, + start, + end, + }) + ), + ] as const, + ]; + }); + + return Object.fromEntries(metricsEntries); +} + +export function finalizeConnections( + connections: ConnectionWithKey[], + metricsMap?: MetricsMap +): ServiceTopologyConnection[] { + return connections.map((conn) => ({ + source: conn.source, + target: conn.target, + metrics: metricsMap?.[conn._key], + })); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_immediate_downstream_dependencies.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_immediate_downstream_dependencies.ts new file mode 100644 index 0000000000000..f4d11a1d84285 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_immediate_downstream_dependencies.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { KibanaRequest } from '@kbn/core/server'; +import { termQuery } from '@kbn/observability-utils-server/es/queries/term_query'; +import { + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_TYPE, + SPAN_SUBTYPE, +} from '@kbn/apm-types'; +import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; +import type { ApmConnectionStatsEntry } from '../../data_registry/data_registry_types'; +import type { ServiceTopologyResponse, ServiceTopologyConnection } from './types'; +import { computeConnectionMetrics } from './get_connection_metrics'; + +/** + * Fast path for depth=1 downstream: uses getConnectionStats which queries + * pre-aggregated service_destination metrics (1m rollups) plus destination map + * for service.name resolution, with proper deduplication when multiple + * span.destination.service.resource values resolve to the same service. + * + * This is O(1) aggregation cost regardless of trace volume — dramatically + * faster for customers with billions of traces or traces with 20,000+ spans. + */ +export async function getImmediateDownstreamDependencies({ + dataRegistry, + request, + serviceName, + startMs, + endMs, +}: { + dataRegistry: ObservabilityAgentBuilderDataRegistry; + request: KibanaRequest; + serviceName: string; + startMs: number; + endMs: number; +}): Promise { + const statsEntries = await dataRegistry.getData('apmConnectionStats', { + request, + start: startMs, + end: endMs, + filter: termQuery(SERVICE_NAME, serviceName), + }); + + if (!statsEntries) { + return { connections: [] }; + } + + const connections: ServiceTopologyConnection[] = statsEntries.map((entry) => ({ + source: { [SERVICE_NAME]: serviceName }, + target: toTarget(entry), + metrics: computeConnectionMetrics(entry.metrics), + })); + + return { connections }; +} + +function toTarget(entry: ApmConnectionStatsEntry) { + if (entry.type === 'service') { + return { [SERVICE_NAME]: entry.serviceName }; + } + + return { + [SPAN_DESTINATION_SERVICE_RESOURCE]: entry.dependencyName, + [SPAN_TYPE]: entry.spanType, + [SPAN_SUBTYPE]: entry.spanSubtype, + }; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_service_topology.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_service_topology.ts new file mode 100644 index 0000000000000..8641f2fb1a293 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_service_topology.ts @@ -0,0 +1,356 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { KibanaRequest, Logger } from '@kbn/core/server'; +import { uniq } from 'lodash'; +import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; +import type { + ObservabilityAgentBuilderCoreSetup, + ObservabilityAgentBuilderPluginSetupDependencies, +} from '../../types'; +import { parseDatemath } from '../../utils/time'; +import { buildApmResources } from '../../utils/build_apm_resources'; +import type { TopologyDirection, ServiceTopologyResponse, ConnectionWithKey } from './types'; +import { buildConnectionsFromSpans } from './build_connections_from_spans'; +import { getConnectionMetrics, finalizeConnections } from './get_connection_metrics'; +import { filterDownstreamConnections, filterUpstreamConnections } from './filter_connections'; +import { getTraceIdsFromExitSpansTargetingDependency } from './get_trace_ids_from_exit_spans'; +import { getImmediateDownstreamDependencies } from './get_immediate_downstream_dependencies'; + +interface TopologyResources { + dataRegistry: ObservabilityAgentBuilderDataRegistry; + core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; + request: KibanaRequest; + logger: Logger; +} + +/** + * Shared pipeline: fetch exit spans from trace IDs, build connections, filter by direction, + * and enrich with service_destination metrics. + */ +async function buildTopologyFromTraceIds({ + resources, + traceIds, + serviceName, + startMs, + endMs, + maxDepth, + filterFn, +}: { + resources: TopologyResources; + traceIds: string[]; + serviceName: string; + startMs: number; + endMs: number; + maxDepth?: number; + filterFn: ( + connections: ConnectionWithKey[], + rootService: string, + maxDepth?: number + ) => ConnectionWithKey[]; +}): Promise { + if (traceIds.length === 0) { + return { connections: [] }; + } + + const spans = await resources.dataRegistry.getData('apmExitSpanSamples', { + request: resources.request, + traceIds, + start: startMs, + end: endMs, + }); + + if (!spans) { + return { connections: [] }; + } + + const filtered = filterFn(buildConnectionsFromSpans(spans), serviceName, maxDepth); + + const serviceNames = uniq(filtered.map((c) => c._sourceName)); + const metricsMap = await getConnectionMetrics({ + dataRegistry: resources.dataRegistry, + request: resources.request, + start: startMs, + end: endMs, + serviceNames, + }); + + return { connections: finalizeConnections(filtered, metricsMap) }; +} + +async function getDownstreamTopology({ + resources, + serviceName, + startMs, + endMs, + maxDepth, +}: { + resources: TopologyResources; + serviceName: string; + startMs: number; + endMs: number; + maxDepth?: number; +}): Promise { + // Fast path: depth=1 uses pre-aggregated metrics instead of trace scanning + if (maxDepth === 1) { + resources.logger.debug( + `Using aggregated traces to return immediate downstream dependencies of "${serviceName}"` + ); + return getImmediateDownstreamDependencies({ + dataRegistry: resources.dataRegistry, + request: resources.request, + serviceName, + startMs, + endMs, + }); + } + + resources.logger.debug( + `Using sampled traces to return downstream dependencies of "${serviceName}"` + ); + + const result = await resources.dataRegistry.getData('apmTraceSampleIds', { + request: resources.request, + serviceName, + start: startMs, + end: endMs, + }); + + const traceIds = result?.traceIds ?? []; + + resources.logger.debug(`Found ${traceIds.length} traces for downstream topology`); + + return buildTopologyFromTraceIds({ + resources, + traceIds, + serviceName, + startMs, + endMs, + maxDepth, + filterFn: filterDownstreamConnections, + }); +} + +async function getUpstreamTopology({ + resources, + serviceName, + startMs, + endMs, + maxDepth, +}: { + resources: TopologyResources; + serviceName: string; + startMs: number; + endMs: number; + maxDepth?: number; +}): Promise { + // Strategy: First try to find traces via the service's own transactions. + // If the service has transactions (it's an instrumented service like "checkout-service"), + // those traces will contain the full call chain including upstream callers. + // This is reliable — no field matching needed. + const result = await resources.dataRegistry.getData('apmTraceSampleIds', { + request: resources.request, + serviceName, + start: startMs, + end: endMs, + }); + + const traceIds = result?.traceIds ?? []; + + if (traceIds.length > 0) { + resources.logger.debug( + `Found ${traceIds.length} traces for upstream topology via service transactions` + ); + + return buildTopologyFromTraceIds({ + resources, + traceIds, + serviceName, + startMs, + endMs, + maxDepth, + filterFn: filterUpstreamConnections, + }); + } + + // Fallback: the service has no transactions, so it's an external dependency (e.g., "postgres"). + // Find traces by exact match on span.destination.service.resource. + resources.logger.debug( + `No transactions found for "${serviceName}", falling back to exit span search (external dependency)` + ); + + const { apmEventClient } = await buildApmResources({ + core: resources.core, + plugins: resources.plugins, + request: resources.request, + logger: resources.logger, + }); + + const depTraceIds = await getTraceIdsFromExitSpansTargetingDependency({ + apmEventClient, + dependencyName: serviceName, + start: startMs, + end: endMs, + }); + + resources.logger.debug(`Found ${depTraceIds.length} traces for upstream topology via exit spans`); + + return buildTopologyFromTraceIds({ + resources, + traceIds: depTraceIds, + serviceName, + startMs, + endMs, + maxDepth, + filterFn: filterUpstreamConnections, + }); +} + +/** + * Optimized path for direction === 'both': fetches trace samples and exit spans once, + * then filters in both directions from the shared connection graph. + * + * This avoids duplicate ES queries — getTraceSampleIds and fetchExitSpanSamplesFromTraceIds + * would otherwise execute identical queries for both downstream and upstream. + */ +async function getBothTopology({ + resources, + serviceName, + startMs, + endMs, + maxDepth, +}: { + resources: TopologyResources; + serviceName: string; + startMs: number; + endMs: number; + maxDepth?: number; +}): Promise { + const result = await resources.dataRegistry.getData('apmTraceSampleIds', { + request: resources.request, + serviceName, + start: startMs, + end: endMs, + }); + + const traceIds = result?.traceIds ?? []; + + // External dependency (no transactions): downstream is empty, only upstream applies. + // Fall back to finding traces via exit spans targeting this dependency. + if (traceIds.length === 0) { + resources.logger.debug( + `No transactions found for "${serviceName}", falling back to exit span search (external dependency)` + ); + + const { apmEventClient } = await buildApmResources({ + core: resources.core, + plugins: resources.plugins, + request: resources.request, + logger: resources.logger, + }); + + const depTraceIds = await getTraceIdsFromExitSpansTargetingDependency({ + apmEventClient, + dependencyName: serviceName, + start: startMs, + end: endMs, + }); + + resources.logger.debug( + `Found ${depTraceIds.length} traces for upstream topology via exit spans` + ); + + return buildTopologyFromTraceIds({ + resources, + traceIds: depTraceIds, + serviceName, + startMs, + endMs, + maxDepth, + filterFn: filterUpstreamConnections, + }); + } + + resources.logger.debug(`Found ${traceIds.length} traces for both-direction topology`); + + // Fetch exit spans once, build connection graph once + const spans = await resources.dataRegistry.getData('apmExitSpanSamples', { + request: resources.request, + traceIds, + start: startMs, + end: endMs, + }); + + if (!spans) { + return { connections: [] }; + } + + const allConnections = buildConnectionsFromSpans(spans); + const downFiltered = filterDownstreamConnections(allConnections, serviceName, maxDepth); + const upFiltered = filterUpstreamConnections(allConnections, serviceName, maxDepth); + + // Single metrics query with union of service names from both directions + const serviceNames = uniq([ + ...downFiltered.map((c) => c._sourceName), + ...upFiltered.map((c) => c._sourceName), + ]); + + const metricsMap = await getConnectionMetrics({ + dataRegistry: resources.dataRegistry, + request: resources.request, + start: startMs, + end: endMs, + serviceNames, + }); + + return { + connections: [ + ...finalizeConnections(downFiltered, metricsMap), + ...finalizeConnections(upFiltered, metricsMap), + ], + }; +} + +export async function getServiceTopology({ + core, + plugins, + dataRegistry, + request, + logger, + serviceName, + direction = 'downstream', + depth, + start, + end, +}: { + core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; + dataRegistry: ObservabilityAgentBuilderDataRegistry; + request: KibanaRequest; + logger: Logger; + serviceName: string; + direction?: TopologyDirection; + depth?: number; + start: string; + end: string; +}): Promise { + const startMs = parseDatemath(start); + const endMs = parseDatemath(end); + + const resources: TopologyResources = { dataRegistry, core, plugins, request, logger }; + const params = { resources, serviceName, startMs, endMs, maxDepth: depth }; + + if (direction === 'downstream') { + return getDownstreamTopology(params); + } + + if (direction === 'upstream') { + return getUpstreamTopology(params); + } + + return getBothTopology(params); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_trace_ids_from_exit_spans.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_trace_ids_from_exit_spans.ts new file mode 100644 index 0000000000000..daa49cc5f445c --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/get_trace_ids_from_exit_spans.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { rangeQuery } from '@kbn/observability-utils-server/es/queries/range_query'; +import type { APMEventClient } from '@kbn/apm-data-access-plugin/server'; +import { ApmDocumentType, RollupInterval } from '@kbn/apm-data-access-plugin/common'; +import { SPAN_DESTINATION_SERVICE_RESOURCE, TRACE_ID } from '@kbn/apm-types'; + +/** + * Get trace IDs from exit spans that target a specific external dependency. + * Used as a fallback for upstream topology when the target has no transactions + * (e.g., databases like "postgres", caches like "redis"). + * + * Searches by exact `span.destination.service.resource` match only — no heuristic/fuzzy matching. + */ +export async function getTraceIdsFromExitSpansTargetingDependency({ + apmEventClient, + dependencyName, + start, + end, + maxTraces = 500, +}: { + apmEventClient: APMEventClient; + dependencyName: string; + start: number; + end: number; + maxTraces?: number; +}): Promise { + const response = await apmEventClient.search( + 'get_trace_ids_from_exit_spans_targeting_dependency', + { + apm: { + sources: [ + { + documentType: ApmDocumentType.SpanEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...rangeQuery(start, end), + { term: { [SPAN_DESTINATION_SERVICE_RESOURCE]: dependencyName } }, + ], + }, + }, + aggs: { + sample: { + sampler: { + shard_size: maxTraces, + }, + aggs: { + traceIds: { + terms: { + field: TRACE_ID, + size: maxTraces, + }, + }, + }, + }, + }, + } + ); + + const buckets = response.aggregations?.sample?.traceIds?.buckets ?? []; + return buckets.map((bucket) => String(bucket.key)); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/handler.ts new file mode 100644 index 0000000000000..e6940c801992a --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/handler.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest, Logger } from '@kbn/core/server'; +import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; +import type { + ObservabilityAgentBuilderCoreSetup, + ObservabilityAgentBuilderPluginSetupDependencies, +} from '../../types'; +import type { TopologyDirection, ServiceTopologyResponse } from './types'; +import { getServiceTopology } from './get_service_topology'; + +export function getToolHandler({ + core, + plugins, + request, + dataRegistry, + logger, + serviceName, + direction, + depth, + start, + end, +}: { + core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; + request: KibanaRequest; + dataRegistry: ObservabilityAgentBuilderDataRegistry; + logger: Logger; + serviceName: string; + direction: TopologyDirection; + depth?: number; + start: string; + end: string; +}): Promise { + return getServiceTopology({ + core, + plugins, + dataRegistry, + request, + logger, + serviceName, + direction, + depth, + start, + end, + }); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/tool.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/tool.ts new file mode 100644 index 0000000000000..55ee0ea8b6bef --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/tool.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 { z } from '@kbn/zod'; +import type { Logger } from '@kbn/core/server'; +import type { BuiltinToolDefinition, StaticToolRegistration } from '@kbn/agent-builder-server'; +import { ToolType } from '@kbn/agent-builder-common'; +import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; +import type { + ObservabilityAgentBuilderCoreSetup, + ObservabilityAgentBuilderPluginSetupDependencies, +} from '../../types'; +import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; +import { timeRangeSchemaOptional } from '../../utils/tool_schemas'; +import { getAgentBuilderResourceAvailability } from '../../utils/get_agent_builder_resource_availability'; +import { getToolHandler } from './handler'; + +export const OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID = 'observability.get_service_topology'; + +const DEFAULT_TIME_RANGE = { start: 'now-1h', end: 'now' }; + +const getServiceTopologyToolSchema = z.object({ + ...timeRangeSchemaOptional(DEFAULT_TIME_RANGE), + serviceName: z.string().min(1).describe('The name of the service to get the topology for'), + direction: z + .enum(['downstream', 'upstream', 'both']) + .default('downstream') + .describe( + 'Direction of dependencies to retrieve. ' + + '"downstream" shows what this service calls (dependencies). ' + + '"upstream" shows what calls this service (callers). ' + + '"both" shows both directions. Defaults to "downstream".' + ), + depth: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Maximum number of hops to traverse. ' + + 'depth=1 returns only immediate (single-hop) dependencies. ' + + 'Omit for unlimited traversal (full multi-hop topology).' + ), +}); + +export function createGetServiceTopologyTool({ + core, + plugins, + dataRegistry, + logger, +}: { + core: ObservabilityAgentBuilderCoreSetup; + plugins: ObservabilityAgentBuilderPluginSetupDependencies; + dataRegistry: ObservabilityAgentBuilderDataRegistry; + logger: Logger; +}): StaticToolRegistration { + const toolDefinition: BuiltinToolDefinition = { + id: OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID, + type: ToolType.builtin, + description: `Retrieves the service topology (dependency graph) for a service, with RED metrics (latency, throughput, error rate) per connection. + +Returns connections with source/target nodes and RED metrics. Supports downstream, upstream, or both directions. + +When to use: +- Checking which direct dependencies are failing or slow (depth: 1) +- Tracing cascading failures through multi-hop dependency chains +- Understanding blast radius of a failing service (direction: "upstream") +- Visualizing the full architecture around a service (direction: "both") + +When NOT to use: +- For service-level metrics without topology, use \`observability.get_trace_metrics\` + +After reviewing topology results, consider: +- Use \`observability.get_trace_metrics\` with timeseries to check latency/error trends over time +- Use \`observability.get_correlated_logs\` to find error patterns in failing dependencies`, + schema: getServiceTopologyToolSchema, + tags: ['observability', 'apm', 'service-map', 'topology'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (toolParams, context) => { + const { serviceName, direction, depth, start, end } = toolParams; + const { request } = context; + + try { + const topology = await getToolHandler({ + core, + plugins, + request, + dataRegistry, + logger, + serviceName, + direction, + depth, + start, + end, + }); + + return { + results: [ + { + type: ToolResultType.other, + data: { + connections: topology.connections, + }, + }, + ], + }; + } catch (error) { + logger.error(`Error getting service topology: ${error.message}`); + logger.debug(error); + + return { + results: [ + { + type: ToolResultType.error, + data: { + message: `Failed to fetch service topology: ${error.message}`, + stack: error.stack, + }, + }, + ], + }; + } + }, + }; + + return toolDefinition; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/types.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/types.ts new file mode 100644 index 0000000000000..228f6e210f8d3 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_service_topology/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type TopologyDirection = 'downstream' | 'upstream' | 'both'; + +export interface ServiceTopologyNode { + 'service.name': string; +} + +export interface ExternalNode { + 'span.destination.service.resource': string; + 'span.type': string; + 'span.subtype': string; +} + +export interface ConnectionMetrics { + errorRate?: number; + latencyMs?: number; + throughputPerMin?: number; +} + +export interface ServiceTopologyConnection { + source: ServiceTopologyNode | ExternalNode; + target: ServiceTopologyNode | ExternalNode; + metrics: ConnectionMetrics | undefined; +} + +export interface ServiceTopologyResponse { + connections: ServiceTopologyConnection[]; +} + +export interface ConnectionWithKey extends ServiceTopologyConnection { + _key: string; + _sourceName: string; + _dependencyName: string; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_traces/get_trace_documents.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_traces/get_trace_documents.ts new file mode 100644 index 0000000000000..629ff944e62f7 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_traces/get_trace_documents.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { MsearchRequestItem } from '@elastic/elasticsearch/lib/api/types'; +import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; +import { TRACE_ID } from '@kbn/apm-types'; +import { timeRangeFilter, termFilter } from '../../utils/dsl_filters'; +import { unwrapEsFields } from '../../utils/unwrap_es_fields'; +import { getTotalHits } from '../../utils/get_total_hits'; +import { DEFAULT_TRACE_FIELDS } from './constants'; + +export interface ServiceAggregate { + serviceName: string; + count: number; + errorCount: number; +} + +interface ServicesAgg { + buckets?: { + key: unknown; + doc_count: number; + error_count?: { doc_count: number }; + }[]; +} + +export async function getTraceDocuments({ + esClient, + traceIds, + index, + startTime, + endTime, + size, + fields = DEFAULT_TRACE_FIELDS, +}: { + esClient: IScopedClusterClient; + traceIds: string[]; + index: string[]; + startTime: number; + endTime: number; + size: number; + fields?: string[]; +}): Promise< + { + items: Record[]; + services: ServiceAggregate[]; + error?: string; + isTruncated: boolean; + }[] +> { + const searches: MsearchRequestItem[] = traceIds.flatMap((traceId) => [ + { index }, + { + track_total_hits: size + 1, // +1 to determine if results are truncated + size, + sort: [{ '@timestamp': { order: 'asc' } }], + _source: false, + fields, + query: { + bool: { + filter: [ + ...timeRangeFilter('@timestamp', { start: startTime, end: endTime }), + ...termFilter(TRACE_ID, traceId), + ], + }, + }, + aggs: { + services: { + terms: { + field: 'service.name', + size: 100, + }, + aggs: { + error_count: { + filter: { term: { 'event.outcome': 'failure' } }, + }, + }, + }, + }, + }, + ]); + const msearchResponse = await esClient.asCurrentUser.msearch({ + searches, + }); + return msearchResponse.responses.map((response, responseIndex) => { + const traceId = traceIds[responseIndex]; + if ('error' in response) { + return { + items: [], + error: `Failed to fetch trace documents for trace.id ${traceId}: ${response.error.type}: ${response.error.reason}`, + services: [], + isTruncated: false, + }; + } + const serviceAggs = (response.aggregations?.services as ServicesAgg)?.buckets ?? []; + const services = serviceAggs + .map((bucket) => ({ + serviceName: bucket.key as string, + count: bucket.doc_count, + errorCount: bucket.error_count?.doc_count ?? 0, + })) + .sort((a, b) => b.count - a.count); + return { + items: response.hits.hits.map((hit) => unwrapEsFields(hit.fields)), + services, + isTruncated: getTotalHits(response) > size, + }; + }); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_traces/handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_traces/handler.ts index 0ffe55c4a3d04..240cf69455d93 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_traces/handler.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_traces/handler.ts @@ -6,81 +6,17 @@ */ import moment from 'moment'; -import type { MsearchRequestItem } from '@elastic/elasticsearch/lib/api/types'; import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import type { Logger } from '@kbn/core/server'; -import { TRACE_ID } from '@kbn/apm-types'; import type { ObservabilityAgentBuilderCoreSetup, ObservabilityAgentBuilderPluginSetupDependencies, } from '../../types'; import { getObservabilityDataSources } from '../../utils/get_observability_data_sources'; import { parseDatemath } from '../../utils/time'; -import { timeRangeFilter, termFilter } from '../../utils/dsl_filters'; -import { unwrapEsFields } from '../../utils/unwrap_es_fields'; -import { getTotalHits } from '../../utils/get_total_hits'; import { getTraceIds } from './get_trace_ids'; - -export async function fetchTraceDocuments({ - esClient, - traceIds, - index, - startTime, - endTime, - size, - fields, -}: { - esClient: IScopedClusterClient; - traceIds: string[]; - index: string[]; - startTime: number; - endTime: number; - size: number; - fields: string[]; -}): Promise< - { - items: Record[]; - error?: string; - isTruncated: boolean; - }[] -> { - const searches: MsearchRequestItem[] = traceIds.flatMap((traceId) => [ - { index }, - { - track_total_hits: size + 1, // +1 to determine if results are truncated - size, - sort: [{ '@timestamp': { order: 'asc' } }], - _source: false, - fields, - query: { - bool: { - filter: [ - ...timeRangeFilter('@timestamp', { start: startTime, end: endTime }), - ...termFilter(TRACE_ID, traceId), - ], - }, - }, - }, - ]); - const msearchResponse = await esClient.asCurrentUser.msearch({ - searches, - }); - return msearchResponse.responses.map((response, responseIndex) => { - const traceId = traceIds[responseIndex]; - if ('error' in response) { - return { - items: [], - error: `Failed to fetch trace documents for trace.id ${traceId}: ${response.error.type}: ${response.error.reason}`, - isTruncated: false, - }; - } - - return { - items: response.hits.hits.map((hit) => unwrapEsFields(hit.fields)), - isTruncated: getTotalHits(response) > size, - }; - }); -} +import { getTraceDocuments } from './get_trace_documents'; +import { DEFAULT_TRACE_FIELDS } from './constants'; export async function getToolHandler({ core, @@ -91,7 +27,7 @@ export async function getToolHandler({ end, index, kqlFilter, - fields, + fields = DEFAULT_TRACE_FIELDS, maxTraces, maxDocsPerTrace, }: { @@ -103,7 +39,7 @@ export async function getToolHandler({ end: string; index?: string; kqlFilter: string; - fields: string[]; + fields?: string[]; maxTraces: number; maxDocsPerTrace: number; }) { @@ -112,14 +48,16 @@ export async function getToolHandler({ dataSources.apmIndexPatterns.transaction, dataSources.apmIndexPatterns.span, dataSources.apmIndexPatterns.error, - ]; - const indices = index?.split(',') ?? [...dataSources.logIndexPatterns, ...apmIndexPatterns]; + ].flatMap((pattern) => pattern.split(',')); + + const allObservabilityIndices = [...apmIndexPatterns, ...dataSources.logIndexPatterns]; + const startTime = parseDatemath(start); const endTime = parseDatemath(end, { roundUp: true }); const traceIds = await getTraceIds({ esClient, - indices, + indices: index?.split(',') ?? allObservabilityIndices, startTime, endTime, kqlFilter, @@ -135,10 +73,10 @@ export async function getToolHandler({ start: moment(startTime).subtract(5, 'minutes').valueOf(), end: moment(endTime).add(5, 'minutes').valueOf(), }; - const traces = await fetchTraceDocuments({ + const traces = await getTraceDocuments({ esClient, traceIds, - index: indices, + index: allObservabilityIndices, startTime: traceTimeWindow.start, endTime: traceTimeWindow.end, size: maxDocsPerTrace, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/index.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/index.ts index 015097e84ac49..b23a1952b2e71 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/index.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/index.ts @@ -10,10 +10,9 @@ export { OBSERVABILITY_GET_LOG_GROUPS_TOOL_ID } from './get_log_groups/tool'; export { OBSERVABILITY_RUN_LOG_RATE_ANALYSIS_TOOL_ID } from './run_log_rate_analysis/tool'; export { OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID } from './get_anomaly_detection_jobs/tool'; export { OBSERVABILITY_GET_SERVICES_TOOL_ID } from './get_services/tool'; -export { OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID } from './get_downstream_dependencies/tool'; -export { OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID } from './get_correlated_logs/tool'; export { OBSERVABILITY_GET_HOSTS_TOOL_ID } from './get_hosts/tool'; export { OBSERVABILITY_GET_TRACE_METRICS_TOOL_ID } from './get_trace_metrics/tool'; export { OBSERVABILITY_GET_TRACES_TOOL_ID } from './get_traces/tool'; export { OBSERVABILITY_GET_RUNTIME_METRICS_TOOL_ID } from './get_runtime_metrics/tool'; export { OBSERVABILITY_GET_INDEX_INFO_TOOL_ID } from './get_index_info'; +export { OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID } from './get_service_topology/tool'; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/register_tools.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/register_tools.ts index 4d01c7e5f0a23..3788a5f05ed51 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/register_tools.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/register_tools.ts @@ -26,16 +26,8 @@ import { OBSERVABILITY_GET_LOG_GROUPS_TOOL_ID, createGetLogGroupsTool, } from './get_log_groups/tool'; -import { - OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - createGetCorrelatedLogsTool, -} from './get_correlated_logs/tool'; import { OBSERVABILITY_GET_HOSTS_TOOL_ID, createGetHostsTool } from './get_hosts/tool'; import { createGetServicesTool, OBSERVABILITY_GET_SERVICES_TOOL_ID } from './get_services/tool'; -import { - createDownstreamDependenciesTool, - OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID, -} from './get_downstream_dependencies/tool'; import { createGetTraceMetricsTool, OBSERVABILITY_GET_TRACE_METRICS_TOOL_ID, @@ -57,6 +49,10 @@ import { createGetTraceChangePointsTool, } from './get_trace_change_points/tool'; import { OBSERVABILITY_GET_INDEX_INFO_TOOL_ID, createGetIndexInfoTool } from './get_index_info'; +import { + OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID, + createGetServiceTopologyTool, +} from './get_service_topology/tool'; import { OBSERVABILITY_GET_TRACES_TOOL_ID, createGetTracesTool } from './get_traces/tool'; const PLATFORM_TOOL_IDS = [ @@ -71,9 +67,7 @@ const OBSERVABILITY_TOOL_IDS = [ OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, OBSERVABILITY_GET_ALERTS_TOOL_ID, OBSERVABILITY_GET_LOG_GROUPS_TOOL_ID, - OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, OBSERVABILITY_GET_SERVICES_TOOL_ID, - OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID, OBSERVABILITY_GET_HOSTS_TOOL_ID, OBSERVABILITY_GET_TRACE_METRICS_TOOL_ID, OBSERVABILITY_GET_TRACES_TOOL_ID, @@ -82,6 +76,7 @@ const OBSERVABILITY_TOOL_IDS = [ OBSERVABILITY_GET_METRIC_CHANGE_POINTS_TOOL_ID, OBSERVABILITY_GET_TRACE_CHANGE_POINTS_TOOL_ID, OBSERVABILITY_GET_INDEX_INFO_TOOL_ID, + OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID, ]; export const OBSERVABILITY_AGENT_TOOL_IDS = [...PLATFORM_TOOL_IDS, ...OBSERVABILITY_TOOL_IDS]; @@ -103,8 +98,6 @@ export async function registerTools({ createGetAlertsTool({ core, logger }), createGetLogGroupsTool({ core, plugins, logger }), createGetServicesTool({ core, plugins, dataRegistry, logger }), - createDownstreamDependenciesTool({ core, dataRegistry, logger }), - createGetCorrelatedLogsTool({ core, logger }), createGetHostsTool({ core, logger, dataRegistry }), createGetTraceMetricsTool({ core, plugins, logger }), createGetTracesTool({ core, plugins, logger }), @@ -113,6 +106,7 @@ export async function registerTools({ createGetMetricChangePointsTool({ core, plugins, logger }), createGetTraceChangePointsTool({ core, plugins, logger }), createGetIndexInfoTool({ core, plugins, logger }), + createGetServiceTopologyTool({ core, plugins, dataRegistry, logger }), ]; for (const tool of observabilityTools) { diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/run_log_rate_analysis/tool.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/run_log_rate_analysis/tool.ts index fed586bace268..cdfc94117485d 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/run_log_rate_analysis/tool.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/run_log_rate_analysis/tool.ts @@ -61,7 +61,7 @@ How it works: Compares a baseline time window to a deviation window and performs statistical correlation analysis to find fields/patterns associated with the change. Do NOT use for: -- Understanding the sequence of events for a specific error (use get_correlated_logs) +- Understanding the sequence of events for a specific error (use get_traces) - Getting a general overview of log types (use get_log_groups) - Investigating individual log entries or transactions`, schema: logRateAnalysisSchema, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/utils/compute_sampling_probability.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/utils/compute_sampling_probability.ts new file mode 100644 index 0000000000000..c76a1f2b22ca2 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/utils/compute_sampling_probability.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. + */ + +// ES constraint: probability must be in (0, 0.5] or exactly 1 +export function computeSamplingProbability({ + totalHits, + targetSampleSize, +}: { + totalHits: number; + targetSampleSize: number; +}): number { + const rawProbability = targetSampleSize / totalHits; + return rawProbability < 0.5 ? rawProbability : 1; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json b/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json index 14f0974c15d4a..e1042ecd880ce 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json @@ -44,9 +44,11 @@ "@kbn/sse-utils-server", "@kbn/kibana-utils-plugin", "@kbn/server-route-repository-client", + "@kbn/core-analytics-browser", "@kbn/apm-types", + "@kbn/slo-schema", "@kbn/apm-types-shared", - "@kbn/observability-utils-server" + "@kbn/observability-utils-server", ], "exclude": ["target/**/*"] } diff --git a/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/parallel.json b/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/parallel.json index 7801a3ed0c8db..78c28395fdafc 100644 --- a/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/parallel.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T18:40:22.069Z", "sha1": "a9a52816a7abff7332d8573055a61fea68f97904", "tests": [] } \ No newline at end of file diff --git a/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/standard.json b/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/standard.json index 3a615d5e833cc..bb6b30f26682d 100644 --- a/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/standard.json +++ b/x-pack/solutions/observability/plugins/observability_onboarding/test/scout/.meta/ui/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T18:40:24.680Z", "sha1": "a9a52816a7abff7332d8573055a61fea68f97904", "tests": [ { diff --git a/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/api/standard.json b/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/api/standard.json index 380489e494e61..a794b6ccd3862 100644 --- a/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/api/standard.json +++ b/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/api/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-04T19:00:09.973Z", "sha1": "90bd13f6210737e4784c51c7176f13a07fcd1b80", "tests": [ { diff --git a/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/ui/parallel.json b/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/ui/parallel.json index 5a6ee03237a3b..03a22c5da9f5f 100644 --- a/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/observability/plugins/profiling/test/scout/.meta/ui/parallel.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-06T14:35:41.125Z", "sha1": "0a1568fd0cffb761c44f009b17c125912124b599", "tests": [ { diff --git a/x-pack/solutions/observability/plugins/slo/kibana.jsonc b/x-pack/solutions/observability/plugins/slo/kibana.jsonc index ddebb7609bc7a..dbae1d79e4bb9 100644 --- a/x-pack/solutions/observability/plugins/slo/kibana.jsonc +++ b/x-pack/solutions/observability/plugins/slo/kibana.jsonc @@ -43,18 +43,20 @@ "cases", "cloud", "discover", - "embeddableEnhanced", "observabilityAIAssistant", "security", "serverless", - "spaces" + "spaces", + "agentBuilder", + "observabilityAgentBuilder" ], "requiredBundles": [ "embeddable", "ingestPipelines", "kibanaReact", "kibanaUtils", - "unifiedSearch" + "unifiedSearch", + "observabilityAgentBuilder" ] } } diff --git a/x-pack/solutions/observability/plugins/slo/moon.yml b/x-pack/solutions/observability/plugins/slo/moon.yml index 394038331c1ea..13c38872460fe 100644 --- a/x-pack/solutions/observability/plugins/slo/moon.yml +++ b/x-pack/solutions/observability/plugins/slo/moon.yml @@ -34,7 +34,6 @@ dependsOn: - '@kbn/alerting-plugin' - '@kbn/rison' - '@kbn/embeddable-plugin' - - '@kbn/embeddable-enhanced-plugin' - '@kbn/lens-plugin' - '@kbn/es-query' - '@kbn/react-kibana-mount' @@ -122,6 +121,8 @@ dependsOn: - '@kbn/deeplinks-management' - '@kbn/deeplinks-observability' - '@kbn/observability-get-padded-alert-time-range-util' + - '@kbn/observability-agent-builder-plugin' + - '@kbn/agent-builder-plugin' tags: - plugin - prod diff --git a/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/burn_rate_rule_editor.tsx b/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/burn_rate_rule_editor.tsx index cd1cd6ba87a7a..2f549e8244801 100644 --- a/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/burn_rate_rule_editor.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/burn_rate_rule_editor.tsx @@ -39,6 +39,7 @@ export function BurnRateRuleEditor(props: Props) { const [selectedSlo, setSelectedSlo] = useState(undefined); const [windowDefs, setWindowDefs] = useState(ruleParams?.windows || []); const [dependencies, setDependencies] = useState(ruleParams?.dependencies || []); + const [hasInteracted, setHasInteracted] = useState(false); const { isLoading, data } = useFetchSloDefinitions({}); useEffect(() => { @@ -51,6 +52,10 @@ export function BurnRateRuleEditor(props: Props) { }); }, [initialSlo]); + const setHasInteractedField = () => { + setHasInteracted(true); + }; + const onSelectedSlo = (slo: SLODefinitionResponse | undefined) => { setSelectedSlo(slo); setWindowDefs(() => { @@ -77,7 +82,12 @@ export function BurnRateRuleEditor(props: Props) { } return ( - + ); }; diff --git a/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/slo_selector.tsx b/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/slo_selector.tsx index fa58c2345894a..8d36f6cfcf50a 100644 --- a/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/slo_selector.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/components/burn_rate_rule_editor/slo_selector.tsx @@ -17,9 +17,10 @@ interface Props { initialSlo?: SLODefinitionResponse; errors?: string[]; onSelected: (slo: SLODefinitionResponse | undefined) => void; + onBlur?: () => void; } -function SloSelector({ initialSlo, onSelected, errors }: Props) { +function SloSelector({ initialSlo, onSelected, errors, onBlur }: Props) { const [options, setOptions] = useState>>([]); const [selectedOptions, setSelectedOptions] = useState>>(); const [searchValue, setSearchValue] = useState(''); @@ -67,6 +68,7 @@ function SloSelector({ initialSlo, onSelected, errors }: Props) { selectedOptions={selectedOptions} async isLoading={isLoading} + onBlur={onBlur} onChange={onChange} fullWidth onSearchChange={onSearchChange} diff --git a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/common/slo_overview_details.tsx b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/common/slo_overview_details.tsx index c6c342c93ac3a..76ca4f279e362 100644 --- a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/common/slo_overview_details.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/common/slo_overview_details.tsx @@ -21,9 +21,13 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { SLOWithSummaryResponse } from '@kbn/slo-schema'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { SloTabId } from '@kbn/deeplinks-observability'; import { OVERVIEW_TAB_ID } from '@kbn/deeplinks-observability'; +import { + OBSERVABILITY_AGENT_ID, + OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, +} from '@kbn/observability-agent-builder-plugin/public'; import { HeaderTitle } from '../../../pages/slo_details/components/header_title'; import { SloDetails } from '../../../pages/slo_details/components/slo_details'; import { useSloDetailsTabs } from '../../../pages/slo_details/hooks/use_slo_details_tabs'; @@ -39,6 +43,7 @@ export function SloOverviewDetailsContent({ slo, initialTabId = OVERVIEW_TAB_ID, }: SloOverviewDetailsContentProps) { + const { agentBuilder } = useKibana().services; const [selectedTabId, setSelectedTabId] = useState(initialTabId); const { tabs } = useSloDetailsTabs({ @@ -48,6 +53,35 @@ export function SloOverviewDetailsContent({ setSelectedTabId, }); + // Configure agent builder global flyout with the SLO attachment + useEffect(() => { + if (!agentBuilder || !slo) { + return; + } + + agentBuilder.setConversationFlyoutActiveConfig({ + agentId: OBSERVABILITY_AGENT_ID, + attachments: [ + { + type: OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, + data: { + sloId: slo.id, + remoteName: slo.remote?.remoteName, + sloInstanceId: slo.instanceId, + attachmentLabel: i18n.translate('xpack.slo.sloDetails.sloAttachmentLabel', { + defaultMessage: '{sloName} SLO', + values: { sloName: slo.name }, + }), + }, + }, + ], + }); + + return () => { + agentBuilder.clearConversationFlyoutActiveConfig(); + }; + }, [agentBuilder, slo]); + return ( <> diff --git a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx index f02011a52194b..d89cf102c023f 100644 --- a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx @@ -63,17 +63,17 @@ export const getOverviewEmbeddableFactory = ({ sloClient: SLORepositoryClient; }): EmbeddableFactory => ({ type: SLO_OVERVIEW_EMBEDDABLE_ID, - buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => { + buildEmbeddable: async ({ + initializeDrilldownsManager, + initialState, + finalizeApi, + uuid, + parentApi, + }) => { const deps = { ...coreStart, ...pluginsStart }; const state = initialState; - const dynamicActionsManager = await deps.embeddableEnhanced?.initializeEmbeddableDynamicActions( - uuid, - () => titleManager.api.title$.getValue(), - initialState - ); - - const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); const titleManager = initializeTitleManager(state); const sloStateManager = initializeStateManager(state, defaultSloEmbeddableState); @@ -81,11 +81,10 @@ export const getOverviewEmbeddableFactory = ({ const reload$ = new Subject(); function serializeState() { - const dynamicActionsState = dynamicActionsManager?.getLatestState() ?? {}; return { ...titleManager.getLatestState(), ...sloStateManager.getLatestState(), - ...dynamicActionsState, + ...drilldownsManager.getLatestState(), }; } @@ -94,7 +93,7 @@ export const getOverviewEmbeddableFactory = ({ parentApi, serializeState, anyStateChange$: merge( - ...(dynamicActionsManager ? [dynamicActionsManager.anyStateChange$] : []), + drilldownsManager.anyStateChange$, titleManager.anyStateChange$, sloStateManager.anyStateChange$ ), @@ -106,10 +105,10 @@ export const getOverviewEmbeddableFactory = ({ remoteName: 'referenceEquality', overviewMode: 'referenceEquality', ...titleComparators, - ...(dynamicActionsManager?.comparators ?? { drilldowns: 'skip', enhancements: 'skip' }), + ...drilldownsManager.comparators, }), onReset: (lastSaved) => { - dynamicActionsManager?.reinitializeState(lastSaved ?? {}); + drilldownsManager.reinitializeState(lastSaved ?? {}); titleManager.reinitializeState(lastSaved); sloStateManager.reinitializeState(lastSaved); }, @@ -118,7 +117,7 @@ export const getOverviewEmbeddableFactory = ({ const api = finalizeApi({ ...unsavedChangesApi, ...titleManager.api, - ...(dynamicActionsManager?.api ?? {}), + ...drilldownsManager.api, ...sloStateManager.api, defaultTitle$, hideTitle$: titleManager.api.hideTitle$, @@ -182,8 +181,8 @@ export const getOverviewEmbeddableFactory = ({ useEffect(() => { return () => { + drilldownsManager.cleanup(); fetchSubscription.unsubscribe(); - maybeStopDynamicActions?.stopDynamicActions(); }; }, []); const renderOverview = () => { diff --git a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/types.ts b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/types.ts index 9e5ca11131b14..05f554c215431 100644 --- a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/types.ts +++ b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/overview/types.ts @@ -4,9 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { HasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public'; -import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; -import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; + +import type { + DefaultEmbeddableApi, + HasDrilldowns, + SerializedDrilldowns, +} from '@kbn/embeddable-plugin/public'; import type { Filter } from '@kbn/es-query'; import type { EmbeddableApiContext, HasSupportedTriggers } from '@kbn/presentation-publishing'; import type { @@ -42,14 +45,12 @@ export type GroupSloCustomInput = SloConfigurationProps & { export type SloOverviewState = Partial & Partial; -export type SloOverviewEmbeddableState = SerializedTitles & - Partial & - SloOverviewState; +export type SloOverviewEmbeddableState = SerializedTitles & SerializedDrilldowns & SloOverviewState; export type SloOverviewApi = DefaultEmbeddableApi & PublishesWritableTitle & PublishesTitle & - HasDynamicActions & + HasDrilldowns & HasSloGroupOverviewConfig & HasEditCapabilities & HasSupportedTriggers; diff --git a/x-pack/solutions/observability/plugins/slo/public/pages/slo_details/slo_details.tsx b/x-pack/solutions/observability/plugins/slo/public/pages/slo_details/slo_details.tsx index 78aa4f2a3c9c7..4e666d863bbe4 100644 --- a/x-pack/solutions/observability/plugins/slo/public/pages/slo_details/slo_details.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/pages/slo_details/slo_details.tsx @@ -17,6 +17,10 @@ import { paths } from '@kbn/slo-shared-plugin/common/locators/paths'; import dedent from 'dedent'; import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { + OBSERVABILITY_AGENT_ID, + OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, +} from '@kbn/observability-agent-builder-plugin/public'; import { HeaderMenu } from '../../components/header_menu/header_menu'; import { LoadingState } from '../../components/loading_state'; import { ActionModalProvider } from '../../context/action_modal'; @@ -42,6 +46,7 @@ export function SloDetailsPage() { http: { basePath }, observabilityAIAssistant, serverless, + agentBuilder, } = useKibana().services; const { ObservabilityPageTemplate } = usePluginContext(); const { hasAtLeast } = useLicense(); @@ -99,6 +104,35 @@ export function SloDetailsPage() { }); }, [observabilityAIAssistant, slo]); + // Configure agent builder global flyout with the SLO attachment + useEffect(() => { + if (!agentBuilder || !slo) { + return; + } + + agentBuilder.setConversationFlyoutActiveConfig({ + agentId: OBSERVABILITY_AGENT_ID, + attachments: [ + { + type: OBSERVABILITY_SLO_ATTACHMENT_TYPE_ID, + data: { + sloId: slo.id, + remoteName, + sloInstanceId, + attachmentLabel: i18n.translate('xpack.slo.sloDetails.sloAttachmentLabel', { + defaultMessage: '{sloName} SLO', + values: { sloName: slo.name }, + }), + }, + }, + ], + }); + + return () => { + agentBuilder.clearConversationFlyoutActiveConfig(); + }; + }, [agentBuilder, sloId, sloInstanceId, remoteName, slo]); + useEffect(() => { if (hasRightLicense === false || permissions?.hasAllReadRequested === false) { navigateToUrl(basePath.prepend(paths.slosWelcome)); diff --git a/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/shared_flyout/create_slo_form_flyout.tsx b/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/shared_flyout/create_slo_form_flyout.tsx index 2bdfb57696175..5b2727d78f9d0 100644 --- a/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/shared_flyout/create_slo_form_flyout.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/shared_flyout/create_slo_form_flyout.tsx @@ -30,7 +30,14 @@ export default function CreateSLOFormFlyout({ const formInitialValues = transformPartialSLODataToFormState(initialValues); return ( - +

diff --git a/x-pack/solutions/observability/plugins/slo/public/types.ts b/x-pack/solutions/observability/plugins/slo/public/types.ts index a80d0aef484ef..a9b4e6e924068 100644 --- a/x-pack/solutions/observability/plugins/slo/public/types.ts +++ b/x-pack/solutions/observability/plugins/slo/public/types.ts @@ -15,7 +15,6 @@ import type { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugi import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { DiscoverStart } from '@kbn/discover-plugin/public'; import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; -import type { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { LensPublicStart } from '@kbn/lens-plugin/public'; @@ -55,6 +54,7 @@ import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plu import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { ApmSourceAccessPluginStart } from '@kbn/apm-sources-access-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; import type { SLORouteRepository } from '../server/routes/get_slo_server_route_repository'; import type { SLOPlugin } from './plugin'; @@ -89,7 +89,6 @@ export interface SLOPublicPluginsStart { discover?: DiscoverStart; discoverShared: DiscoverSharedPublicStart; embeddable: EmbeddableStart; - embeddableEnhanced?: EmbeddableEnhancedPluginStart; fieldFormats: FieldFormatsStart; lens: LensPublicStart; licensing: LicensingPluginStart; @@ -108,6 +107,7 @@ export interface SLOPublicPluginsStart { fieldsMetadata: FieldsMetadataPublicStart; apmSourcesAccess: ApmSourceAccessPluginStart; contentManagement: ContentManagementPublicStart; + agentBuilder?: AgentBuilderPluginStart; } export type SLOPublicSetup = ReturnType; diff --git a/x-pack/solutions/observability/plugins/slo/server/agent_builder/register_data_provider.ts b/x-pack/solutions/observability/plugins/slo/server/agent_builder/register_data_provider.ts new file mode 100644 index 0000000000000..c09c5e5351ed1 --- /dev/null +++ b/x-pack/solutions/observability/plugins/slo/server/agent_builder/register_data_provider.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { GetSLOParams } from '@kbn/slo-schema'; +import type { CoreSetup, Logger, KibanaRequest } from '@kbn/core/server'; +import type { SLOPluginSetupDependencies, SLOPluginStartDependencies } from '../types'; +import { getSloClientWithRequest } from '../client'; + +export function registerDataProviders({ + core, + plugins, + logger, +}: { + core: CoreSetup; + plugins: SLOPluginSetupDependencies; + logger: Logger; +}) { + const { observabilityAgentBuilder } = plugins; + if (!observabilityAgentBuilder) { + return; + } + + observabilityAgentBuilder.registerDataProvider( + 'sloDetails', + async ({ + request, + sloId, + sloInstanceId, + remoteName, + }: { + request: KibanaRequest; + sloId: string; + sloInstanceId?: GetSLOParams['instanceId']; + remoteName?: GetSLOParams['remoteName']; + }) => { + const [coreStart, pluginStart] = await core.getStartServices(); + const spaceId = + (await pluginStart.spaces?.spacesService.getActiveSpace(request))?.id ?? 'default'; + + const sloClient = getSloClientWithRequest({ + request, + soClient: coreStart.savedObjects.getScopedClient(request), + esClient: coreStart.elasticsearch.client.asInternalUser, + scopedClusterClient: coreStart.elasticsearch.client.asScoped(request), + spaceId, + logger, + }); + + return sloClient.getSlo(sloId, { instanceId: sloInstanceId, remoteName }); + } + ); +} diff --git a/x-pack/solutions/observability/plugins/slo/server/client/index.ts b/x-pack/solutions/observability/plugins/slo/server/client/index.ts index 6636e1c22d9bb..f67ca414d053b 100644 --- a/x-pack/solutions/observability/plugins/slo/server/client/index.ts +++ b/x-pack/solutions/observability/plugins/slo/server/client/index.ts @@ -16,6 +16,8 @@ import type { FindSLOResponse, GetSLOGroupedStatsParams, GetSLOGroupedStatsResponse, + GetSLOParams, + GetSLOResponse, } from '@kbn/slo-schema'; import { once } from 'lodash'; import { FindSLO } from '../services/find_slo'; @@ -24,11 +26,18 @@ import { DefaultSLODefinitionRepository } from '../services/slo_definition_repos import { DefaultSLOSettingsRepository } from '../services/slo_settings_repository'; import { DefaultSummarySearchClient } from '../services/summary_search_client/summary_search_client'; import { getSummaryIndices } from '../services/utils/get_summary_indices'; +import { + DefaultBurnRatesClient, + DefaultSummaryClient, + GetSLO, + SLODefinitionClient, +} from '../services'; export interface SloClient { getSummaryIndices(): Promise; getGroupedStats(params: GetSLOGroupedStatsParams): Promise; findSlos(params: FindSLOParams): Promise; + getSlo(sloId: string, params?: GetSLOParams): Promise; } export function getSloClientWithRequest({ @@ -77,5 +86,21 @@ export function getSloClientWithRequest({ const findSLO = new FindSLO(repository, summarySearchClient); return await findSLO.execute(params); }, + + getSlo: async (sloId: string, params: GetSLOParams = {}) => { + const repository = new DefaultSLODefinitionRepository(soClient, logger); + const burnRatesClient = new DefaultBurnRatesClient(scopedClusterClient.asCurrentUser); + const summaryClient = new DefaultSummaryClient( + scopedClusterClient.asCurrentUser, + burnRatesClient + ); + const definitionClient = new SLODefinitionClient( + repository, + scopedClusterClient.asCurrentUser, + logger + ); + const getSloService = new GetSLO(definitionClient, summaryClient); + return getSloService.execute(sloId, spaceId, params); + }, }; } diff --git a/x-pack/solutions/observability/plugins/slo/server/plugin.ts b/x-pack/solutions/observability/plugins/slo/server/plugin.ts index aede85803ad9d..8f62c83f492ee 100644 --- a/x-pack/solutions/observability/plugins/slo/server/plugin.ts +++ b/x-pack/solutions/observability/plugins/slo/server/plugin.ts @@ -56,6 +56,7 @@ import type { SLOServerStart, } from './types'; import { StaleInstancesCleanupTask } from './services/tasks/stale_instances_cleanup_task/stale_instances_cleanup_task'; +import { registerDataProviders } from './agent_builder/register_data_provider'; const sloRuleTypes = [SLO_BURN_RATE_RULE_TYPE_ID]; @@ -163,6 +164,8 @@ export class SLOPlugin }; }) as SLORoutesDependencies['plugins']; + registerDataProviders({ core, plugins, logger: this.logger }); + registerServerRoutes({ core, dependencies: { diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_grouped_stats.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_grouped_stats.ts index a99d5c34a9245..5c3f05aaa568e 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_grouped_stats.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_grouped_stats.ts @@ -8,6 +8,7 @@ import type { estypes } from '@elastic/elasticsearch'; import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { + ALL_VALUE, SLO_STATUS, apmTransactionDurationIndicatorTypeSchema, apmTransactionErrorRateIndicatorTypeSchema, @@ -15,6 +16,7 @@ import { type GetSLOGroupedStatsResponse, type GroupedStatsResult, } from '@kbn/slo-schema'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { termsQuery, termQuery } from '@kbn/observability-plugin/server'; import type { SLOSettings } from '../domain/models'; import { typedSearch } from '../utils/queries'; @@ -27,6 +29,24 @@ interface SloTypeConfig { getFilters: (params: GetSLOGroupedStatsParams) => estypes.QueryDslQueryContainer[]; } +function environmentFilter(environment?: string): QueryDslQueryContainer[] { + if (!environment) { + return []; + } + return [ + { + bool: { + should: [ + { term: { 'service.environment': environment } }, + { term: { 'service.environment': ALL_VALUE } }, + { bool: { must_not: { exists: { field: 'service.environment' } } } }, + ], + minimum_should_match: 1, + }, + }, + ]; +} + const SLO_TYPE_CONFIG: Record = { apm: { groupByField: 'service.name', @@ -37,7 +57,7 @@ const SLO_TYPE_CONFIG: Record = { apmTransactionErrorRateIndicatorTypeSchema.value ), ...termsQuery('service.name', ...(params.serviceNames ?? [])), - ...termQuery('service.environment', params.environment), + ...environmentFilter(params.environment), ], }, }; diff --git a/x-pack/solutions/observability/plugins/slo/server/services/index.ts b/x-pack/solutions/observability/plugins/slo/server/services/index.ts index cea9f08720a9f..adc3e95168396 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/index.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/index.ts @@ -24,3 +24,4 @@ export * from './find_slo_groups'; export * from './get_slo_health'; export * from './summary_search_client/summary_search_client'; export * from './search_slo_definitions'; +export * from './slo_definition_client'; diff --git a/x-pack/solutions/observability/plugins/slo/server/types.ts b/x-pack/solutions/observability/plugins/slo/server/types.ts index f086f217176b8..6db1a94e8d217 100644 --- a/x-pack/solutions/observability/plugins/slo/server/types.ts +++ b/x-pack/solutions/observability/plugins/slo/server/types.ts @@ -24,6 +24,7 @@ import type { TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import type { ObservabilityAgentBuilderPluginSetup } from '@kbn/observability-agent-builder-plugin/server'; import type { SloClient } from './client'; export type { SLOConfig } from '../common/config'; @@ -48,6 +49,7 @@ export interface SLOPluginSetupDependencies { dataViews: DataViewsServerPluginStart; security: SecurityPluginStart; sloShared: SloSharedPluginSetup; + observabilityAgentBuilder?: ObservabilityAgentBuilderPluginSetup; } export interface SLOPluginStartDependencies { diff --git a/x-pack/solutions/observability/plugins/slo/test/scout/.meta/ui/standard.json b/x-pack/solutions/observability/plugins/slo/test/scout/.meta/ui/standard.json index e3fbecb2fba6e..a2c1a16b38b59 100644 --- a/x-pack/solutions/observability/plugins/slo/test/scout/.meta/ui/standard.json +++ b/x-pack/solutions/observability/plugins/slo/test/scout/.meta/ui/standard.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-06T14:35:43.602Z", "sha1": "d0bd24d5de564361dc06d1d0fd1efd125c785ed4", "tests": [ { diff --git a/x-pack/solutions/observability/plugins/slo/tsconfig.json b/x-pack/solutions/observability/plugins/slo/tsconfig.json index 9cfde60be8cdb..5c7f725e7b13d 100644 --- a/x-pack/solutions/observability/plugins/slo/tsconfig.json +++ b/x-pack/solutions/observability/plugins/slo/tsconfig.json @@ -29,7 +29,6 @@ "@kbn/alerting-plugin", "@kbn/rison", "@kbn/embeddable-plugin", - "@kbn/embeddable-enhanced-plugin", "@kbn/lens-plugin", "@kbn/es-query", "@kbn/react-kibana-mount", @@ -116,6 +115,8 @@ "@kbn/react-query", "@kbn/deeplinks-management", "@kbn/deeplinks-observability", - "@kbn/observability-get-padded-alert-time-range-util" + "@kbn/observability-get-padded-alert-time-range-util", + "@kbn/observability-agent-builder-plugin", + "@kbn/agent-builder-plugin" ] } diff --git a/x-pack/solutions/observability/plugins/synthetics/common/embeddables/stats_overview/types.ts b/x-pack/solutions/observability/plugins/synthetics/common/embeddables/stats_overview/types.ts index d2fd2edec5c19..9ce3625c8c461 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/embeddables/stats_overview/types.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/embeddables/stats_overview/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { SerializedTitles } from '@kbn/presentation-publishing-schemas'; interface Option { @@ -26,5 +26,5 @@ export interface OverviewStatsEmbeddableCustomState { } export type OverviewStatsEmbeddableState = SerializedTitles & - DynamicActionsSerializedState & + SerializedDrilldowns & OverviewStatsEmbeddableCustomState; diff --git a/x-pack/solutions/observability/plugins/synthetics/kibana.jsonc b/x-pack/solutions/observability/plugins/synthetics/kibana.jsonc index 1724d2bdaf30d..670107ce67d1f 100644 --- a/x-pack/solutions/observability/plugins/synthetics/kibana.jsonc +++ b/x-pack/solutions/observability/plugins/synthetics/kibana.jsonc @@ -53,7 +53,6 @@ "telemetry", "licenseManagement", "observabilityAIAssistant", - "embeddableEnhanced", "maintenanceWindows" ], "requiredBundles": [ diff --git a/x-pack/solutions/observability/plugins/synthetics/moon.yml b/x-pack/solutions/observability/plugins/synthetics/moon.yml index 435ccbd7ded2a..4dda9ffdc1d82 100644 --- a/x-pack/solutions/observability/plugins/synthetics/moon.yml +++ b/x-pack/solutions/observability/plugins/synthetics/moon.yml @@ -34,7 +34,6 @@ dependsOn: - '@kbn/discover-plugin' - '@kbn/home-plugin' - '@kbn/embeddable-plugin' - - '@kbn/embeddable-enhanced-plugin' - '@kbn/data-plugin' - '@kbn/kibana-utils-plugin' - '@kbn/inspector-plugin' diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/embeddables/stats_overview/stats_overview_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/embeddables/stats_overview/stats_overview_embeddable_factory.tsx index ec375e77922e5..f6e1b1d08ed59 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/embeddables/stats_overview/stats_overview_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/embeddables/stats_overview/stats_overview_embeddable_factory.tsx @@ -8,7 +8,11 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; -import type { DefaultEmbeddableApi, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import type { + DefaultEmbeddableApi, + EmbeddableFactory, + HasDrilldowns, +} from '@kbn/embeddable-plugin/public'; import type { PublishesWritableTitle, PublishesTitle, @@ -24,7 +28,6 @@ import { import { initializeUnsavedChanges } from '@kbn/presentation-publishing'; import { BehaviorSubject, Subject, map, merge } from 'rxjs'; import type { StartServicesAccessor } from '@kbn/core-lifecycle-browser'; -import type { HasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public'; import type { ClientPluginsStart } from '../../../plugin'; import { StatsOverviewComponent } from './stats_overview_component'; import { openMonitorConfiguration } from '../common/monitors_open_configuration'; @@ -51,7 +54,7 @@ export type StatsOverviewApi = DefaultEmbeddableApi { const factory: EmbeddableFactory = { type: SYNTHETICS_STATS_OVERVIEW_EMBEDDABLE, - buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + buildEmbeddable: async ({ + initializeDrilldownsManager, + initialState, + finalizeApi, + parentApi, + uuid, + }) => { const [coreStart, pluginStart] = await getStartServices(); const titleManager = initializeTitleManager(initialState); @@ -67,19 +76,13 @@ export const getStatsOverviewEmbeddableFactory = ( const reload$ = new Subject(); const filters$ = new BehaviorSubject(initialState.filters); - const { embeddableEnhanced } = pluginStart; - const dynamicActionsManager = await embeddableEnhanced?.initializeEmbeddableDynamicActions( - uuid, - () => titleManager.api.title$.getValue(), - initialState - ); - const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); function serializeState() { return { ...titleManager.getLatestState(), filters: filters$.getValue(), - ...(dynamicActionsManager?.getLatestState() ?? {}), + ...drilldownsManager.getLatestState(), }; } @@ -90,18 +93,18 @@ export const getStatsOverviewEmbeddableFactory = ( anyStateChange$: merge( titleManager.anyStateChange$, filters$, - ...(dynamicActionsManager ? [dynamicActionsManager.anyStateChange$] : []) + drilldownsManager.anyStateChange$ ).pipe(map(() => undefined)), getComparators: () => ({ ...titleComparators, filters: 'referenceEquality', - ...(dynamicActionsManager?.comparators ?? { drilldowns: 'skip', enhancements: 'skip' }), + ...drilldownsManager.comparators, }), defaultState: { filters: DEFAULT_FILTERS, }, onReset: (lastSaved) => { - dynamicActionsManager?.reinitializeState(lastSaved ?? {}); + drilldownsManager.reinitializeState(lastSaved ?? {}); titleManager.reinitializeState(lastSaved); filters$.next(lastSaved?.filters ?? DEFAULT_FILTERS); }, @@ -109,7 +112,7 @@ export const getStatsOverviewEmbeddableFactory = ( const api = finalizeApi({ ...titleManager.api, - ...(dynamicActionsManager?.api ?? {}), + ...drilldownsManager.api, ...unsavedChangesApi, supportedTriggers: () => [], defaultTitle$, @@ -153,8 +156,8 @@ export const getStatsOverviewEmbeddableFactory = ( useEffect(() => { return () => { + drilldownsManager.cleanup(); fetchSubscription.unsubscribe(); - maybeStopDynamicActions?.stopDynamicActions(); }; }, []); return ( diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.test.tsx index e00a471936095..465483d0dbd5c 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.test.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.test.tsx @@ -8,6 +8,9 @@ import React from 'react'; import { render } from '../../utils/testing/rtl_helpers'; import { MonitorAddPage } from './monitor_add_page'; +import * as useCloneMonitorModule from './hooks/use_clone_monitor'; +import { GETTING_STARTED_ROUTE } from '../../../../../common/constants'; +import { act } from '@testing-library/react'; describe('MonitorAddPage', () => { it('renders correctly', async () => { @@ -58,6 +61,34 @@ describe('MonitorAddPage', () => { expect(getByLabelText(/Loading/)).toBeInTheDocument(); }); + it('redirects to getting started page when no locations are available', async () => { + const useCloneMonitorSpy = jest + .spyOn(useCloneMonitorModule, 'useCloneMonitor') + .mockReturnValue({ + data: undefined, + status: 'success' as any, + loading: false, + error: undefined, + refetch: jest.fn(), + }); + let history: ReturnType['history']; + + act(() => { + ({ history } = render(, { + state: { + serviceLocations: { + locations: [], + locationsLoaded: true, + loading: false, + }, + }, + })); + }); + + expect(history.location.pathname).toBe(GETTING_STARTED_ROUTE); + useCloneMonitorSpy.mockRestore(); + }); + it('renders an error', async () => { const { getByText } = render(, { state: { diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx index cb7fbb18db06a..da35b5892dbf5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx @@ -9,6 +9,7 @@ import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useTrackPageview } from '@kbn/observability-shared-plugin/public'; +import { Redirect } from 'react-router-dom'; import { useCloneMonitor } from './hooks/use_clone_monitor'; import { useCanUsePublicLocations } from '../../../../hooks/use_capabilities'; import { CanUsePublicLocationsCallout } from './steps/can_use_public_locations_callout'; @@ -23,6 +24,7 @@ import { LocationsLoadingError } from './locations_loading_error'; import { ADD_MONITOR_STEPS } from './steps/step_config'; import { useMonitorAddEditBreadcrumbs } from './use_breadcrumbs'; import { LoadingState } from '../monitors_page/overview/overview/monitor_detail_flyout'; +import { GETTING_STARTED_ROUTE } from '../../../../../common/constants'; export const MonitorAddPage = () => { useTrackPageview({ app: 'synthetics', path: 'add-monitor' }); @@ -40,16 +42,25 @@ export const MonitorAddPage = () => { useEffect(() => { dispatch(getServiceLocations()); }, [dispatch]); - const { locationsLoaded, error: locationsError } = useSelector(selectServiceLocationsState); + const { + locations, + locationsLoaded, + loading: locationsLoading, + error: locationsError, + } = useSelector(selectServiceLocationsState); if (locationsError) { return ; } - if (!locationsLoaded || cloneMonitorLoading) { + if (!locationsLoaded || locationsLoading || cloneMonitorLoading) { return ; } + if (locationsLoaded && locations.length === 0) { + return ; + } + return ( { 'source.inline.script': { type: 'yaml', value: - '"step(\\"Visit /users api route\\", async () => {\\\\n const response = await page.goto(\'https://nextjs-test-synthetics.vercel.app/api/users\');\\\\n expect(response.status()).toEqual(200);\\\\n});"', + 'c3RlcCgiVmlzaXQgL3VzZXJzIGFwaSByb3V0ZSIsIGFzeW5jICgpID0+IHtcbiAgY29uc3QgcmVzcG9uc2UgPSBhd2FpdCBwYWdlLmdvdG8oJ2h0dHBzOi8vbmV4dGpzLXRlc3Qtc3ludGhldGljcy52ZXJjZWwuYXBwL2FwaS91c2VycycpO1xuICBleHBlY3QocmVzcG9uc2Uuc3RhdHVzKCkpLnRvRXF1YWwoMjAwKTtcbn0pOw==', + }, + 'source.inline.encoding': { + type: 'text', + value: 'base64', }, 'source.project.content': { type: 'text', @@ -915,6 +919,9 @@ describe('formatSyntheticsPolicy', () => { 'source.inline.script': { type: 'yaml', }, + 'source.inline.encoding': { + type: 'text', + }, 'source.project.content': { type: 'text', }, @@ -1119,6 +1126,7 @@ const testNewPolicy = { timeout: { type: 'text' }, tags: { type: 'yaml' }, 'source.inline.script': { type: 'yaml' }, + 'source.inline.encoding': { type: 'text' }, 'source.project.content': { type: 'text' }, params: { type: 'yaml' }, playwright_options: { type: 'yaml' }, @@ -1180,6 +1188,7 @@ const browserConfig: any = { 'url.port': null, 'source.inline.script': 'step("Visit /users api route", async () => {\\n const response = await page.goto(\'https://nextjs-test-synthetics.vercel.app/api/users\');\\n expect(response.status()).toEqual(200);\\n});', + 'source.inline.encoding': 'base64', 'source.project.content': '', playwright_text_assertion: '', urls: '', diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts index 0d85c09684810..5583624d6f161 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts @@ -80,6 +80,17 @@ export const formatSyntheticsPolicy = ( } }); + // This field is NOT in the monitor config, but needs to be set in the policy + // so Heartbeat knows to decode the base64-encoded script + const encodingVar = dataStream?.vars?.['source.inline.encoding']; + if (monitorType === 'browser' && encodingVar && config[ConfigKey.SOURCE_INLINE]) { + encodingVar.value = 'base64'; + const inlineScript = dataStream.vars?.[ConfigKey.SOURCE_INLINE]; + if (inlineScript && typeof inlineScript.value === 'string') { + inlineScript.value = Buffer.from(inlineScript.value).toString('base64'); + } + } + const processorItem = dataStream?.vars?.processors; if (processorItem) { processorItem.value = processorsFormatter(config as MonitorFields & ProcessorFields); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/formatting_utils.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/formatting_utils.ts index b215e81eb1019..94e93007917b1 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/formatting_utils.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/formatters/private_formatters/formatting_utils.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { inlineSourceFormatter } from '../formatting_utils'; import type { MonitorFields } from '../../../../common/runtime_types'; import { ConfigKey } from '../../../../common/runtime_types'; @@ -55,11 +54,6 @@ export const tlsArrayToYamlFormatter: FormatterFn = (fields, key) => { }; export const stringToJsonFormatter: FormatterFn = (fields, key) => { - if (key === ConfigKey.SOURCE_INLINE) { - const value = inlineSourceFormatter(fields, key); - - return value ? JSON.stringify(value) : null; - } const value = (fields[key] as string) ?? ''; return value ? JSON.stringify(value) : null; }; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/synthetics_private_location.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/synthetics_private_location.test.ts index b209072c8032b..2c7353fa978d5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/synthetics_private_location.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/synthetics_private_location.test.ts @@ -11,6 +11,7 @@ import { MonitorTypeEnum, ScheduleUnit, SourceType } from '../../../common/runti import { SyntheticsPrivateLocation } from './synthetics_private_location'; import { testMonitorPolicy } from './test_policy'; import { formatSyntheticsPolicy } from '../formatters/private_formatters/format_synthetics_policy'; +import { handleMultilineStringFormatter } from '../formatters/formatting_utils'; import { savedObjectsServiceMock } from '@kbn/core-saved-objects-server-mocks'; import type { SyntheticsServerSetup } from '../../types'; import type { PrivateLocationAttributes } from '../../runtime_types/private_locations'; @@ -177,6 +178,9 @@ describe('SyntheticsPrivateLocation', () => { }); it('formats monitors stream properly', () => { + const expectedInlineSource = Buffer.from( + handleMultilineStringFormatter(dummyBrowserConfig['source.inline.script'] as string) + ).toString('base64'); const test = formatSyntheticsPolicy( testMonitorPolicy, MonitorTypeEnum.BROWSER, @@ -247,8 +251,11 @@ describe('SyntheticsPrivateLocation', () => { }, 'source.inline.script': { type: 'yaml', - value: - "\"step('Go to https://www.elastic.co/', async () => {\\n await page.goto('https://www.elastic.co/');\\n});\"", + value: expectedInlineSource, + }, + 'source.inline.encoding': { + type: 'text', + value: 'base64', }, synthetics_args: { type: 'text', diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/test_policy.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/test_policy.ts index 009eb0fb6358e..87bdaa63d283c 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/test_policy.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/test_policy.ts @@ -137,6 +137,7 @@ export const testMonitorPolicy = { timeout: { type: 'text' }, tags: { type: 'yaml' }, 'source.inline.script': { type: 'yaml' }, + 'source.inline.encoding': { type: 'text' }, params: { type: 'yaml' }, screenshots: { type: 'text' }, synthetics_args: { type: 'text' }, diff --git a/x-pack/solutions/observability/plugins/synthetics/tsconfig.json b/x-pack/solutions/observability/plugins/synthetics/tsconfig.json index 44f03a81e642d..27faba6557b83 100644 --- a/x-pack/solutions/observability/plugins/synthetics/tsconfig.json +++ b/x-pack/solutions/observability/plugins/synthetics/tsconfig.json @@ -28,7 +28,6 @@ "@kbn/discover-plugin", "@kbn/home-plugin", "@kbn/embeddable-plugin", - "@kbn/embeddable-enhanced-plugin", "@kbn/data-plugin", "@kbn/kibana-utils-plugin", "@kbn/inspector-plugin", diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/services/service_slos.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/services/service_slos.spec.ts index d34b6e7fb58f5..6013605551676 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/services/service_slos.spec.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/apm/services/service_slos.spec.ts @@ -195,6 +195,46 @@ export default function ServiceSlos({ getService }: DeploymentAgnosticFtrProvide await sloApi.delete(stagingSlo.id, adminRoleAuthc); }); + it('includes SLOs created with wildcard (*) environment when filtering by a specific environment', async () => { + const prodSlo = await sloApi.create( + createApmSloInput('Prod SLO', serviceName, 'production'), + adminRoleAuthc + ); + const wildcardSlo = await sloApi.create( + createApmSloInput('All Envs SLO', serviceName, '*'), + adminRoleAuthc + ); + const stagingSlo = await sloApi.create( + createApmSloInput('Staging SLO', serviceName, 'staging'), + adminRoleAuthc + ); + + const prodResponse = await getServiceSlos({ serviceName, environment: 'production' }); + + expect(prodResponse.status).to.be(200); + expect(prodResponse.body.results.length).to.be(2); + expect(prodResponse.body.total).to.be(2); + + const foundProdSlo = prodResponse.body.results.find( + (slo: { id: string }) => slo.id === prodSlo.id + ); + expect(foundProdSlo).to.be.ok(); + + const foundWildcardSlo = prodResponse.body.results.find( + (slo: { id: string }) => slo.id === wildcardSlo.id + ); + expect(foundWildcardSlo).to.be.ok(); + + const foundStagingSlo = prodResponse.body.results.find( + (slo: { id: string }) => slo.id === stagingSlo.id + ); + expect(foundStagingSlo).to.be(undefined); + + await sloApi.delete(prodSlo.id, adminRoleAuthc); + await sloApi.delete(wildcardSlo.id, adminRoleAuthc); + await sloApi.delete(stagingSlo.id, adminRoleAuthc); + }); + it('returns status counts for the service', async () => { const createdSlo = await sloApi.create( createApmSloInput('Status Count Test SLO', serviceName), diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/ai_insights/log.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/ai_insights/log.spec.ts index ee0da8a27b82a..c47e251aa6919 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/ai_insights/log.spec.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/ai_insights/log.spec.ts @@ -87,14 +87,14 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { getLogId: () => errorLogId, getLogIndex: () => errorLogIndex, mockedSummary: MOCKED_AI_SUMMARY_ERROR, - expectedContextTags: [''], + expectedContextTags: [''], }, { name: 'info', getLogId: () => infoLogId, getLogIndex: () => infoLogIndex, mockedSummary: MOCKED_AI_SUMMARY_INFO, - expectedContextTags: [''], + expectedContextTags: [''], }, ]; diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/index.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/index.ts index 4aa06c903f68d..b624e4eae78e9 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/index.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/index.ts @@ -11,19 +11,18 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) describe('Observability Agent', function () { // tools loadTestFile(require.resolve('./tools/get_alerts.spec.ts')); - loadTestFile(require.resolve('./tools/get_downstream_dependencies.spec.ts')); loadTestFile(require.resolve('./tools/get_services.spec.ts')); loadTestFile(require.resolve('./tools/get_anomaly_detection_jobs.spec.ts')); loadTestFile(require.resolve('./tools/get_runtime_metrics.spec.ts')); loadTestFile(require.resolve('./tools/run_log_rate_analysis.spec.ts')); loadTestFile(require.resolve('./tools/get_log_groups.spec.ts')); - loadTestFile(require.resolve('./tools/get_correlated_logs.spec.ts')); loadTestFile(require.resolve('./tools/get_hosts.spec.ts')); loadTestFile(require.resolve('./tools/get_trace_metrics.spec.ts')); loadTestFile(require.resolve('./tools/get_log_change_points.spec.ts')); loadTestFile(require.resolve('./tools/get_metric_change_points.spec.ts')); loadTestFile(require.resolve('./tools/get_trace_change_points.spec.ts')); loadTestFile(require.resolve('./tools/get_index_info.spec.ts')); + loadTestFile(require.resolve('./tools/get_service_topology.spec.ts')); loadTestFile(require.resolve('./tools/get_traces.spec.ts')); // ai insights diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_anomaly_detection_jobs.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_anomaly_detection_jobs.spec.ts index db768c1fdba24..361961dfba895 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_anomaly_detection_jobs.spec.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_anomaly_detection_jobs.spec.ts @@ -16,7 +16,8 @@ import { duration as momentDuration } from 'moment'; import type { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context'; import { createAgentBuilderApiClient } from '../utils/agent_builder_client'; -const SERVICE_NAME = 'service-a'; +const SERVICE_A = 'service-a'; +const SERVICE_B = 'service-b'; const ENVIRONMENT = 'production'; const START = 'now-12h'; const END = 'now'; @@ -51,14 +52,23 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { // Generate APM data with anomalies using timerange const range = timerange(startUnix.toDate(), endUnix.toDate()); - const { client, generator } = generateApmDataWithAnomalies({ + const serviceA = generateApmDataWithAnomalies({ apmEsClient: apmSynthtraceEsClient, range, - serviceName: SERVICE_NAME, + serviceName: SERVICE_A, environment: ENVIRONMENT, language: 'nodejs', }); - await client.index(generator); + await serviceA.client.index(serviceA.generator); + + const serviceB = generateApmDataWithAnomalies({ + apmEsClient: apmSynthtraceEsClient, + range, + serviceName: SERVICE_B, + environment: ENVIRONMENT, + language: 'dotnet', + }); + await serviceB.client.index(serviceB.generator); // Create anomaly detection job const editorClient = await roleScopedSupertest.getSupertestWithRoleScope('editor', { @@ -71,7 +81,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { .send({ environments: [ENVIRONMENT] }) .expect(200); - await retry.waitFor('ML job to have anomalies', async () => { + await retry.waitFor('ML job to have anomalies for both services', async () => { const toolResults = await agentBuilderApiClient.executeTool({ id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, @@ -79,14 +89,19 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }); const { topAnomalies } = toolResults[0].data.jobs[0]; - const hasLatencyAnomaly = topAnomalies.some( - ({ fieldName }) => fieldName === 'transaction_latency' + + const hasServiceAAnomalies = topAnomalies.some((a) => + a.influencers?.some( + (inf) => inf.fieldName === 'service.name' && inf.fieldValues.includes(SERVICE_A) + ) ); - const hasThroughputAnomaly = topAnomalies.some( - ({ fieldName }) => fieldName === 'transaction_throughput' + const hasServiceBAnomalies = topAnomalies.some((a) => + a.influencers?.some( + (inf) => inf.fieldName === 'service.name' && inf.fieldValues.includes(SERVICE_B) + ) ); - return hasLatencyAnomaly && hasThroughputAnomaly; + return hasServiceAAnomalies && hasServiceBAnomalies; }); }); @@ -138,7 +153,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(throughputAnomaly?.byFieldName).to.be('transaction.type'); expect(throughputAnomaly?.byFieldValue).to.be('request'); expect(throughputAnomaly?.partitionFieldName).to.be('service.name'); - expect(throughputAnomaly?.partitionFieldValue).to.be('service-a'); + expect(throughputAnomaly?.partitionFieldValue).to.be(SERVICE_A); expect(throughputAnomaly?.fieldName).to.be('transaction_throughput'); expect(throughputAnomaly?.anomalyScore).to.be.greaterThan(10); }); @@ -231,5 +246,304 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(jobs).to.have.length(1); expect(jobs[0].topAnomalies).to.be.empty(); }); + + it('filters anomalies by minAnomalyScore', async () => { + // With very high threshold, should return fewer or no anomalies + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO, minAnomalyScore: 99 }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + // All returned anomalies should have score >= 99 + topAnomalies.forEach((anomaly) => { + expect(anomaly.anomalyScore).to.be.greaterThan(98); + }); + }); + + it('excludes anomalyScoreExplanation by default', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + topAnomalies.forEach((anomaly) => { + expect(anomaly).not.to.have.property('anomalyScoreExplanation'); + }); + }); + + it('includes anomalyScoreExplanation when includeExplanation is true', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO, includeExplanation: true }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + topAnomalies.forEach((anomaly) => { + expect(anomaly).to.have.property('anomalyScoreExplanation'); + }); + }); + + it('filters by group', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO, group: 'apm' }, + }); + + expect(toolResults[0].data.jobs).to.have.length(1); + expect(toolResults[0].data.jobs[0].jobId).to.contain('apm'); + }); + + it('returns empty response for non-existent group', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO, group: 'nonexistent-group' }, + }); + + expect(toolResults[0].data.jobs).to.be.empty(); + }); + + describe('influencerFilter', () => { + function getServiceNames( + anomalies: GetAnomalyDetectionJobsToolResult['data']['jobs'][0]['topAnomalies'] + ) { + return [ + ...new Set( + anomalies.flatMap( + (a) => + a.influencers + ?.filter((inf) => inf.fieldName === 'service.name') + .flatMap((inf) => inf.fieldValues) ?? [] + ) + ), + ].sort(); + } + + it('filters by single service', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: `service.name: "${SERVICE_A}"`, + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_A]); + }); + + it('OR: returns anomalies for both services', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: `service.name: "${SERVICE_A}" OR service.name: "${SERVICE_B}"`, + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_A, SERVICE_B]); + }); + + it('AND: narrows results to anomalies matching both conditions', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: `service.name: "${SERVICE_A}" AND transaction.type: "request"`, + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_A]); + }); + + it('NOT: excludes service-a, returns only service-b', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: `NOT service.name: "${SERVICE_A}"`, + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_B]); + }); + + it('wildcard: matches both services', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: 'service.name: service*', + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_A, SERVICE_B]); + }); + + it('exists (field: *): returns anomalies that have a service.name influencer', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: 'service.name: *', + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_A, SERVICE_B]); + }); + + it('parenthesized OR shorthand: returns only the listed services', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: `service.name: ("${SERVICE_A}" OR "${SERVICE_B}")`, + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_A, SERVICE_B]); + }); + + it('unquoted value', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: `service.name: ${SERVICE_B}`, + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_B]); + }); + + it('AND + NOT: includes service-a, excludes service-b', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: `service.name: "${SERVICE_A}" AND NOT service.name: "${SERVICE_B}"`, + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + + const serviceNames = getServiceNames(topAnomalies); + expect(serviceNames).to.eql([SERVICE_A]); + }); + + it('returns empty anomalies when filter matches nothing', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { + start: START_ISO, + end: END_ISO, + influencerFilter: 'service.name: "nonexistent-service"', + }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies).to.be.empty(); + }); + }); + + it('includes influencers in anomaly response', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.greaterThan(0); + topAnomalies.forEach((anomaly) => { + expect(anomaly).to.have.property('influencers'); + expect(anomaly.influencers).to.be.an('array'); + anomaly.influencers?.forEach((inf) => { + expect(inf).to.have.property('fieldName'); + expect(inf).to.have.property('fieldValues'); + }); + }); + }); + + it('limits anomaly records per job with anomalyRecordsLimit', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO, anomalyRecordsLimit: 3 }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies.length).to.be.lessThan(4); + }); + + it('returns no anomalies when anomalyRecordsLimit is 0', async () => { + const toolResults = + await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ANOMALY_DETECTION_JOBS_TOOL_ID, + params: { start: START_ISO, end: END_ISO, anomalyRecordsLimit: 0 }, + }); + + const { topAnomalies } = toolResults[0].data.jobs[0]; + expect(topAnomalies).to.be.empty(); + }); }); } diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_correlated_logs.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_correlated_logs.spec.ts deleted file mode 100644 index 460c8c8108610..0000000000000 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_correlated_logs.spec.ts +++ /dev/null @@ -1,1007 +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 expect from '@kbn/expect'; -import { times } from 'lodash'; -import { timerange } from '@kbn/synthtrace-client'; -import { - type LogsSynthtraceEsClient, - generateCorrelatedLogsData, - createLogSequence, - type CorrelatedLogEvent, -} from '@kbn/synthtrace'; -import { OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID } from '@kbn/observability-agent-builder-plugin/server/tools'; -import type { GetCorrelatedLogsToolResult } from '@kbn/observability-agent-builder-plugin/server/tools/get_correlated_logs/types'; -import type { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context'; -import { createAgentBuilderApiClient } from '../utils/agent_builder_client'; - -interface Log { - message?: string; - 'log.level'?: string; - 'service.name'?: string; -} - -async function indexCorrelatedLogs({ - logsEsClient, - logs, -}: { - logsEsClient: LogsSynthtraceEsClient; - logs: CorrelatedLogEvent[]; -}): Promise { - await logsEsClient.clean(); - const range = timerange('now-5m', 'now'); - const { client, generator } = generateCorrelatedLogsData({ range, logsEsClient, logs }); - await client.index(generator); -} - -export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { - const roleScopedSupertest = getService('roleScopedSupertest'); - const synthtrace = getService('synthtrace'); - - describe(`tool: ${OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID}`, function () { - let agentBuilderApiClient: ReturnType; - let logsSynthtraceEsClient: LogsSynthtraceEsClient; - - before(async () => { - const scoped = await roleScopedSupertest.getSupertestWithRoleScope('editor'); - agentBuilderApiClient = createAgentBuilderApiClient(scoped); - logsSynthtraceEsClient = synthtrace.createLogsSynthtraceEsClient(); - }); - - after(async () => { - // await logsSynthtraceEsClient.clean(); - }); - - describe('with single error and `trace.id` as correlation ID', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: createLogSequence({ - service: 'payment-service', - correlation: { 'trace.id': 'trace-123' }, - logs: [ - { 'log.level': 'info', message: 'Starting payment processing' }, - { 'log.level': 'debug', message: 'Validating payment details' }, - { 'log.level': 'error', message: 'Payment gateway timeout' }, - { 'log.level': 'warn', message: 'Retrying payment' }, - { 'log.level': 'info', message: 'Payment completed' }, - ], - }), - }); - }); - - it('returns one log group with all logs', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "payment-service"', - }, - }); - - const { sequences, message } = results[0].data; - - expect(sequences.length).to.be(1); - expect(sequences[0].logs.length).to.be(5); - expect(sequences[0].correlation.field).to.be('trace.id'); - expect(sequences[0].correlation.value).to.be('trace-123'); - expect(message).to.be(undefined); - }); - - it('includes the error log and surrounding logs', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "payment-service"', - }, - }); - - const { sequences } = results[0].data; - const messages = sequences[0].logs.map((log) => log.message); - - expect(messages).to.eql([ - 'Starting payment processing', - 'Validating payment details', - 'Payment gateway timeout', - 'Retrying payment', - 'Payment completed', - ]); - }); - - it('does not return logs that do not match the specified terms', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "non-existing-service"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(0); - }); - }); - - describe('with multiple errors sharing the same correlation ID', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: createLogSequence({ - service: 'checkout-service', - correlation: { 'request.id': 'req-456' }, - logs: [ - { 'log.level': 'info', message: 'Request started' }, - { 'log.level': 'error', message: 'Database connection failed' }, - { 'log.level': 'error', message: 'Rollback failed' }, - { 'log.level': 'warn', message: 'Request aborted' }, - ], - }), - }); - }); - - it('creates only one group for multiple errors with same correlation ID', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "checkout-service"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - }); - - it('includes both error logs in the same group', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "checkout-service"', - }, - }); - - const group = results[0].data.sequences[0]; - const errorLogs = group.logs.filter( - (log: Log) => log['log.level']?.toUpperCase() === 'ERROR' - ); - expect(errorLogs.length).to.be(2); - - const messages = errorLogs.map((log: Log) => log.message); - expect(messages).to.contain('Database connection failed'); - expect(messages).to.contain('Rollback failed'); - }); - }); - - describe('with multiple errors having different correlation IDs', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - { - 'log.level': 'info', - message: 'Payment flow started', - 'service.name': 'multi-service', - 'trace.id': 'trace-payment', - }, - { - 'log.level': 'error', - message: 'Payment error', - 'service.name': 'multi-service', - 'trace.id': 'trace-payment', - }, - - { - 'log.level': 'info', - message: 'Refund flow started', - 'service.name': 'multi-service', - 'transaction.id': 'txn-refund', - }, - { - 'log.level': 'error', - message: 'Refund error', - 'service.name': 'multi-service', - 'transaction.id': 'txn-refund', - }, - ], - }); - }); - - it('creates separate sequences for errors with different correlation IDs', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "multi-service"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(2); - }); - - it('sequences logs correctly by their correlation ID', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "multi-service"', - }, - }); - - const { sequences } = results[0].data; - - // Find payment group by message content - const paymentGroup = sequences.find((group) => - group.logs.some((log: Log) => log.message === 'Payment error') - ); - expect(paymentGroup).to.not.be(undefined); - expect(paymentGroup!.correlation.field).to.be('trace.id'); - expect(paymentGroup!.correlation.value).to.be('trace-payment'); - - // Find refund group by message content - const refundGroup = sequences.find((group) => - group.logs.some((log: Log) => log.message === 'Refund error') - ); - expect(refundGroup).to.not.be(undefined); - expect(refundGroup!.correlation.field).to.be('transaction.id'); - expect(refundGroup!.correlation.value).to.be('txn-refund'); - }); - }); - - describe('with errors lacking correlation IDs', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - { - 'log.level': 'info', - message: 'Uncorrelated info', - 'service.name': 'no-correlation-service', - }, - { - 'log.level': 'error', - message: 'Uncorrelated error', - 'service.name': 'no-correlation-service', - }, - ], - }); - }); - - it('returns empty results when errors have no correlation IDs', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "no-correlation-service"', - }, - }); - - const { sequences, message } = results[0].data; - expect(sequences.length).to.be(0); - expect(message).to.contain('No log sequences found.'); - }); - }); - - describe('with KQL filtering', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - { - 'log.level': 'error', - message: 'Error in service A', - 'service.name': 'service-a', - 'trace.id': 'trace-a', - }, - - { - 'log.level': 'error', - message: 'Error in service B', - 'service.name': 'service-b', - 'trace.id': 'trace-b', - }, - ], - }); - }); - - it('filters logs by service name', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "service-a"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].logs[0]).to.have.property('message', 'Error in service A'); - }); - }); - - describe('with alternative error severity formats', () => { - // Tests that errors are detected via alternative severity formats only (not log.level) - // Each describe block ingests logs with only a single severity format - - // Syslog severity: 0=Emergency, 1=Alert, 2=Critical, 3=Error, 4=Warning, 5=Notice, 6=Info, 7=Debug - describe('syslog.severity', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: createLogSequence({ - service: 'syslog-service', - correlation: { 'trace.id': 'syslog-trace' }, - logs: [ - { message: 'Syslog request started', 'syslog.severity': 6 }, // info - { message: 'Syslog error occurred', 'syslog.severity': 3 }, // error - ], - }), - }); - }); - - it('detects errors using syslog.severity (≤4)', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "syslog-service"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].logs.length).to.be(2); - }); - }); - - // OpenTelemetry SeverityNumber: 1-4=Trace, 5-8=Debug, 9-12=Info, 13-16=Warn, 17-20=Error, 21-24=Fatal - describe('OpenTelemetry SeverityNumber', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: createLogSequence({ - service: 'otel-service', - correlation: { 'request.id': 'otel-req' }, - logs: [ - { message: 'OpenTelemetry request started', SeverityNumber: 9 }, // info - { message: 'OpenTelemetry error occurred', SeverityNumber: 17 }, // error - ], - }), - }); - }); - - it('detects errors using SeverityNumber (≥13)', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "otel-service"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].logs.length).to.be(2); - }); - }); - - // HTTP status codes: 2xx=Success, 4xx=Client error, 5xx=Server error - describe('HTTP status codes', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: createLogSequence({ - service: 'http-service', - correlation: { 'correlation.id': 'http-corr' }, - logs: [ - { message: 'HTTP request started', 'http.response.status_code': 200 }, // success - { message: 'HTTP error occurred', 'http.response.status_code': 500 }, // server error - ], - }), - }); - }); - - it('detects errors using http.response.status_code (≥500)', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "http-service"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].logs.length).to.be(2); - }); - }); - }); - - describe('with multiple correlation IDs on the same log', () => { - const shared = { - 'service.name': 'priority-service', - 'trace.id': 'trace-priority-123', - 'request.id': 'request-priority-456', - 'transaction.id': 'txn-priority-789', - }; - - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - { 'log.level': 'info', message: 'Request with multiple IDs started', ...shared }, - { 'log.level': 'error', message: 'Error with multiple correlation IDs', ...shared }, - ], - }); - }); - - it('uses trace.id when multiple correlation IDs are present (priority order)', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "priority-service"', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - - // Verify trace.id is used as the correlation field (highest priority) - expect(sequences[0].correlation.field).to.be('trace.id'); - expect(sequences[0].correlation.value).to.be('trace-priority-123'); - - expect(sequences[0].logs.map((log) => log.message)).to.eql([ - 'Request with multiple IDs started', - 'Error with multiple correlation IDs', - ]); - }); - }); - - describe('with additional log severity levels (SEVERE, WARNING, WARN)', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - { - 'log.level': 'severe', - message: 'Java severe error', - 'service.name': 'java-service', - 'trace.id': 'java-trace', - }, - { - 'log.level': 'warning', - message: 'System warning', - 'service.name': 'system-service', - 'request.id': 'system-req', - }, - { - 'log.level': 'warn', - message: 'Application warn', - 'service.name': 'app-service', - 'transaction.id': 'app-txn', - }, - ], - }); - }); - - [ - { service: 'java-service', 'log.level': 'SEVERE', message: 'Java severe error' }, - { service: 'system-service', 'log.level': 'WARNING', message: 'System warning' }, - { service: 'app-service', 'log.level': 'WARN', message: 'Application warn' }, - ].forEach(({ service, 'log.level': logLevel, message }) => { - it(`detects errors using ${logLevel} level`, async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: `service.name: "${service}"`, - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].logs[0]).to.have.property('message', message); - }); - }); - }); - - describe('with additional correlation identifiers', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - // session.id - { - 'log.level': 'info', - message: 'Session started', - 'service.name': 'session-service', - 'session.id': 'session-abc-123', - }, - { - 'log.level': 'error', - message: 'Session error', - 'service.name': 'session-service', - 'session.id': 'session-abc-123', - }, - - // http.request.id - { - 'log.level': 'info', - message: 'HTTP request received', - 'service.name': 'http-server', - 'http.request.id': 'http-req-456', - }, - { - 'log.level': 'error', - message: 'HTTP processing error', - 'service.name': 'http-server', - 'http.request.id': 'http-req-456', - }, - - // event.id - { - 'log.level': 'info', - message: 'Event processing started', - 'service.name': 'event-processor', - 'event.id': 'evt-789', - }, - { - 'log.level': 'error', - message: 'Event processing failed', - 'service.name': 'event-processor', - 'event.id': 'evt-789', - }, - - // cloud.trace_id - { - 'log.level': 'info', - message: 'Cloud trace started', - 'service.name': 'cloud-service', - 'cloud.trace_id': 'cloud-trace-xyz', - }, - { - 'log.level': 'info', - message: 'Cloud trace still running', - 'service.name': 'cloud-service', - 'cloud.trace_id': 'cloud-trace-xyz', - }, - { - 'log.level': 'error', - message: 'Cloud operation failed', - 'service.name': 'cloud-service', - 'cloud.trace_id': 'cloud-trace-xyz', - }, - - // no correlation ID - { - 'log.level': 'info', - message: 'Starting', - 'service.name': 'no-corr-id-service', - }, - { - 'log.level': 'error', - message: 'Crashing', - 'service.name': 'no-corr-id-service', - }, - ], - }); - }); - - [ - { - correlationField: 'session.id', - service: 'session-service', - expectedMessages: ['Session started', 'Session error'], - }, - { - correlationField: 'http.request.id', - service: 'http-server', - expectedMessages: ['HTTP request received', 'HTTP processing error'], - }, - { - correlationField: 'event.id', - service: 'event-processor', - expectedMessages: ['Event processing started', 'Event processing failed'], - }, - { - correlationField: 'cloud.trace_id', - service: 'cloud-service', - expectedMessages: [ - 'Cloud trace started', - 'Cloud trace still running', - 'Cloud operation failed', - ], - }, - - { - service: 'no-corr-id-service', - expectedMessages: [], - }, - ].forEach(({ correlationField, service, expectedMessages }) => { - it(`Correlate by ${correlationField ?? 'N/A'}`, async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: `service.name: "${service}"`, - }, - }); - - const { sequences } = results[0].data; - expect(sequences.flatMap((group) => group.logs.map(({ message }) => message))).to.eql( - expectedMessages - ); - }); - }); - }); - - describe('with logId parameter', () => { - let targetLogId: string; - let targetIndex: string; - - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - { - 'log.level': 'info', - message: 'Log for ID lookup', - 'service.name': 'id-service', - 'trace.id': 'trace-id-target', - }, - { - 'log.level': 'error', - message: 'Error correlated with ID target', - 'service.name': 'id-service', - 'trace.id': 'trace-id-target', - }, - ], - }); - - const es = getService('es'); - const result = await es.search({ - index: 'logs-*', - q: 'message:"Log for ID lookup"', - }); - - if (result.hits.hits.length === 0) { - throw new Error('Could not find inserted log for ID lookup'); - } - - targetLogId = result.hits.hits[0]._id!; - targetIndex = result.hits.hits[0]._index; - }); - - it('finds correlated logs based on the provided logId', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - logId: targetLogId, - index: targetIndex, - start: 'now-15m', - end: 'now', - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - const messages = sequences[0].logs.map((log) => log.message); - expect(messages).to.eql(['Log for ID lookup', 'Error correlated with ID target']); - }); - }); - - describe('with logSourceFields parameter', () => { - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: [ - { - 'log.level': 'info', - message: 'Log with fields', - 'service.name': 'fields-service', - 'trace.id': 'trace-fields', - }, - { - 'log.level': 'error', - message: 'Error with fields', - 'service.name': 'fields-service', - 'trace.id': 'trace-fields', - }, - ], - }); - }); - - it('returns only specified fields', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "fields-service"', - logSourceFields: ['message', 'service.name'], - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - const logs = sequences[0].logs; - - expect(logs).to.eql([ - { - message: 'Log with fields', - 'service.name': 'fields-service', - }, - { - message: 'Error with fields', - 'service.name': 'fields-service', - }, - ]); - }); - }); - - describe('with errorLogsOnly=false', () => { - // Tests that ANY log can be an anchor when errorLogsOnly is false - // Useful for investigating slow requests or specific events that aren't errors - - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: createLogSequence({ - service: 'non-error-anchor-service', - correlation: { 'trace.id': 'trace-non-error' }, - logs: [ - { 'log.level': 'info', message: 'Request started' }, - { 'log.level': 'info', message: 'Slow database query' }, - { 'log.level': 'info', message: 'Request completed' }, - ], - }), - }); - }); - - it('returns sequences when anchoring on non-error logs', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "non-error-anchor-service"', - errorLogsOnly: false, - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].logs.length).to.be(3); - - const messages = sequences[0].logs.map((log) => log.message); - expect(messages).to.eql(['Request started', 'Slow database query', 'Request completed']); - }); - - it('returns empty when errorLogsOnly=true (default) and no errors exist', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "non-error-anchor-service"', - // errorLogsOnly defaults to true - }, - }); - - const { sequences, message } = results[0].data; - expect(sequences.length).to.be(0); - expect(message).to.contain('No log sequences found'); - }); - }); - - describe('with custom correlationFields', () => { - // Tests that user-specified correlation fields work - // Useful when logs use non-standard correlation identifiers - - before(async () => { - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs: createLogSequence({ - service: 'custom-correlation-service', - correlation: { order_id: 'ORD-12345' }, - logs: [ - { 'log.level': 'info', message: 'Order created' }, - { 'log.level': 'info', message: 'Payment processing' }, - { 'log.level': 'error', message: 'Order fulfillment failed' }, - ], - }), - }); - }); - - it('correlates logs using custom field (order_id)', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "custom-correlation-service"', - correlationFields: ['order_id'], - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].correlation.field).to.be('order_id'); - expect(sequences[0].correlation.value).to.be('ORD-12345'); - expect(sequences[0].logs.length).to.be(3); - - const messages = sequences[0].logs.map((log) => log.message); - expect(messages).to.eql([ - 'Order created', - 'Payment processing', - 'Order fulfillment failed', - ]); - }); - - it('returns empty when custom field is not in default correlationFields', async () => { - // Without specifying correlationFields, order_id won't be recognized - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "custom-correlation-service"', - // correlationFields not specified, so order_id won't be used - }, - }); - - const { sequences, message } = results[0].data; - expect(sequences.length).to.be(0); - expect(message).to.contain('No log sequences found'); - }); - }); - - describe('with limit parameters', () => { - describe('maxSequences', () => { - before(async () => { - const logs = times(5, (i) => ({ - 'log.level': 'error', - message: `Error in trace ${i}`, - 'service.name': 'limit-service', - 'trace.id': `trace-limit-${i}`, - })); - - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs, - }); - }); - - it('limits the number of returned sequences', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "limit-service"', - maxSequences: 3, - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(3); - }); - }); - - describe('maxLogsPerSequence', () => { - before(async () => { - const logs = [ - // info logs - ...times(20, (i) => ({ - 'log.level': 'info', - message: `Log ${i}`, - 'service.name': 'limit-logs-service', - 'trace.id': 'trace-limit-logs', - '@timestamp': Date.now() - (20 - i) * 1000, - })), - - // anchor - { - 'log.level': 'error', - message: 'Error log', - 'service.name': 'limit-logs-service', - 'trace.id': 'trace-limit-logs', - '@timestamp': Date.now(), - }, - ]; - - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs, - }); - }); - - it('limits the number of logs per sequence', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-10m', - end: 'now', - kqlFilter: 'service.name: "limit-logs-service"', - maxLogsPerSequence: 5, - }, - }); - - const { sequences } = results[0].data; - expect(sequences.length).to.be(1); - expect(sequences[0].logs.length).to.be(5); - expect(sequences[0].isTruncated).to.be(true); - }); - }); - }); - - describe('when the number of anchors in a single sequence is more than `maxSequences`', () => { - before(async () => { - const logs = [ - // Trace A: 60 errors (recent) - ...times(60, (i) => ({ - 'log.level': 'error', - message: `Error A ${i}`, - 'service.name': 'starvation-service', - 'trace.id': 'trace-A', - '@timestamp': Date.now() - i * 1000, // 0s to 59s ago - })), - // Trace B: 1 error (older) - { - 'log.level': 'error', - message: 'Error B', - 'service.name': 'starvation-service', - 'trace.id': 'trace-B', - '@timestamp': Date.now() - 70000, // 70s ago - }, - ]; - - await indexCorrelatedLogs({ - logsEsClient: logsSynthtraceEsClient, - logs, - }); - }); - - it('returns two sequences (a single long sequence should not cause starvation)', async () => { - const results = await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_CORRELATED_LOGS_TOOL_ID, - params: { - start: 'now-5m', - end: 'now', - kqlFilter: 'service.name: "starvation-service"', - maxSequences: 10, - }, - }); - - const { sequences } = results[0].data; - - expect(sequences.length).to.be(2); - - const traceIds = sequences.map((s) => s.correlation.value).sort(); - expect(traceIds).to.eql(['trace-A', 'trace-B']); - }); - }); - }); -} diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_downstream_dependencies.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_downstream_dependencies.spec.ts deleted file mode 100644 index d503c9b17fac2..0000000000000 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_downstream_dependencies.spec.ts +++ /dev/null @@ -1,108 +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 expect from '@kbn/expect'; -import { timerange } from '@kbn/synthtrace-client'; -import { type ApmSynthtraceEsClient, generateDependenciesData } from '@kbn/synthtrace'; -import type { OtherResult } from '@kbn/agent-builder-common'; -import { OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID } from '@kbn/observability-agent-builder-plugin/server/tools'; -import type { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context'; -import { createAgentBuilderApiClient } from '../utils/agent_builder_client'; - -const SERVICE_NAME = 'service-a'; -const ENVIRONMENT = 'production'; -const START = 'now-15m'; -const END = 'now'; -const DEPENDENCY_RESOURCE = 'elasticsearch/my-backend'; - -interface GetDownstreamDependenciesToolResult extends OtherResult { - data: { - dependencies: Array>; - }; -} - -export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { - const roleScopedSupertest = getService('roleScopedSupertest'); - const synthtrace = getService('synthtrace'); - - describe(`tool: ${OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID}`, function () { - let agentBuilderApiClient: ReturnType; - let apmSynthtraceEsClient: ApmSynthtraceEsClient; - - before(async () => { - const scoped = await roleScopedSupertest.getSupertestWithRoleScope('editor'); - agentBuilderApiClient = createAgentBuilderApiClient(scoped); - - apmSynthtraceEsClient = await synthtrace.createApmSynthtraceEsClient(); - await apmSynthtraceEsClient.clean(); - - const { client, generator } = generateDependenciesData({ - range: timerange(START, END), - apmEsClient: apmSynthtraceEsClient, - serviceName: SERVICE_NAME, - environment: ENVIRONMENT, - agentName: 'nodejs', - transactionName: 'POST /api/checkout', - dependencies: [ - { - spanName: 'GET /dep', - spanType: 'db', - spanSubtype: 'elasticsearch', - destination: DEPENDENCY_RESOURCE, - duration: 100, - }, - ], - }); - - await client.index(generator); - }); - - after(async () => { - await apmSynthtraceEsClient.clean(); - }); - - describe('when fetching downstream dependencies', () => { - let resultData: GetDownstreamDependenciesToolResult['data']; - - before(async () => { - const results = - await agentBuilderApiClient.executeTool({ - id: OBSERVABILITY_GET_DOWNSTREAM_DEPENDENCIES_TOOL_ID, - params: { - serviceName: SERVICE_NAME, - environment: ENVIRONMENT, - start: START, - end: END, - }, - }); - - expect(results).to.have.length(1); - resultData = results[0].data; - }); - - it('returns the correct tool results structure', () => { - expect(resultData).to.have.property('dependencies'); - expect(Array.isArray(resultData.dependencies)).to.be(true); - }); - - it('returns downstream dependencies for the given service and time range', () => { - const dependencies = resultData.dependencies; - - expect(dependencies.length > 0).to.be(true); - - const hasExpectedBackend = dependencies.some( - (d) => - d['span.destination.service.resource'] === DEPENDENCY_RESOURCE && - d['span.type'] === 'db' && - d['span.subtype'] === 'elasticsearch' - ); - - expect(hasExpectedBackend).to.be(true); - }); - }); - }); -} diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_service_topology.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_service_topology.spec.ts new file mode 100644 index 0000000000000..95d4072b8f750 --- /dev/null +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_service_topology.spec.ts @@ -0,0 +1,483 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { timerange } from '@kbn/synthtrace-client'; +import { + type ApmSynthtraceEsClient, + API_GATEWAY_SERVICE, + BATCH_WORKER_SERVICE, + CHECKOUT_SERVICE, + CYCLE_SERVICE_A, + CYCLE_SERVICE_B, + FRONTEND_SERVICE, + generateCycleTopologyData, + generateTopologyData, + generateTraceIsolationData, + KAFKA_CONSUMER_SERVICE, + KAFKA_DEPENDENCY, + PAYMENT_SERVICE, + POSTGRES_DEPENDENCY, + RECOMMENDATION_SERVICE, + REDIS_DEPENDENCY, + POSTGRES_DB, + REDIS_DB, +} from '@kbn/synthtrace'; +import type { OtherResult } from '@kbn/agent-builder-common'; +import { OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID } from '@kbn/observability-agent-builder-plugin/server/tools'; +import { uniq } from 'lodash'; +import type { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context'; +import { createAgentBuilderApiClient } from '../utils/agent_builder_client'; + +const START = 'now-15m'; +const END = 'now'; + +/** + * Topology generated by synthtrace (generateTopologyData): + * + * frontend (nodejs) + * → checkout-service (java) [destination: "checkout-proxy:5050"] + * → postgres (db) + * → redis (cache) + * → kafka (messaging) + * → recommendation-service (python) [destination: "recommendation-lb:8080"] + * → postgres (db) + * + * IMPORTANT: span.destination.service.resource values intentionally differ from service.name + * (e.g., "checkout-proxy:5050" instead of "checkout-service") to prevent heuristic matching. + */ +interface ServiceTopologyConnection { + source: { 'service.name': string }; + target: + | { 'service.name': string } + | { + 'span.destination.service.resource': string; + 'span.type': string; + 'span.subtype': string; + }; + metrics: { + errorRate: number | null; + latencyMs: number | null; + throughputPerMin: number | null; + } | null; +} + +interface GetServiceTopologyToolResult extends OtherResult { + data: { + connections: ServiceTopologyConnection[]; + }; +} + +const getTargetName = (c: ServiceTopologyConnection) => + 'service.name' in c.target + ? c.target['service.name'] + : c.target['span.destination.service.resource']; + +const getSourceName = (c: ServiceTopologyConnection) => c.source['service.name']; + +const getConnectionByTarget = (connections: ServiceTopologyConnection[], targetName: string) => + connections.find((c) => getTargetName(c) === targetName); + +export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { + const roleScopedSupertest = getService('roleScopedSupertest'); + const synthtrace = getService('synthtrace'); + + describe(`tool: ${OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID}`, function () { + let agentBuilderApiClient: ReturnType; + let apmSynthtraceEsClient: ApmSynthtraceEsClient; + + before(async () => { + const scoped = await roleScopedSupertest.getSupertestWithRoleScope('editor'); + agentBuilderApiClient = createAgentBuilderApiClient(scoped); + + apmSynthtraceEsClient = await synthtrace.createApmSynthtraceEsClient(); + await apmSynthtraceEsClient.clean(); + + const { client, generator } = generateTopologyData({ + range: timerange(START, END), + apmEsClient: apmSynthtraceEsClient, + }); + + await client.index(generator); + }); + + after(async () => { + await apmSynthtraceEsClient.clean(); + }); + + const executeTopology = async (params: { + serviceName: string; + direction?: 'downstream' | 'upstream' | 'both'; + depth?: number; + }) => { + const results = await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_SERVICE_TOPOLOGY_TOOL_ID, + params: { start: START, end: END, ...params }, + }); + return results[0].data.connections; + }; + + describe('downstream from frontend', () => { + it('returns checkout-service and recommendation-service as targets', async () => { + const connections = await executeTopology({ + serviceName: FRONTEND_SERVICE.serviceName, + }); + + const targets = connections.map(getTargetName); + + expect(targets).to.contain(CHECKOUT_SERVICE.serviceName); + expect(targets).to.contain(RECOMMENDATION_SERVICE.serviceName); + }); + + it('resolves service.name even when span.destination.service.resource differs', async () => { + // The trace-based pipeline joins exit spans with entry transactions + // via parent.id/span.id to resolve the downstream service.name + // (e.g., "checkout-proxy:5050" → "checkout-service"). + const connections = await executeTopology({ + serviceName: FRONTEND_SERVICE.serviceName, + }); + const immediateTargets = connections + .filter((c) => getSourceName(c) === FRONTEND_SERVICE.serviceName) + .map(getTargetName) + .sort(); + + // Resolved service names — not raw resource names like "checkout-proxy:5050" + expect(immediateTargets).to.eql( + [CHECKOUT_SERVICE.serviceName, RECOMMENDATION_SERVICE.serviceName].sort() + ); + }); + + it('depth=1 fast path resolves service.name for instrumented services', async () => { + // The depth=1 downstream fast path queries pre-aggregated metrics + // plus the destination map, which resolves span.destination.service.resource + // to service.name for instrumented downstream services. + const connections = await executeTopology({ + serviceName: FRONTEND_SERVICE.serviceName, + direction: 'downstream', + depth: 1, + }); + const targets = connections.map(getTargetName).sort(); + + expect(targets).to.eql( + [CHECKOUT_SERVICE.serviceName, RECOMMENDATION_SERVICE.serviceName].sort() + ); + }); + + it('depth=1 fast path returns the same dependencies and metrics as trace-based', async () => { + const [traceBasedConnections, metricsBasedConnections] = await Promise.all([ + executeTopology({ serviceName: FRONTEND_SERVICE.serviceName }), + executeTopology({ + serviceName: FRONTEND_SERVICE.serviceName, + direction: 'downstream', + depth: 1, + }), + ]); + + // Filter trace-based to immediate deps only (source = frontend) + const traceBasedImmediate = traceBasedConnections.filter( + (c) => getSourceName(c) === FRONTEND_SERVICE.serviceName + ); + + expect(traceBasedImmediate).to.eql(metricsBasedConnections); + expect(metricsBasedConnections.map((c) => ({ source: c.source, target: c.target }))).to.eql( + [ + { + source: { 'service.name': FRONTEND_SERVICE.serviceName }, + target: { 'service.name': CHECKOUT_SERVICE.serviceName }, + }, + { + source: { 'service.name': FRONTEND_SERVICE.serviceName }, + target: { 'service.name': RECOMMENDATION_SERVICE.serviceName }, + }, + ] + ); + }); + }); + + describe('downstream from checkout-service', () => { + it('returns postgres, redis, kafka — but not siblings or parents', async () => { + const connections = await executeTopology({ + serviceName: CHECKOUT_SERVICE.serviceName, + direction: 'downstream', + }); + const targets = connections.map(getTargetName); + const sources = connections.map(getSourceName); + + // Direct dependencies + expect(targets).to.contain(POSTGRES_DEPENDENCY.resource); + expect(targets).to.contain(REDIS_DEPENDENCY.resource); + expect(targets).to.contain(KAFKA_DEPENDENCY.resource); + + // No sibling (recommendation-service) or parent (frontend) connections + expect(sources).not.to.contain(RECOMMENDATION_SERVICE.serviceName); + expect(sources).not.to.contain(FRONTEND_SERVICE.serviceName); + }); + }); + + describe('upstream from checkout-service', () => { + it('returns frontend as caller', async () => { + const connections = await executeTopology({ + serviceName: CHECKOUT_SERVICE.serviceName, + direction: 'upstream', + }); + const sources = connections.map(getSourceName); + + expect(sources).to.contain(FRONTEND_SERVICE.serviceName); + }); + }); + + describe('both directions from checkout-service', () => { + it('returns frontend upstream and postgres downstream', async () => { + const connections = await executeTopology({ + serviceName: CHECKOUT_SERVICE.serviceName, + direction: 'both', + }); + const sources = connections.map(getSourceName); + const targets = connections.map(getTargetName); + + expect(sources).to.contain(FRONTEND_SERVICE.serviceName); + expect(targets).to.contain(POSTGRES_DEPENDENCY.resource); + }); + }); + + describe('upstream from postgres (multi-hop + blast radius)', () => { + it('includes direct callers, indirect callers, and excludes sibling deps', async () => { + const connections = await executeTopology({ + serviceName: POSTGRES_DEPENDENCY.resource, + direction: 'upstream', + }); + const sources = connections.map(getSourceName); + const targets = connections.map(getTargetName); + + // Direct callers of postgres + expect(sources).to.contain(CHECKOUT_SERVICE.serviceName); + expect(sources).to.contain(RECOMMENDATION_SERVICE.serviceName); + + // Multi-hop: frontend → checkout-service is in the same trace + expect(sources).to.contain(FRONTEND_SERVICE.serviceName); + + // Sibling deps (redis, kafka) should not appear + expect(targets).not.to.contain(REDIS_DEPENDENCY.resource); + expect(targets).not.to.contain(KAFKA_DEPENDENCY.resource); + }); + }); + + describe('RED metrics', () => { + it('returns latency in ms, throughput, and error rate', async () => { + const connections = await executeTopology({ + serviceName: CHECKOUT_SERVICE.serviceName, + direction: 'downstream', + }); + const { metrics } = getConnectionByTarget(connections, POSTGRES_DEPENDENCY.resource)!; + + expect(metrics?.latencyMs).to.be.a('number'); + expect(metrics?.latencyMs).to.be.greaterThan(0); + // Regression: must be ms not µs — synthtrace spans are 30-40ms + expect(metrics?.latencyMs).to.be.lessThan(1000); + expect(metrics?.throughputPerMin).to.be.greaterThan(0); + expect(metrics?.errorRate).to.be(0); + }); + }); + + describe('depth=1 downstream from frontend (immediate deps only)', () => { + it('returns direct dependencies but not their children', async () => { + const connections = await executeTopology({ + serviceName: FRONTEND_SERVICE.serviceName, + direction: 'downstream', + depth: 1, + }); + const sources = connections.map(getSourceName); + const targets = connections.map(getTargetName); + + // Direct deps of frontend — depth=1 uses the metrics-based fast path + // which resolves service.name via the destination map + expect(targets).to.contain(CHECKOUT_SERVICE.serviceName); + expect(targets).to.contain(RECOMMENDATION_SERVICE.serviceName); + + // All sources should be the root service only + const uniqueSources = uniq(sources); + expect(uniqueSources).to.eql([FRONTEND_SERVICE.serviceName]); + + // No grandchild deps (postgres, redis, kafka belong to checkout-service) + expect(targets).not.to.contain(POSTGRES_DEPENDENCY.resource); + expect(targets).not.to.contain(REDIS_DEPENDENCY.resource); + expect(targets).not.to.contain(KAFKA_DEPENDENCY.resource); + }); + }); + + describe('depth=1 upstream from checkout-service (immediate callers only)', () => { + it('returns direct callers but not their ancestors', async () => { + const connections = await executeTopology({ + serviceName: CHECKOUT_SERVICE.serviceName, + direction: 'upstream', + depth: 1, + }); + const sources = connections.map(getSourceName); + + // frontend is a direct caller + expect(sources).to.contain(FRONTEND_SERVICE.serviceName); + + // Only one hop back — no ancestors beyond frontend + expect(connections.length).to.be(1); + }); + }); + + describe('non-existent service', () => { + it('returns empty connections', async () => { + const connections = await executeTopology({ + serviceName: 'non-existent-service', + direction: 'downstream', + }); + + expect(connections.length).to.be(0); + }); + }); + + describe('external dependency target fields', () => { + it('includes span.type and span.subtype for external dependencies', async () => { + const connections = await executeTopology({ + serviceName: CHECKOUT_SERVICE.serviceName, + direction: 'downstream', + }); + const toPostgres = connections.find( + (c) => + 'span.destination.service.resource' in c.target && + c.target['span.destination.service.resource'] === POSTGRES_DEPENDENCY.resource + ); + + expect(toPostgres).to.be.ok(); + // External nodes must include span.type and span.subtype for the LLM + // to understand the dependency type (db, cache, messaging, etc.) + expect(toPostgres!.target).to.have.property('span.type', POSTGRES_DEPENDENCY.spanType); + expect(toPostgres!.target).to.have.property( + 'span.subtype', + POSTGRES_DEPENDENCY.spanSubtype + ); + }); + }); + + describe('depth=1 upstream from postgres (immediate callers only)', () => { + it('returns direct callers but not their ancestors', async () => { + const connections = await executeTopology({ + serviceName: POSTGRES_DEPENDENCY.resource, + direction: 'upstream', + depth: 1, + }); + const sources = connections.map(getSourceName); + + // Direct callers of postgres + expect(sources).to.contain(CHECKOUT_SERVICE.serviceName); + expect(sources).to.contain(RECOMMENDATION_SERVICE.serviceName); + + // frontend is 2 hops away — should NOT appear with depth=1 + expect(sources).not.to.contain(FRONTEND_SERVICE.serviceName); + }); + }); + + /** + * Trace isolation: shared intermediate services across traces + * + * When two different traces share an instrumented intermediate service, + * the tool should only return connections from the queried service's traces. + * + * Topology: + * Trace 1: api-gateway → payment-service → kafka-consumer → postgres + * Trace 2: batch-worker → kafka-consumer → redis + * + * kafka-consumer appears in both traces but with different downstream deps. + * Querying api-gateway downstream should show kafka-consumer → postgres + * but NOT kafka-consumer → redis (which belongs to batch-worker's trace). + */ + describe('trace isolation: shared intermediate services across traces', () => { + before(async () => { + await apmSynthtraceEsClient.clean(); + + const { client, generator } = generateTraceIsolationData({ + range: timerange(START, END), + apmEsClient: apmSynthtraceEsClient, + }); + + await client.index(generator); + }); + + after(async () => { + await apmSynthtraceEsClient.clean(); + }); + + it('downstream from api-gateway does not include redis from batch-worker trace', async () => { + const connections = await executeTopology({ + serviceName: API_GATEWAY_SERVICE.serviceName, + direction: 'downstream', + }); + const targets = connections.map(getTargetName); + + expect(targets).to.contain(PAYMENT_SERVICE.serviceName); + expect(targets).to.contain(KAFKA_CONSUMER_SERVICE.serviceName); + expect(targets).to.contain(POSTGRES_DB.resource); + expect(targets).not.to.contain(REDIS_DB.resource); + }); + + it('downstream from batch-worker does not include postgres from api-gateway trace', async () => { + const connections = await executeTopology({ + serviceName: BATCH_WORKER_SERVICE.serviceName, + direction: 'downstream', + }); + const targets = connections.map(getTargetName); + + expect(targets).to.contain(KAFKA_CONSUMER_SERVICE.serviceName); + expect(targets).to.contain(REDIS_DB.resource); + expect(targets).not.to.contain(POSTGRES_DB.resource); + }); + }); + + /** + * Cycle detection: A → B → A (callback pattern) + * + * When a service graph contains a cycle (service-b calls back to service-a), + * the BFS traversal must terminate without infinite looping. + * + * Topology: + * cycle-service-a → cycle-service-b → cycle-service-a (callback) + */ + describe('cycle: service-a → service-b → service-a (callback pattern)', () => { + before(async () => { + await apmSynthtraceEsClient.clean(); + + const { client, generator } = generateCycleTopologyData({ + range: timerange(START, END), + apmEsClient: apmSynthtraceEsClient, + }); + + await client.index(generator); + }); + + after(async () => { + await apmSynthtraceEsClient.clean(); + }); + + it('downstream traversal terminates and returns both directions', async () => { + const connections = await executeTopology({ + serviceName: CYCLE_SERVICE_A.serviceName, + direction: 'downstream', + }); + const sources = connections.map(getSourceName); + const targets = connections.map(getTargetName); + + // A→B edge + expect(sources).to.contain(CYCLE_SERVICE_A.serviceName); + expect(targets).to.contain(CYCLE_SERVICE_B.serviceName); + + // B→A callback edge + expect(sources).to.contain(CYCLE_SERVICE_B.serviceName); + expect(targets).to.contain(CYCLE_SERVICE_A.serviceName); + + // Exactly 2 connections — no duplicates from infinite traversal + expect(connections.length).to.be(2); + }); + }); + }); +} diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_traces.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_traces.spec.ts index d04d29f6c6a1a..6895e8cf4f7f3 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_traces.spec.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_traces.spec.ts @@ -11,9 +11,10 @@ import type { ApmSynthtraceEsClient, LogsSynthtraceEsClient } from '@kbn/synthtr import { generateGetTracesApmDataset, DEFAULT_TRACE_CONFIGS, - generateCorrelatedLogsData, + DEFAULT_LOGS, + generateLogsData, createLogSequence, - type CorrelatedLogEvent, + type LogEntry, } from '@kbn/synthtrace'; import type { OtherResult } from '@kbn/agent-builder-common'; import { OBSERVABILITY_GET_TRACES_TOOL_ID } from '@kbn/observability-agent-builder-plugin/server/tools'; @@ -37,10 +38,10 @@ async function indexCorrelatedLogs({ logs, }: { logsEsClient: LogsSynthtraceEsClient; - logs: CorrelatedLogEvent[]; + logs: LogEntry[]; }): Promise { const range = timerange('now-5m', 'now'); - const { client, generator } = generateCorrelatedLogsData({ range, logsEsClient, logs }); + const { client, generator } = generateLogsData({ range, logsEsClient, logs }); await client.index(generator); } @@ -86,7 +87,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { before(async () => { const { traceId, serviceName, environment } = DEFAULT_TRACE_CONFIGS[0]; - const correlatedLogs = createLogSequence({ + const logs = createLogSequence({ service: serviceName, correlation: { 'trace.id': traceId, @@ -95,17 +96,11 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { defaults: { 'service.environment': environment, }, - logs: [ - { 'log.level': 'info', message: 'Checkout request received' }, - { 'log.level': 'debug', message: 'Calling downstream cart service' }, - { 'log.level': 'error', message: 'Database query failed: timeout' }, - { 'log.level': 'warn', message: 'Retrying operation' }, - { 'log.level': 'info', message: 'Checkout completed' }, - ], + logs: DEFAULT_LOGS, }); await indexCorrelatedLogs({ logsEsClient: logsSynthtraceEsClient, - logs: correlatedLogs, + logs, }); }); diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/slo/get_slo_grouped_stats.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/slo/get_slo_grouped_stats.ts index fbee811c9187a..b516d06b6b95c 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/slo/get_slo_grouped_stats.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/slo/get_slo_grouped_stats.ts @@ -162,6 +162,80 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(serviceB.summary.healthy).to.eql(1); }); + it('includes SLOs with wildcard (*) environment when filtering by a specific environment', async () => { + const now = new Date().toISOString(); + const docs = [ + createApmSummaryDoc('slo-1', 'service-a', 'HEALTHY', now, { environment: 'production' }), + createApmSummaryDoc('slo-2', 'service-a', 'VIOLATED', now, { environment: '*' }), + createApmSummaryDoc('slo-3', 'service-b', 'HEALTHY', now, { environment: '*' }), + createApmSummaryDoc('slo-4', 'service-b', 'DEGRADING', now, { environment: 'staging' }), + ]; + + await insertSummaryDocs(docs); + + const response = await supertestWithoutAuth + .post(`/internal/slos/_grouped_stats`) + .set(adminRoleAuthc.apiKeyHeader) + .set(internalHeaders) + .send({ type: 'apm', environment: 'production' }) + .expect(200); + + expect(response.body.results).to.have.length(2); + + const serviceA = response.body.results.find( + (r: { entity: string }) => r.entity === 'service-a' + ); + const serviceB = response.body.results.find( + (r: { entity: string }) => r.entity === 'service-b' + ); + + expect(serviceA).to.be.ok(); + expect(serviceA.summary.healthy).to.eql(1); + expect(serviceA.summary.violated).to.eql(1); + + expect(serviceB).to.be.ok(); + expect(serviceB.summary.healthy).to.eql(1); + expect(serviceB.summary.degrading).to.eql(0); + }); + + it('includes SLOs with missing service.environment when filtering by a specific environment', async () => { + const now = new Date().toISOString(); + const docs = [ + createApmSummaryDoc('slo-1', 'service-a', 'HEALTHY', now, { environment: 'production' }), + createApmSummaryDoc('slo-2', 'service-a', 'DEGRADING', now, { + environment: null as unknown as string, + }), + createApmSummaryDoc('slo-3', 'service-b', 'VIOLATED', now, { + environment: null as unknown as string, + }), + ]; + + await insertSummaryDocs(docs); + + const response = await supertestWithoutAuth + .post(`/internal/slos/_grouped_stats`) + .set(adminRoleAuthc.apiKeyHeader) + .set(internalHeaders) + .send({ type: 'apm', environment: 'production' }) + .expect(200); + + expect(response.body.results).to.have.length(2); + + const serviceA = response.body.results.find( + (r: { entity: string }) => r.entity === 'service-a' + ); + const serviceB = response.body.results.find( + (r: { entity: string }) => r.entity === 'service-b' + ); + + expect(serviceA).to.be.ok(); + expect(serviceA.summary.healthy).to.eql(1); + expect(serviceA.summary.degrading).to.eql(1); + + expect(serviceB).to.be.ok(); + expect(serviceB.summary.violated).to.eql(1); + }); + it('respects size parameter', async () => { const now = new Date().toISOString(); const docs = [ diff --git a/x-pack/solutions/observability/test/ensemble/README.md b/x-pack/solutions/observability/test/ensemble/README.md new file mode 100644 index 0000000000000..f8dc2d7d028a3 --- /dev/null +++ b/x-pack/solutions/observability/test/ensemble/README.md @@ -0,0 +1,58 @@ +# Ensemble Workflows + +Automated end-to-end testing for Observability products. These workflows use [Ensemble](https://ensemble.elastic.dev) to spin up ESS clusters, deploy resources, and run tests. + +## Prerequisites + +- [Ensemble CLI](https://ensemble.elastic.dev) installed +- [GitHub CLI](https://cli.github.com/) (`gh`) authenticated +- GCP auth: `gcloud auth application-default login` (for cluster creation) + +--- + +## Synthetics + +Scalability testing for Synthetics private locations. Creates monitors, deploys Elastic Agent, and validates performance under load. + +### Workflows + +#### scalability.yaml + +Full workflow - creates cluster from scratch. + +```bash +cd x-pack/solutions/observability/test/ensemble/synthetics +GH_PAGER= ensemble client submit --config scalability.yaml --daemon +``` + +**Steps:** +1. **Find latest kibana version** - Fetches unstable build artifacts from CI +2. **Create cluster** - Spins up ESS cluster via `oblt-cli` with latest ES/Kibana images +3. **Run Synthetics Forge** - Creates space, agent policy, private location, and monitors +4. **Deploy Elastic Agent** - Runs agent container with Chromium/Playwright for browser monitors +5. **Wait for agent enrollment** - 20s wait for agent to check in +6. **Verify agent health** - Confirms agent enrolled via Fleet API +7. **Cleanup agent container** - Removes Docker container (if `cleanup_after=y`) +8. **Cleanup cluster** - Destroys ESS cluster (if `cleanup_after=y`) + +#### scalability_dev.yaml + +Uses an existing cluster. For iterating without waiting for cluster creation. + +```bash +cd x-pack/solutions/observability/test/ensemble/synthetics +GH_PAGER= ensemble client submit --config scalability_dev.yaml --daemon +``` + +> `GH_PAGER=` disables gh CLI pager - required due to an Ensemble bug with `gh api` param execution. + +### Without Ensemble + +Run synthetics_forge directly: + +```bash +HTTP=10 TCP=10 ICMP=10 BROWSER=5 \ +node x-pack/scripts/synthetics_forge.js create +``` + +See [@kbn/synthetics-forge README](../../packages/kbn-synthetics-forge/README.md). diff --git a/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/cpu-memory-graph.png b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/cpu-memory-graph.png new file mode 100644 index 0000000000000..cb3bbde28d31f Binary files /dev/null and b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/cpu-memory-graph.png differ diff --git a/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitor-4.png b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitor-4.png new file mode 100644 index 0000000000000..92121cbff13f8 Binary files /dev/null and b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitor-4.png differ diff --git a/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitors-1-and-5.png b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitors-1-and-5.png new file mode 100644 index 0000000000000..333ff466c99b5 Binary files /dev/null and b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitors-1-and-5.png differ diff --git a/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitors-2-and-3.png b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitors-2-and-3.png new file mode 100644 index 0000000000000..626fcfcbbe477 Binary files /dev/null and b/x-pack/solutions/observability/test/ensemble/synthetics/results/assets/monitors-2-and-3.png differ diff --git a/x-pack/solutions/observability/test/ensemble/synthetics/results/scalability_test_report.md b/x-pack/solutions/observability/test/ensemble/synthetics/results/scalability_test_report.md new file mode 100644 index 0000000000000..23a55792ccab2 --- /dev/null +++ b/x-pack/solutions/observability/test/ensemble/synthetics/results/scalability_test_report.md @@ -0,0 +1,1354 @@ +# Synthetics Private Location Scalability Test Report + +**Date**: January 2026 +**Tester**: Faisal +**Objective**: Find browser monitor capacity limits and validate capacity formula + +--- + +## 1. Elastic Official Recommendations + +From [Elastic Documentation](https://www.elastic.co/guide/en/observability/current/synthetics-private-location.html): + +### Resource Requirements for Browser Monitors + +| Monitor Type | Memory | CPU | Notes | +|--------------|--------|-----|-------| +| **Browser** | 2 GiB per concurrent instance | 2 cores per instance | Chromium/Playwright overhead | + +> [!NOTE] +> **Elastic's guidance**: "Start by allocating at least 2 GiB of memory and two cores per browser instance to ensure consistent performance and avoid out-of-memory errors." + +--- + +## 2. Test Environment + +### Local Setup +- **Elasticsearch**: Local snapshot (yarn es snapshot) +- **Kibana**: Local dev (yarn start) +- **Agent Image**: `elastic-agent-complete` (includes Chromium) +- **Agent Version**: 9.4.0-SNAPSHOT + +### Container Configuration +- **Memory**: 2 GiB +- **CPU**: 2 cores +- **Docker command**: +```bash +node x-pack/scripts/synthetics_private_location.js --container-memory 2g --container-cpus 2 +``` + +--- + +## 3. How to Interpret Results + +### Understanding Schedule Gaps + +**Schedule: Every 3 minutes** + +| Gap from Previous | Meaning | +|-------------------|---------| +| **~3:00** | Perfect - on schedule | +| **< 3:00** (e.g., 2:33) | Slightly ahead - still fine | +| **3:30 - 4:00** | Starting to fall behind | +| **> 4:00** | Significantly behind schedule | +| **Pending > 0** | Monitor missed its window completely | + +> [!WARNING] +> ### Warning Signs (Approaching Limit) +> 1. Gaps **consistently > 3 minutes** and growing +> 2. **Pending monitors > 0** +> 3. Gaps **increasing over time** (3:30 → 4:00 → 4:30) + +--- + +## 4. Test Results: Browser Monitors (2 GiB / 2 CPU) + +**Container**: `--memory=2g --cpus=2` +**Schedule**: Every 3 minutes (default) +**Date**: 2026-01-28 + +### 4.1 Results: 5 Browser Monitors + +**Status**: PASS - All monitors healthy + +#### Resource Usage + +![CPU Memory Graph](./assets/cpu-memory-graph.png) + +- **CPU**: Periodic spikes to 200% (maxing both cores) every ~3 minutes +- **Memory**: Stabilizes around 500-600MB with peaks to ~1.12GB during execution + +#### Concurrency Analysis + +The agent runs monitors in pairs, with up to **2 monitors concurrently**: + +| Time Slot | Monitors Running | Concurrent Count | +|-----------|------------------|------------------| +| :25-:33 | 1, 5 | 2 | +| :38-:41 | 2, 3 | 2 | +| :44-:46 | 4 | 1 | + +#### Screenshots: Concurrent Execution + +**Monitors 1 and 5 - Run concurrently** +![Monitors 1 and 5](./assets/monitors-1-and-5.png) + +- Monitor 1: 2:34:33, 2:31:31, 2:28:31, 2:25:33 (Duration: 5.8-7.5 sec) +- Monitor 5: 2:34:33, 2:31:30, 2:28:31, 2:25:33 (Duration: 5.1-7.7 sec) +- **Same timestamps = running concurrently** + +**Monitors 2 and 3 - Run concurrently** +![Monitors 2 and 3](./assets/monitors-2-and-3.png) + +- Monitor 2: 2:34:41, 2:31:38, 2:28:38, 2:25:40 (Duration: 3.8-6.9 sec) +- Monitor 3: 2:34:40, 2:31:39, 2:28:39, 2:25:40 (Duration: 4.8-5.3 sec) +- **Same timestamps = running concurrently** + +**Monitor 4 - Runs alone** +![Monitor 4](./assets/monitor-4.png) + +- Monitor 4: 2:37:46, 2:34:45, 2:31:44, 2:28:44, 2:25:45 (Duration: 3.2-4.9 sec) +- **Different timestamps = runs alone** + +#### Key Finding + +With 5 browser monitors on a 3-minute schedule, the agent runs up to **2 monitors concurrently**. This aligns with Elastic's recommendation of 2 GiB + 2 CPU per concurrent browser instance. The 200% CPU spikes confirm both cores are fully utilized during concurrent execution. + +--- + +### 4.2 Results: 10 Browser Monitors + +**Status**: PASS - All monitors healthy + +#### Execution Intervals (Monitor 1) + +| Run | Time | Gap from Previous | Duration | Status | +|-----|------|-------------------|----------|--------| +| 1 | 14:01:06 | 2 min 59 sec | 4.2s | OK | +| 2 | 13:58:07 | 2 min 33 sec | 5.2s | OK | +| 3 | 13:55:34 | 3 min 0 sec | 5.3s | OK | +| 4 | 13:52:34 | 6 min 3 sec* | 5.5s | OK | +| 5 | 13:46:31 | 3 min 0 sec | 5.0s | OK | +| 6 | 13:43:31 | 3 min 0 sec | 4.8s | OK | +| 7 | 13:40:31 | 3 min 1 sec | 4.5s | OK | +| 8 | 13:37:29 | 2 min 56 sec | 3.7s | OK | +| 9 | 13:34:33 | 3 min 2 sec | 7.5s | OK | +| 10 | 13:31:31 | 3 min 0 sec | 5.8s | OK | + +*Gap at run 4→5: Cleanup when adding monitors 6-10 + +#### Key Finding + +- All intervals ~3 minutes (on schedule) +- Durations: 3.7s - 7.5s (well under 3-minute window) +- Pending: 0 +- **NOT at the limit - agent handling 10 monitors comfortably** + +--- + +### 4.3 Results: 25 Browser Monitors + +**Status**: PASS - All monitors healthy + +#### Resource Usage +- **CPU**: 203% peak (both cores maxed) +- **Memory**: 914 MiB / 2 GiB (45% peak) + +#### Full Execution History + +| Monitor | Run 1 (latest) | Gap | Run 2 | Gap | Run 3 | Avg Duration | +|---------|----------------|-----|-------|-----|-------|--------------| +| 1 | 14:42:06 | ~3m | 14:39:06 | ~3m | 14:36:07 | 4194ms | +| 2 | 14:41:52 | ~3m | 14:38:52 | ~3m | 14:35:52 | 4025ms | +| 3 | 14:42:13 | ~3m | 14:39:14 | ~3m | 14:36:13 | 3847ms | +| 4 | 14:42:26 | ~3m | 14:39:27 | ~3m | 14:36:26 | 4239ms | +| 5 | 14:41:38 | ~3m | 14:38:37 | ~3m | 14:35:37 | 4657ms | +| 6 | 14:41:38 | ~3m | 14:38:37 | ~3m | 14:35:36 | 4367ms | +| 7 | 14:41:31 | ~3m | 14:38:31 | ~3m | 14:35:30 | 4525ms | +| 8 | 14:41:42 | ~3m | 14:38:43 | ~3m | 14:35:42 | 3415ms | +| 9 | 14:41:43 | ~3m | 14:38:43 | ~3m | 14:35:43 | 4143ms | +| 10 | 14:41:30 | ~3m | 14:38:31 | ~3m | 14:35:30 | 4680ms | +| 11 | 14:41:56 | ~3m | 14:38:57 | ~3m | 14:35:56 | 3466ms | +| 12 | 14:42:04 | ~3m | 14:39:05 | ~3m | 14:36:04 | 3655ms | +| 13 | 14:42:11 | ~3m | 14:39:12 | ~3m | 14:36:12 | 3973ms | +| 14 | 14:42:20 | ~3m | 14:39:20 | ~3m | 14:36:20 | 3618ms | +| 15 | 14:42:17 | ~3m | 14:39:18 | ~3m | 14:36:18 | 3280ms | +| 16 | 14:41:58 | ~3m | 14:39:00 | ~3m | 14:35:58 | 3790ms | +| 17 | 14:41:52 | ~3m | 14:38:51 | ~3m | 14:35:50 | 4311ms | +| 18 | 14:42:27 | ~3m | 14:39:27 | ~3m | 14:36:27 | 4162ms | +| 19 | 14:42:33 | ~3m | 14:39:33 | ~3m | 14:36:33 | 3620ms | +| 20 | 14:42:34 | ~3m | 14:39:33 | ~3m | 14:36:33 | 4159ms | +| 21 | 14:42:40 | ~3m | 14:39:40 | ~3m | 14:36:40 | 3810ms | +| 22 | 14:42:46 | ~3m | 14:39:47 | ~3m | 14:36:47 | 4015ms | +| 23 | 14:42:40 | ~3m | 14:39:40 | ~3m | 14:36:40 | 4348ms | +| 24 | 14:42:48 | ~3m | 14:39:48 | ~3m | 14:36:48 | 4073ms | +| 25 | 14:42:52 | ~3m | 14:39:52 | ~3m | 14:36:52 | 3202ms | + +#### Key Finding + +- All gaps: **~3 minutes** (perfectly on schedule) +- Average duration: **3.2s - 4.7s** per monitor +- Cycle spread: **~82 seconds** (14:41:30 to 14:42:52) +- **NOT at the limit** - agent handling 25 monitors comfortably + +--- + +### 4.4 Results: 50 Browser Monitors + +**Status**: PASS - All monitors healthy + +#### Resource Usage +- **CPU**: ~200% peak (both cores maxed) +- **Memory**: ~1 GiB / 2 GiB (~50% peak) + +#### Full Execution History + +| Monitor | Run 1 (latest) | Gap | Run 2 | Avg Duration | +|---------|----------------|-----|-------|--------------| +| 1 | 14:57:58 | ~3m | 14:54:54 | 5277ms | +| 2 | 14:57:29 | ~3m | 14:54:30 | 4149ms | +| 3 | 14:57:50 | ~3m | 14:54:48 | 4302ms | +| 4 | 14:57:58 | ~3m | 14:54:53 | 4872ms | +| 5 | 14:57:29 | ~3m | 14:54:30 | 4450ms | +| 6 | 14:57:23 | ~3m | 14:54:24 | 5780ms | +| 7 | 14:57:43 | ~3m | 14:54:42 | 3753ms | +| 8 | 14:57:36 | ~3m | 14:54:37 | 4891ms | +| 9 | 14:57:23 | ~3m | 14:54:24 | 5697ms | +| 10 | 14:57:50 | ~3m | 14:54:49 | 4752ms | +| 11 | 14:57:36 | ~3m | 14:54:37 | 4345ms | +| 12 | 14:57:43 | ~3m | 14:54:43 | 4719ms | +| 13 | 14:58:06 | ~3m | 14:54:59 | 4894ms | +| 14 | 14:58:05 | ~3m | 14:55:00 | 4258ms | +| 15 | 14:58:12 | ~3m | 14:55:05 | 4897ms | +| 16 | 14:58:12 | ~3m | 14:55:06 | 4609ms | +| 17 | 14:58:20 | ~3m | 14:55:12 | 5050ms | +| 18 | 14:58:20 | ~3m | 14:55:12 | 5335ms | +| 19 | 14:58:27 | ~3m | 14:55:19 | 4460ms | +| 20 | 14:58:26 | ~3m | 14:55:20 | 4582ms | +| 21 | 14:55:26 | ~3m | 14:52:27 | 5049ms | +| 22 | 14:55:26 | ~3m | 14:52:27 | 4786ms | +| 23 | 14:55:33 | ~3m | 14:52:36 | 5287ms | +| 24 | 14:55:33 | ~3m | 14:52:33 | 3907ms | +| 25 | 14:55:37 | ~3m | 14:52:42 | 3868ms | +| 26 | 14:55:46 | ~3m | 14:52:48 | 3746ms | +| 27 | 14:55:39 | ~3m | 14:52:43 | 4571ms | +| 28 | 14:55:47 | ~3m | 14:52:49 | 4151ms | +| 29 | 14:55:53 | ~3m | 14:52:54 | 3853ms | +| 30 | 14:55:54 | ~3m | 14:52:55 | 4296ms | +| 31 | 14:55:59 | ~3m | 14:53:00 | 3902ms | +| 32 | 14:56:06 | ~3m | 14:53:07 | 4793ms | +| 33 | 14:56:00 | ~3m | 14:53:00 | 4121ms | +| 34 | 14:56:07 | ~3m | 14:53:07 | 5141ms | +| 35 | 14:56:13 | ~3m | 14:53:14 | 4260ms | +| 36 | 14:56:13 | ~3m | 14:53:13 | 4688ms | +| 37 | 14:56:18 | ~3m | 14:53:21 | 4253ms | +| 38 | 14:56:19 | ~3m | 14:53:21 | 4622ms | +| 39 | 14:56:26 | ~3m | 14:53:26 | 3504ms | +| 40 | 14:56:27 | ~3m | 14:53:27 | 4258ms | +| 41 | 14:56:33 | ~3m | 14:53:32 | 4803ms | +| 42 | 14:56:41 | ~3m | 14:53:37 | 4256ms | +| 43 | 14:56:33 | ~3m | 14:53:32 | 4362ms | +| 44 | 14:56:40 | ~3m | 14:53:38 | 4748ms | +| 45 | 14:56:47 | ~3m | 14:53:44 | 4851ms | +| 46 | 14:56:46 | ~3m | 14:53:43 | 3744ms | +| 47 | 14:56:52 | ~3m | 14:53:49 | 3954ms | +| 48 | 14:56:52 | ~3m | 14:53:50 | 3908ms | +| 49 | 14:56:58 | ~3m | 14:53:54 | 3751ms | +| 50 | 14:56:58 | ~3m | 14:53:55 | 4404ms | + +#### Key Finding + +- All gaps: **~3 minutes** (on schedule) +- Average duration: **3.5s - 5.8s** per monitor +- Cycle spread: **~157 seconds** (87% capacity) +- **Still not at the limit** - agent handling 50 monitors with headroom + +--- + +### 4.5 Results: 60 Browser Monitors + +**Status**: FAIL - 8 monitors pending, gaps exceeding schedule + +#### Resource Usage +- **CPU**: ~200% peak (both cores maxed) +- **Memory**: ~1 GiB / 2 GiB (~50% peak) +- **Pending**: 8 monitors + +#### Monitors with Gaps Exceeding Schedule (>5 min) + +| Monitor | Gap | Status | +|---------|-----|--------| +| 8 | 15:06:36 → 15:13:26 = ~7 min | DELAYED | +| 9 | 15:06:23 → 15:13:32 = ~7 min | DELAYED | +| 19 | 15:04:25 → 15:12:54 = ~8.5 min | DELAYED | +| 23 | 15:04:39 → 15:12:48 = ~8 min | DELAYED | +| 26 | 15:04:52 → 15:13:00 = ~8 min | DELAYED | +| 27 | 15:04:45 → 15:13:19 = ~8.5 min | DELAYED | +| 28 | 15:04:52 → 15:12:17 = ~7.5 min | DELAYED | +| 34 | 15:05:09 → 15:12:03 = ~7 min | DELAYED | +| 39 | 15:05:29 → 15:12:11 = ~6.5 min | DELAYED | +| 40 | 15:05:29 → 15:13:20 = ~8 min | DELAYED | +| 48 | 15:05:54 → 15:13:25 = ~7.5 min | DELAYED | +| 50 | 15:06:00 → 15:13:31 = ~7.5 min | DELAYED | + +#### New Monitors (51-60) - Reduced Throughput + +| Monitor | Runs in 15m | Effective Interval | +|---------|-------------|-------------------| +| 51-60 | 2 runs each | ~6 min (2x schedule) | + +#### Key Finding + +- **8 monitors pending** - agent cannot keep up +- **12 monitors** showing 6-8 min gaps (2x schedule) +- **Monitors 51-60** running at ~50% throughput +- **Breaking point confirmed at ~57 monitors** + +--- + +### 4.6 Results: Complex Journey Test (10 monitors, ~24s duration) + +**Status**: At capacity - 1 monitor failing + +**Journey**: 5-step navigation (Homepage → Products → Elasticsearch → Observability → Pricing) + +#### Execution Results + +| Monitor | Time | Duration | Status | +|---------|------|----------|--------| +| 1 | 15:48:05 | 23153ms | up | +| 2 | 15:46:15 | 21619ms | up | +| 3 | 15:46:44 | 26471ms | up | +| 4 | 15:47:11 | 23506ms | up | +| 5 | 15:46:40 | 26392ms | up | +| 6 | 15:44:57 | 0ms | **down** | +| 7 | 15:47:40 | 25711ms | up | +| 8 | 15:47:10 | 25045ms | up | +| 9 | 15:47:40 | 25362ms | up | +| 10 | 15:46:10 | 21021ms | up | + +#### Key Finding + +- **Average Duration**: ~24 seconds (vs ~4.5s for simple journey) +- **9 up, 1 down, 0 pending** - at the breaking point +- With longer journey durations, fewer monitors can be supported + +--- + +### 4.7 Results: Heavy Journey Test (6 monitors, ~37s duration) + +**Status**: At capacity - 1 down, 1 pending + +**Journey**: 5-step navigation with 3s waits (Homepage → Products → Elasticsearch → Observability → Pricing) + +#### Execution Results + +| Monitor | Time | Duration | Status | +|---------|------|----------|--------| +| 1 | 16:12:11 | 33777ms | up | +| 2 | - | - | **pending** | +| 3 | 16:11:32 | 37658ms | up | +| 4 | 16:11:36 | 41412ms | up | +| 5 | 16:13:05 | 0ms | **down** | +| 6 | 16:13:35 | 35096ms | up | + +#### Key Finding + +- **Average Duration**: ~37 seconds +- **4 up, 1 down, 1 pending** - at the breaking point +- **33% failure rate** with 6 monitors at this duration +- The longer the journey, the fewer monitors can run successfully + +--- + +### 4.8 Edge Case: Very Heavy Journey Rerun (`timeout: null`, `domcontentloaded`) + +**Status**: PASS + +**Journey**: 8-step navigation with 10s waits (~120s total) + +#### Execution Result (Rerun) +| Monitors | Up | Down | Pending | Avg Duration | Result | +|----------|----|------|---------|--------------|--------| +| 1 | 1 | 0 | 0 | ~97.0s | **PASS** | + +#### Finding (Rerun) + +With browser monitors created using `timeout: null` and journey steps using +`waitForLoadState('domcontentloaded')`, this very-heavy scenario completed successfully. + +| Scenario | Monitor Timeout | Wait Strategy | Observed Outcome | +|----------|-----------------|---------------|------------------| +| Very heavy journey (~120s total) | `null` | `domcontentloaded` | **Success (up)** | + +> [!IMPORTANT] +> This confirms the prior failure mode was step wait behavior (`networkidle` path), not a global browser monitor timeout limit. + +--- + +### 4.9 Results: 10-Minute Schedule Validation + +**Schedule**: Every 10 minutes +**Purpose**: Validate that the 1.4 overhead factor is universal across different schedules + +#### Formula Prediction for 10-Minute Schedule + +``` +capacity_constant = (600s × 2 CPUs) / 1.4 = 857 +``` + +| Journey Type | Duration | Formula Predicts | +|--------------|----------|------------------| +| Medium (3 steps + waits) | ~21s | 857 / 21 = **40 monitors** | + +#### Test Results + +| Journey | Duration | Monitors | Predicted Max | Result | Memory Peak | +|---------|----------|----------|---------------|--------|-------------| +| Medium | ~21s | 35 | 40 | **PASS (100%)** | 1.14 GB | + +#### Analysis + +- 100% success rate (35 up, 0 down, 0 pending) +- Memory stayed comfortable at 1.14 GB (57% of 2 GB) +- Used conservative 35 monitors (vs 40 predicted) for safety margin + +> [!TIP] +> **Key Finding**: The 1.4 overhead factor is universal across schedules. + +#### Test 3 Execution History (35 Medium Journey Monitors) + +| Monitor | Run 1 (Cycle 1) | Run 2 (Cycle 2) | Gap | Avg Duration | +|---------|-----------------|-----------------|-----|--------------| +| Monitor 1 | 18:40:39 | 18:50:57 | 10m 18s | 20.56s | +| Monitor 2 | 18:37:20 | 18:47:12 | 9m 52s | 22.07s | +| Monitor 3 | 18:36:53 | 18:47:36 | 10m 43s | 21.36s | +| Monitor 4 | 18:40:15 | 18:50:33 | 10m 18s | 21.37s | +| Monitor 5 | 18:42:49 | 18:53:09 | 10m 20s | 20.25s | +| Monitor 6 | 18:40:36 | 18:50:55 | 10m 19s | 20.95s | +| Monitor 7 | 18:40:59 | 18:51:19 | 10m 20s | 21.98s | +| Monitor 8 | 18:38:48 | 18:49:04 | 10m 16s | 21.26s | +| Monitor 9 | 18:37:42 | 18:47:56 | 10m 14s | 20.30s | +| Monitor 10 | 18:39:08 | 18:49:26 | 10m 18s | 19.81s | +| Monitor 11 | 18:39:13 | 18:49:28 | 10m 15s | 21.56s | +| Monitor 12 | 18:39:54 | 18:50:11 | 10m 17s | 19.89s | +| Monitor 13 | 18:38:02 | 18:48:18 | 10m 16s | 20.51s | +| Monitor 14 | 18:38:28 | 18:48:41 | 10m 13s | 20.34s | +| Monitor 15 | 18:38:50 | 18:49:04 | 10m 14s | 21.30s | +| Monitor 16 | 18:42:28 | 18:52:47 | 10m 19s | 20.52s | +| Monitor 17 | 18:39:51 | 18:50:12 | 10m 21s | 20.95s | +| Monitor 18 | 18:42:31 | 18:52:49 | 10m 18s | 19.77s | +| Monitor 19 | 18:41:01 | 18:51:20 | 10m 19s | 20.97s | +| Monitor 20 | 18:36:53 | 18:46:51 | 9m 58s | 22.38s | +| Monitor 21 | 18:42:08 | 18:52:29 | 10m 21s | 21.04s | +| Monitor 22 | 18:41:46 | 18:52:06 | 10m 20s | 19.73s | +| Monitor 23 | 18:42:52 | 18:53:12 | 10m 20s | 20.32s | +| Monitor 24 | 18:41:23 | 18:51:43 | 10m 20s | 22.15s | +| Monitor 25 | 18:42:07 | 18:52:25 | 10m 18s | 19.21s | +| Monitor 26 | 18:38:26 | 18:48:41 | 10m 15s | 21.74s | +| Monitor 27 | 18:41:45 | 18:52:06 | 10m 21s | 21.35s | +| Monitor 28 | 18:37:41 | 18:47:56 | 10m 15s | 20.49s | +| Monitor 29 | 18:39:34 | 18:49:48 | 10m 14s | 19.37s | +| Monitor 30 | 18:43:09 | 18:53:31 | 10m 22s | 19.95s | +| Monitor 31 | 18:41:26 | 18:51:44 | 10m 18s | 23.13s | +| Monitor 32 | 18:38:04 | 18:48:20 | 10m 16s | 21.46s | +| Monitor 33 | 18:37:19 | 18:47:34 | 10m 15s | 22.19s | +| Monitor 34 | 18:39:30 | 18:49:49 | 10m 19s | 20.99s | +| Monitor 35 | 18:40:18 | 18:50:34 | 10m 16s | 21.46s | + +**Cycle Summary**: +- **Cycle 1**: 18:36:53 → 18:43:09 (Spread: 6m 16s) +- **Cycle 2**: 18:46:51 → 18:53:31 (Spread: 6m 40s) +- **Average gap**: ~10m 17s (on schedule) +- **Overall avg duration**: ~20.9s +- **Result**: 35/35 UP in both cycles + +--- + +### 4.10 Results: Heavy Journey Test Rerun (18 monitors, 10-min schedule, `timeout: null`, `domcontentloaded`) + +**Schedule**: Every 10 minutes +**Purpose**: Test capacity with heavy journey using overhead factor 1.8 + +#### Formula Prediction (Overhead 1.8) + +``` +capacity_constant = (600s × 2 CPUs) / 1.8 = 667 +max_monitors = 667 / 37s = 18 monitors +``` + +#### Test Results (Rerun) + +| Monitors | Up | Down | Pending | Avg Duration | Result | +|----------|-----|------|---------|--------------|--------| +| 18 | 18 | 0 | 0 | ~18.35s | **PASS** | + +#### Error Analysis (Rerun) + +No `CMD_TIMEOUT` and no Playwright `50000ms` timeout errors were observed in this rerun window. + +#### Rerun Metrics Snapshot + +| Metric | Value | +|--------|-------| +| Summary docs in window | 44 | +| Status mix | 44 up / 0 down | +| Avg duration | ~18.35s | +| p95 duration | ~27.39s | + +> [!TIP] +> **Key Finding (updated)**: Using `domcontentloaded` removed timeout-driven flakiness for this scenario under the same agent limits. + +> [!IMPORTANT] +> **Recommendation for Heavy Journeys**: +> - Keep capacity planning (overhead/concurrency), and standardize waits in test scripts. +> - For scalability testing, prefer deterministic waits like `domcontentloaded` over `networkidle` when possible. + +--- + +### 4.11 Results: Heavy Journey with Extended Timeout (7 monitors, ~54s, 3-min schedule) + +**Schedule**: Every 3 minutes +**Purpose**: Historical baseline validation using explicit monitor timeout increase +**Timeout**: 120 seconds (increased from default 46s) + +#### Configuration Changes + +To test heavy journeys without timeout interference, the monitor timeout was increased: + +```typescript +// api_client.ts - createBrowserMonitor +timeout: '120', // Changed from '16' +schedule: { number: '3', unit: 'm' }, +``` + +#### Formula Prediction + +``` +capacity_constant = (180s × 2 CPUs) / 1.4 = 257 +max_monitors = 257 / 37s = ~7 monitors (for expected 37s duration) +``` + +#### Test Results + +| Monitors | Up | Down | Pending | Avg Duration | Cycle Spread | Status | +|----------|-----|------|---------|--------------|--------------|--------| +| 7 | 7 | 0 | 0 | ~54s | ~163-200s | **PASS** | + +#### Execution History + +| Monitor | Cycle 1 Time | Duration | Cycle 2 Time | Duration | +|---------|--------------|----------|--------------|----------| +| Monitor 1 | 13:59:11 | 59.6s | 14:03:11 | 63.2s | +| Monitor 2 | 13:59:54 | 49.9s | 14:04:10 | 53.7s | +| Monitor 3 | 14:00:55 | 58.7s | 14:05:05 | 52.2s | +| Monitor 4 | 13:59:01 | 50.1s | 14:03:26 | 56.4s | +| Monitor 5 | 14:02:27 | 52.7s | 14:05:21 | 52.7s | +| Monitor 6 | 13:59:56 | 42.8s | 14:04:26 | 57.2s | +| Monitor 7 | 14:02:01 | 63.0s | 14:05:54 | 47.7s | + +#### Key Findings + +1. **No CMD_TIMEOUT errors**: With 120s timeout, all 5 steps complete successfully +2. **No pending monitors**: Capacity formula is validated - 7 monitors fit within schedule + +#### Observed Overhead Calculation + +``` +Monitors: 7 +Avg Duration: ~54s (higher than expected 37s due to network conditions) +Cycle Spread: ~163s (Cycle 2) +Theoretical: (7 × 54) / 2 = 189s +Observed Overhead: 163 / 189 = 0.86 (actually faster than theoretical!) +``` + +> [!TIP] +> **Historical finding**: In the earlier baseline set, increasing timeout removed monitor-level timeout pressure for this scenario: +> - Heavy journeys complete all 5 steps +> - The 1.4 overhead formula is validated +> - Observed overhead for long journeys is actually **~1.0-1.1** (lower than 1.4) + +#### Why Observed Overhead is Lower for Heavy Journeys + +| Journey Duration | Observed Overhead | Explanation | +|------------------|-------------------|-------------| +| ~4.5s (simple) | 1.40-1.64 | Browser startup is significant relative to execution | +| ~21s (medium) | 1.09-1.14 | Startup cost is amortized | +| ~54s (heavy) | 0.86-1.1 | Startup cost is minimal relative to execution | + +> [!IMPORTANT] +> **Conclusion**: The 1.4 overhead factor is **conservative** for long journeys in this baseline test set. +> This section is baseline-only; final reruns in 4.8/4.10/4.12.x show wait strategy (`domcontentloaded`) also materially affects outcomes. + +--- + +### 4.12 Results: Production-Ready 2.5 Overhead Factor Validation + +**Purpose**: Validate capacity formula using a conservative 2.5 overhead factor suitable for production deployments. + +#### Why Test 2.5 Overhead? + +Based on testing with 1.4 overhead (sections 4.1-4.11), we observed: + +1. **CPU was always at 200%** (maxed) even in "passing" tests +2. **Observed overhead varied from 1.09 to 1.64** depending on conditions +3. **No headroom** for network variability, system load, or unexpected factors + +Using **2.5 overhead** provides: +- ~52% buffer over worst observed capacity overhead (1.64) +- CPU headroom for production stability +- Safety margin for real-world variability + +#### Formula with 2.5 Overhead + +``` +capacity_constant = (schedule_interval × concurrency) / 2.5 +max_monitors = capacity_constant / avg_duration_seconds +``` + +> [!NOTE] +> **Naming update (Option A):** In this section, rows are mapped by **script profile**, not fixed "heavy/medium/complex" duration tiers. +> The same script profile can shift in observed runtime when wait strategy changes (for example `networkidle` -> `domcontentloaded`). + +**3-Minute Schedule (2 CPU)**: +``` +capacity_constant = (180 × 2) / 2.5 = 144 +``` + +| Script Profile | Duration | Predicted Max | +|--------------|----------|---------------| +| Profile A (simple 1-step nav) | ~4.5s | 144 / 4.5 = **32 monitors** | +| Profile B (5-step nav) | ~24s | 144 / 24 = **6 monitors** | +| Profile C (5-step nav + waits, prior baseline) | ~37s | 144 / 37 = **4 monitors** | + +**10-Minute Schedule (2 CPU)**: +``` +capacity_constant = (600 × 2) / 2.5 = 480 +``` + +| Script Profile | Duration | Predicted Max | +|--------------|----------|---------------| +| Profile D (3-step nav + waits) | ~21s | 480 / 21 = **23 monitors** | +| Profile C (5-step nav + waits, prior baseline) | ~40s | 480 / 40 = **12 monitors** | + +--- + +#### 4.12.1 Profile A: Simple Journey (32 monitors, ~4.5s, 3-min schedule) + +**Schedule**: Every 3 minutes +**Journey**: Simple (1 step) +**Monitors**: 32 +**Result**: **PASS** + +| Monitors | Up | Down | Pending | Avg Duration | Cycle Spread | Status | +|----------|-----|------|---------|--------------|--------------|--------| +| 32 | 32 | 0 | 0 | ~4.5s | ~103s | **PASS** | + +**Cycle Analysis**: +- Cycle 1: 11:40:26 → 11:42:32 (Spread: ~126s) +- Cycle 2: 11:43:31 → 11:45:14 (Spread: ~103s) +- Schedule interval: 180s +- **Capacity used**: 103/180 = **57%** + +**Overhead Calculation**: +``` +Theoretical = (32 × 4.5) / 2 = 72s +Actual cycle spread = ~103s +Observed overhead = 103 / 72 = 1.43 +``` + +> [!TIP] +> With 2.5 overhead predicting 32 monitors max, the test **passed with 43% headroom**. CPU was not constantly maxed, providing stability buffer. + +--- + +#### 4.12.2 Profile B: 5-Step Journey (6 monitors, ~24s, 3-min schedule) + +**Schedule**: Every 3 minutes +**Journey**: Complex (5 steps) +**Monitors**: 6 +**Result**: **PARTIAL** + +| Monitors | Up | Down | Pending | Avg Duration | Cycle Spread | Status | +|----------|-----|------|---------|--------------|--------------|--------| +| 6 | 4 | 2 | 0 | ~27s (range: 22-45s) | ~90s | **PARTIAL** | + +**Execution Analysis**: + +| Monitor | Duration | Status | Notes | +|---------|----------|--------|-------| +| Monitor 1 | 28-31s | up | Stable | +| Monitor 2 | 24-27s | up/down | Intermittent | +| Monitor 3 | 25-27s | up | Stable | +| Monitor 4 | 30s | up | Stable | +| Monitor 5 | 44-45s | down | Consistently hitting timeout | +| Monitor 6 | 23-28s | up | Stable | + +> [!NOTE] +> The formula predicted 6 monitors max for ~24s journey, but journey duration **varied significantly** (22-45s) due to network conditions. 4/6 monitors (67%) stable when journey completes < 35s. + +--- + +#### 4.12.3 Profile C Rerun (4 monitors, 3-min schedule, `timeout: null`, `domcontentloaded`) + +**Schedule**: Every 3 minutes +**Journey**: Heavy (5 steps + waits) +**Monitors**: 4 +**Result**: **PASS** + +| Monitors | Up | Down | Pending | Actual Duration | Status | +|----------|-----|------|---------|-----------------|--------| +| 4 | 4 | 0 | 0 | ~25.12s avg | **PASS** | + +**Error Analysis**: + +| Metric | Value | +|--------|-------| +| Summary docs in window | 12 | +| Status mix | 12 up / 0 down | +| Avg duration | ~25.12s | +| p95 duration | ~26.27s | + +> [!TIP] +> This rerun was stable with no timeout failures. + +--- + +#### 4.12.4 Profile D: 3-Step + Waits Journey (23 monitors, ~21s, 10-min schedule) + +**Schedule**: Every 10 minutes +**Journey**: Medium (3 steps + waits) +**Monitors**: 23 +**Result**: **PASS** + +| Monitors | Up | Down | Pending | Avg Duration | Cycle Spread | Status | +|----------|-----|------|---------|--------------|--------------|--------| +| 23 | 23 | 0 | 0 | ~25s (range: 21-33s) | ~9m | **PASS** | + +**Cycle Analysis**: +- Cycle 1: 12:27:21 → 12:37:20 (~10 min), 21/23 up (2 transient browser launch failures) +- Cycle 2: 12:38:16 → 12:48:14 (~10 min), **23/23 up (100%)** + +**Overhead Calculation**: +``` +Theoretical: 23 monitors × 21s = 483s (8.05 min) +Actual cycle: ~550s (9.2 min) +Observed overhead: 550 / 483 = 1.14 +``` + +> [!TIP] +> With 2.5 overhead providing a 480-second capacity constant, 23 monitors at ~25s actual duration fit comfortably within the 10-minute schedule. + +--- + +#### 4.12.5 Profile C Rerun (12 monitors, 10-min schedule, `timeout: null`, `domcontentloaded`) + +**Schedule**: Every 10 minutes +**Journey**: Heavy (5 steps + waits) +**Monitors**: 12 +**Result**: **PASS** + +| Monitors | Up | Down | Pending | Actual Duration | Status | +|----------|-----|------|---------|-----------------|--------| +| 12 | 12 | 0 | 0 | ~19.79s avg | **PASS** | + +Rerun metrics: +- Summary docs in window: 30 +- Status mix: 30 up / 0 down +- p95 duration: ~27.41s + +> [!TIP] +> No timeout errors were observed in this rerun. + +--- + +#### 4.12 Summary: 2.5 Overhead Results + +| Test | Script Profile | Schedule | Monitors | Result | Notes | +|------|---------|----------|----------|--------|-------| +| 4.12.1 | Profile A (simple 1-step nav, ~4.5s) | 3-min | 32 | **PASS** | 100% success, 43% headroom | +| 4.12.2 | Profile B (5-step nav, ~24s) | 3-min | 6 | **PARTIAL** | 67% success, network variability | +| 4.12.3 | Profile C rerun (`timeout: null`, `domcontentloaded`) | 3-min | 4 | **PASS** | 12 up / 0 down in summary window | +| 4.12.4 | Profile D (3-step nav + waits, ~21s) | 10-min | 23 | **PASS** | 100% success on 2nd cycle | +| 4.12.5 | Profile C rerun (`timeout: null`, `domcontentloaded`) | 10-min | 12 | **PASS** | 30 up / 0 down in summary window | + +> [!IMPORTANT] +> **The 2.5 overhead factor still provides useful capacity guidance.** +> Reruns show wait strategy matters: switching to `domcontentloaded` removed timeout-driven failures for these heavy scenarios. + +--- + +### 4.13 Results: SYNTHETICS_LIMIT_BROWSER Environment Variable Test + +**Objective**: Verify whether `SYNTHETICS_LIMIT_BROWSER` is actually applied by the agent scheduler, and measure impact on throughput/latency on a constrained 2-CPU host. + +#### Test Setup + +- **Monitors**: 24 browser monitors +- **Schedule**: 3 minutes +- **Journey**: simple browser journey (`page.goto('https://www.elastic.co')`) +- **Agent limits (both runs)**: `--cpus=2 --memory=2g` +- **Run A**: `SYNTHETICS_LIMIT_BROWSER=2` +- **Run B**: `SYNTHETICS_LIMIT_BROWSER=4` + +#### Results + +| Metric | `LIMIT=2` | `LIMIT=4` | +|:-------|:----------|:----------| +| Max concurrent browser starts (same second, from agent logs) | 2 | 4 | +| Completed summary checks in window (`summary.final_attempt=true`) | 64 | 60 | +| Status counts | 64 up / 0 down | 60 up / 0 down | +| Duration p50 | 4.47s | 10.35s | +| Duration p95 | 6.48s | 15.23s | +| CPU peak | ~206% | ~201% | +| Memory peak | ~948 MiB | ~1.52 GiB | + +#### Key Findings + +1. **`SYNTHETICS_LIMIT_BROWSER` is applied** - setting `4` increased observed concurrent browser starts from 2 to 4. +2. **No core-count auto-tuning observed** - concurrency was not capped back to 2 by scheduler logic. +3. **Throughput did not improve on 2 CPU** - completed checks were slightly lower (60 vs 64) in the same window. +4. **Latency worsened at higher concurrency** - p50/p95 durations increased substantially with `LIMIT=4`. +5. **CPU stayed saturated in both runs** and memory pressure increased with `LIMIT=4`, indicating contention rather than throughput gain. + +> [!IMPORTANT] +> **Conclusion**: `SYNTHETICS_LIMIT_BROWSER` controls an upper concurrency limit and can raise concurrent browser execution beyond 2. On a 2-CPU agent, increasing it to 4 increased contention (higher duration, higher memory) without throughput gain. For this host size, `2` remains the stable setting. + +--- + +### Summary Table + +#### 1.4 Overhead Tests (Historical Baseline Dataset) + +| Monitors | Journey | Timeout | Pending | Agent CPU% | Agent Memory | Status | +|----------|---------|---------|---------|------------|--------------|--------| +| 5 | Simple | 46s | 0 | 200% peak | 1.12GB peak | PASS | +| 10 | Simple | 46s | 0 | 200% peak | 502MB peak | PASS | +| 25 | Simple | 46s | 0 | 203% peak | 914MB peak | PASS | +| 50 | Simple | 46s | 0 | ~200% peak | ~1GB peak | PASS | +| 60 | Simple | 46s | 8 | ~200% peak | ~1GB peak | **FAIL** | +| 7 | Heavy | **120s** | 0 | ~200% peak | ~1GB peak | **PASS** | + +#### 2.5 Overhead Tests (Production Recommended) + +| Monitors | Script Profile | Schedule | Headroom | Status | Notes | +|----------|---------|----------|----------|--------|-------| +| 32 | Profile A (simple 1-step nav, ~4.5s) | 3-min | 43% | **PASS** | 100% success | +| 6 | Profile B (5-step nav, ~24s) | 3-min | - | **PARTIAL** | 67% success, network variability | +| 4 | Profile C rerun (`timeout: null`, `domcontentloaded`) | 3-min | - | **PASS** | 12 up / 0 down | +| 23 | Profile D (3-step nav + waits, ~21s) | 10-min | - | **PASS** | 100% on 2nd cycle | +| 12 | Profile C rerun (`timeout: null`, `domcontentloaded`) | 10-min | - | **PASS** | 30 up / 0 down | + +#### SYNTHETICS_LIMIT_BROWSER Tests + +| Configuration | CPU | Memory | Completed Checks | Up | Down | Notes | +|---------------|-----|--------|------------------|----|------|-------| +| `SYNTHETICS_LIMIT_BROWSER=2` | 2 cores | 2 GB | 64 | 64 | 0 | Max observed concurrency: 2 | +| `SYNTHETICS_LIMIT_BROWSER=4` | 2 cores | 2 GB | 60 | 60 | 0 | Max observed concurrency: 4; higher p50/p95 | + +### Cycle Spread Analysis + +| Monitors | Concurrent | Cycle Spread | Gap | Status | +|----------|------------|--------------|-----|--------| +| 5 | 2 | ~3s | 3m | OK | +| 10 | 2 | ~20s | 3m | OK | +| 25 | 2 | ~82s | 3m | OK | +| 50 | 2 | ~157s | 3m | OK (87% capacity) | +| 60 | 2 | >180s | 6-8m | **FAIL** | + +> [!TIP] +> The agent runs 2 concurrent browsers (matching 2 CPU cores). As monitors increase, cycle spread grows. The breaking point occurs when cycle spread exceeds the schedule interval (180s for 3-min schedule). + +--- + +## 5. Summary & Recommendations + +After running all the tests above, patterns emerged in how monitors behave at different capacities. The relationship between monitor count, journey duration, and cycle spread became clear, allowing the derivation of a predictable capacity formula. + +### The Capacity Formula + +Based on the empirical data collected from our tests: + +``` +max_monitors = capacity_constant / avg_duration_seconds + +Where: capacity_constant = (schedule_interval × concurrency) / overhead_factor +``` + +#### Formula Components Explained + +| Component | Value | Explanation | +|-----------|-------|-------------| +| **schedule_interval** | 180s | The monitor schedule (3 minutes = 180 seconds). This is the time window in which all monitors must complete one cycle. | +| **concurrency** | CPU cores | Number of browsers running in parallel. With 2 CPU cores, the agent runs 2 browsers concurrently. | +| **overhead_factor** | 1.4 | Empirically measured overhead for browser startup, scheduling delays, and context switching. Derived from: actual_cycle_spread / theoretical_cycle_spread. | +| **avg_duration_seconds** | varies | Average time for a single monitor journey to complete (e.g., 4.5s for simple, 24s for complex). | + +#### Observed Overhead by Test + +The overhead factor was calculated for each test using: +``` +Theoretical = (monitors × avg_duration) / concurrency +Observed Overhead = Actual Cycle Spread / Theoretical +``` + +**Test 4.3: 25 Browser Monitors (Simple Journey)** + +| Metric | Value | +|--------|-------| +| Monitors | 25 | +| Avg Duration | ~4.0s | +| Cycle Spread | 82s (14:41:30 → 14:42:52) | +| Theoretical | (25 × 4.0) / 2 = **50s** | +| **Observed Overhead** | 82 / 50 = **1.64** | + +--- + +**Test 4.4: 50 Browser Monitors (Simple Journey)** + +| Metric | Value | +|--------|-------| +| Monitors | 50 | +| Avg Duration | ~4.5s | +| Cycle Spread | 157s | +| Theoretical | (50 × 4.5) / 2 = **112.5s** | +| **Observed Overhead** | 157 / 112.5 = **1.40** | + +--- + +**Test 4.6: 10 Monitors (Complex Journey ~24s)** + +| Metric | Value | +|--------|-------| +| Monitors | 10 | +| Avg Duration | ~24s | +| Cycle Spread | 188s (15:44:57 → 15:48:05) | +| Theoretical | (10 × 24) / 2 = **120s** | +| **Observed Overhead** | 188 / 120 = **1.57** | + +--- + +**Test 4.7: 6 Monitors (Heavy Journey ~37s)** + +| Metric | Value | +|--------|-------| +| Monitors | 6 | +| Avg Duration | ~37s | +| Cycle Spread | 123s (16:11:32 → 16:13:35) | +| Theoretical | (6 × 37) / 2 = **111s** | +| **Observed Overhead** | 123 / 111 = **1.11** | + +--- + +**Test 4.9: 35 Monitors (Medium Journey ~21s, 10-min schedule)** + +| Metric | Value | +|--------|-------| +| Monitors | 35 | +| Avg Duration | ~21s | +| Cycle Spread | 400s (18:46:51 → 18:53:31) | +| Theoretical | (35 × 21) / 2 = **367.5s** | +| **Observed Overhead** | 400 / 367.5 = **1.09** | + +--- + +**Summary: Observed Overhead Across All Tests** + +| Test | Monitors | Avg Duration | Cycle Spread | Theoretical | **Observed Overhead** | +|------|----------|--------------|--------------|-------------|----------------------| +| 4.3 (Simple) | 25 | 4.0s | 82s | 50s | **1.64** | +| 4.4 (Simple) | 50 | 4.5s | 157s | 112.5s | **1.40** | +| 4.6 (Complex) | 10 | 24s | 188s | 120s | **1.57** | +| 4.7 (Heavy) | 6 | 37s | 123s | 111s | **1.11** | +| 4.9 (Medium, 10-min) | 35 | 21s | 400s | 367.5s | **1.09** | +| 4.11 (Heavy, 120s timeout) | 7 | 54s | 163s | 189s | **0.86** | + +> [!NOTE] +> **Key Observation**: The overhead factor varies from **0.86 to 1.64** depending on workload: +> - **Higher overhead (1.5-1.6)**: Fewer monitors, shorter durations → more browser startups relative to execution time +> - **Lower overhead (0.86-1.1)**: More monitors, longer durations → browser startup cost is amortized +> +> The **1.4 average** used in the formula provides a **conservative** estimate that works across all scenarios. + +#### Formula Breakdown + +> [!INFO] +> **Think of it as "browser-seconds" budgeting:** + +1. **schedule_interval × concurrency** = Total "browser-seconds" available per cycle + - Example: 180s × 2 cores = 360 browser-seconds + +2. **Divide by overhead_factor** = Usable browser-seconds after accounting for overhead + - Example: 360 / 1.4 = 257 usable browser-seconds + +3. **Divide by avg_duration** = Number of monitors that fit in the available time + - Example: 257 / 4.5s = 57 monitors + +### Formula Validation: Test Results vs Predictions (Historical Baseline) + +| Test | Schedule | Duration | Timeout | Monitors | Formula Predicts | Actual Result | Match | +|------|----------|----------|---------|----------|------------------|---------------|-------| +| Simple journey | 3 min | 4.5s | 46s | 50 | 57 max | PASS | Yes | +| Simple journey | 3 min | 4.5s | 46s | 60 | 57 max | FAIL (8 pending) | Yes | +| Complex journey | 3 min | 24s | 46s | 10 | 10.7 max | At limit (1 down) | Yes | +| Heavy journey | 3 min | 37s | 46s | 6 | 6.9 max | At limit (1 down, 1 pending) | Yes* | +| Medium journey | 10 min | 21s | 46s | 35 | 40 max | PASS (100%) | Yes | +| **Heavy journey** | **3 min** | **54s** | **120s** | **7** | **7 max** | **PASS (100%)** | **Yes** | + +*Heavy journey at 46s timeout was limited by timeout, not capacity + +> [!TIP] +> In the historical baseline dataset, the formula predicted capacity well across tested schedules/journeys. +> Final reruns should still be interpreted with script/wait strategy context. + +### Capacity Constant by Agent Size + +| Agent Config | Concurrency | Capacity Constant | Formula | +|--------------|-------------|-------------------|---------| +| 2 GiB / 2 CPU | 2 | **257** | (180 × 2) / 1.4 | +| 4 GiB / 4 CPU | 4 | **514** | (180 × 4) / 1.4 | +| 6 GiB / 6 CPU | 6 | **771** | (180 × 6) / 1.4 | +| 8 GiB / 8 CPU | 8 | **1028** | (180 × 8) / 1.4 | + +### Max Monitors by Agent Size and Journey Duration + +| Journey Duration | 2 CPU (257) | 4 CPU (514) | 6 CPU (771) | 8 CPU (1028) | +|------------------|-------------|-------------|-------------|--------------| +| 4.5s (simple) | 57 | 114 | 171 | 228 | +| 24s (complex) | 10 | 21 | 32 | 43 | +| 37s (heavy) | 7 | 14 | 21 | 28 | + +> [!NOTE] +> Memory requirement = 2 GiB per CPU core (for browser monitors) + +### Capacity by Journey Complexity + +| Script Profile | Steps | Avg Duration | Max Monitors | Notes | +|--------------|-------|--------------|--------------|-------| +| Profile A (simple 1-step nav) | 1 | ~4.5s | ~57 | Single page load | +| Profile B (5-step nav) | 5 | ~24s | ~10 | Multi-page navigation | +| Profile C (5-step nav + waits, baseline) | 5 + waits | ~37s | ~7 | Baseline profile | +| Profile E (8-step nav + waits, baseline) | 8 + waits | ~120s | N/A | Sensitive to wait strategy / timeout config | + +### Capacity by Schedule Interval (2 CPU Agent) + +| Schedule | Interval (s) | Capacity Constant | Max Monitors (4.5s) | Max Monitors (24s) | Max Monitors (37s) | +|----------|--------------|-------------------|---------------------|--------------------|--------------------| +| 3 min | 180 | **257** | 57 | 10 | 7 | +| 5 min | 300 | **428** | 95 | 17 | 11 | +| 10 min | 600 | **857** | 190 | 35 | 23 | +| 15 min | 900 | **1286** | 285 | 53 | 34 | +| 20 min | 1200 | **1714** | 380 | 71 | 46 | +| 30 min | 1800 | **2571** | 571 | 107 | 69 | + +> [!NOTE] +> Longer schedules increase capacity linearly. A 10-minute schedule allows ~3.3x more monitors than a 3-minute schedule. + +### How to Use the Formula + +Before calculating capacity, follow these two steps: + +#### Step 1: Check Timeout Configuration and Step-Level Waits + +Do not assume a universal browser default timeout of `46s`. +In these tests, timeout behavior differed by monitor configuration and by operation-level browser waits. + +| Journey Duration / Behavior | Timeout Risk | Action Required | +|------------------|--------------|-----------------| +| < 30s and stable loads | Low | Proceed to Step 2 | +| 30-60s with `networkidle` waits | Medium | Watch for Playwright `50000ms` step timeout | +| Frequent `waitForLoadState` timeouts | High | Adjust wait strategy / target page / load | +| Monitor-level `CMD_TIMEOUT` observed | High | Revisit monitor timeout configuration | + +> [!WARNING] +> Two different timeout classes can fail a run: monitor-level timeout (`CMD_TIMEOUT`) and browser operation timeout (`Timeout 50000ms exceeded`). + +#### Step 2: Calculate Capacity + +Once your journey duration is within the timeout limit: + +``` +capacity_constant = (schedule_interval_seconds × CPU_cores) / 1.4 +max_monitors = capacity_constant / avg_journey_duration +``` + +#### User Recommendation Table + +| Journey Duration | Timeout Risk | 3-min Schedule (257) | 10-min Schedule (857) | Recommendation | +|------------------|--------------|----------------------|-----------------------|----------------| +| ~5s (simple) | None | 51 monitors | 171 monitors | Safe zone | +| ~10s | None | 25 monitors | 85 monitors | Safe zone | +| ~20s | None | 12 monitors | 42 monitors | Safe zone | +| ~30s | Low | 8 monitors | 28 monitors | Monitor memory usage | +| ~40s | Medium | 6 monitors | 21 monitors | Test with chosen wait strategy under load | +| ~45s | Medium-High | 5 monitors | 19 monitors | Validate step waits and timeout config | +| >46s | Context-dependent | N/A | N/A | Requires explicit timeout/wait strategy validation | + +> [!IMPORTANT] +> **Two constraints govern capacity:** +> 1. **Timeout constraint**: Monitor-level timeout must be configured appropriately for the journey. +> 2. **Capacity constraint**: Total cycle spread must fit within schedule interval +> +> Check timeout first, then calculate capacity. + +### Key Observations + +> [!TIP] +> **The Magic Formula**: `max_monitors = capacity_constant / avg_duration` where `capacity_constant = (schedule_interval × CPUs) / 1.4` + +1. **Concurrent browsers = CPU cores**: With 2 CPU cores, agent runs 2 browsers concurrently +2. **Duration is the key factor**: Simple journeys (~4.5s) allow ~57 monitors; complex journeys (~24s) allow ~10 monitors +3. **Formula is validated**: Tested across 3 different journey durations with consistent results +4. **Breaking point is predictable**: When `cycle_spread > schedule_interval`, monitors go pending + +> [!WARNING] +> **Timeout note**: Initial reruns with `networkidle` showed Playwright `50000ms` operation timeouts. +> Final reruns with `domcontentloaded` were stable for the targeted scenarios. + +--- + +## 6. The Bottom Line + +Through empirical testing, a reliable formula has been validated to predict how many browser monitors a single agent can handle. + +### The Capacity Formula + +``` +Max Monitors = (Schedule Interval × CPU Cores) / (Overhead Factor × Average Duration) +``` + +* **Schedule Interval**: In seconds (e.g., 180s for 3 minutes). +* **CPU Cores**: The number of cores assigned to the container (e.g., 2). +* **Average Duration**: How long one monitor run takes (in seconds). +* **Overhead Factor**: The "safety margin" variable (see below). + +### 1. Which Overhead Factor Should You Use? + +The reports tested two factors. Choose based on your risk tolerance: + +* **Use 2.5 (Recommended for Production)**: + * **Why**: Provides a ~50% safety buffer. Keeps CPU usage stable and handles network variability. + * **Result**: 100% reliability in tests. + * **Example**: On a 2-core agent with a 3-minute schedule, you can safely run **32 simple monitors** (4.5s duration). + +* **Use 1.4 (Aggressive / Max Capacity)**: + * **Why**: Pushes the agent to the absolute limit (200% CPU usage on 2 cores). Zero room for error. + * **Result**: Validated as the "breaking point" in tests. + * **Example**: On the same setup, you can squeeze in **50-57 simple monitors**, but any hiccup will cause gaps. + +### 2. Timeout Behavior to Plan For + +There are two independent timeout behaviors to consider: + +* **Monitor-level timeout (`CMD_TIMEOUT`)**: depends on monitor configuration. +* **Browser operation timeout (`50000ms`)**: can fail an individual Playwright operation such as `page.waitForLoadState()`. + +* **Rerun finding**: with browser monitor `timeout: null`, initial `networkidle` reruns showed `Timeout 50000ms exceeded` failures, while follow-up `domcontentloaded` reruns for the same scenarios were stable. +* **Practical fix options**: + 1. Keep monitor timeout configuration explicit and environment-appropriate. + 2. Reduce brittle waits (`networkidle`), simplify waits, or use a more stable target for scalability testing. + 3. Reduce concurrency pressure on constrained CPU when testing heavy journeys. + +> [!WARNING] +> **Nested Timeout**: Even after increasing the monitor timeout, you may hit a **Playwright/Chromium-level timeout of 50000ms (50s)** for operations like `page.waitForLoadState()`. This is a separate timeout within the browser automation layer. If your individual step operations exceed 50s, you'll see errors like `page.waitForLoadState: Timeout 50000ms exceeded`. + +### 3. Reliability & Trustworthiness + +The data is highly reliable because it was cross-validated: +1. **Consistency**: The formula accurately predicted the breaking point across different schedules (3-min vs 10-min) and journey types (Simple vs Complex). +2. **Concurrency**: Confirmed that the agent runs exactly **N** browsers concurrently, where N = CPU cores. +3. **Failure Mode**: The failure mechanisms are distinguishable. + * **Capacity Failure**: Monitors go "Pending" (queue builds up). + * **Monitor Timeout Failure**: Monitors go "Down" with `CMD_TIMEOUT`. + * **Browser Step Timeout Failure**: Monitors go "Down" with `page.waitForLoadState: Timeout 50000ms exceeded`. + +--- + +## Appendix: Commands Reference + +### Check pending monitors +```bash +curl -s -u "elastic:changeme" \ + "http://localhost:5601/s/scalability-test/internal/synthetics/overview_status" \ + -H "kbn-xsrf: true" -H "x-elastic-internal-origin: kibana" \ + | jq '{pending: .pending, up: .up, down: .down}' +``` + +### Monitor agent resources +```bash +docker stats --no-stream +``` + +### Create monitors +```bash +node x-pack/scripts/synthetics_forge.js \ + --kibana-url http://localhost:5601 \ + --space scalability-test \ + --private-location-id \ + --browser 10 +``` + +### Cleanup monitors +```bash +node x-pack/scripts/synthetics_forge.js cleanup \ + --kibana-url http://localhost:5601 \ + --space scalability-test +``` + +### Query monitor execution history +```bash +curl -s -u "elastic:changeme" "http://localhost:9200/synthetics-*/_search" \ + -H "Content-Type: application/json" -d '{ + "size": 50, + "query": { + "bool": { + "filter": [ + { "term": { "monitor.type": "browser" } }, + { "term": { "synthetics.type": "heartbeat/summary" } }, + { "term": { "summary.final_attempt": true } }, + { "range": { "@timestamp": { "gte": "now-15m" } } } + ] + } + }, + "sort": [{ "@timestamp": "desc" }], + "_source": ["@timestamp", "monitor.name", "monitor.duration", "monitor.status"] +}' +``` + +**Query explanation:** +- `monitor.type: browser` - Filter for browser monitors only +- `synthetics.type: heartbeat/summary` - Get the final summary document (not intermediate steps) +- `summary.final_attempt: true` - Only the final attempt (excludes retries) +- `@timestamp: now-15m` - Last 15 minutes of data +- `monitor.duration.us` - Execution duration in microseconds + +--- + +## Journey Scripts Tested + +> [!NOTE] +> The scripts below are historical baseline scripts (`networkidle`) used for original sections. +> Rerun sections used `domcontentloaded` for targeted heavy-profile validation. + +### Simple Journey (~4.5s) +```javascript +step('Navigate to Elastic', async () => { + await page.goto('https://www.elastic.co'); +}); +``` + +### Complex Journey (~24s) +```javascript +step('Homepage', async () => { + await page.goto('https://www.elastic.co'); + await page.waitForLoadState('networkidle'); +}); +step('Products', async () => { + await page.goto('https://www.elastic.co/products'); + await page.waitForLoadState('networkidle'); +}); +step('Elasticsearch', async () => { + await page.goto('https://www.elastic.co/elasticsearch'); + await page.waitForLoadState('networkidle'); +}); +step('Observability', async () => { + await page.goto('https://www.elastic.co/observability'); + await page.waitForLoadState('networkidle'); +}); +step('Pricing', async () => { + await page.goto('https://www.elastic.co/pricing'); + await page.waitForLoadState('networkidle'); +}); +``` + +### Medium Journey (~21s) - Used for 10-minute validation +```javascript +step('Homepage', async () => { + await page.goto('https://www.elastic.co'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +step('Products', async () => { + await page.goto('https://www.elastic.co/products'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +step('Elasticsearch', async () => { + await page.goto('https://www.elastic.co/elasticsearch'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +``` + +### Heavy Journey (~37s) - Near max before timeout +```javascript +step('Homepage', async () => { + await page.goto('https://www.elastic.co'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +step('Products', async () => { + await page.goto('https://www.elastic.co/products'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +step('Elasticsearch', async () => { + await page.goto('https://www.elastic.co/elasticsearch'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +step('Observability', async () => { + await page.goto('https://www.elastic.co/observability'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +step('Pricing', async () => { + await page.goto('https://www.elastic.co/pricing'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); +}); +``` + +### Very Heavy Journey (~120s) - Exceeds default timeout +```javascript +step('Homepage', async () => { + await page.goto('https://www.elastic.co'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +step('Products', async () => { + await page.goto('https://www.elastic.co/products'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +step('Elasticsearch', async () => { + await page.goto('https://www.elastic.co/elasticsearch'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +step('Observability', async () => { + await page.goto('https://www.elastic.co/observability'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +step('Security', async () => { + await page.goto('https://www.elastic.co/security'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +step('Kibana', async () => { + await page.goto('https://www.elastic.co/kibana'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +step('Integrations', async () => { + await page.goto('https://www.elastic.co/integrations'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +step('Pricing', async () => { + await page.goto('https://www.elastic.co/pricing'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(10000); +}); +``` diff --git a/x-pack/solutions/observability/test/ensemble/synthetics/scalability.yaml b/x-pack/solutions/observability/test/ensemble/synthetics/scalability.yaml new file mode 100644 index 0000000000000..1ac70e5d6d3c5 --- /dev/null +++ b/x-pack/solutions/observability/test/ensemble/synthetics/scalability.yaml @@ -0,0 +1,247 @@ +name: Synthetics Scalability Test +description: Scalability test for Synthetics monitors + +params: + - name: github_token + description: GitHub authentication token + type: exec + command: gh + args: + - auth + - token + + - name: github_username + description: GitHub Username + type: exec + command: gh + args: + - api + - user + - -q + - ".login" + + - name: github_email + description: GitHub email + type: exec + command: git + args: + - config + - user.email + + - name: gcp_app_credentials + description: GCP application default credentials JSON + type: automation + automation: gcp_auth + + - name: cleanup_after (y/n) + description: "Delete cluster and agent when done?" + type: string + + - name: http + description: HTTP monitors to create + type: string + + - name: tcp + description: TCP monitors to create + type: string + + - name: icmp + description: ICMP monitors to create + type: string + + - name: browser + description: Browser monitors to create + type: string + +steps: + - name: Find latest kibana version + id: artifact + type: services.artifacts.search + with: + channel: "unstable" + github_token: "{{ param.github_token }}" + + - name: Create cluster + id: cluster + type: providers.oblt_cli.create_custom_cluster + with: + gcp_app_credentials: ".secrets/credentials.json" + github_username: "{{ param.github_username }}" + github_email: "{{ param.github_email }}" + github_token: "{{ param.github_token }}" + template: ess + parameters: + StackVersion: "{{ step.artifact.data.version }}" + ElasticsearchDockerImage: "{{ step.artifact.data.docker.elasticsearch }}" + KibanaDockerImage: "{{ step.artifact.data.docker.kibana }}" + EnableIntegrations: true + EphemeralCluster: true + ExpiryDate: '{{ now() + timedelta(hours=6) }}' + resources: + - type: file + content: "{{ param.gcp_app_credentials }}" + filename: ".secrets/credentials.json" + retry: + max: 3 + interval: 5 + + - name: Run Synthetics Forge + id: run_forge + type: providers.shell.run + with: + script_file: run_forge.sh + resources: + - type: env + variables: + KIBANA_URL: "{{ step.cluster.data.kibana_url }}" + KIBANA_USERNAME: "{{ step.cluster.data.kibana_username }}" + KIBANA_PASSWORD: "{{ step.cluster.data.kibana_password }}" + HTTP: "{{ param.http }}" + TCP: "{{ param.tcp }}" + ICMP: "{{ param.icmp }}" + BROWSER: "{{ param.browser }}" + FORGE_ACTION: "create" + OUTPUT_FILE: "/tmp/forge_output.json" + - type: file + filename: run_forge.sh + content: | + #!/bin/bash + set -e + echo "KIBANA_URL: $KIBANA_URL" + echo "KIBANA_USERNAME: $KIBANA_USERNAME" + echo "KIBANA_PASSWORD: $KIBANA_PASSWORD" + node /Users/faisal/elastic/kibana/x-pack/scripts/synthetics_forge.js + echo "Forge output:" + cat $OUTPUT_FILE + + - name: Wait for enrollment token to be active + type: builtin.wait + with: + time: 10 + + - name: Deploy Elastic Agent + id: deploy_agent + type: providers.shell.run + with: + script_file: deploy_agent.sh + resources: + - type: env + variables: + FLEET_URL: "{{ step.cluster.data.fleet_url }}" + OUTPUT_FILE: "/tmp/forge_output.json" + - type: file + filename: deploy_agent.sh + content: | + #!/bin/bash + set -e + + AGENT_VERSION="8.17.1" + ENROLLMENT_TOKEN=$(jq -r '.enrollmentToken' $OUTPUT_FILE) + + echo "Fleet URL: $FLEET_URL" + echo "Agent Version: $AGENT_VERSION" + echo "Enrollment Token: $ENROLLMENT_TOKEN" + + docker rm -f synthetics-agent 2>/dev/null || true + + # Use elastic-agent-complete for browser monitor support (includes Chromium/Playwright) + docker run -d \ + --name synthetics-agent \ + -e FLEET_URL="$FLEET_URL" \ + -e FLEET_ENROLLMENT_TOKEN="$ENROLLMENT_TOKEN" \ + -e FLEET_ENROLL=1 \ + -e FLEET_INSECURE=true \ + docker.elastic.co/beats/elastic-agent-complete:$AGENT_VERSION + + sleep 15 + echo "Elastic Agent container started" + docker logs synthetics-agent 2>&1 | grep -i "enroll\|success" | head -5 || true + + - name: Wait for agent enrollment + type: builtin.wait + with: + time: 20 + + - name: Verify agent health + type: providers.shell.run + with: + script_file: verify_agent.sh + resources: + - type: env + variables: + KIBANA_URL: "{{ step.cluster.data.kibana_url }}" + KIBANA_USERNAME: "{{ step.cluster.data.kibana_username }}" + KIBANA_PASSWORD: "{{ step.cluster.data.kibana_password }}" + - type: file + filename: verify_agent.sh + content: | + #!/bin/bash + echo "Agent enrollment status:" + docker logs synthetics-agent 2>&1 | grep -i "enroll" | tail -3 || true + echo "" + AGENT_COUNT=$(curl -s -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/api/fleet/agents" -H "kbn-xsrf: true" | jq '.items | length') + echo "Agents enrolled: $AGENT_COUNT" + + - name: Cleanup agent container + type: providers.shell.run + when: always + with: + script_file: cleanup_agent.sh + resources: + - type: env + variables: + CLEANUP: "{{ param['cleanup_after (y/n)'] }}" + - type: file + filename: cleanup_agent.sh + content: | + #!/bin/bash + if [ "$CLEANUP" != "y" ]; then + echo "Skipping agent cleanup (cleanup_after=$CLEANUP)" + echo "Agent container 'synthetics-agent' is still running" + exit 0 + fi + echo "Stopping and removing Elastic Agent container..." + docker rm -f synthetics-agent 2>/dev/null || true + echo "Agent cleanup complete" + + - name: Cleanup cluster + type: providers.shell.run + when: always + with: + script_file: cleanup_cluster.sh + resources: + - type: env + variables: + CLEANUP: "{{ param['cleanup_after (y/n)'] }}" + CLUSTER_NAME: "{{ step.cluster.data.cluster_name | default('') }}" + GITHUB_USERNAME: "{{ param.github_username }}" + GITHUB_EMAIL: "{{ param.github_email }}" + GITHUB_TOKEN: "{{ param.github_token }}" + - type: file + content: "{{ param.gcp_app_credentials }}" + filename: ".secrets/credentials.json" + - type: file + filename: cleanup_cluster.sh + content: | + #!/bin/bash + export GOOGLE_APPLICATION_CREDENTIALS=".secrets/credentials.json" + + git config --global user.name "$GITHUB_USERNAME" + git config --global user.email "$GITHUB_EMAIL" + + if [ "$CLEANUP" != "y" ]; then + echo "Skipping cluster deletion (cleanup_after=$CLEANUP)" + echo "Cluster '$CLUSTER_NAME' is still running" + exit 0 + fi + + if [ -z "$CLUSTER_NAME" ]; then + echo "No cluster name found, skipping deletion" + exit 0 + fi + + echo "Destroying cluster: $CLUSTER_NAME" + oblt-cli cluster destroy --cluster-name "$CLUSTER_NAME" --force + echo "Cluster destroyed" + diff --git a/x-pack/solutions/observability/test/ensemble/synthetics/scalability_dev.yaml b/x-pack/solutions/observability/test/ensemble/synthetics/scalability_dev.yaml new file mode 100644 index 0000000000000..508edad4bf413 --- /dev/null +++ b/x-pack/solutions/observability/test/ensemble/synthetics/scalability_dev.yaml @@ -0,0 +1,298 @@ +name: Synthetics Scalability Test (Dev) +description: Dev version - reuses existing cluster, starts at Forge step + +params: + - name: github_token + description: GitHub authentication token + type: exec + command: gh + args: + - auth + - token + + - name: github_username + description: GitHub Username + type: exec + command: gh + args: + - api + - user + - -q + - ".login" + + - name: github_email + description: GitHub email + type: exec + command: git + args: + - config + - user.email + + - name: gcp_app_credentials + description: GCP application default credentials JSON + type: automation + automation: gcp_auth + + - name: cluster_name + description: "Existing cluster name (e.g. ensemble-ess-xxxxx)" + type: string + + - name: http + description: HTTP monitors to create + type: string + + - name: tcp + description: TCP monitors to create + type: string + + - name: icmp + description: ICMP monitors to create + type: string + + - name: browser + description: Browser monitors to create + type: string + +steps: + - name: Get existing cluster + id: cluster + type: providers.oblt_cli.search_cluster + with: + gcp_app_credentials: ".secrets/credentials.json" + github_username: "{{ param.github_username }}" + github_email: "{{ param.github_email }}" + github_token: "{{ param.github_token }}" + cluster_name: "{{ param.cluster_name }}" + resources: + - type: file + content: "{{ param.gcp_app_credentials }}" + filename: ".secrets/credentials.json" + + - name: Cleanup existing agent container + type: providers.shell.run + with: + script_file: cleanup_existing_agent.sh + resources: + - type: file + filename: cleanup_existing_agent.sh + content: | + #!/bin/bash + echo "Removing any existing synthetics-agent container..." + docker rm -f synthetics-agent 2>/dev/null || true + echo "Agent container cleanup complete" + + - name: Cleanup existing synthetics resources + type: providers.shell.run + with: + script_file: cleanup_existing_synthetics.sh + resources: + - type: env + variables: + KIBANA_URL: "{{ step.cluster.data.kibana_url }}" + KIBANA_USERNAME: "{{ step.cluster.data.kibana_username }}" + KIBANA_PASSWORD: "{{ step.cluster.data.kibana_password }}" + FORGE_ACTION: "cleanup" + - type: file + filename: cleanup_existing_synthetics.sh + content: | + #!/bin/bash + + PREFIX="scalability-test" + POLICY_NAME="${PREFIX}-policy" + LOCATION_NAME="${PREFIX}-location" + SPACE_ID="${PREFIX}" + + echo "=== Cleanup resources with prefix: $PREFIX ===" + + echo "" + echo "Step 1: Run forge cleanup (monitors)" + node /Users/faisal/elastic/kibana/x-pack/scripts/synthetics_forge.js || true + + echo "" + echo "Step 2: Unenroll and delete agents from $POLICY_NAME" + POLICY_ID=$(curl -s -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/api/fleet/agent_policies" \ + -H "kbn-xsrf: true" | jq -r ".items[] | select(.name == \"$POLICY_NAME\") | .id") + + if [ -n "$POLICY_ID" ] && [ "$POLICY_ID" != "null" ]; then + echo " Found policy: $POLICY_ID" + AGENT_IDS=$(curl -s -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/api/fleet/agents?kuery=policy_id:$POLICY_ID" \ + -H "kbn-xsrf: true" | jq -r '.items[].id') + + for AGENT_ID in $AGENT_IDS; do + echo " Deleting agent: $AGENT_ID" + curl -s -X POST -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/api/fleet/agents/$AGENT_ID/unenroll" \ + -H "kbn-xsrf: true" -H "Content-Type: application/json" \ + -d '{"force": true}' > /dev/null + sleep 1 + curl -s -X DELETE -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/api/fleet/agents/$AGENT_ID" \ + -H "kbn-xsrf: true" > /dev/null + done + else + echo " No policy found" + fi + + echo "" + echo "Step 3: Delete private location $LOCATION_NAME" + LOCATION_ID=$(curl -s -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/s/$SPACE_ID/api/synthetics/private_locations" \ + -H "kbn-xsrf: true" | jq -r ".[] | select(.label == \"$LOCATION_NAME\") | .id") + + if [ -n "$LOCATION_ID" ] && [ "$LOCATION_ID" != "null" ]; then + echo " Deleting: $LOCATION_ID" + curl -s -X DELETE -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/s/$SPACE_ID/api/synthetics/private_locations/$LOCATION_ID" \ + -H "kbn-xsrf: true" > /dev/null + else + echo " No location found" + fi + + echo "" + echo "Step 4: Delete agent policy $POLICY_NAME" + if [ -n "$POLICY_ID" ] && [ "$POLICY_ID" != "null" ]; then + echo " Deleting: $POLICY_ID" + curl -s -X POST -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/api/fleet/agent_policies/delete" \ + -H "kbn-xsrf: true" -H "Content-Type: application/json" \ + -d "{\"agentPolicyId\": \"$POLICY_ID\", \"force\": true}" > /dev/null + else + echo " No policy to delete" + fi + + echo "" + echo "=== Cleanup complete ===" + + - name: Run Synthetics Forge + id: run_forge + type: providers.shell.run + with: + script_file: run_forge.sh + resources: + - type: env + variables: + KIBANA_URL: "{{ step.cluster.data.kibana_url }}" + KIBANA_USERNAME: "{{ step.cluster.data.kibana_username }}" + KIBANA_PASSWORD: "{{ step.cluster.data.kibana_password }}" + HTTP: "{{ param.http }}" + TCP: "{{ param.tcp }}" + ICMP: "{{ param.icmp }}" + BROWSER: "{{ param.browser }}" + FORGE_ACTION: "create" + OUTPUT_FILE: "/tmp/forge_output.json" + - type: file + filename: run_forge.sh + content: | + #!/bin/bash + set -e + echo "KIBANA_URL: $KIBANA_URL" + echo "KIBANA_USERNAME: $KIBANA_USERNAME" + echo "KIBANA_PASSWORD: $KIBANA_PASSWORD" + node /Users/faisal/elastic/kibana/x-pack/scripts/synthetics_forge.js + echo "Forge output:" + cat $OUTPUT_FILE + + - name: Wait for enrollment token to be active + type: builtin.wait + with: + time: 10 + + - name: Deploy Elastic Agent + id: deploy_agent + type: providers.shell.run + with: + script_file: deploy_agent.sh + resources: + - type: env + variables: + FLEET_URL: "{{ step.cluster.data.fleet_url }}" + OUTPUT_FILE: "/tmp/forge_output.json" + - type: file + filename: deploy_agent.sh + content: | + #!/bin/bash + set -e + + AGENT_VERSION="8.17.1" + ENROLLMENT_TOKEN=$(jq -r '.enrollmentToken' $OUTPUT_FILE) + + echo "Fleet URL: $FLEET_URL" + echo "Agent Version: $AGENT_VERSION" + echo "Enrollment Token: $ENROLLMENT_TOKEN" + + docker rm -f synthetics-agent 2>/dev/null || true + + # Use elastic-agent-complete for browser monitor support (includes Chromium/Playwright) + docker run -d \ + --name synthetics-agent \ + -e FLEET_URL="$FLEET_URL" \ + -e FLEET_ENROLLMENT_TOKEN="$ENROLLMENT_TOKEN" \ + -e FLEET_ENROLL=1 \ + -e FLEET_INSECURE=true \ + docker.elastic.co/beats/elastic-agent-complete:$AGENT_VERSION + + sleep 15 + echo "Elastic Agent container started" + docker logs synthetics-agent 2>&1 | grep -i "enroll\|success" | head -5 || true + + - name: Wait for agent enrollment + type: builtin.wait + with: + time: 20 + + - name: Verify agent health + type: providers.shell.run + with: + script_file: verify_agent.sh + resources: + - type: env + variables: + KIBANA_URL: "{{ step.cluster.data.kibana_url }}" + KIBANA_USERNAME: "{{ step.cluster.data.kibana_username }}" + KIBANA_PASSWORD: "{{ step.cluster.data.kibana_password }}" + - type: file + filename: verify_agent.sh + content: | + #!/bin/bash + echo "Agent enrollment status:" + docker logs synthetics-agent 2>&1 | grep -i "enroll" | tail -3 || true + echo "" + AGENT_COUNT=$(curl -s -u "$KIBANA_USERNAME:$KIBANA_PASSWORD" \ + "$KIBANA_URL/api/fleet/agents" -H "kbn-xsrf: true" | jq '.items | length') + echo "Agents enrolled: $AGENT_COUNT" + + # - name: Cleanup synthetics resources + # type: providers.shell.run + # when: always + # with: + # script_file: cleanup_synthetics.sh + # resources: + # - type: env + # variables: + # KIBANA_URL: "{{ step.cluster.data.kibana_url }}" + # KIBANA_USERNAME: "{{ step.cluster.data.kibana_username }}" + # KIBANA_PASSWORD: "{{ step.cluster.data.kibana_password }}" + # FORGE_ACTION: "cleanup" + # - type: file + # filename: cleanup_synthetics.sh + # content: | + # #!/bin/bash + # echo "Cleaning up synthetics resources..." + # node /Users/faisal/elastic/kibana/x-pack/scripts/synthetics_forge.js || true + # echo "Synthetics cleanup complete" + + # - name: Cleanup agent container + # type: providers.shell.run + # when: always + # with: + # script_file: cleanup_agent.sh + # resources: + # - type: file + # filename: cleanup_agent.sh + # content: | + # #!/bin/bash + # echo "Stopping and removing Elastic Agent container..." + # docker rm -f synthetics-agent 2>/dev/null || true + # echo "Agent cleanup complete" diff --git a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/telemetry/_telemetry.ts b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/telemetry/_telemetry.ts index 9f73c1793f133..d798ed1fc461b 100644 --- a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/telemetry/_telemetry.ts +++ b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/telemetry/_telemetry.ts @@ -89,7 +89,7 @@ export default function ({ getService, getPageObjects }: ObservabilityTelemetryF ]); // should reset the profiles when navigating away from Discover - await common.navigateToApp('home'); + await testSubjects.click('logo'); await retry.waitFor('home page to open', async () => { return await testSubjects.exists('homeApp'); }); diff --git a/x-pack/solutions/search/plugins/search_getting_started/test/scout/.meta/ui/parallel.json b/x-pack/solutions/search/plugins/search_getting_started/test/scout/.meta/ui/parallel.json index 9a00ab71c3105..d7d30d68a1b68 100644 --- a/x-pack/solutions/search/plugins/search_getting_started/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/search/plugins/search_getting_started/test/scout/.meta/ui/parallel.json @@ -1,21 +1,18 @@ { - "lastModified": "2026-02-04T19:00:17.940Z", - "sha1": "528585286818b11947d9b812de02e3c479f67634", + "sha1": "b62bb77d48c8edac0e8fd294bc9e27701d4749b8", "tests": [ { "id": "ace16ea93cb8904-1f34fdab863ed0b", "title": "Getting Started - Admin verifies page elements are rendered correctly", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-search", - "@cloud-serverless-search" + "@ess", + "@svlSearch" ], "location": { "file": "x-pack/solutions/search/plugins/search_getting_started/test/scout/ui/parallel_tests/getting_started_admin.spec.ts", - "line": 21, - "column": 9 + "line": 17, + "column": 7 } }, { @@ -23,15 +20,13 @@ "title": "Getting Started - Admin Add data button navigates to correct pages", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-search", - "@cloud-serverless-search" + "@ess", + "@svlSearch" ], "location": { "file": "x-pack/solutions/search/plugins/search_getting_started/test/scout/ui/parallel_tests/getting_started_admin.spec.ts", - "line": 90, - "column": 9 + "line": 94, + "column": 7 } }, { @@ -39,15 +34,13 @@ "title": "Getting Started - Admin Skip and go to Home button navigates to home page", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-search", - "@cloud-serverless-search" + "@ess", + "@svlSearch" ], "location": { "file": "x-pack/solutions/search/plugins/search_getting_started/test/scout/ui/parallel_tests/getting_started_admin.spec.ts", - "line": 109, - "column": 9 + "line": 113, + "column": 7 } }, { @@ -55,15 +48,13 @@ "title": "Getting Started - Admin Elasticsearch endpoint copy button shows feedback", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-search", - "@cloud-serverless-search" + "@ess", + "@svlSearch" ], "location": { "file": "x-pack/solutions/search/plugins/search_getting_started/test/scout/ui/parallel_tests/getting_started_admin.spec.ts", - "line": 114, - "column": 9 + "line": 118, + "column": 7 } }, { @@ -71,15 +62,13 @@ "title": "Getting Started - Admin Connection details flyout works correctly", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-search", - "@cloud-serverless-search" + "@ess", + "@svlSearch" ], "location": { "file": "x-pack/solutions/search/plugins/search_getting_started/test/scout/ui/parallel_tests/getting_started_admin.spec.ts", - "line": 123, - "column": 9 + "line": 127, + "column": 7 } }, { @@ -87,15 +76,13 @@ "title": "Getting Started - Admin Tutorial cards open embedded console", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-search", - "@cloud-serverless-search" + "@ess", + "@svlSearch" ], "location": { "file": "x-pack/solutions/search/plugins/search_getting_started/test/scout/ui/parallel_tests/getting_started_admin.spec.ts", - "line": 139, - "column": 9 + "line": 143, + "column": 7 } }, { @@ -103,15 +90,13 @@ "title": "Getting Started - Admin Language selector shows correct code examples", "expectedStatus": "passed", "tags": [ - "@local-stateful-classic", - "@cloud-stateful-classic", - "@local-serverless-search", - "@cloud-serverless-search" + "@ess", + "@svlSearch" ], "location": { "file": "x-pack/solutions/search/plugins/search_getting_started/test/scout/ui/parallel_tests/getting_started_admin.spec.ts", - "line": 161, - "column": 9 + "line": 166, + "column": 7 } }, { diff --git a/x-pack/solutions/search/plugins/search_homepage/moon.yml b/x-pack/solutions/search/plugins/search_homepage/moon.yml index a647a7418d4b3..5313aad8d17c4 100644 --- a/x-pack/solutions/search/plugins/search_homepage/moon.yml +++ b/x-pack/solutions/search/plugins/search_homepage/moon.yml @@ -54,6 +54,7 @@ dependsOn: - '@kbn/cloud' - '@kbn/agent-builder-plugin' - '@kbn/scout-search' + - '@kbn/management-settings-ids' tags: - plugin - prod diff --git a/x-pack/solutions/search/plugins/search_homepage/public/components/search_homepage/metric_panels.tsx b/x-pack/solutions/search/plugins/search_homepage/public/components/search_homepage/metric_panels.tsx index 1fbd6d6585055..1a709af2914c5 100644 --- a/x-pack/solutions/search/plugins/search_homepage/public/components/search_homepage/metric_panels.tsx +++ b/x-pack/solutions/search/plugins/search_homepage/public/components/search_homepage/metric_panels.tsx @@ -20,6 +20,7 @@ import { import { css } from '@emotion/react'; import type { SharePublicStart } from '@kbn/share-plugin/public/plugin'; import type { ApplicationStart } from '@kbn/core/public'; +import { DATA_SOURCES_ENABLED_SETTING_ID } from '@kbn/management-settings-ids'; import { WORKFLOWS_UI_SETTING_ENABLED_ID } from '../../../common'; import { useAssetBasePath } from '../../hooks/use_asset_base_path'; import { useKibana } from '../../hooks/use_kibana'; @@ -28,6 +29,7 @@ const PANEL_TYPES = [ 'discover', 'dashboards', 'agentBuilder', + 'dataSources', 'workflows', 'machineLearning', 'dataManagement', @@ -100,6 +102,21 @@ export const METRIC_PANEL_ITEMS: Array = [ dataTestSubj: 'searchHomepageNavLinks-agentBuilder', }, + { + type: 'dataSources' as const, + getImageUrl: (assetBasePath: string) => `${assetBasePath}/search_connect_visibility.svg`, + metricDescription: i18n.translate('xpack.searchHomepage.metricPanels.empty.dataSources.desc', { + defaultMessage: + 'Connect and manage external data sources like GitHub, Notion, and SharePoint to use directly in Agent Builder.', + }), + metricTitle: i18n.translate('xpack.searchHomepage.metricPanels.empty.dataSources.title', { + defaultMessage: 'Data Sources', + }), + onPanelClick: ({ application }) => { + application.navigateToApp('data_sources'); + }, + dataTestSubj: 'searchHomepageNavLinks-dataSources', + }, { type: 'workflows' as const, getImageUrl: (assetBasePath: string) => `${assetBasePath}/search_relevance.svg`, @@ -202,12 +219,14 @@ export const MetricPanels = () => { const { chrome, uiSettings } = services; const isWorkflowsUiEnabled = uiSettings.get(WORKFLOWS_UI_SETTING_ENABLED_ID, false); + const isDataSourcesEnabled = uiSettings.get(DATA_SOURCES_ENABLED_SETTING_ID, false); const panels = useMemo(() => { const capabilityChecks: Record = { discover: chrome.navLinks.get('discover') !== undefined, dashboards: chrome.navLinks.get('dashboards') !== undefined, agentBuilder: chrome.navLinks.get('agent_builder') !== undefined, + dataSources: isDataSourcesEnabled && chrome.navLinks.get('data_sources') !== undefined, workflows: isWorkflowsUiEnabled && chrome.navLinks.get('workflows') !== undefined, machineLearning: chrome.navLinks.get('ml:overview') !== undefined || chrome.navLinks.get('ml') !== undefined, @@ -215,7 +234,7 @@ export const MetricPanels = () => { }; return METRIC_PANEL_ITEMS.filter((panel) => capabilityChecks[panel.type]); - }, [chrome.navLinks, isWorkflowsUiEnabled]); + }, [chrome.navLinks, isWorkflowsUiEnabled, isDataSourcesEnabled]); return ( diff --git a/x-pack/solutions/search/plugins/search_homepage/test/scout/.meta/ui/parallel.json b/x-pack/solutions/search/plugins/search_homepage/test/scout/.meta/ui/parallel.json index 76a4490b68951..06de1778d6b8c 100644 --- a/x-pack/solutions/search/plugins/search_homepage/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/search/plugins/search_homepage/test/scout/.meta/ui/parallel.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-07T18:16:04.629Z", "sha1": "56e1d3ba196cdafb74fe057fa1c8174dd77beaed", "tests": [ { diff --git a/x-pack/solutions/search/plugins/search_homepage/tsconfig.json b/x-pack/solutions/search/plugins/search_homepage/tsconfig.json index 73e3a8643f917..61494deeaac3b 100644 --- a/x-pack/solutions/search/plugins/search_homepage/tsconfig.json +++ b/x-pack/solutions/search/plugins/search_homepage/tsconfig.json @@ -48,6 +48,7 @@ "@kbn/cloud", "@kbn/agent-builder-plugin", "@kbn/scout-search", + "@kbn/management-settings-ids", ], "exclude": [ "target/**/*", diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts index 1ac105be357fb..31907ef34fb22 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts @@ -110,3 +110,24 @@ export const INFERENCE_ENDPOINTS_TABLE_CAPTION = i18n.translate( defaultMessage: 'Inference endpoints table', } ); + +export const EMPTY_FILTER_MESSAGE = i18n.translate( + 'xpack.searchInferenceEndpoints.filter.emptyMessage', + { + defaultMessage: 'No options', + } +); + +export const GROUP_BY_NONE = i18n.translate( + 'xpack.searchInferenceEndpoints.groupBy.options.none.label', + { + defaultMessage: 'None', + } +); + +export const GROUP_BY_MODELS = i18n.translate( + 'xpack.searchInferenceEndpoints.groupBy.options.models.label', + { + defaultMessage: 'Models', + } +); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/__mocks__/inference_endpoints.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/__mocks__/inference_endpoints.ts new file mode 100644 index 0000000000000..e2da4642200e4 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/__mocks__/inference_endpoints.ts @@ -0,0 +1,331 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; + +export const InferenceEndpoints: InferenceAPIConfigResponse[] = [ + { + inference_id: '.anthropic-claude-3.7-sonnet-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'anthropic-claude-3.7-sonnet', + }, + }, + { + inference_id: '.anthropic-claude-3.7-sonnet-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'anthropic-claude-3.7-sonnet', + }, + }, + { + inference_id: '.anthropic-claude-4.5-sonnet-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'anthropic-claude-4.5-sonnet', + }, + }, + { + inference_id: '.anthropic-claude-4.5-sonnet-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'anthropic-claude-4.5-sonnet', + }, + }, + { + inference_id: '.elser-2-elastic', + task_type: 'sparse_embedding', + service: 'elastic', + service_settings: { + model_id: 'elser_model_2', + }, + chunking_settings: { + strategy: 'sentence', + max_chunk_size: 250, + sentence_overlap: 1, + }, + }, + { + inference_id: '.elser-2-elasticsearch', + task_type: 'sparse_embedding', + service: 'elasticsearch', + service_settings: { + num_threads: 1, + model_id: '.elser_model_2_linux-x86_64', + adaptive_allocations: { + enabled: true, + min_number_of_allocations: 0, + max_number_of_allocations: 32, + }, + }, + chunking_settings: { + strategy: 'sentence', + max_chunk_size: 250, + sentence_overlap: 1, + }, + }, + { + inference_id: '.google-gemini-2.5-flash-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'google-gemini-2.5-flash', + }, + }, + { + inference_id: '.google-gemini-2.5-flash-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'google-gemini-2.5-flash', + }, + }, + { + inference_id: '.google-gemini-2.5-pro-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'google-gemini-2.5-pro', + }, + }, + { + inference_id: '.google-gemini-2.5-pro-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'google-gemini-2.5-pro', + }, + }, + { + inference_id: '.google-gemini-embedding-001', + task_type: 'text_embedding', + service: 'elastic', + service_settings: { + model_id: 'google-gemini-embedding-001', + similarity: 'cosine', + dimensions: 768, + }, + chunking_settings: { + strategy: 'sentence', + max_chunk_size: 250, + sentence_overlap: 1, + }, + }, + { + inference_id: '.gp-llm-v2-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'gp-llm-v2', + }, + }, + { + inference_id: '.gp-llm-v2-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'gp-llm-v2', + }, + }, + { + inference_id: '.jina-embeddings-v3', + task_type: 'text_embedding', + service: 'elastic', + service_settings: { + model_id: 'jina-embeddings-v3', + similarity: 'cosine', + dimensions: 1024, + }, + chunking_settings: { + strategy: 'sentence', + max_chunk_size: 250, + sentence_overlap: 1, + }, + }, + { + inference_id: '.jina-reranker-v2-base-multilingual', + task_type: 'rerank', + service: 'elastic', + service_settings: { + model_id: 'jina-reranker-v2-base-multilingual', + }, + }, + { + inference_id: '.jina-reranker-v3', + task_type: 'rerank', + service: 'elastic', + service_settings: { + model_id: 'jina-reranker-v3', + }, + }, + { + inference_id: '.multilingual-e5-small-elasticsearch', + task_type: 'text_embedding', + service: 'elasticsearch', + service_settings: { + num_threads: 1, + model_id: '.multilingual-e5-small_linux-x86_64', + adaptive_allocations: { + enabled: true, + min_number_of_allocations: 0, + max_number_of_allocations: 32, + }, + }, + chunking_settings: { + strategy: 'sentence', + max_chunk_size: 250, + sentence_overlap: 1, + }, + }, + { + inference_id: '.openai-gpt-4.1-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-4.1', + }, + }, + { + inference_id: '.openai-gpt-4.1-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-4.1', + }, + }, + { + inference_id: '.openai-gpt-4.1-mini-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-4.1-mini', + }, + }, + { + inference_id: '.openai-gpt-4.1-mini-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-4.1-mini', + }, + }, + { + inference_id: '.openai-gpt-5.2-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-5.2', + }, + }, + { + inference_id: '.openai-gpt-5.2-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-5.2', + }, + }, + { + inference_id: '.openai-gpt-oss-120b-chat_completion', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-oss-120b', + }, + }, + { + inference_id: '.openai-gpt-oss-120b-completion', + task_type: 'completion', + service: 'elastic', + service_settings: { + model_id: 'openai-gpt-oss-120b', + }, + }, + { + inference_id: '.openai-text-embedding-3-large', + task_type: 'text_embedding', + service: 'elastic', + service_settings: { + model_id: 'openai-text-embedding-3-large', + similarity: 'cosine', + dimensions: 3072, + }, + chunking_settings: { + strategy: 'sentence', + max_chunk_size: 250, + sentence_overlap: 1, + }, + }, + { + inference_id: '.openai-text-embedding-3-small', + task_type: 'text_embedding', + service: 'elastic', + service_settings: { + model_id: 'openai-text-embedding-3-small', + similarity: 'cosine', + dimensions: 1536, + }, + chunking_settings: { + strategy: 'sentence', + max_chunk_size: 250, + sentence_overlap: 1, + }, + }, + { + inference_id: '.rainbow-sprinkles-elastic', + task_type: 'chat_completion', + service: 'elastic', + service_settings: { + model_id: 'rainbow-sprinkles', + }, + }, + { + inference_id: '.rerank-v1-elasticsearch', + task_type: 'rerank', + service: 'elasticsearch', + service_settings: { + num_threads: 1, + model_id: '.rerank-v1', + adaptive_allocations: { + enabled: true, + min_number_of_allocations: 0, + max_number_of_allocations: 32, + }, + }, + task_settings: { + return_documents: true, + }, + }, + { + inference_id: 'alibabacloud-endpoint-without-model-id', + task_type: 'completion', + service: 'alibabacloud-ai-search', + service_settings: { + api_key: 'test-api-key', + service_id: 'test-service-id', + host: 'test-host', + workspace: 'test-workspace', + http_schema: 'https', + rate_limit: { + requests_per_minute: 1000, + }, + }, + }, + { + inference_id: 'hugging-face-endpoint-without-model-id', + task_type: 'text_embedding', + service: 'hugging_face', + service_settings: { + api_key: 'test-api-key', + url: 'https://example.com/model-endpoint', + }, + }, +]; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/constants.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/constants.ts index 9922690c3d582..ab41c123515fd 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/constants.ts +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { FilterOptions } from './types'; +import type { FilterOptions } from '../../types'; export const DEFAULT_FILTER_OPTIONS: FilterOptions = { provider: [], diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/multi_select_filter.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/multi_select_filter.tsx index 371b204e0acd6..4b0759515e437 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/multi_select_filter.tsx +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/multi_select_filter.tsx @@ -86,7 +86,7 @@ export const MultiSelectFilter: React.FC = ({ searchProps={{ placeholder: buttonLabel, }} - emptyMessage="No options" + emptyMessage={i18n.EMPTY_FILTER_MESSAGE} onChange={onChange} singleSelection={false} renderOption={renderOption} diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/service_provider_filter.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/service_provider_filter.tsx index 11f0779886b65..19065fbf929a7 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/service_provider_filter.tsx +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/service_provider_filter.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import type { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common'; import { SERVICE_PROVIDERS } from '@kbn/inference-endpoint-ui-common'; -import type { FilterOptions } from '../types'; +import type { FilterOptions } from '../../../types'; import type { MultiSelectFilterOption } from './multi_select_filter'; import { MultiSelectFilter } from './multi_select_filter'; import * as i18n from './translations'; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/task_type_filter.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/task_type_filter.tsx index 586d967081f2d..0a22de023f731 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/task_type_filter.tsx +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/task_type_filter.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types'; -import type { FilterOptions } from '../types'; +import type { FilterOptions } from '../../../types'; import type { MultiSelectFilterOption } from './multi_select_filter'; import { MultiSelectFilter } from './multi_select_filter'; import * as i18n from './translations'; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/translations.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/translations.ts index f373e8f5d5a46..a5d4a6018018f 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/translations.ts +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/filter/translations.ts @@ -7,14 +7,8 @@ import { i18n } from '@kbn/i18n'; -export { SERVICE_PROVIDER, TASK_TYPE } from '../../../../common/translations'; +export { SERVICE_PROVIDER, TASK_TYPE, EMPTY_FILTER_MESSAGE } from '../../../../common/translations'; -export const EMPTY_FILTER_MESSAGE = i18n.translate( - 'xpack.searchInferenceEndpoints.filter.emptyMessage', - { - defaultMessage: 'No options', - } -); export const OPTIONS = (totalCount: number) => i18n.translate('xpack.searchInferenceEndpoints.filter.options', { defaultMessage: '{totalCount, plural, one {# option} other {# options}}', diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/group_by_select.test.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/group_by_select.test.tsx new file mode 100644 index 0000000000000..fc0fb0e29fd2d --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/group_by_select.test.tsx @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { EuiThemeProvider } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import { GroupBySelect } from './group_by_select'; +import { GroupByOptions } from '../../types'; + +// Wrapper component to provide necessary context +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('GroupBySelect', () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the filter button with "None" as default selection', () => { + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('group-by-select')).toBeInTheDocument(); + expect(getByText(/Group:/i)).toBeInTheDocument(); + expect(getByText(/None/i)).toBeInTheDocument(); + }); + + it('should render the filter button with "Models" when model_id is selected', () => { + const { getByText } = render( + + + + ); + + expect(getByText(/Group:/i)).toBeInTheDocument(); + expect(getByText(/Models/i)).toBeInTheDocument(); + }); + + it('should open the popover when the filter button is clicked', async () => { + const { getByRole } = render( + + + + ); + + const filterButton = getByRole('button'); + fireEvent.click(filterButton); + + await waitFor(() => { + // Both options should be visible in the popover + const options = document.querySelectorAll('[role="option"]'); + expect(options.length).toBeGreaterThan(0); + }); + }); + + it('should close the popover when the filter button is clicked again', async () => { + const { getByRole } = render( + + + + ); + + const filterButton = getByRole('button'); + + // Open popover + fireEvent.click(filterButton); + await waitFor(() => { + expect(document.querySelector('[role="option"]')).toBeInTheDocument(); + }); + + // Close popover + fireEvent.click(filterButton); + await waitFor(() => { + expect(document.querySelector('[role="option"]')).not.toBeInTheDocument(); + }); + }); + + it('should call onChange with GroupByOptions.Model when "Models" option is selected', async () => { + const { getByRole } = render( + + + + ); + + // Open popover + const filterButton = getByRole('button'); + fireEvent.click(filterButton); + + await waitFor(() => { + const options = document.querySelectorAll('[role="option"]'); + expect(options.length).toBeGreaterThan(0); + }); + + // Find and click the "Models" option + const options = document.querySelectorAll('[role="option"]'); + const modelsOption = Array.from(options).find((option) => + option.textContent?.includes('Models') + ); + + expect(modelsOption).toBeDefined(); + if (modelsOption) { + fireEvent.click(modelsOption); + } + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith(GroupByOptions.Model); + }); + }); + + it('should call onChange with GroupByOptions.None when "None" option is selected', async () => { + const { getByRole } = render( + + + + ); + + // Open popover + const filterButton = getByRole('button'); + fireEvent.click(filterButton); + + await waitFor(() => { + const options = document.querySelectorAll('[role="option"]'); + expect(options.length).toBeGreaterThan(0); + }); + + // Find and click the "None" option + const options = document.querySelectorAll('[role="option"]'); + const noneOption = Array.from(options).find((option) => option.textContent?.includes('None')); + + expect(noneOption).toBeDefined(); + if (noneOption) { + fireEvent.click(noneOption); + } + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith(GroupByOptions.None); + }); + }); + + it('should close the popover after selecting an option', async () => { + const { getByRole } = render( + + + + ); + + // Open popover + const filterButton = getByRole('button'); + fireEvent.click(filterButton); + + await waitFor(() => { + expect(document.querySelector('[role="option"]')).toBeInTheDocument(); + }); + + // Select an option + const options = document.querySelectorAll('[role="option"]'); + const modelsOption = Array.from(options).find((option) => + option.textContent?.includes('Models') + ); + + if (modelsOption) { + fireEvent.click(modelsOption); + } + + // Popover should close after selection + await waitFor(() => { + expect(document.querySelector('[role="option"]')).not.toBeInTheDocument(); + }); + }); + + it('should mark the current selection as checked', async () => { + const { getByRole } = render( + + + + ); + + // Open popover + const filterButton = getByRole('button'); + fireEvent.click(filterButton); + + await waitFor(() => { + const options = document.querySelectorAll('[role="option"]'); + expect(options.length).toBe(2); + }); + + // Check that the "Models" option is marked as checked + const options = document.querySelectorAll('[role="option"]'); + const modelsOption = Array.from(options).find((option) => + option.textContent?.includes('Models') + ) as HTMLElement; + + expect(modelsOption).toBeDefined(); + if (modelsOption) { + expect(modelsOption.getAttribute('aria-selected')).toBe('true'); + } + }); +}); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/group_by_select.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/group_by_select.tsx new file mode 100644 index 0000000000000..e9de2b582195a --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/group_by_select.tsx @@ -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 React, { useCallback, useMemo, useState } from 'react'; +import { + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiSelectable, + EuiText, + type EuiSelectableOption, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { EMPTY_FILTER_MESSAGE, GROUP_BY_NONE, GROUP_BY_MODELS } from '../../../common/translations'; +import { GroupByOptions } from '../../types'; +import { GroupByFilterButton, GroupBySelectableContainer } from './styles'; + +interface GroupBySelectProps { + value: GroupByOptions; + onChange: (value: GroupByOptions) => void; +} + +const GROUP_BY_OPTIONS = [ + { + key: GroupByOptions.None, + label: GROUP_BY_NONE, + }, + { + key: GroupByOptions.Model, + label: GROUP_BY_MODELS, + }, +]; + +function parseGroupByValue(value: string | undefined): GroupByOptions { + switch (value) { + case GroupByOptions.Model: + return GroupByOptions.Model; + case GroupByOptions.None: + default: + return GroupByOptions.None; + } +} + +export const GroupBySelect = ({ value, onChange }: GroupBySelectProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const handleValueChange = useCallback( + (newOptions: EuiSelectableOption[]) => { + const selectedOption = newOptions.find((option) => option.checked === 'on'); + onChange(parseGroupByValue(selectedOption?.key)); + setIsPopoverOpen(false); + }, + [onChange] + ); + const { options, selectedOptionLabel } = useMemo(() => { + let selectedOption = GROUP_BY_OPTIONS[0].label; + const selectableOptions: EuiSelectableOption[] = GROUP_BY_OPTIONS.map((option) => { + if (option.key === value) { + selectedOption = option.label; + return { ...option, checked: 'on' }; + } + return option; + }); + return { + options: selectableOptions, + selectedOptionLabel: selectedOption, + }; + }, [value]); + + return ( + + setIsPopoverOpen((prevValue) => !prevValue)} + isSelected={isPopoverOpen} + > + + + + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + panelPaddingSize="none" + repositionOnScroll + > + ( + {option.label} + )} + listProps={{ + onFocusBadge: false, + }} + > + {(list, _search) =>
{list}
} +
+
+
+ ); +}; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/group_header_button.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/group_header_button.tsx new file mode 100644 index 0000000000000..9c0f9c5f1ecd9 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/group_header_button.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge, EuiFlexGroup, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import type { GroupedInferenceEndpointsData } from '../../../types'; + +export interface GroupByHeaderButtonProps { + data: GroupedInferenceEndpointsData; +} +export const GroupByHeaderButton = ({ data }: GroupByHeaderButtonProps) => { + return ( + + + {data.groupLabel} + + + {i18n.translate( + 'xpack.searchInferenceEndpoints.groupedEndpoints.headers.endpointsCountBadge', + { + defaultMessage: + '{endpointCount} {endpointCount, plural, one {endpoint} other {endpoints}}', + values: { + endpointCount: data.endpoints.length, + }, + } + )} + + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/grouped_endpoints_tables.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/grouped_endpoints_tables.tsx new file mode 100644 index 0000000000000..c1056f800bff9 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/grouped_endpoints_tables.tsx @@ -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 React from 'react'; +import { + EuiAccordion, + type EuiBasicTableColumn, + EuiFlexGroup, + EuiInMemoryTable, + EuiPanel, + EuiEmptyPrompt, + EuiSpacer, +} from '@elastic/eui'; + +import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; +import { i18n } from '@kbn/i18n'; +import { useGroupedData } from '../../../hooks/use_grouped_data'; +import type { GroupByOptions, FilterOptions } from '../../../types'; +import { GroupPanelStyle } from './styles'; +import { GroupByHeaderButton } from './group_header_button'; +import { INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES } from '../types'; + +export interface GroupedEndpointsTablesProps { + inferenceEndpoints: InferenceAPIConfigResponse[]; + groupBy: GroupByOptions; + filterOptions: FilterOptions; + searchKey: string; + columns: EuiBasicTableColumn[]; +} + +export const GroupedEndpointsTables = ({ + inferenceEndpoints, + groupBy, + filterOptions, + searchKey, + columns, +}: GroupedEndpointsTablesProps) => { + const groupedEndpoints = useGroupedData(inferenceEndpoints, groupBy, filterOptions, searchKey); + + if (inferenceEndpoints.length === 0 || groupedEndpoints.length === 0) { + // No data after filters / search key + return ( + + {i18n.translate('xpack.searchInferenceEndpoints.table.noItemsMessage', { + defaultMessage: 'No items found', + })} +

+ } + /> + ); + } + + return ( + + {groupedEndpoints.map((groupedData) => ( + + } + data-test-subj={`${groupedData.groupId}-accordion`} + initialIsOpen + paddingSize="none" + > + + INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES[0] + ? { + pageSizeOptions: INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES, + } + : undefined + } + sorting={{ + sort: { + field: 'inference_id', + direction: 'asc', + }, + }} + tableCaption={i18n.translate( + 'xpack.searchInferenceEndpoints.groupedEndpoints.tableCaption', + { + defaultMessage: 'Inference endpoints list grouped by {groupBy}: {groupId}', + values: { + groupBy, + groupId: groupedData.groupId, + }, + } + )} + /> + + + ))} + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/styles.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/styles.ts new file mode 100644 index 0000000000000..c211d84fb24d0 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/grouped_endpoints/styles.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { css } from '@emotion/react'; +import { type UseEuiTheme } from '@elastic/eui'; + +export const GroupPanelStyle = ({ euiTheme }: UseEuiTheme) => css` + .euiAccordion__triggerWrapper { + background-color: ${euiTheme.colors.backgroundBaseSubdued}; + border-bottom: ${euiTheme.border.thin}; + padding-left: ${euiTheme.size.s}; + } +`; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/styles.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/styles.ts new file mode 100644 index 0000000000000..7aa7e7d5df4af --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/styles.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { css } from '@emotion/react'; +import type { UseEuiTheme } from '@elastic/eui'; + +export const GroupByFilterButton = ({ euiTheme }: UseEuiTheme) => css` + width: ${euiTheme.base * 10}px; +`; + +export const GroupBySelectableContainer = ({ euiTheme }: UseEuiTheme) => css` + width: ${euiTheme.base * 10}px; +`; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.test.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.test.tsx index afaf311ed5383..fcc1c57e60453 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.test.tsx +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.test.tsx @@ -5,9 +5,12 @@ * 2.0. */ +import '@testing-library/jest-dom'; + import React from 'react'; -import { act, render, screen, within } from '@testing-library/react'; +import { act, render, screen, within, fireEvent } from '@testing-library/react'; import { EuiThemeProvider } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; import { TabularPage } from './tabular_page'; import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; @@ -160,144 +163,263 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { const renderTabularPageWithProviders = () => { return render( - + + + ); }; describe('When the tabular page is loaded', () => { - it('should display all inference ids in the table', () => { - renderTabularPageWithProviders(); + describe('group by none', () => { + beforeAll(() => { + window.history.pushState({}, '', '?groupBy=none'); + }); + beforeEach(() => { + renderTabularPageWithProviders(); + }); + afterAll(() => { + window.history.pushState({}, '', '/'); + }); - const rows = screen.getAllByRole('row'); - expect(rows[1]).toHaveTextContent('.elser-2-elastic'); - expect(rows[2]).toHaveTextContent('.elser-2-elasticsearch'); - expect(rows[3]).toHaveTextContent('.multilingual-e5-small-elasticsearch'); - expect(rows[4]).toHaveTextContent('.multilingual-embed-v1-elastic'); - expect(rows[5]).toHaveTextContent('.rerank-v1-elastic'); - expect(rows[6]).toHaveTextContent('.sparkles'); - expect(rows[7]).toHaveTextContent('custom-inference-id'); - expect(rows[8]).toHaveTextContent('elastic-rerank'); - expect(rows[9]).toHaveTextContent('local-model'); - expect(rows[10]).toHaveTextContent('my-elser-model-05'); - expect(rows[11]).toHaveTextContent('third-party-model'); - }); + it('should display all inference ids in the table', () => { + const table = screen.getByTestId('inferenceEndpointTable'); + const rows = within(table).getAllByRole('row'); + expect(rows[1]).toHaveTextContent('.elser-2-elastic'); + expect(rows[2]).toHaveTextContent('.elser-2-elasticsearch'); + expect(rows[3]).toHaveTextContent('.multilingual-e5-small-elasticsearch'); + expect(rows[4]).toHaveTextContent('.multilingual-embed-v1-elastic'); + expect(rows[5]).toHaveTextContent('.rerank-v1-elastic'); + expect(rows[6]).toHaveTextContent('.sparkles'); + expect(rows[7]).toHaveTextContent('custom-inference-id'); + expect(rows[8]).toHaveTextContent('elastic-rerank'); + expect(rows[9]).toHaveTextContent('local-model'); + expect(rows[10]).toHaveTextContent('my-elser-model-05'); + expect(rows[11]).toHaveTextContent('third-party-model'); + }); - it('should display all service providers and model ids in the table', () => { - renderTabularPageWithProviders(); + it('should display all service providers and model ids in the table', () => { + const table = screen.getByTestId('inferenceEndpointTable'); + const rows = within(table).getAllByRole('row'); + // Row 1: .elser-2-elastic + expect(rows[1]).toHaveTextContent('Elastic'); + expect(rows[1]).toHaveTextContent(elasticDescription); + expect(rows[1]).toHaveTextContent('elser_model_2'); + + // Row 2: .elser-2-elasticsearch + expect(rows[2]).toHaveTextContent('Elasticsearch'); + expect(rows[2]).toHaveTextContent(elasticsearchDescription); + expect(rows[2]).toHaveTextContent('.elser_model_2'); + + // Row 3: .multilingual-e5-small-elasticsearch + expect(rows[3]).toHaveTextContent('Elasticsearch'); + expect(rows[3]).toHaveTextContent(elasticsearchDescription); + expect(rows[3]).toHaveTextContent('.multilingual-e5-small'); + + // Row 4: .multilingual-embed-v1-elastic + expect(rows[4]).toHaveTextContent('Elastic'); + expect(rows[4]).toHaveTextContent(elasticDescription); + expect(rows[4]).toHaveTextContent('multilingual-embed-v1'); + + // Row 5: .rerank-v1-elastic + expect(rows[5]).toHaveTextContent('Elastic'); + expect(rows[5]).toHaveTextContent(elasticDescription); + expect(rows[5]).toHaveTextContent('rerank-v1'); + + // Row 6: .sparkles + expect(rows[6]).toHaveTextContent('Elastic'); + expect(rows[6]).toHaveTextContent(elasticDescription); + expect(rows[6]).toHaveTextContent('rainbow-sprinkles'); + + // Row 7: custom-inference-id + expect(rows[7]).toHaveTextContent('Elastic'); + expect(rows[7]).toHaveTextContent('elser_model_2'); + + // Row 8: elastic-rerank + expect(rows[8]).toHaveTextContent('Elasticsearch'); + expect(rows[8]).toHaveTextContent('.rerank-v1'); + + // Row 9: local-model + expect(rows[9]).toHaveTextContent('Elasticsearch'); + expect(rows[9]).toHaveTextContent('.own_model'); + + // Row 10: my-elser-model-05 + expect(rows[10]).toHaveTextContent('Elasticsearch'); + expect(rows[10]).toHaveTextContent('.elser_model_2'); + + // Row 11: third-party-model + expect(rows[11]).toHaveTextContent('OpenAI'); + expect(rows[11]).toHaveTextContent('.own_model'); + }); - const rows = screen.getAllByRole('row'); - // Row 1: .elser-2-elastic - expect(rows[1]).toHaveTextContent('Elastic'); - expect(rows[1]).toHaveTextContent(elasticDescription); - expect(rows[1]).toHaveTextContent('elser_model_2'); - - // Row 2: .elser-2-elasticsearch - expect(rows[2]).toHaveTextContent('Elasticsearch'); - expect(rows[2]).toHaveTextContent(elasticsearchDescription); - expect(rows[2]).toHaveTextContent('.elser_model_2'); - - // Row 3: .multilingual-e5-small-elasticsearch - expect(rows[3]).toHaveTextContent('Elasticsearch'); - expect(rows[3]).toHaveTextContent(elasticsearchDescription); - expect(rows[3]).toHaveTextContent('.multilingual-e5-small'); - - // Row 4: .multilingual-embed-v1-elastic - expect(rows[4]).toHaveTextContent('Elastic'); - expect(rows[4]).toHaveTextContent(elasticDescription); - expect(rows[4]).toHaveTextContent('multilingual-embed-v1'); - - // Row 5: .rerank-v1-elastic - expect(rows[5]).toHaveTextContent('Elastic'); - expect(rows[5]).toHaveTextContent(elasticDescription); - expect(rows[5]).toHaveTextContent('rerank-v1'); - - // Row 6: .sparkles - expect(rows[6]).toHaveTextContent('Elastic'); - expect(rows[6]).toHaveTextContent(elasticDescription); - expect(rows[6]).toHaveTextContent('rainbow-sprinkles'); - - // Row 7: custom-inference-id - expect(rows[7]).toHaveTextContent('Elastic'); - expect(rows[7]).toHaveTextContent('elser_model_2'); - - // Row 8: elastic-rerank - expect(rows[8]).toHaveTextContent('Elasticsearch'); - expect(rows[8]).toHaveTextContent('.rerank-v1'); - - // Row 9: local-model - expect(rows[9]).toHaveTextContent('Elasticsearch'); - expect(rows[9]).toHaveTextContent('.own_model'); - - // Row 10: my-elser-model-05 - expect(rows[10]).toHaveTextContent('Elasticsearch'); - expect(rows[10]).toHaveTextContent('.elser_model_2'); - - // Row 11: third-party-model - expect(rows[11]).toHaveTextContent('OpenAI'); - expect(rows[11]).toHaveTextContent('.own_model'); - }); + it('should only disable delete action for preconfigured endpoints', () => { + const table = screen.getByTestId('inferenceEndpointTable'); + const preconfiguredRow = within(table).getByText('.elser-2-elastic').closest('tr'); - it('should only disable delete action for preconfigured endpoints', () => { - renderTabularPageWithProviders(); + expect(preconfiguredRow).not.toBeNull(); + + act(() => { + within(preconfiguredRow!).getByTestId('euiCollapsedItemActionsButton').click(); + }); + + const deleteAction = screen.getByTestId(/inferenceUIDeleteAction/); - act(() => { - screen.getAllByTestId('euiCollapsedItemActionsButton')[0].click(); + expect(deleteAction).toBeDisabled(); }); - const deleteAction = screen.getByTestId(/inferenceUIDeleteAction/); + it('should not disable delete action for other endpoints', () => { + const table = screen.getByTestId('inferenceEndpointTable'); + const userDefinedRow = within(table).getByText('custom-inference-id').closest('tr'); - expect(deleteAction).toBeDisabled(); - }); + expect(userDefinedRow).not.toBeNull(); - it('should not disable delete action for other endpoints', () => { - renderTabularPageWithProviders(); + act(() => { + within(userDefinedRow!).getByTestId('euiCollapsedItemActionsButton').click(); + }); + + const deleteAction = screen.getByTestId(/inferenceUIDeleteAction/); - act(() => { - screen.getAllByTestId('euiCollapsedItemActionsButton')[6].click(); + expect(deleteAction).toBeEnabled(); }); - const deleteAction = screen.getByTestId(/inferenceUIDeleteAction/); + it('should show preconfigured badge only for preconfigured endpoints', () => { + const preconfigured = 'PRECONFIGURED'; + + const table = screen.getByTestId('inferenceEndpointTable'); + const rows = within(table).getAllByRole('row'); + expect(rows[1]).toHaveTextContent(preconfigured); + expect(rows[2]).toHaveTextContent(preconfigured); + expect(rows[3]).toHaveTextContent(preconfigured); + expect(rows[4]).toHaveTextContent(preconfigured); + expect(rows[5]).toHaveTextContent(preconfigured); + expect(rows[6]).toHaveTextContent(preconfigured); + expect(rows[7]).not.toHaveTextContent(preconfigured); + expect(rows[8]).not.toHaveTextContent(preconfigured); + expect(rows[9]).not.toHaveTextContent(preconfigured); + expect(rows[10]).not.toHaveTextContent(preconfigured); + expect(rows[11]).not.toHaveTextContent(preconfigured); + }); - expect(deleteAction).toBeEnabled(); + it('should show tech preview badge only for reranker-v1 model, multilingual-embed-v1, rerank-v1, and preconfigured elser_model_2', () => { + const techPreview = 'TECH PREVIEW'; + + const table = screen.getByTestId('inferenceEndpointTable'); + const rows = within(table).getAllByRole('row'); + expect(rows[1]).not.toHaveTextContent(techPreview); + expect(rows[2]).not.toHaveTextContent(techPreview); + expect(rows[3]).not.toHaveTextContent(techPreview); + expect(rows[4]).toHaveTextContent(techPreview); + expect(rows[5]).toHaveTextContent(techPreview); + expect(rows[6]).not.toHaveTextContent(techPreview); + expect(rows[7]).not.toHaveTextContent(techPreview); + expect(rows[8]).toHaveTextContent(techPreview); + expect(rows[9]).not.toHaveTextContent(techPreview); + expect(rows[10]).not.toHaveTextContent(techPreview); + expect(rows[11]).not.toHaveTextContent(techPreview); + }); }); + describe('group by models', () => { + beforeEach(() => { + renderTabularPageWithProviders(); + }); - it('should show preconfigured badge only for preconfigured endpoints', () => { - renderTabularPageWithProviders(); + it('should display accordions with tables for model groups', () => { + const groupAccordions = screen.getAllByTestId(/-accordion$/); + expect(groupAccordions).toHaveLength(5); + const groupTables = screen.getAllByTestId(/-table$/); + expect(groupTables).toHaveLength(5); + }); - const preconfigured = 'PRECONFIGURED'; - - const rows = screen.getAllByRole('row'); - expect(rows[1]).toHaveTextContent(preconfigured); - expect(rows[2]).toHaveTextContent(preconfigured); - expect(rows[3]).toHaveTextContent(preconfigured); - expect(rows[4]).toHaveTextContent(preconfigured); - expect(rows[5]).toHaveTextContent(preconfigured); - expect(rows[6]).toHaveTextContent(preconfigured); - expect(rows[7]).not.toHaveTextContent(preconfigured); - expect(rows[8]).not.toHaveTextContent(preconfigured); - expect(rows[9]).not.toHaveTextContent(preconfigured); - expect(rows[10]).not.toHaveTextContent(preconfigured); - expect(rows[11]).not.toHaveTextContent(preconfigured); - }); + it('should have expected endpoint table columns', () => { + const endpointTables = screen.getAllByTestId(/-table$/); - it('should show tech preview badge only for reranker-v1 model, multilingual-embed-v1, rerank-v1, and preconfigured elser_model_2', () => { - renderTabularPageWithProviders(); + endpointTables.forEach((table) => { + const columnHeaders = within(table).getAllByRole('columnheader'); + const headerLabels = columnHeaders.map((header) => header.textContent?.trim() ?? ''); + + expect(headerLabels).toEqual(['Endpoint', 'Model', 'Service', '']); + }); + }); - const techPreview = 'TECH PREVIEW'; - - const rows = screen.getAllByRole('row'); - expect(rows[1]).not.toHaveTextContent(techPreview); - expect(rows[2]).not.toHaveTextContent(techPreview); - expect(rows[3]).not.toHaveTextContent(techPreview); - expect(rows[4]).toHaveTextContent(techPreview); - expect(rows[5]).toHaveTextContent(techPreview); - expect(rows[6]).not.toHaveTextContent(techPreview); - expect(rows[7]).not.toHaveTextContent(techPreview); - expect(rows[8]).toHaveTextContent(techPreview); - expect(rows[9]).not.toHaveTextContent(techPreview); - expect(rows[10]).not.toHaveTextContent(techPreview); - expect(rows[11]).not.toHaveTextContent(techPreview); + it('should show expected group labels and endpoint counts', () => { + const expectedGroups = [ + { + groupId: 'elastic', + label: 'Elastic', + countLabel: '6 endpoints', + }, + { + groupId: '.own_model', + label: '.own_model', + countLabel: '2 endpoints', + }, + { + groupId: 'multilingual-e5', + label: 'Multilingual E5', + countLabel: '1 endpoint', + }, + { + groupId: 'multilingual-embed-v1', + label: 'multilingual-embed-v1', + countLabel: '1 endpoint', + }, + { + groupId: 'rerank-v1', + label: 'rerank-v1', + countLabel: '1 endpoint', + }, + ]; + + expectedGroups.forEach(({ groupId, label, countLabel }) => { + const accordionHeader = screen.getByTestId(`${groupId}-accordion-header`); + expect(within(accordionHeader).getByText(label)).toBeInTheDocument(); + expect(within(accordionHeader).getByText(countLabel)).toBeInTheDocument(); + }); + }); + + it('should show empty prompt when search removes all groups', async () => { + fireEvent.change(screen.getByTestId('search-field-endpoints'), { + target: { value: 'no-matching-endpoint' }, + }); + + expect(await screen.findByText('No items found')).toBeInTheDocument(); + }); + + it('should disable delete action for preconfigured endpoints in grouped tables', () => { + const elserTable = screen.getByTestId('elastic-table'); + + const preconfiguredRow = within(elserTable).getByText('.elser-2-elastic').closest('tr'); + + expect(preconfiguredRow).not.toBeNull(); + + act(() => { + within(preconfiguredRow as HTMLElement) + .getByTestId('euiCollapsedItemActionsButton') + .click(); + }); + + const deleteAction = screen.getByTestId(/inferenceUIDeleteAction/); + + expect(deleteAction).toBeDisabled(); + }); + + it('should enable delete action for user-defined endpoints in grouped tables', () => { + const elserTable = screen.getByTestId('elastic-table'); + + const userDefinedRow = within(elserTable).getByText('custom-inference-id').closest('tr'); + + expect(userDefinedRow).not.toBeNull(); + + act(() => { + within(userDefinedRow as HTMLElement) + .getByTestId('euiCollapsedItemActionsButton') + .click(); + }); + + const deleteAction = screen.getByTestId(/inferenceUIDeleteAction/); + + expect(deleteAction).toBeEnabled(); + }); }); it('should show the correct task type badge for each endpoint', () => { diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx index 1fa0552e53cbc..ee80ac791766a 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx @@ -18,6 +18,8 @@ import type { import type { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common'; import { EisCloudConnectPromoCallout, EisPromotionalCallout } from '@kbn/search-api-panels'; import { CLOUD_CONNECT_NAV_ID } from '@kbn/deeplinks-management/constants'; + +import { docLinks } from '../../../common/doc_links'; import { ENDPOINT, ENDPOINT_COPY_ID_ACTION_LABEL, @@ -28,30 +30,45 @@ import { SERVICE_PROVIDER, } from '../../../common/translations'; -import { useTableData } from '../../hooks/use_table_data'; +import { useKibana } from '../../hooks/use_kibana'; import { useEndpointActions } from '../../hooks/use_endpoint_actions'; -import type { FilterOptions } from './types'; -import { INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES } from './types'; +import { useFilteredInferenceEndpoints } from '../../hooks/use_filtered_endpoints'; +import { type FilterOptions, GroupByOptions } from '../../types'; +import { getModelId } from '../../utils/get_model_id'; +import { isEndpointPreconfigured } from '../../utils/preconfigured_endpoint_helper'; +import { EditInferenceFlyout } from '../edit_inference_endpoints/edit_inference_flyout'; import { DEFAULT_FILTER_OPTIONS } from './constants'; import { ServiceProviderFilter } from './filter/service_provider_filter'; import { TaskTypeFilter } from './filter/task_type_filter'; import { TableSearch } from './search/table_search'; +import { GroupBySelect } from './group_by_select'; import { EndpointInfo } from './render_table_columns/render_endpoint/endpoint_info'; import { Model } from './render_table_columns/render_model/model'; import { ServiceProvider } from './render_table_columns/render_service_provider/service_provider'; import { DeleteAction } from './render_table_columns/render_actions/actions/delete/delete_action'; -import { useKibana } from '../../hooks/use_kibana'; -import { getModelId } from '../../utils/get_model_id'; -import { isEndpointPreconfigured } from '../../utils/preconfigured_endpoint_helper'; -import { EditInferenceFlyout } from '../edit_inference_endpoints/edit_inference_flyout'; -import { docLinks } from '../../../common/doc_links'; +import { INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES } from './types'; + import { EndpointStats } from './endpoint_stats'; +import { GroupedEndpointsTables } from './grouped_endpoints/grouped_endpoints_tables'; const searchContainerStyles = ({ euiTheme }: UseEuiTheme) => css` width: ${euiTheme.base * 25}px; `; +const initializeGroupBy = (): GroupByOptions => { + const params = new URLSearchParams(window.location.search); + const groupByParam = params.get('groupBy') ?? ''; + + switch (groupByParam) { + case GroupByOptions.None: + return GroupByOptions.None; + case GroupByOptions.Model: + default: + return GroupByOptions.Model; + } +}; + interface TabularPageProps { inferenceEndpoints: InferenceAPIConfigResponse[]; } @@ -61,6 +78,7 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) services: { cloud, application }, } = useKibana(); const [searchKey, setSearchKey] = useState(''); + const [groupBy, setGroupBy] = useState(initializeGroupBy); const [filterOptions, setFilterOptions] = useState(DEFAULT_FILTER_OPTIONS); const { @@ -92,11 +110,12 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) setFilterOptions((prev) => ({ ...prev, ...newFilterOptions })); }, []); - const tableData = useTableData(inferenceEndpoints, filterOptions, searchKey); + const tableData = useFilteredInferenceEndpoints(inferenceEndpoints, filterOptions, searchKey); const tableColumns = useMemo>>( () => [ { + id: 'inference_id-column', field: 'inference_id', name: ENDPOINT, 'data-test-subj': 'endpointCell', @@ -115,6 +134,7 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) width: '300px', }, { + id: 'model-column', name: MODEL, 'data-test-subj': 'modelCell', render: (endpointInfo: InferenceInferenceEndpointInfo) => { @@ -124,6 +144,7 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) width: '200px', }, { + id: 'service-column', field: 'service', name: SERVICE_PROVIDER, 'data-test-subj': 'providerCell', @@ -211,27 +232,47 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) uniqueTaskTypes={uniqueProvidersAndTaskTypes.taskTypes} /> + + { + setGroupBy(value ?? GroupByOptions.None); + }} + /> +
- + {groupBy === GroupByOptions.None ? ( + + ) : ( + + + + )} {showDeleteAction && selectedInferenceEndpoint && ( ({ + provider, + type, +}); + +describe('useFilteredInferenceEndpoints', () => { + const emptyFilters = makeFilters(); + + it('should return all endpoints when no filters or search key are applied', () => { + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, emptyFilters, '') + ); + + expect(result.current).toHaveLength(InferenceEndpoints.length); + expect(result.current).toEqual(InferenceEndpoints); + }); + + describe('provider filters', () => { + it('should filter endpoints by single provider', () => { + const filters = makeFilters([ServiceProviderKeys.elasticsearch]); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current).toHaveLength(3); + expect(result.current.every((endpoint) => endpoint.service === 'elasticsearch')).toBe(true); + }); + + it('should filter endpoints by multiple providers', () => { + const filters = makeFilters([ServiceProviderKeys.elasticsearch, ServiceProviderKeys.elastic]); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current).toHaveLength(29); + expect( + result.current.every( + (endpoint) => endpoint.service === 'elasticsearch' || endpoint.service === 'elastic' + ) + ).toBe(true); + }); + + it('should return empty array when provider filter has no matches', () => { + const filters = makeFilters([ServiceProviderKeys.anthropic]); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current).toHaveLength(0); + }); + }); + + describe('type filters', () => { + it('should filter endpoints by single task type', () => { + const filters = makeFilters([], ['text_embedding']); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current).toHaveLength(6); + expect(result.current.every((endpoint) => endpoint.task_type === 'text_embedding')).toBe( + true + ); + }); + + it('should filter endpoints by multiple task types', () => { + const filters = makeFilters([], ['sparse_embedding', 'rerank']); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current).toHaveLength(5); + expect( + result.current.every( + (endpoint) => endpoint.task_type === 'sparse_embedding' || endpoint.task_type === 'rerank' + ) + ).toBe(true); + }); + }); + + describe('combined provider and type filters', () => { + it('should apply both provider and type filters', () => { + const filters = makeFilters([ServiceProviderKeys.elasticsearch], ['text_embedding']); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].service).toBe('elasticsearch'); + expect(result.current[0].task_type).toBe('text_embedding'); + expect(result.current[0].inference_id).toBe('.multilingual-e5-small-elasticsearch'); + }); + + it('should return empty array when combined filters have no matches', () => { + const filters = makeFilters([ServiceProviderKeys.elasticsearch], ['completion']); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current).toHaveLength(0); + }); + }); + + describe('search by inference_id', () => { + it('should filter by inference_id (case insensitive)', () => { + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, emptyFilters, 'ELSER') + ); + + expect(result.current).toHaveLength(2); + expect(result.current.every((endpoint) => endpoint.inference_id.includes('elser'))).toBe( + true + ); + }); + + it('should filter by partial inference_id match', () => { + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, emptyFilters, 'gp-llm-v2') + ); + + expect(result.current).toHaveLength(2); + expect(result.current.every((endpoint) => endpoint.inference_id.includes('gp-llm-v2'))).toBe( + true + ); + }); + + it('should return empty array when search has no matches', () => { + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, emptyFilters, 'nonexistent-id') + ); + + expect(result.current).toHaveLength(0); + }); + }); + + describe('search by model_id', () => { + it('should filter by model_id when present in service_settings', () => { + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, emptyFilters, 'elser_model_2') + ); + + expect(result.current).toHaveLength(2); + expect( + result.current.every((endpoint) => { + const modelId = + 'model_id' in endpoint.service_settings + ? endpoint.service_settings.model_id + : undefined; + return modelId && modelId.includes('elser_model_2'); + }) + ).toBe(true); + }); + + it('should filter by model_id for other models', () => { + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, emptyFilters, 'rainbow-sprinkles') + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].service).toBe('elastic'); + expect(result.current[0].inference_id).toBe('.rainbow-sprinkles-elastic'); + }); + + it('should match endpoints by model_id case insensitively', () => { + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, emptyFilters, 'GPT-4.1') + ); + + expect(result.current).toHaveLength(4); + expect( + result.current.every((endpoint) => endpoint.inference_id.includes('openai-gpt-4.1')) + ).toBe(true); + }); + }); + + describe('combined filters and search', () => { + it('should apply provider filter and search together', () => { + const filters = makeFilters([ServiceProviderKeys.elasticsearch]); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, 'rerank-v1') + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].inference_id).toBe('.rerank-v1-elasticsearch'); + }); + + it('should apply type filter and search together', () => { + const filters = makeFilters([], ['text_embedding']); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, 'jina') + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].service).toBe('elastic'); + expect(result.current[0].task_type).toBe('text_embedding'); + expect(result.current[0].inference_id).toBe('.jina-embeddings-v3'); + }); + + it('should apply all filters (provider, type, and search) together', () => { + const filters = makeFilters([ServiceProviderKeys.elasticsearch], ['sparse_embedding']); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, 'elser') + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].inference_id).toBe('.elser-2-elasticsearch'); + expect(result.current[0].service).toBe('elasticsearch'); + expect(result.current[0].task_type).toBe('sparse_embedding'); + }); + + it('should return empty array when combined filters and search have no matches', () => { + const filters = makeFilters([ServiceProviderKeys.elasticsearch], ['completion']); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, 'nonexistent') + ); + + expect(result.current).toHaveLength(0); + }); + }); + + describe('all services coverage', () => { + it('should filter each service type correctly', () => { + const services = ['elasticsearch', 'elastic']; + + services.forEach((service) => { + const filters = makeFilters([service as ServiceProviderKeys]); + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(InferenceEndpoints, filters, '') + ); + + expect(result.current.length).toBeGreaterThan(0); + expect(result.current.every((endpoint) => endpoint.service === service)).toBe(true); + }); + }); + }); + + describe('edge cases', () => { + it('should return empty array when inferenceEndpoints is empty', () => { + const { result } = renderHook(() => useFilteredInferenceEndpoints([], emptyFilters, '')); + + expect(result.current).toHaveLength(0); + }); + + it('should handle endpoints with no model_id in service_settings', () => { + const endpointsWithNoModelId: InferenceAPIConfigResponse[] = [ + { + inference_id: 'endpoint-no-model', + task_type: 'sparse_embedding', + service: 'elasticsearch', + service_settings: { + num_allocations: 1, + num_threads: 1, + }, + task_settings: {}, + }, + ]; + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(endpointsWithNoModelId, emptyFilters, 'endpoint-no-model') + ); + expect(result.current).toHaveLength(1); + expect(result.current[0].inference_id).toBe('endpoint-no-model'); + + const { result: result2 } = renderHook(() => + useFilteredInferenceEndpoints(endpointsWithNoModelId, emptyFilters, 'some-model-id') + ); + expect(result2.current).toHaveLength(0); + }); + + it('should search by service_settings.model field (alternative to model_id)', () => { + const endpointsWithModelField: InferenceAPIConfigResponse[] = [ + { + inference_id: 'bedrock-endpoint', + task_type: 'text_embedding', + service: 'amazonbedrock', + service_settings: { + model: 'amazon.titan-embed-text-v1', + }, + task_settings: {}, + }, + ]; + + const { result } = renderHook(() => + useFilteredInferenceEndpoints(endpointsWithModelField, emptyFilters, 'titan-embed') + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].inference_id).toBe('bedrock-endpoint'); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_table_data.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_filtered_endpoints.ts similarity index 54% rename from x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_table_data.tsx rename to x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_filtered_endpoints.ts index b98a2fa8f314e..e4de0980c51c8 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_table_data.tsx +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_filtered_endpoints.ts @@ -5,50 +5,41 @@ * 2.0. */ -import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; import { useMemo } from 'react'; +import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; import { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common'; -import type { InferenceInferenceEndpointInfo } from '@elastic/elasticsearch/lib/api/types'; -import type { FilterOptions } from '../components/all_inference_endpoints/types'; + +import type { FilterOptions } from '../types'; import { getModelId } from '../utils/get_model_id'; -/** - * Hook that filters inference endpoints based on provider, type, and search criteria. - * Sorting and pagination are handled by EuiInMemoryTable. - */ -export const useTableData = ( +export const useFilteredInferenceEndpoints = ( inferenceEndpoints: InferenceAPIConfigResponse[], filterOptions: FilterOptions, searchKey: string -): InferenceInferenceEndpointInfo[] => { - return useMemo(() => { +): InferenceAPIConfigResponse[] => { + const filteredData: InferenceAPIConfigResponse[] = useMemo(() => { let filteredEndpoints = inferenceEndpoints; - // Filter by provider if (filterOptions.provider.length > 0) { filteredEndpoints = filteredEndpoints.filter((endpoint) => filterOptions.provider.includes(ServiceProviderKeys[endpoint.service]) ); } - // Filter by task type if (filterOptions.type.length > 0) { filteredEndpoints = filteredEndpoints.filter((endpoint) => filterOptions.type.includes(endpoint.task_type) ); } - // Filter by search key (matches inference_id or model_id) - if (searchKey) { + return filteredEndpoints.filter((endpoint) => { const lowerSearchKey = searchKey.toLowerCase(); - filteredEndpoints = filteredEndpoints.filter((endpoint) => { - const inferenceIdMatch = endpoint.inference_id.toLowerCase().includes(lowerSearchKey); - const modelId = getModelId(endpoint); - const modelIdMatch = modelId ? modelId.toLowerCase().includes(lowerSearchKey) : false; - return inferenceIdMatch || modelIdMatch; - }); - } - - return filteredEndpoints; + const inferenceIdMatch = endpoint.inference_id.toLowerCase().includes(lowerSearchKey); + const modelId = getModelId(endpoint); + const modelIdMatch = modelId ? modelId.toLowerCase().includes(lowerSearchKey) : false; + return inferenceIdMatch || modelIdMatch; + }); }, [inferenceEndpoints, searchKey, filterOptions]); + + return filteredData; }; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_grouped_data.test.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_grouped_data.test.ts new file mode 100644 index 0000000000000..8362a57baa263 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_grouped_data.test.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 { renderHook } from '@testing-library/react'; + +import { InferenceEndpoints } from '../__mocks__/inference_endpoints'; + +import { UNKNOWN_MODEL_ID_FALLBACK } from '../utils/group_by'; +import { useGroupedData } from './use_grouped_data'; +import { GroupByOptions } from '../types'; + +describe('useGroupedData', () => { + it('should throw an error when groupBy is set to None', () => { + expect(() => + renderHook(() => + useGroupedData(InferenceEndpoints, GroupByOptions.None, { provider: [], type: [] }, '') + ) + ).toThrowError('Grouping is not enabled'); + }); + + it('should group endpoints by model_id', () => { + const { result } = renderHook(() => + useGroupedData(InferenceEndpoints, GroupByOptions.Model, { provider: [], type: [] }, '') + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('should return empty object when no endpoints provided', () => { + const { result } = renderHook(() => + useGroupedData([], GroupByOptions.Model, { provider: [], type: [] }, '') + ); + + expect(result.current).toEqual([]); + }); + + it('should sort elastic endpoints first when grouping by model', () => { + const { result } = renderHook(() => + useGroupedData(InferenceEndpoints, GroupByOptions.Model, { provider: [], type: [] }, '') + ); + + expect(result.current[0].groupId).toBe('elastic'); + }); + + it('should group endpoints with unknown model_id under unknown model group', () => { + const { result } = renderHook(() => + useGroupedData(InferenceEndpoints, GroupByOptions.Model, { provider: [], type: [] }, '') + ); + + const unknownModelGroup = result.current.find( + (group) => group.groupId === UNKNOWN_MODEL_ID_FALLBACK + ); + expect(unknownModelGroup).toBeDefined(); + expect(unknownModelGroup!.groupLabel).toBe('Unknown Model'); + expect(unknownModelGroup!.endpoints).toHaveLength(2); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_grouped_data.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_grouped_data.ts new file mode 100644 index 0000000000000..65dad7425236f --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_grouped_data.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 { useMemo } from 'react'; +import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; + +import { type FilterOptions, type GroupedInferenceEndpointsData, GroupByOptions } from '../types'; + +import { useFilteredInferenceEndpoints } from './use_filtered_endpoints'; +import { GroupByReducer, GroupBySort } from '../utils/group_by'; + +export type UseGroupedDataResult = GroupedInferenceEndpointsData[]; + +export const useGroupedData = ( + inferenceEndpoints: InferenceAPIConfigResponse[], + groupBy: GroupByOptions, + filterOptions: FilterOptions, + searchKey: string +): UseGroupedDataResult => { + if (groupBy === GroupByOptions.None) { + throw new Error('Grouping is not enabled'); + } + + const filteredEndpoints = useFilteredInferenceEndpoints( + inferenceEndpoints, + filterOptions, + searchKey + ); + + const groupedEndpoints = useMemo(() => { + const groupedEndpointsMap = filteredEndpoints.reduce< + Record + >(GroupByReducer(groupBy), {}); + const groupedEndpointList = Object.values(groupedEndpointsMap); + groupedEndpointList.sort(GroupBySort(groupBy)); + return groupedEndpointList; + }, [groupBy, filteredEndpoints]); + + return groupedEndpoints; +}; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_table_data.test.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_table_data.test.tsx deleted file mode 100644 index 0adf381ac5f59..0000000000000 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/hooks/use_table_data.test.tsx +++ /dev/null @@ -1,206 +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 { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; -import { renderHook } from '@testing-library/react'; -import { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common'; -import { useTableData } from './use_table_data'; -import type { FilterOptions } from '../components/all_inference_endpoints/types'; - -const inferenceEndpoints: InferenceAPIConfigResponse[] = [ - { - inference_id: 'my-elser-model-04', - task_type: 'sparse_embedding', - service: 'elasticsearch', - service_settings: { - num_allocations: 1, - num_threads: 1, - model_id: '.elser_model_2', - }, - task_settings: {}, - }, - { - inference_id: 'my-elser-model-01', - task_type: 'sparse_embedding', - service: 'elasticsearch', - service_settings: { - num_allocations: 1, - num_threads: 1, - model_id: '.elser_model_2', - }, - task_settings: {}, - }, - { - inference_id: 'my-openai-model-05', - task_type: 'text_embedding', - service: 'openai', - service_settings: { - url: 'https://somewhere.com', - model_id: 'third-party-model', - }, - task_settings: {}, - }, -]; - -describe('useTableData', () => { - it('should return all data when no filters are applied', () => { - const filterOptions: FilterOptions = { provider: [], type: [] }; - const { result } = renderHook(() => useTableData(inferenceEndpoints, filterOptions, '')); - - expect(result.current.length).toBe(3); - }); - - it('should filter data by provider', () => { - const filterOptions: FilterOptions = { - provider: [ServiceProviderKeys.elasticsearch], - type: [], - }; - const { result } = renderHook(() => useTableData(inferenceEndpoints, filterOptions, '')); - - expect(result.current.length).toBe(2); - expect(result.current.every((endpoint) => endpoint.service === 'elasticsearch')).toBe(true); - }); - - it('should filter data by task type', () => { - const filterOptions: FilterOptions = { provider: [], type: ['text_embedding'] }; - const { result } = renderHook(() => useTableData(inferenceEndpoints, filterOptions, '')); - - expect(result.current.length).toBe(1); - expect(result.current[0].task_type).toBe('text_embedding'); - }); - - it('should filter data by both provider and type', () => { - const filterOptions: FilterOptions = { - provider: [ServiceProviderKeys.elasticsearch], - type: ['sparse_embedding'], - }; - const { result } = renderHook(() => useTableData(inferenceEndpoints, filterOptions, '')); - - expect(result.current.length).toBe(2); - expect( - result.current.every( - (endpoint) => - endpoint.service === 'elasticsearch' && endpoint.task_type === 'sparse_embedding' - ) - ).toBe(true); - }); - - it('should filter data based on searchKey matching inference_id', () => { - const filterOptions: FilterOptions = { provider: [], type: [] }; - const { result } = renderHook(() => - useTableData(inferenceEndpoints, filterOptions, 'model-05') - ); - - expect(result.current.length).toBe(1); - expect(result.current[0].inference_id).toBe('my-openai-model-05'); - }); - - it('should filter data based on searchKey matching model_id', () => { - const filterOptions: FilterOptions = { provider: [], type: [] }; - const { result } = renderHook(() => - useTableData(inferenceEndpoints, filterOptions, 'third-party') - ); - - expect(result.current.length).toBe(1); - expect(result.current[0].inference_id).toBe('my-openai-model-05'); - expect(result.current[0].service_settings.model_id).toBe('third-party-model'); - }); - - it('should filter data case-insensitively', () => { - const filterOptions: FilterOptions = { provider: [], type: [] }; - const { result } = renderHook(() => useTableData(inferenceEndpoints, filterOptions, 'ELSER')); - - expect(result.current.length).toBe(2); - expect(result.current.every((item) => item.inference_id.includes('elser'))).toBe(true); - }); - - it('should combine provider, type, and search filters', () => { - const filterOptions: FilterOptions = { - provider: [ServiceProviderKeys.elasticsearch], - type: ['sparse_embedding'], - }; - const { result } = renderHook(() => - useTableData(inferenceEndpoints, filterOptions, 'model-01') - ); - - expect(result.current.length).toBe(1); - expect(result.current[0].inference_id).toBe('my-elser-model-01'); - }); - - it('should return empty array when no endpoints match filters', () => { - const filterOptions: FilterOptions = { provider: [ServiceProviderKeys.cohere], type: [] }; - const { result } = renderHook(() => useTableData(inferenceEndpoints, filterOptions, '')); - - expect(result.current.length).toBe(0); - }); - - it('should return empty array when inferenceEndpoints is empty', () => { - const filterOptions: FilterOptions = { provider: [], type: [] }; - const { result } = renderHook(() => useTableData([], filterOptions, '')); - - expect(result.current.length).toBe(0); - }); - - it('should filter by multiple providers', () => { - const filterOptions: FilterOptions = { - provider: [ServiceProviderKeys.elasticsearch, ServiceProviderKeys.openai], - type: [], - }; - const { result } = renderHook(() => useTableData(inferenceEndpoints, filterOptions, '')); - - expect(result.current.length).toBe(3); - }); - - it('should handle endpoints with no model_id in service_settings', () => { - const endpointsWithNoModelId: InferenceAPIConfigResponse[] = [ - { - inference_id: 'endpoint-no-model', - task_type: 'sparse_embedding', - service: 'elasticsearch', - service_settings: { - num_allocations: 1, - num_threads: 1, - }, - task_settings: {}, - }, - ]; - const filterOptions: FilterOptions = { provider: [], type: [] }; - - // Should still find by inference_id - const { result } = renderHook(() => - useTableData(endpointsWithNoModelId, filterOptions, 'endpoint-no-model') - ); - expect(result.current.length).toBe(1); - - // Should not match when searching for non-existent model_id - const { result: result2 } = renderHook(() => - useTableData(endpointsWithNoModelId, filterOptions, 'some-model-id') - ); - expect(result2.current.length).toBe(0); - }); - - it('should search by service_settings.model field (alternative to model_id)', () => { - const endpointsWithModelField: InferenceAPIConfigResponse[] = [ - { - inference_id: 'bedrock-endpoint', - task_type: 'text_embedding', - service: 'amazonbedrock', - service_settings: { - model: 'amazon.titan-embed-text-v1', - }, - task_settings: {}, - }, - ]; - const filterOptions: FilterOptions = { provider: [], type: [] }; - const { result } = renderHook(() => - useTableData(endpointsWithModelField, filterOptions, 'titan-embed') - ); - - expect(result.current.length).toBe(1); - expect(result.current[0].inference_id).toBe('bedrock-endpoint'); - }); -}); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/types.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/types.ts index 216e6405969d8..c492f98cb71a4 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/types.ts +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; import type { ConsolePluginSetup, ConsolePluginStart } from '@kbn/console-plugin/public'; import type { AppMountParameters, CoreStart } from '@kbn/core/public'; import type { MlPluginStart } from '@kbn/ml-plugin/public'; @@ -13,6 +14,8 @@ import type { SearchNavigationPluginStart } from '@kbn/search-navigation/public' import type { ServerlessPluginStart } from '@kbn/serverless/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { CloudStart } from '@kbn/cloud-plugin/public'; +import type { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common'; +import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types'; export * from '../common/types'; @@ -46,3 +49,19 @@ export interface InferenceUsageResponse { indexes: string[]; pipelines: string[]; } + +export enum GroupByOptions { + None = 'none', + Model = 'model_id', +} + +export interface FilterOptions { + provider: ServiceProviderKeys[]; + type: InferenceTaskType[]; +} + +export interface GroupedInferenceEndpointsData { + groupId: string; + groupLabel: string; + endpoints: InferenceAPIConfigResponse[]; +} diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/group_by.test.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/group_by.test.ts new file mode 100644 index 0000000000000..508124c1ad7dd --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/group_by.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InferenceEndpoints } from '../__mocks__/inference_endpoints'; + +import { type GroupedInferenceEndpointsData, GroupByOptions } from '../types'; +import { GroupByReducer, GroupBySort, UNKNOWN_MODEL_ID_FALLBACK } from './group_by'; +import { ELASTIC_GROUP_ID } from './known_models'; + +const makeGroup = (groupId: string, groupLabel: string): GroupedInferenceEndpointsData => ({ + groupId, + groupLabel, + endpoints: [], +}); + +describe('group by utils', () => { + describe('GroupByReducer', () => { + it('throws when grouping is not enabled', () => { + expect(() => GroupByReducer(GroupByOptions.None)).toThrow('Grouping is not enabled'); + }); + + describe('GroupByOptions.Model', () => { + const reducer = GroupByReducer(GroupByOptions.Model); + + it('groups endpoints matching a known model group by that group id', () => { + const anthropicEndpoints = InferenceEndpoints.filter((e) => + [ + '.anthropic-claude-3.7-sonnet-chat_completion', + '.anthropic-claude-3.7-sonnet-completion', + ].includes(e.inference_id) + ); + const result = anthropicEndpoints.reduce(reducer, {}); + + expect(Object.keys(result)).toEqual(['anthropic']); + expect(result.anthropic.endpoints).toHaveLength(2); + expect(result.anthropic.endpoints).toEqual(anthropicEndpoints); + }); + + it('groups Elastic-branded endpoints (jina, elser, rainbow-sprinkles, rerank) under the elastic group', () => { + const elasticModelEndpoints = InferenceEndpoints.filter((e) => + [ + '.elser-2-elastic', + '.jina-embeddings-v3', + '.rainbow-sprinkles-elastic', + '.rerank-v1-elasticsearch', + ].includes(e.inference_id) + ); + const result = elasticModelEndpoints.reduce(reducer, {}); + + expect(Object.keys(result)).toEqual([ELASTIC_GROUP_ID]); + expect(result[ELASTIC_GROUP_ID].endpoints).toHaveLength(4); + }); + + it('groups endpoints with no recognised model id by their model id directly', () => { + const unknownModelEndpoints = InferenceEndpoints.filter((e) => + ['.gp-llm-v2-chat_completion', '.gp-llm-v2-completion'].includes(e.inference_id) + ); + const result = unknownModelEndpoints.reduce(reducer, {}); + + expect(Object.keys(result)).toEqual(['gp-llm-v2']); + expect(result['gp-llm-v2'].groupLabel).toBe('gp-llm-v2'); + expect(result['gp-llm-v2'].endpoints).toHaveLength(2); + }); + + it('groups endpoints with no model id at all under the unknown model fallback', () => { + const noModelIdEndpoints = InferenceEndpoints.filter((e) => + [ + 'alibabacloud-endpoint-without-model-id', + 'hugging-face-endpoint-without-model-id', + ].includes(e.inference_id) + ); + const result = noModelIdEndpoints.reduce(reducer, {}); + + expect(Object.keys(result)).toEqual([UNKNOWN_MODEL_ID_FALLBACK]); + expect(result[UNKNOWN_MODEL_ID_FALLBACK].groupLabel).toBe('Unknown Model'); + expect(result[UNKNOWN_MODEL_ID_FALLBACK].endpoints).toHaveLength(2); + }); + + it('accumulates endpoints from different groups into separate keys', () => { + const mixed = InferenceEndpoints.filter((e) => + [ + '.anthropic-claude-3.7-sonnet-chat_completion', + '.google-gemini-2.5-flash-chat_completion', + '.openai-gpt-4.1-chat_completion', + ].includes(e.inference_id) + ); + const result = mixed.reduce(reducer, {}); + + expect(Object.keys(result).sort()).toEqual(['anthropic', 'google', 'openai']); + expect(result.anthropic.endpoints).toHaveLength(1); + expect(result.google.endpoints).toHaveLength(1); + expect(result.openai.endpoints).toHaveLength(1); + }); + }); + }); + describe('GroupBySort', () => { + describe('GroupByOptions.Model', () => { + const sort = GroupBySort(GroupByOptions.Model); + + it('returns 0 when both groups have the same label', () => { + const a = makeGroup('some-model', 'Some Model'); + const b = makeGroup('some-model', 'Some Model'); + expect(sort(a, b)).toBe(0); + }); + + it('sorts the Elastic group before any other group', () => { + const elastic = makeGroup(ELASTIC_GROUP_ID, 'Elastic'); + const other = makeGroup('openai', 'OpenAI'); + expect(sort(elastic, other)).toBe(-1); + expect(sort(other, elastic)).toBe(1); + }); + + it('sorts alphabetically by label when neither group is Elastic', () => { + const anthropic = makeGroup('anthropic', 'Anthropic'); + const openai = makeGroup('openai', 'OpenAI'); + expect(sort(anthropic, openai)).toBeLessThan(0); + expect(sort(openai, anthropic)).toBeGreaterThan(0); + }); + + it('keeps Elastic first even when compared with a label that sorts before "Elastic" alphabetically', () => { + const elastic = makeGroup(ELASTIC_GROUP_ID, 'Elastic'); + const aardvark = makeGroup('aardvark', 'Aardvark'); + expect(sort(elastic, aardvark)).toBe(-1); + expect(sort(aardvark, elastic)).toBe(1); + }); + }); + + describe('GroupByOptions.None (default)', () => { + const sort = GroupBySort(GroupByOptions.None); + + it('returns 0 when both groups have the same label', () => { + const a = makeGroup('model-a', 'Model A'); + const b = makeGroup('model-a', 'Model A'); + expect(sort(a, b)).toBe(0); + }); + + it('sorts alphabetically by label in ascending order', () => { + const alpha = makeGroup('alpha', 'Alpha'); + const beta = makeGroup('beta', 'Beta'); + expect(sort(alpha, beta)).toBeLessThan(0); + expect(sort(beta, alpha)).toBeGreaterThan(0); + }); + + it('does NOT give Elastic special priority', () => { + const elastic = makeGroup(ELASTIC_GROUP_ID, 'Elastic'); + const aardvark = makeGroup('aardvark', 'Aardvark'); + // 'Elastic' > 'Aardvark' alphabetically, so elastic should sort after + expect(sort(elastic, aardvark)).toBeGreaterThan(0); + expect(sort(aardvark, elastic)).toBeLessThan(0); + }); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/group_by.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/group_by.ts new file mode 100644 index 0000000000000..05563adcf3531 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/group_by.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; +import { i18n } from '@kbn/i18n'; + +import { type GroupedInferenceEndpointsData, GroupByOptions } from '../types'; + +import { getModelId } from './get_model_id'; +import { KNOWN_MODEL_GROUPS, ELASTIC_GROUP_ID } from './known_models'; + +export const UNKNOWN_MODEL_ID_FALLBACK = 'unknown_model'; +const UNKNOWN_MODEL_LABEL_FALLBACK = i18n.translate( + 'xpack.searchInferenceEndpoints.groupedEndpoints.unknownModelLabel', + { + defaultMessage: 'Unknown Model', + } +); + +export const GroupByModelReducer = ( + acc: Record, + endpoint: InferenceAPIConfigResponse +): Record => { + const modelId = getModelId(endpoint) ?? UNKNOWN_MODEL_ID_FALLBACK; + + // Test model against predefined known model groups. If it matches, group by the known group. + // otherwise group by the model ID. In the future endpoints should have metadata that allows for more robust grouping, + // but this is a start to make the UI more user friendly. + const knownGroup = KNOWN_MODEL_GROUPS.find((group) => group.groupTest(modelId)); + if (knownGroup) { + if (knownGroup.groupId in acc) { + acc[knownGroup.groupId].endpoints.push(endpoint); + } else { + acc[knownGroup.groupId] = { + groupId: knownGroup.groupId, + groupLabel: knownGroup.groupLabel, + endpoints: [endpoint], + }; + } + } else { + if (modelId in acc) { + acc[modelId].endpoints.push(endpoint); + } else { + acc[modelId] = { + groupId: modelId, + groupLabel: modelId === UNKNOWN_MODEL_ID_FALLBACK ? UNKNOWN_MODEL_LABEL_FALLBACK : modelId, + endpoints: [endpoint], + }; + } + } + + return acc; +}; + +export function GroupByReducer(groupBy: GroupByOptions) { + switch (groupBy) { + case GroupByOptions.Model: + return GroupByModelReducer; + case GroupByOptions.None: + default: + throw new Error('Grouping is not enabled'); + } +} + +export function defaultGroupedInferenceEndpointsDataCompare( + a: GroupedInferenceEndpointsData, + b: GroupedInferenceEndpointsData +) { + if (a.groupLabel === b.groupLabel) { + return 0; + } + if (a.groupLabel < b.groupLabel) { + return -1; + } + if (a.groupLabel > b.groupLabel) { + return 1; + } + return 0; +} + +export function ModelsGroupBySort( + a: GroupedInferenceEndpointsData, + b: GroupedInferenceEndpointsData +) { + if (a.groupLabel === b.groupLabel) { + return 0; + } + if (a.groupId === ELASTIC_GROUP_ID) { + return -1; + } + if (b.groupId === ELASTIC_GROUP_ID) { + return 1; + } + if (a.groupLabel < b.groupLabel) { + return -1; + } + if (a.groupLabel > b.groupLabel) { + return 1; + } + return 0; +} + +export function GroupBySort(groupBy: GroupByOptions) { + switch (groupBy) { + case GroupByOptions.Model: + return ModelsGroupBySort; + default: + return defaultGroupedInferenceEndpointsDataCompare; + } +} diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/known_models.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/known_models.ts new file mode 100644 index 0000000000000..9d12a28f8507b --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/utils/known_models.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 { i18n } from '@kbn/i18n'; + +export const ELASTIC_GROUP_ID = 'elastic'; + +export interface KnownModelGroup { + groupId: string; + groupLabel: string; + groupTest: (modelId: string) => boolean; +} + +export const KNOWN_MODEL_GROUPS: KnownModelGroup[] = [ + { + groupId: ELASTIC_GROUP_ID, + groupLabel: i18n.translate('xpack.searchInferenceEndpoints.knownModelGroups.elastic.label', { + defaultMessage: 'Elastic', + }), + groupTest: (modelId: string) => { + const normalizedModelId = modelId.toLowerCase(); + return ( + normalizedModelId.includes('elser_model') || + normalizedModelId.includes('jina') || + normalizedModelId.includes('rainbow-sprinkles') || + normalizedModelId.includes('.rerank-v1') + ); + }, + }, + { + groupId: 'anthropic', + groupLabel: i18n.translate('xpack.searchInferenceEndpoints.knownModelGroups.anthropic.label', { + defaultMessage: 'Anthropic', + }), + groupTest: (modelId: string) => { + const normalizedModelId = modelId.toLowerCase(); + return normalizedModelId.includes('claude') || normalizedModelId.includes('anthropic'); + }, + }, + { + groupId: 'google', + groupLabel: i18n.translate('xpack.searchInferenceEndpoints.knownModelGroups.google.label', { + defaultMessage: 'Google', + }), + groupTest: (modelId: string) => { + const normalizedModelId = modelId.toLowerCase(); + return ( + normalizedModelId.includes('google-gemini') || + normalizedModelId.includes('google') || + normalizedModelId.includes('gemini') + ); + }, + }, + { + groupId: 'openai', + groupLabel: i18n.translate('xpack.searchInferenceEndpoints.knownModelGroups.openAI.label', { + defaultMessage: 'OpenAI', + }), + groupTest: (modelId: string) => { + const normalizedModelId = modelId.toLowerCase(); + return ( + normalizedModelId.includes('openai-gpt') || + normalizedModelId.includes('openai-text-embedding') || + normalizedModelId.includes('openai') + ); + }, + }, + { + groupId: 'multilingual-e5', + groupLabel: i18n.translate('xpack.searchInferenceEndpoints.knownModelGroups.e5.label', { + defaultMessage: 'Multilingual E5', + }), + groupTest: (modelId: string) => modelId.toLowerCase().includes('multilingual-e5'), + }, +]; diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts b/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts index e45d0d6ffd45d..72acf6ab08384 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts +++ b/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts @@ -139,7 +139,8 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout schema.object({ _id: schema.string(), _index: schema.string(), - }) + }), + { maxSize: 10000 } ) ), }), diff --git a/x-pack/solutions/search/test/functional_search/tests/inference_management.ts b/x-pack/solutions/search/test/functional_search/tests/inference_management.ts index c81406e0ca2fd..de2226367d802 100644 --- a/x-pack/solutions/search/test/functional_search/tests/inference_management.ts +++ b/x-pack/solutions/search/test/functional_search/tests/inference_management.ts @@ -44,21 +44,73 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('endpoint tabular view', () => { - it('is loaded successfully', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectHeaderToBeExist(); - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectTabularViewToBeLoaded(); + describe('group by', () => { + it('defaults to group by models', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupBySelection( + 'Models' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByViewToBeDisplayed(); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByTable( + 'elastic' + ); + }); + + it('can switch to group by none', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.selectGroupByOption( + 'none' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupBySelection( + 'None' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectTabularViewToBeLoaded(); + }); + + it('can collapse group accordions', async () => { + const modelGroup = 'elastic'; + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByViewToBeDisplayed(); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByAccordionsToBeOpen( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.toggleGroupByAccordion( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByAccordionsToBeClosed( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.toggleGroupByAccordion( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByAccordionsToBeOpen( + modelGroup + ); + }); }); - - it('displays model column with model ids', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectModelColumnToBeDisplayed(); - }); - - it('can search by model name', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSearchByModelName(); - }); - - it('preconfigured endpoints can not be deleted', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectPreconfiguredEndpointsCannotBeDeleted(); + describe('group by None', () => { + beforeEach(async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupBySelection( + 'Models' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.selectGroupByOption( + 'none' + ); + }); + + it('is loaded successfully', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectHeaderToBeExist(); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectTabularViewToBeLoaded(); + }); + + it('displays model column with model ids', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectModelColumnToBeDisplayed(); + }); + + it('can search by model name', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSearchByModelName(); + }); + + it('preconfigured endpoints can not be deleted', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectPreconfiguredEndpointsCannotBeDeleted(); + }); }); it('displays endpoint stats bar with counts', async () => { diff --git a/x-pack/solutions/search/test/functional_search/tests/search_index_details.ts b/x-pack/solutions/search/test/functional_search/tests/search_index_details.ts index 6ead91b80f7fc..047e361cabdbe 100644 --- a/x-pack/solutions/search/test/functional_search/tests/search_index_details.ts +++ b/x-pack/solutions/search/test/functional_search/tests/search_index_details.ts @@ -203,6 +203,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('add field button is enabled', async () => { await pageObjects.searchIndexDetailsPage.changeTab('mappingsTab'); + await pageObjects.searchIndexDetailsPage.dismissIngestTourIfShown(); await pageObjects.searchIndexDetailsPage.expectAddFieldToBeEnabled(); }); it('edit settings button is enabled', async () => { diff --git a/x-pack/solutions/search/test/page_objects/inference_management_page.ts b/x-pack/solutions/search/test/page_objects/inference_management_page.ts index 8475d8f1be79f..57bd1c789ea52 100644 --- a/x-pack/solutions/search/test/page_objects/inference_management_page.ts +++ b/x-pack/solutions/search/test/page_objects/inference_management_page.ts @@ -11,6 +11,7 @@ import type { FtrProviderContext } from './ftr_provider_context'; export function SearchInferenceManagementPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); + const find = getService('find'); const retry = getService('retry'); return { @@ -189,6 +190,64 @@ export function SearchInferenceManagementPageProvider({ getService }: FtrProvide await elserCopyEndpointId.click(); expect((await browser.getClipboardValue()).includes('.elser-2-elasticsearch')).to.be(true); }, + + async expectGroupBySelection(label: string) { + await testSubjects.existOrFail('group-by-select'); + await testSubjects.existOrFail('group-by-button'); + expect(await testSubjects.getVisibleText('group-by-button')).contain(label); + }, + + async selectGroupByOption(key: string) { + await testSubjects.existOrFail('group-by-button'); + await testSubjects.click('group-by-button'); + await testSubjects.existOrFail('group-by-selectable'); + await testSubjects.existOrFail(`group-by-option-${key}`); + await testSubjects.click(`group-by-option-${key}`); + }, + + async expectGroupByViewToBeDisplayed() { + await testSubjects.existOrFail('group-by-tables-container'); + await testSubjects.missingOrFail('inferenceEndpointTable'); + }, + + async expectGroupByTable(groupId: string) { + await testSubjects.existOrFail(`${groupId}-accordion`); + await testSubjects.existOrFail(`${groupId}-table`); + }, + + async expectGroupByAccordionsToBeOpen(groupId: string) { + await testSubjects.existOrFail(`${groupId}-accordion`); + + await retry.tryWithRetries( + `Waiting for ${groupId} accordion to be open`, + async () => { + const isOpen = + (await ( + await find.byCssSelector(`[aria-controls="${groupId}-group-accordion"]`) + ).getAttribute('aria-expanded')) === 'true'; + expect(isOpen).equal(true, `${groupId} accordion is closed`); + }, + { timeout: 5000, retryCount: 5 } + ); + }, + async expectGroupByAccordionsToBeClosed(groupId: string) { + await testSubjects.existOrFail(`${groupId}-accordion`); + await retry.tryWithRetries( + `Waiting for ${groupId} accordion to be closed`, + async () => { + const isClosed = + (await ( + await find.byCssSelector(`[aria-controls="${groupId}-group-accordion"]`) + ).getAttribute('aria-expanded')) === 'false'; + expect(isClosed).equal(true, `${groupId} accordion is still open`); + }, + { timeout: 5000, retryCount: 5 } + ); + }, + async toggleGroupByAccordion(groupId: string) { + await testSubjects.existOrFail(`${groupId}-accordion`); + await (await find.byCssSelector(`[aria-controls="${groupId}-group-accordion"]`)).click(); + }, }, AddInferenceFlyout: { diff --git a/x-pack/solutions/search/test/page_objects/search_index_details_page.ts b/x-pack/solutions/search/test/page_objects/search_index_details_page.ts index 1312c3fd8b9ee..522c2e21b39ba 100644 --- a/x-pack/solutions/search/test/page_objects/search_index_details_page.ts +++ b/x-pack/solutions/search/test/page_objects/search_index_details_page.ts @@ -364,5 +364,12 @@ export function SearchIndexDetailPageProvider({ getService }: FtrProviderContext const isMappingsFieldEnabled = await testSubjects.isEnabled('indexDetailsMappingsAddField'); expect(isMappingsFieldEnabled).to.be(true); }, + + async dismissIngestTourIfShown() { + if (await testSubjects.exists('searchIngestTourCloseButton')) { + await testSubjects.click('searchIngestTourCloseButton'); + await testSubjects.missingOrFail('searchIngestTourCloseButton', { timeout: 2000 }); + } + }, }; } diff --git a/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts b/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts index 433ce354587e3..795edead67d75 100644 --- a/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts +++ b/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts @@ -30,21 +30,73 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('endpoint tabular view', () => { - it('is loaded successfully', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectHeaderToBeExist(); - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectTabularViewToBeLoaded(); - }); + describe('group by', () => { + it('defaults to group by models', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupBySelection( + 'Models' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByViewToBeDisplayed(); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByTable( + 'elastic' + ); + }); + it('can switch to group by none', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.selectGroupByOption( + 'none' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupBySelection( + 'None' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectTabularViewToBeLoaded(); + }); - it('displays model column with model ids', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectModelColumnToBeDisplayed(); + it('can collapse group accordions', async () => { + const modelGroup = 'elastic'; + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByViewToBeDisplayed(); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByAccordionsToBeOpen( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.toggleGroupByAccordion( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByAccordionsToBeClosed( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.toggleGroupByAccordion( + modelGroup + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupByAccordionsToBeOpen( + modelGroup + ); + }); }); - it('can search by model name', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSearchByModelName(); - }); + describe('group by None', () => { + beforeEach(async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectGroupBySelection( + 'Models' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.selectGroupByOption( + 'none' + ); + }); + + it('is loaded successfully', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectHeaderToBeExist(); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectTabularViewToBeLoaded(); + }); + + it('displays model column with model ids', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectModelColumnToBeDisplayed(); + }); + + it('can search by model name', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSearchByModelName(); + }); - it('preconfigured endpoints can not be deleted', async () => { - await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectPreconfiguredEndpointsCannotBeDeleted(); + it('preconfigured endpoints can not be deleted', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectPreconfiguredEndpointsCannotBeDeleted(); + }); }); it('displays endpoint stats bar with counts', async () => { diff --git a/x-pack/solutions/search/test/serverless/functional/test_suites/search_index_detail.ts b/x-pack/solutions/search/test/serverless/functional/test_suites/search_index_detail.ts index 847129d635adf..0a8cc315279f2 100644 --- a/x-pack/solutions/search/test/serverless/functional/test_suites/search_index_detail.ts +++ b/x-pack/solutions/search/test/serverless/functional/test_suites/search_index_detail.ts @@ -188,6 +188,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('add field button is enabled', async () => { await pageObjects.searchIndexDetailsPage.changeTab('mappingsTab'); + await pageObjects.searchIndexDetailsPage.dismissIngestTourIfShown(); await pageObjects.searchIndexDetailsPage.expectAddFieldToBeEnabled(); }); it('edit settings button is enabled', async () => { diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts index 1a52025a9bcda..51a7d2dd458db 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts @@ -9,6 +9,8 @@ import { schema } from '@kbn/config-schema'; export const INDEX_PATTERN_REGEX = /^[^A-Z^\\/?"<>|\s#,]+$/; +const PINNED_IDS_MAX_SIZE = 1024; + /** * Entity ID for relationship queries. * isOrigin indicates whether this entity is the center/origin of the graph @@ -23,6 +25,7 @@ export const graphRequestSchema = schema.object({ nodesLimit: schema.maybe(schema.number()), showUnknownTarget: schema.maybe(schema.boolean()), query: schema.object({ + pinnedIds: schema.maybe(schema.arrayOf(schema.string(), { maxSize: PINNED_IDS_MAX_SIZE })), // Origin event IDs - optional, may be empty when opening from entity flyout originEventIds: schema.maybe( schema.arrayOf(schema.object({ id: schema.string(), isAlert: schema.boolean() })) diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx index 28e32448b0257..61ba59cbcd46d 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx @@ -118,6 +118,26 @@ describe('', () => { }); }); + it('should render relationship node', async () => { + const { container } = renderGraphPreview({ + nodes: [ + { + id: 'rel1', + label: 'Owns', + shape: 'relationship', + }, + ], + edges: [], + interactive: false, + }); + + await waitFor(() => { + const nodeEl = container.querySelector('[data-id="rel1"]'); + expect(nodeEl).not.toBeNull(); + expect(nodeEl).toHaveTextContent('Owns'); + }); + }); + it('should render 2 nodes connected', async () => { const { container } = renderGraphPreview({ nodes: [ @@ -575,6 +595,63 @@ describe('', () => { }); }); + it('should center on origin relationship nodes for entity origin scenario', async () => { + const props = { + nodes: [ + { id: 'originEntity', label: 'Origin Entity', color: 'primary', shape: 'ellipse' }, + { id: 'target1', label: 'Target 1', color: 'primary', shape: 'hexagon' }, + { id: 'target2', label: 'Target 2', color: 'primary', shape: 'rectangle' }, + { id: 'rel-owns', label: 'Owns', shape: 'relationship', isOrigin: true }, + { + id: 'rel-communicates', + label: 'Communicates with', + shape: 'relationship', + isOrigin: true, + }, + { id: 'rel-depends', label: 'Depends on', shape: 'relationship' }, + ] as NodeViewModel[], + edges: [ + { id: 'e1', source: 'originEntity', target: 'rel-owns', color: 'primary' }, + { id: 'e2', source: 'originEntity', target: 'rel-communicates', color: 'primary' }, + { id: 'e3', source: 'rel-owns', target: 'target1', color: 'primary' }, + { id: 'e4', source: 'rel-communicates', target: 'target2', color: 'primary' }, + { id: 'e5', source: 'target1', target: 'rel-depends', color: 'primary' }, + ] as EdgeViewModel[], + interactive: true, + }; + + const { container, rerender } = render( + + + + ); + + await waitFor(() => { + expect(container.querySelectorAll('.react-flow__nodes .react-flow__node')).toHaveLength(6); + }); + + const newNodes: NodeViewModel[] = [ + { id: 'target1a', label: 'Target 1a', color: 'primary', shape: 'diamond' }, + ]; + + rerender( + + + + ); + + await waitFor(() => { + expect(container.querySelectorAll('.react-flow__nodes .react-flow__node')).toHaveLength(7); + }); + + await waitFor(() => { + expect(mockFitView).toHaveBeenCalledWith({ + ...fitViewOptions, + nodes: [{ id: newNodes[0].id }], + }); + }); + }); + it('should handle mixed valid and invalid node IDs', async () => { const onCenterGraphAfterRefresh = jest .fn() diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.test.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.test.ts index 09855705419f5..0c6b19290d996 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.test.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.test.ts @@ -268,4 +268,154 @@ describe('layoutGraph', () => { expect(typeof target!.position.y).toBe('number'); }); }); + + describe('mixed label and relationship nodes', () => { + it('should position label and relationship nodes at distinct coordinates', () => { + // Graph structure: Actor → RelationshipNode → Target1 + // → LabelNode → Target2 + const nodes: Array> = [ + { + id: 'actor', + type: 'entity', + position: { x: 0, y: 0 }, + data: { + id: 'actor', + label: 'Actor', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + }, + { + id: 'rel', + type: 'relationship', + position: { x: 0, y: 0 }, + data: { + id: 'rel', + label: 'Owns', + shape: 'relationship', + }, + }, + { + id: 'label', + type: 'label', + position: { x: 0, y: 0 }, + data: { + id: 'label', + label: 'Action', + color: 'primary', + shape: 'label', + }, + }, + { + id: 'target1', + type: 'entity', + position: { x: 0, y: 0 }, + data: { + id: 'target1', + label: 'Target 1', + color: 'primary', + shape: 'hexagon', + icon: 'storage', + }, + }, + { + id: 'target2', + type: 'entity', + position: { x: 0, y: 0 }, + data: { + id: 'target2', + label: 'Target 2', + color: 'primary', + shape: 'hexagon', + icon: 'storage', + }, + }, + ]; + + const edges: Array> = [ + { id: 'actor-rel', source: 'actor', target: 'rel' }, + { id: 'actor-label', source: 'actor', target: 'label' }, + { id: 'rel-target1', source: 'rel', target: 'target1' }, + { id: 'label-target2', source: 'label', target: 'target2' }, + ]; + + const result = layoutGraph(nodes, edges); + + const relNode = result.nodes.find((n) => n.id === 'rel'); + const labelNode = result.nodes.find((n) => n.id === 'label'); + const target1 = result.nodes.find((n) => n.id === 'target1'); + const target2 = result.nodes.find((n) => n.id === 'target2'); + + expect(relNode).toBeDefined(); + expect(labelNode).toBeDefined(); + expect(target1).toBeDefined(); + expect(target2).toBeDefined(); + + // Relationship and label nodes should not overlap + expect(relNode!.position.y).not.toBe(labelNode!.position.y); + + // Target nodes should have different Y coordinates + expect(target1!.position.y).not.toBe(target2!.position.y); + }); + + it('should layout relationship node connected between two entity nodes', () => { + // Graph structure: Entity1 → Relationship → Entity2 + const nodes: Array> = [ + { + id: 'entity1', + type: 'entity', + position: { x: 0, y: 0 }, + data: { + id: 'entity1', + label: 'Entity 1', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + }, + { + id: 'rel', + type: 'relationship', + position: { x: 0, y: 0 }, + data: { + id: 'rel', + label: 'Depends on', + shape: 'relationship', + }, + }, + { + id: 'entity2', + type: 'entity', + position: { x: 0, y: 0 }, + data: { + id: 'entity2', + label: 'Entity 2', + color: 'primary', + shape: 'hexagon', + icon: 'storage', + }, + }, + ]; + + const edges: Array> = [ + { id: 'e1-rel', source: 'entity1', target: 'rel' }, + { id: 'rel-e2', source: 'rel', target: 'entity2' }, + ]; + + const result = layoutGraph(nodes, edges); + + const entity1 = result.nodes.find((n) => n.id === 'entity1'); + const relNode = result.nodes.find((n) => n.id === 'rel'); + const entity2 = result.nodes.find((n) => n.id === 'entity2'); + + expect(entity1).toBeDefined(); + expect(relNode).toBeDefined(); + expect(entity2).toBeDefined(); + + // Nodes should be laid out left-to-right (increasing X) + expect(relNode!.position.x).toBeGreaterThan(entity1!.position.x); + expect(entity2!.position.x).toBeGreaterThan(relNode!.position.x); + }); + }); }); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts index 13800d1ea738b..b4adfdde8c2eb 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts @@ -43,7 +43,15 @@ export const layoutGraph = ( }) .setDefaultEdgeLabel(() => ({})); - edges.forEach((edge) => g.setEdge(edge.source, edge.target)); + // Build set of stacked node IDs (nodes with parentId) to filter edges + const stackedNodeIds = new Set(nodes.filter((node) => node.parentId).map((node) => node.id)); + + // Only add edges where both source and target are NOT stacked nodes + // Stacked nodes are positioned inside their parent group, not by Dagre + edges + .filter((edge) => !stackedNodeIds.has(edge.source) && !stackedNodeIds.has(edge.target)) + .forEach((edge) => g.setEdge(edge.source, edge.target)); + const nodesOfParent: { [key: string]: Array> } = {}; nodes.forEach((node) => { @@ -93,10 +101,14 @@ export const layoutGraph = ( Dagre.layout(g); - alignNodesCenterInPlace(g, (nodeId: string) => { - const node = nodesById[nodeId].data; - return node && isStackedLabel(node); - }); + alignNodesCenterInPlace( + g, + (nodeId: string) => { + const node = nodesById[nodeId].data; + return node && isStackedLabel(node); + }, + nodesById + ); const layoutedNodes = nodes.map((node) => { // For stacked nodes, we want to keep the original position relative to the parent @@ -191,6 +203,7 @@ const layoutStackedLabels = ( * Shared context for graph alignment operations. * - Y/Height/setY: accessors for node vertical position and height in Dagre * - prevNodeY: tracks original Y positions before adjustments for cascading calculations + * - nodesById: map of node ID to node data for accessing node properties */ interface GraphHelpers { g: Dagre.graphlib.Graph; @@ -199,6 +212,7 @@ interface GraphHelpers { Height: (id: string) => number; setY: (id: string, y: number) => number; prevNodeY: Record; + nodesById: Record>; } /** Returns child nodes (successors) that pass the filter. */ @@ -247,10 +261,21 @@ const findSiblingsWithSharedChildren = ( return siblingsWithSharedChildren; }; +/** + * Calculates the center Y position from a set of node IDs. + */ +const calculateCenterY = (nodeIds: string[], Y: (id: string) => number): number => { + if (nodeIds.length === 0) return 0; + + const first = nodeIds.reduce((min, nodeId) => (Y(nodeId) < Y(min) ? nodeId : min), nodeIds[0]); + const last = nodeIds.reduce((max, nodeId) => (Y(nodeId) > Y(max) ? nodeId : max), nodeIds[0]); + return Y(first) + (Y(last) - Y(first)) / 2; +}; + /** * Positions a node with multiple children at the vertical center of its children. * If siblings share the same children (fan-in pattern), distributes them evenly - * around that center to prevent overlap while maintaining visual balance. + * around a common center (based on union of all siblings' children) to prevent overlap. */ const handleMultipleChildren = ( helpers: GraphHelpers, @@ -260,16 +285,6 @@ const handleMultipleChildren = ( const { g, filter, Y, Height, setY, prevNodeY } = helpers; const currY = Y(currNode); - const first = children.reduce( - (min, childNode) => (Y(childNode) < Y(min) ? childNode : min), - children[0] - ); - const last = children.reduce( - (max, childNode) => (Y(childNode) > Y(max) ? childNode : max), - children[0] - ); - const centerY = Y(first) + (Y(last) - Y(first)) / 2; - const parents = getFilteredPredecessors(g, currNode, filter); const siblingsWithSharedChildren = findSiblingsWithSharedChildren( helpers, @@ -279,17 +294,25 @@ const handleMultipleChildren = ( ); if (siblingsWithSharedChildren.length > 1) { - siblingsWithSharedChildren.sort((a, b) => Y(a) - Y(b)); + // Calculate common centerY from union of ALL children of ALL siblings + const allChildrenSet = new Set(); + for (const sibling of siblingsWithSharedChildren) { + const siblingChildren = getFilteredSuccessors(g, sibling, filter); + siblingChildren.forEach((child) => allChildrenSet.add(child)); + } + const allChildren = Array.from(allChildrenSet); + const commonCenterY = calculateCenterY(allChildren, Y); const siblingIndex = siblingsWithSharedChildren.indexOf(currNode); const siblingCount = siblingsWithSharedChildren.length; const spacing = Height(currNode) + GRID_SIZE_OFFSET; const totalHeight = (siblingCount - 1) * spacing; - const newY = centerY - totalHeight / 2 + siblingIndex * spacing; + const newY = commonCenterY - totalHeight / 2 + siblingIndex * spacing; prevNodeY[currNode] = currY; setY(currNode, snapped(newY)); } else { + const centerY = calculateCenterY(children, Y); prevNodeY[currNode] = currY; setY(currNode, snapped(centerY)); } @@ -401,7 +424,11 @@ const handleSingleParent = (helpers: GraphHelpers, currNode: string, parent: str * Runs in O(V + E) on Dagre's directed graphs. * Mutates the Dagre graph in place. */ -const alignNodesCenterInPlace = (g: Dagre.graphlib.Graph, filter: (node: string) => boolean) => { +const alignNodesCenterInPlace = ( + g: Dagre.graphlib.Graph, + filter: (node: string) => boolean, + nodesById: Record> +) => { const helpers: GraphHelpers = { g, filter, @@ -409,6 +436,7 @@ const alignNodesCenterInPlace = (g: Dagre.graphlib.Graph, filter: (node: string) Height: (id: string) => (g.node(id) as Dagre.Node).height, setY: (id: string, y: number) => ((g.node(id) as Dagre.Node).y = y), prevNodeY: {}, + nodesById, }; const topo = topsort(g, filter); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_centering.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_centering.stories.tsx index ab1e7817209f5..6663e1a8e0184 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_centering.stories.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_centering.stories.tsx @@ -96,6 +96,22 @@ const createEntityNode = ({ : 'globe', } as NodeViewModel); +const createRelationshipStoryNode = ({ + id, + label, + isOrigin = false, +}: { + id: string; + label: string; + isOrigin?: boolean; +}): NodeViewModel => + ({ + id, + shape: 'relationship', + label, + isOrigin, + } as NodeViewModel); + const createLabelNode = ({ id, isOrigin = false, @@ -182,6 +198,55 @@ export const SingleOriginEvent: Story = { }, }; +export const SingleOriginEntity: Story = { + args: { + title: 'Single Origin Entity', + description: + 'Graph with one origin entity that has two relationship nodes to two targets. Each target has another relationship to a further target. Relationship nodes connected to the origin entity are marked as origin for centering.', + nodes: [ + // Origin entity + createEntityNode({ id: 'originEntity', shape: 'ellipse' }), + + // First-level targets + createEntityNode({ id: 'target1', shape: 'hexagon' }), + createEntityNode({ id: 'target2', shape: 'rectangle' }), + + // Second-level targets + createEntityNode({ id: 'target1a', shape: 'diamond' }), + createEntityNode({ id: 'target2a', shape: 'pentagon' }), + + // Relationship nodes from origin entity (marked as origin for centering) + createRelationshipStoryNode({ id: 'rel-owns', label: 'Owns', isOrigin: true }), + createRelationshipStoryNode({ + id: 'rel-communicates', + label: 'Communicates with', + isOrigin: true, + }), + + createRelationshipStoryNode({ id: 'rel-depends', label: 'Depends on' }), + createRelationshipStoryNode({ id: 'rel-supervises', label: 'Supervises' }), + ], + edges: [ + // Origin entity -> relationship nodes + createEdge({ id: 'origin-to-owns', source: 'originEntity', target: 'rel-owns' }), + createEdge({ + id: 'origin-to-communicates', + source: 'originEntity', + target: 'rel-communicates', + }), + + createEdge({ id: 'owns-to-target1', source: 'rel-owns', target: 'target1' }), + createEdge({ id: 'communicates-to-target2', source: 'rel-communicates', target: 'target2' }), + + createEdge({ id: 'target1-to-depends', source: 'target1', target: 'rel-depends' }), + createEdge({ id: 'target2-to-supervises', source: 'target2', target: 'rel-supervises' }), + + createEdge({ id: 'depends-to-target1a', source: 'rel-depends', target: 'target1a' }), + createEdge({ id: 'supervises-to-target2a', source: 'rel-supervises', target: 'target2a' }), + ], + }, +}; + export const SingleOriginAlert: Story = { args: { title: 'Single Origin Alert', diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.stories.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.stories.test.tsx index f2d9f4355d57e..17cb1c3fd80fc 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.stories.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.stories.test.tsx @@ -23,6 +23,8 @@ import { GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENT_DETAILS_ITEM_ID, GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_ITEM_ID, GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_TOOLTIP_ID, + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_TOOLTIP_ID, } from '../test_ids'; import * as previewAnnotations from '../../../.storybook/preview'; import { NOTIFICATIONS_ADD_ERROR_ACTION } from '../../../.storybook/constants'; @@ -283,6 +285,51 @@ describe('GraphInvestigation Component', () => { expect(tooltip).toHaveTextContent('Details not available'); }); }); + + it('shows the option `Show entity relationships` as enabled when entity node is enriched', async () => { + const { container, getByTestId } = renderStory(); + + await expandNode(container, 'projects/your-project-id/roles/customRole'); + + const showRelationshipsItem = getByTestId( + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + expect(showRelationshipsItem).toHaveTextContent('Show entity relationships'); + expect(showRelationshipsItem).not.toHaveAttribute('disabled'); + }); + + it('shows the option `Show entity relationships` as disabled when entity node is not enriched', async () => { + const { container, getByTestId, queryByTestId } = renderStory(); + + await expandNode(container, 'admin@example.com'); + const showRelationshipsItem = getByTestId( + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + expect(showRelationshipsItem).toHaveTextContent('Show entity relationships'); + expect(showRelationshipsItem).toHaveAttribute('disabled'); + + // can't use userEvent.hover since we get the following error: + // 'Unable to perform pointer interaction as the element has pointer-events: none:' + fireEvent.mouseOver(showRelationshipsItem); + + // Wait for tooltip and execute validation + await waitAndExecute(() => { + const tooltip = queryByTestId(GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_TOOLTIP_ID); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Entity relationships not available'); + }); + }); + + it('does not show `Show entity relationships` option for grouped entities', async () => { + const { container, queryByTestId } = renderGroupedActorStory(); + + await expandNode(container, 'mixed-entities'); + + const showRelationshipsItem = queryByTestId( + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + expect(showRelationshipsItem).not.toBeInTheDocument(); + }); }); describe('searchBar', () => { diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx index 9dc9285bf59d2..434f3c566e7b9 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx @@ -17,6 +17,10 @@ import { Panel } from '@xyflow/react'; import { getEsQueryConfig } from '@kbn/data-service'; import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; import useSessionStorage from 'react-use/lib/useSessionStorage'; +import { + GRAPH_ACTOR_ENTITY_FIELDS, + GRAPH_TARGET_ENTITY_FIELDS, +} from '@kbn/cloud-security-posture-common/constants'; import { Graph, isEntityNode, type NodeProps } from '../../..'; import { Callout } from '../callout/callout'; import { type UseFetchGraphDataParams, useFetchGraphData } from '../../hooks/use_fetch_graph_data'; @@ -30,11 +34,15 @@ import { analyzeDocuments } from '../node/label_node/analyze_documents'; import { EVENT_ID, GRAPH_NODES_LIMIT, TOGGLE_SEARCH_BAR_STORAGE_KEY } from '../../common/constants'; import { Actions } from '../controls/actions'; import { AnimatedSearchBarContainer, useBorder } from './styles'; -import { CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER, addFilter } from './search_filters'; +import { + CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER, + addFilter, + getFilterValues, +} from './search_filters'; import { useEntityNodeExpandPopover } from '../popovers/node_expand/use_entity_node_expand_popover'; import { useLabelNodeExpandPopover } from '../popovers/node_expand/use_label_node_expand_popover'; import type { NodeViewModel } from '../types'; -import { isLabelNode, showErrorToast } from '../utils'; +import { isLabelNode, isRelationshipNode, showErrorToast } from '../utils'; import { GRAPH_SCOPE_ID } from '../constants'; const useGraphPopovers = ({ @@ -43,12 +51,16 @@ const useGraphPopovers = ({ setSearchFilters, nodeDetailsClickHandler, onOpenNetworkPreview, + expandedEntityIds, + onToggleEntityRelationships, }: { dataViewId: string; searchFilters: Filter[]; setSearchFilters: React.Dispatch>; nodeDetailsClickHandler?: (node: NodeProps) => void; onOpenNetworkPreview?: (ip: string, scopeId: string) => void; + expandedEntityIds: Set; + onToggleEntityRelationships: (node: NodeProps, action: 'show' | 'hide') => void; }) => { const [currentIps, setCurrentIps] = useState([]); const [currentCountryCodes, setCurrentCountryCodes] = useState([]); @@ -60,7 +72,9 @@ const useGraphPopovers = ({ setSearchFilters, dataViewId, searchFilters, - nodeDetailsClickHandler + nodeDetailsClickHandler, + expandedEntityIds, + onToggleEntityRelationships ); const labelExpandPopover = useLabelNodeExpandPopover( setSearchFilters, @@ -163,8 +177,9 @@ export interface GraphInvestigationProps { /** * The origin events for the graph investigation view. + * Optional - may be empty when opening from entity flyout. */ - originEventIds: Array<{ + originEventIds?: Array<{ /** * The ID of the origin event. */ @@ -176,6 +191,22 @@ export interface GraphInvestigationProps { isAlert: boolean; }>; + /** + * Entity IDs for fetching relationships from entity store. + * isOrigin indicates whether this entity is the center/origin of the graph. + */ + entityIds?: Array<{ + /** + * The ID of the entity. + */ + id: string; + + /** + * Whether this entity is the origin of the graph (for centering). + */ + isOrigin: boolean; + }>; + /** * The initial timerange for the graph investigation view. */ @@ -221,7 +252,13 @@ type EsQuery = UseFetchGraphDataParams['req']['query']['esQuery']; */ export const GraphInvestigation = memo( ({ - initialState: { indexPatterns, dataView, originEventIds, timeRange: initialTimeRange }, + initialState: { + indexPatterns, + dataView, + originEventIds, + entityIds, + timeRange: initialTimeRange, + }, showInvestigateInTimeline = false, showToggleSearch = false, onInvestigateInTimeline, @@ -237,6 +274,32 @@ export const GraphInvestigation = memo( const lastValidEsQuery = useRef(); const [kquery, setKQuery] = useState(EMPTY_QUERY); + // Track which entities have their relationships expanded + const [expandedEntityIds, setExpandedEntityIds] = useState>(() => new Set()); + + // Convert expandedEntityIds Set to API format + const entityIdsForApi = useMemo(() => { + if (expandedEntityIds.size === 0) return undefined; + + return Array.from(expandedEntityIds).map((id) => ({ + id, + isOrigin: false, // User-expanded entities are not the graph origin + })); + }, [expandedEntityIds]); + + // Toggle handler for entity relationships + const onToggleEntityRelationships = useCallback((node: NodeProps, action: 'show' | 'hide') => { + setExpandedEntityIds((prev) => { + const next = new Set(prev); + if (action === 'show') { + next.add(node.id); + } else { + next.delete(node.id); + } + return next; + }); + }, []); + const onInvestigateInTimelineCallback = useCallback(() => { const query = { ...kquery }; @@ -244,7 +307,7 @@ export const GraphInvestigation = memo( const hasKqlQuery = query.query.trim() !== ''; - if (originEventIds.length > 0) { + if (originEventIds && originEventIds.length > 0) { if (!hasKqlQuery || searchFilters.length > 0) { filters = originEventIds.reduce((acc, { id }) => { return addFilter(dataView?.id ?? '', acc, EVENT_ID, id); @@ -284,6 +347,13 @@ export const GraphInvestigation = memo( return lastValidEsQuery.current; }, [dataView, kquery, notifications, searchFilters, uiSettings]); + const pinnedIds = useMemo(() => { + return getFilterValues(searchFilters, [ + ...GRAPH_ACTOR_ENTITY_FIELDS, + ...GRAPH_TARGET_ENTITY_FIELDS, + ]).map(String); + }, [searchFilters]); + const { data, refresh, isFetching, isError, error } = useFetchGraphData({ req: { query: { @@ -292,6 +362,8 @@ export const GraphInvestigation = memo( esQuery, start: timeRange.from, end: timeRange.to, + entityIds: entityIdsForApi, + pinnedIds, }, nodesLimit: GRAPH_NODES_LIMIT, }, @@ -331,6 +403,8 @@ export const GraphInvestigation = memo( setSearchFilters, nodeDetailsClickHandler: onOpenEventPreview ? nodeDetailsClickHandler : undefined, onOpenNetworkPreview, + expandedEntityIds, + onToggleEntityRelationships, }); const nodeExpandButtonClickHandler = (...args: unknown[]) => @@ -345,11 +419,13 @@ export const GraphInvestigation = memo( eventPopover, ].some(({ state: { isOpen } }) => isOpen); - const { originEventIdsSet, originAlertIdsSet } = useMemo(() => { + const { originEventIdsSet, originAlertIdsSet, originEntityIdsSet } = useMemo(() => { const eventIds = new Set(); const alertIds = new Set(); + const entityIdsWithOrigin = new Set(); - originEventIds.forEach(({ id, isAlert }) => { + // Add origin event IDs (for centering when opening from events flyout) + originEventIds?.forEach(({ id, isAlert }) => { if (isAlert) { alertIds.add(id); } else { @@ -357,11 +433,34 @@ export const GraphInvestigation = memo( } }); + // Add entity IDs that are marked as origin (for centering when opening from entity flyout) + entityIds?.forEach(({ id, isOrigin }) => { + if (isOrigin) { + entityIdsWithOrigin.add(id); + } + }); + return { originEventIdsSet: eventIds, originAlertIdsSet: alertIds, + originEntityIdsSet: entityIdsWithOrigin, }; - }, [originEventIds]); + }, [originEventIds, entityIds]); + + // Build a map of relationship node IDs to their source entities (from edges) + // This allows us to determine if a relationship node is connected to an origin entity + const relationshipNodeSources = useMemo(() => { + const sourcesMap = new Map(); + data?.edges?.forEach((edge) => { + // Check if target is a relationship node by checking if it starts with 'rel(' + if (edge.target.startsWith('rel(')) { + const sources = sourcesMap.get(edge.target) || []; + sources.push(edge.source); + sourcesMap.set(edge.target, sources); + } + }); + return sourcesMap; + }, [data?.edges]); const nodes = useMemo(() => { return ( @@ -398,13 +497,27 @@ export const GraphInvestigation = memo( countryClickHandler: createCountryClickHandler(nodeCountryCodes), eventClickHandler: createEventClickHandler(analysis, text), }; + } else if (isRelationshipNode(node)) { + // Check if any source entity connected to this relationship node is an origin + const sources = relationshipNodeSources.get(node.id) || []; + const isOrigin = sources.some((sourceId) => originEntityIdsSet.has(sourceId)); + return { + ...node, + ...(isOrigin && { isOrigin }), + }; } return { ...node }; }) ?? [] ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data?.nodes, originEventIdsSet, originAlertIdsSet]); + }, [ + data?.nodes, + originEventIdsSet, + originAlertIdsSet, + originEntityIdsSet, + relationshipNodeSources, + ]); // Get callout state based on current graph state const calloutState = useGraphCallout(nodes); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.test.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.test.ts index 78177f71327d9..e9b64f8d261fe 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.test.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.test.ts @@ -20,6 +20,7 @@ import { addFilter, containsFilter, removeFilter, + getFilterValues, } from './search_filters'; const dataViewId = 'test-data-view'; @@ -303,4 +304,72 @@ describe('search_filters', () => { expect(newFilters[1].meta.params).toHaveLength(2); }); }); + + describe('getFilterValues', () => { + it('should return empty array for empty filters', () => { + const filters: Filter[] = []; + + expect(getFilterValues(filters, key)).toEqual([]); + }); + + it('should return value from phrase filter with matching key', () => { + const filters: Filter[] = [buildFilterMock(key, value)]; + + expect(getFilterValues(filters, key)).toEqual([value]); + }); + + it('should return empty array when key does not match', () => { + const filters: Filter[] = [buildFilterMock('other-key', value)]; + + expect(getFilterValues(filters, key)).toEqual([]); + }); + + it('should return values from combined filter', () => { + const filters: Filter[] = [ + buildCombinedFilterMock([buildFilterMock(key, 'value1'), buildFilterMock(key, 'value2')]), + ]; + + expect(getFilterValues(filters, key)).toEqual(['value1', 'value2']); + }); + + it('should skip disabled filters', () => { + const disabledFilter = buildFilterMock(key, value); + disabledFilter.meta.disabled = true; + const filters: Filter[] = [disabledFilter, buildFilterMock(key, 'enabled-value')]; + + expect(getFilterValues(filters, key)).toEqual(['enabled-value']); + }); + + it('should handle multiple keys', () => { + const filters: Filter[] = [ + buildFilterMock('key1', 'value1'), + buildFilterMock('key2', 'value2'), + buildFilterMock('key3', 'value3'), + ]; + + expect(getFilterValues(filters, ['key1', 'key2'])).toEqual(['value1', 'value2']); + }); + + it('should return values from mixed phrase and combined filters', () => { + const filters: Filter[] = [ + buildFilterMock(key, 'phrase-value'), + buildCombinedFilterMock([ + buildFilterMock(key, 'combined-value1'), + buildFilterMock('other-key', 'other-value'), + ]), + ]; + + expect(getFilterValues(filters, key)).toEqual(['phrase-value', 'combined-value1']); + }); + + it('should handle readonly string array for keys', () => { + const readonlyKeys = ['key1', 'key2'] as const; + const filters: Filter[] = [ + buildFilterMock('key1', 'value1'), + buildFilterMock('key2', 'value2'), + ]; + + expect(getFilterValues(filters, readonlyKeys)).toEqual(['value1', 'value2']); + }); + }); }); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.ts index 75fd2f0576a42..05c0bd6468861 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.ts @@ -16,6 +16,7 @@ import type { Filter, PhraseFilter } from '@kbn/es-query'; import type { CombinedFilter, PhraseFilterMetaParams, + PhraseFilterValue, } from '@kbn/es-query/src/filters/build_filters'; export const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation'; @@ -167,3 +168,45 @@ export const removeFilter = (filters: Filter[], key: string, value: string) => { return filters; }; + +/** + * Helper function to extract filter value(s) from a single filter. + * Handles both simple phrase filters and combined filters recursively. + */ +const getFilterValue = ( + filter: Filter, + keys: string[] +): PhraseFilterValue[] | PhraseFilterValue | null => { + if (isCombinedFilter(filter)) { + return filter.meta.params + .map((param) => getFilterValue(param, keys)) + .filter((value): value is PhraseFilterValue | PhraseFilterValue[] => value !== null) + .flat(); + } + + return filter.meta.key && keys.includes(filter.meta.key) + ? (filter.meta.params as PhraseFilterMetaParams)?.query + : null; +}; + +/** + * Extracts all values from filters that match the specified keys. + * Handles both simple phrase filters and combined filters. + * Skips disabled filters. + * + * @param filters - The list of filters to extract values from. + * @param key - The key or array of keys to match against filter keys. + * @returns An array of all values from matching filters. + */ +export const getFilterValues = ( + filters: Filter[], + key: string | readonly string[] +): PhraseFilterValue[] => { + const keys = Array.isArray(key) ? key : [key]; + + return filters + .filter((filter) => !filter.meta.disabled) + .map((filter) => getFilterValue(filter, keys as string[])) + .filter((value): value is PhraseFilterValue | PhraseFilterValue[] => value !== null) + .flat(); +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx index a40214b1c3bc7..bf64beba57adf 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx @@ -18,12 +18,15 @@ import { GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID, GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID, GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_TOOLTIP_ID, } from '../../test_ids'; import { addFilter } from '../../graph_investigation/search_filters'; import { RELATED_ENTITY } from '../../../common/constants'; const mockSetSearchFilters = jest.fn(); const mockOnShowEntityDetailsClick = jest.fn(); +const mockOnToggleEntityRelationships = jest.fn(); const dataViewId = 'test-data-view'; @@ -141,7 +144,7 @@ describe('useEntityNodeExpandPopover', () => { }); describe('itemsFn - single-entity mode', () => { - it('should return all 4 menu items when docMode is single-entity and onShowEntityDetailsClick is provided', () => { + it('should return all menu items when docMode is single-entity and onShowEntityDetailsClick is provided', () => { const node = createMockNode('single-entity', 'user'); renderHook(() => useEntityNodeExpandPopover( @@ -155,23 +158,28 @@ describe('useEntityNodeExpandPopover', () => { expect(capturedItemsFn).not.toBeNull(); const items = capturedItemsFn!(node); - expect(items).toHaveLength(5); // 4 items + 1 separator + // 6 items: entity relationships, actions by, actions on, related, separator, entity details + expect(items).toHaveLength(6); expect(items[0]).toMatchObject({ type: 'item', - testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID, + testSubject: GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, }); expect(items[1]).toMatchObject({ type: 'item', - testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID, + testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID, }); expect(items[2]).toMatchObject({ type: 'item', - testSubject: GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID, + testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID, }); expect(items[3]).toMatchObject({ - type: 'separator', + type: 'item', + testSubject: GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID, }); expect(items[4]).toMatchObject({ + type: 'separator', + }); + expect(items[5]).toMatchObject({ type: 'item', testSubject: GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_ITEM_ID, disabled: false, @@ -428,4 +436,250 @@ describe('useEntityNodeExpandPopover', () => { } }); }); + + describe('entity relationships', () => { + it('should render "Show entity relationships" item for enriched single-entity node', () => { + const node = createMockNode('single-entity', 'user', true); + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + new Set(), + mockOnToggleEntityRelationships + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toBeDefined(); + expect(relationshipsItem).toMatchObject({ + type: 'item', + testSubject: GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, + disabled: false, + }); + if (relationshipsItem?.type === 'item') { + expect(relationshipsItem.label).toContain('Show entity relationships'); + } + }); + + it('should disable "Show entity relationships" when entity is not enriched', () => { + const node = createMockNode('single-entity', 'user', false); + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + new Set(), + mockOnToggleEntityRelationships + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toMatchObject({ + type: 'item', + testSubject: GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, + disabled: true, + showToolTip: true, + }); + if (relationshipsItem?.type === 'item') { + expect(relationshipsItem.toolTipText).toBe('Entity relationships not available'); + expect(relationshipsItem.toolTipProps).toMatchObject({ + position: 'bottom', + 'data-test-subj': GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_TOOLTIP_ID, + }); + } + }); + + it('should disable "Show entity relationships" when onToggleEntityRelationships is not provided', () => { + const node = createMockNode('single-entity', 'user', true); + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + new Set(), + undefined // No toggle callback + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toMatchObject({ + disabled: true, + showToolTip: true, + }); + }); + + it('should show "Hide entity relationships" when entity ID is in expandedEntityIds', () => { + const node = createMockNode('single-entity', 'user', true); + const expandedEntityIds = new Set([node.id]); + + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + expandedEntityIds, + mockOnToggleEntityRelationships + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toBeDefined(); + if (relationshipsItem?.type === 'item') { + expect(relationshipsItem.label).toContain('Hide entity relationships'); + expect(relationshipsItem.disabled).toBe(false); + } + }); + + it('should show "Show entity relationships" when entity ID is not in expandedEntityIds', () => { + const node = createMockNode('single-entity', 'user', true); + const expandedEntityIds = new Set(['other-entity-id']); + + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + expandedEntityIds, + mockOnToggleEntityRelationships + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toBeDefined(); + if (relationshipsItem?.type === 'item') { + expect(relationshipsItem.label).toContain('Show entity relationships'); + } + }); + + it('should not render "Show entity relationships" for grouped-entities mode', () => { + const node = createMockNode('grouped-entities', 'user', true); + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + new Set(), + mockOnToggleEntityRelationships + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toBeUndefined(); + }); + + it('should call onToggleEntityRelationships with correct action when clicked', () => { + const node = createMockNode('single-entity', 'user', true); + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + new Set(), + mockOnToggleEntityRelationships + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toBeDefined(); + if (relationshipsItem?.type === 'item' && relationshipsItem.onClick) { + relationshipsItem.onClick(); + expect(mockOnToggleEntityRelationships).toHaveBeenCalledWith(node, 'show'); + } + }); + + it('should call onToggleEntityRelationships with hide action when entity is expanded', () => { + const node = createMockNode('single-entity', 'user', true); + const expandedEntityIds = new Set([node.id]); + + renderHook(() => + useEntityNodeExpandPopover( + mockSetSearchFilters, + dataViewId, + searchFilters, + mockOnShowEntityDetailsClick, + expandedEntityIds, + mockOnToggleEntityRelationships + ) + ); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const relationshipsItem = items.find( + (item) => + item.type === 'item' && + item.testSubject === GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID + ); + + expect(relationshipsItem).toBeDefined(); + if (relationshipsItem?.type === 'item' && relationshipsItem.onClick) { + relationshipsItem.onClick(); + expect(mockOnToggleEntityRelationships).toHaveBeenCalledWith(node, 'hide'); + } + }); + }); }); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts index c76189e4ed91f..2b5ecaa8ca939 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts @@ -19,6 +19,8 @@ import { GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID, GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_ITEM_ID, GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_TOOLTIP_ID, + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_TOOLTIP_ID, } from '../../test_ids'; import type { ItemExpandPopoverListItemProps, @@ -81,7 +83,9 @@ export const useEntityNodeExpandPopover = ( setSearchFilters: React.Dispatch>, dataViewId: string, searchFilters: Filter[], - onShowEntityDetailsClick?: (node: NodeProps) => void + onShowEntityDetailsClick?: (node: NodeProps) => void, + expandedEntityIds?: Set, + onToggleEntityRelationships?: (node: NodeProps, action: NodeToggleAction) => void ) => { const onToggleExploreRelatedEntitiesClick = useCallback( (node: NodeProps, action: NodeToggleAction) => { @@ -141,6 +145,15 @@ export const useEntityNodeExpandPopover = ( ? 'hide' : 'show'; + // Determine if entity relationships are currently shown + const entityRelationshipsAction: NodeToggleAction = expandedEntityIds?.has(node.id) + ? 'hide' + : 'show'; + + // Entity relationships feature requires enriched entity and the toggle callback + const shouldDisableEntityRelationships = + !onToggleEntityRelationships || !isEntityNodeEnriched(node.data); + const shouldDisableEntityDetailsListItem = !onShowEntityDetailsClick || !['single-entity', 'grouped-entities'].includes(docMode) || @@ -186,6 +199,44 @@ export const useEntityNodeExpandPopover = ( // For 'single-entity', show filter actions + entity details if (docMode === 'single-entity') { return [ + { + type: 'item', + iconType: 'cluster', + testSubject: GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, + label: + entityRelationshipsAction === 'show' + ? i18n.translate( + 'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showEntityRelationships', + { + defaultMessage: 'Show entity relationships', + } + ) + : i18n.translate( + 'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideEntityRelationships', + { + defaultMessage: 'Hide entity relationships', + } + ), + disabled: shouldDisableEntityRelationships, + onClick: () => { + onToggleEntityRelationships?.(node, entityRelationshipsAction); + }, + showToolTip: shouldDisableEntityRelationships, + toolTipText: shouldDisableEntityRelationships + ? i18n.translate( + 'securitySolutionPackages.csp.graph.graphNodeExpandPopover.entityRelationshipsNotAvailable', + { + defaultMessage: 'Entity relationships not available', + } + ) + : undefined, + toolTipProps: shouldDisableEntityRelationships + ? { + position: 'bottom', + 'data-test-subj': GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_TOOLTIP_ID, + } + : undefined, + }, { type: 'item', iconType: 'sortRight', @@ -267,6 +318,8 @@ export const useEntityNodeExpandPopover = ( onToggleActionsOnEntityClick, onToggleExploreRelatedEntitiesClick, onShowEntityDetailsClick, + onToggleEntityRelationships, + expandedEntityIds, searchFilters, ] ); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts index cac20fab8fc01..a0fd9b8321b63 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts @@ -22,6 +22,12 @@ export const GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_ITEM_ID = export const GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_TOOLTIP_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowEntityDetailsTooltip` as const; +export const GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID = + `${GRAPH_INVESTIGATION_TEST_ID}ShowEntityRelationships` as const; + +export const GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_TOOLTIP_ID = + `${GRAPH_INVESTIGATION_TEST_ID}ShowEntityRelationshipsTooltip` as const; + export const GRAPH_LABEL_EXPAND_POPOVER_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}GraphLabelExpandPopover` as const; export const GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID = diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts index 7b74f455213be..28f3b7d99517d 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts @@ -85,13 +85,13 @@ export const useFetchGraphData = ({ options, }: UseFetchGraphDataParams): UseFetchGraphDataResult => { const queryClient = useQueryClient(); - const { esQuery, originEventIds, start, end } = req.query; + const { esQuery, originEventIds, entityIds, start, end, pinnedIds } = req.query; const { services: { http }, } = useKibana(); const QUERY_KEY = useMemo( - () => ['useFetchGraphData', originEventIds, start, end, esQuery], - [end, esQuery, originEventIds, start] + () => ['useFetchGraphData', originEventIds, entityIds, start, end, esQuery, pinnedIds], + [end, entityIds, esQuery, originEventIds, start, pinnedIds] ); const { isLoading, isError, data, isFetching, error } = useQuery( diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts index 3ab5ca790121f..78269af947e3f 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts @@ -40,6 +40,7 @@ interface BuildEsqlQueryParams { isEnrichPolicyExists: boolean; spaceId: string; alertsMappingsIncluded: boolean; + pinnedIds?: string[]; } /** @@ -56,6 +57,7 @@ export const fetchEvents = async ({ indexPatterns, spaceId, esQuery, + pinnedIds, }: { esClient: IScopedClusterClient; logger: Logger; @@ -66,6 +68,7 @@ export const fetchEvents = async ({ indexPatterns: string[]; spaceId: string; esQuery?: EsQuery; + pinnedIds?: string[]; }): Promise> => { const originAlertIds = originEventIds.filter((originEventId) => originEventId.isAlert); @@ -98,6 +101,7 @@ export const fetchEvents = async ({ isEnrichPolicyExists, spaceId, alertsMappingsIncluded, + pinnedIds, }); logger.trace(`Executing query [${query}]`); @@ -114,6 +118,7 @@ export const fetchEvents = async ({ ...originEventIds .filter((originEventId) => originEventId.isAlert) .map((originEventId, idx) => ({ [`og_alrt_id${idx}`]: originEventId.id })), + ...(pinnedIds ?? []).map((id, idx) => ({ [`pinned_id${idx}`]: id })), ], }) .toRecords(); @@ -196,6 +201,25 @@ const checkEnrichPolicyExists = async ( } }; +/** + * Generates ESQL statement for evaluating pinned IDs. + * This checks if the document _id, actorEntityId, or targetEntityId matches any of the pinned IDs. + */ +const buildPinnedEsql = (pinnedIds?: string[]): string => { + if (!pinnedIds || pinnedIds.length === 0) { + return '| EVAL pinned = TO_STRING(null)'; + } + + const pinnedParamsStr = pinnedIds.map((_id, idx) => `?pinned_id${idx}`).join(', '); + + return `| EVAL pinned = CASE( + _id IN (${pinnedParamsStr}), _id, + actorEntityId IN (${pinnedParamsStr}), actorEntityId, + targetEntityId IN (${pinnedParamsStr}), targetEntityId, + null + )`; +}; + /** * Generates ESQL statements for building entity fields with enrichment data. * This is used when entity store enrichment is available (via LOOKUP JOIN or ENRICH). @@ -253,6 +277,7 @@ const buildEsqlQuery = ({ isEnrichPolicyExists, spaceId, alertsMappingsIncluded, + pinnedIds, }: BuildEsqlQueryParams): string => { const SECURITY_ALERTS_PARTIAL_IDENTIFIER = '.alerts-security.alerts-'; const enrichPolicyName = getEnrichPolicyId(spaceId); @@ -293,6 +318,7 @@ const buildEsqlQuery = ({ ${targetEntityIdEvals} | MV_EXPAND actorEntityId | MV_EXPAND targetEntityId +${buildPinnedEsql(pinnedIds)} | EVAL actorEntityFieldHint = CASE( ${actorFieldHintCases}, "" @@ -425,9 +451,12 @@ ${buildEnrichedEntityFieldsEsql()} targetEntityType, targetEntitySubType, isOrigin, - isOriginAlert + isOriginAlert, + pinned +| EVAL pinnedSort = CASE(pinned IS NULL, 1, 0) +| SORT action DESC, pinnedSort ASC, isOrigin | LIMIT 1000 -| SORT action DESC, isOrigin`; +| DROP pinnedSort`; return query; }; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts index 7d5a56dcc3592..c111c311e3549 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts @@ -95,6 +95,7 @@ describe('fetchGraph', () => { indexPatterns: baseParams.indexPatterns, spaceId: baseParams.spaceId, esQuery: undefined, + pinnedIds: undefined, }); }); @@ -211,4 +212,30 @@ describe('fetchGraph', () => { 'Failed to fetch entity relationships: Connection refused' ); }); + + describe('Pinned IDs', () => { + it('should pass pinnedIds to fetchEvents when provided', async () => { + const pinnedIds = ['entity-1', 'entity-2']; + + await fetchGraph({ ...baseParams, pinnedIds }); + + expect(mockedFetchEvents).toHaveBeenCalledWith( + expect.objectContaining({ pinnedIds: ['entity-1', 'entity-2'] }) + ); + }); + + it('should pass pinnedIds as undefined to fetchEvents when not provided', async () => { + await fetchGraph(baseParams); + + expect(mockedFetchEvents).toHaveBeenCalledWith( + expect.objectContaining({ pinnedIds: undefined }) + ); + }); + + it('should pass empty pinnedIds array to fetchEvents when provided as empty', async () => { + await fetchGraph({ ...baseParams, pinnedIds: [] }); + + expect(mockedFetchEvents).toHaveBeenCalledWith(expect.objectContaining({ pinnedIds: [] })); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts index 3825a4a8571d9..c47860af2fe9c 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts @@ -22,6 +22,7 @@ export interface FetchGraphParams { spaceId: string; esQuery?: EsQuery; entityIds?: EntityId[]; + pinnedIds?: string[]; } export interface FetchGraphResult { @@ -47,6 +48,7 @@ export const fetchGraph = async ({ spaceId, esQuery, entityIds, + pinnedIds, }: FetchGraphParams): Promise => { // Only fetch events when originEventIds or esQuery are provided const hasOriginEventIds = originEventIds.length > 0; @@ -68,6 +70,7 @@ export const fetchGraph = async ({ indexPatterns, spaceId, esQuery, + pinnedIds, }).catch((error) => { logger.error(`Failed to fetch events: ${error.message}`); throw error; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts index cd4261ddb9ca1..99189b26ef072 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts @@ -30,7 +30,7 @@ import { NON_ENRICHED_ENTITY_TYPE_PLURAL, NON_ENRICHED_ENTITY_TYPE_SINGULAR, } from './types'; -import { transformEntityTypeToIconAndShape } from './utils'; +import { transformEntityTypeToIconAndShape, compareConnectorNodes } from './utils'; interface ConnectorEdges { source: string; @@ -449,16 +449,7 @@ const sortNodes = (nodesMap: Record) => { } } - // Sort connector nodes: relationship before label, then alphabetical by label - connectorNodes.sort((a, b) => { - // Primary sort: relationship before label - if (a.shape === 'relationship' && b.shape === 'label') return -1; - if (a.shape === 'label' && b.shape === 'relationship') return 1; - // Secondary sort: alphabetical by label - const labelA = ('label' in a && a.label) || ''; - const labelB = ('label' in b && b.label) || ''; - return labelA.localeCompare(labelB); - }); + connectorNodes.sort(compareConnectorNodes); return [...groupNodes, ...connectorNodes, ...otherNodes]; }; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/route.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/route.ts index 2d0c9ac0a9ebd..2ffa93a0eb7d7 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/route.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/route.ts @@ -43,8 +43,8 @@ export const defineGraphRoute = (router: CspRouter) => async (context: CspRequestHandlerContext, request, response) => { const cspContext = await context.csp; const { nodesLimit, showUnknownTarget = false } = request.body; - const { originEventIds, start, end, indexPatterns, esQuery, entityIds } = request.body - .query as GraphRequest['query']; + const { originEventIds, start, end, indexPatterns, esQuery, entityIds, pinnedIds } = request + .body.query as GraphRequest['query']; const spaceId = await cspContext.spacesService?.getSpaceId(request); const isGraphEnabled = await ( await context.core @@ -70,6 +70,7 @@ export const defineGraphRoute = (router: CspRouter) => end, esQuery, entityIds, + pinnedIds, }, showUnknownTarget, nodesLimit, diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts index a5afc63d8cc4e..23cf66c9c159a 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts @@ -64,6 +64,7 @@ export interface EventEdge extends GraphEdge { * they should share the same label node because they originate from the same document(s). */ labelNodeId: string; + pinned?: string | null; } /** diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql.utils.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql.utils.ts index c80966c124631..2d3a85096c6df 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql.utils.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql.utils.ts @@ -94,7 +94,7 @@ export const formatJsonProperty = (propertyName: string, valueVar: string): stri */ export const buildLookupJoinEsql = (lookupIndexName: string): string => { return `| DROP entity.id -| DROP entity.target.id +| DROP entity.target.id // rename entity.*fields before next pipeline to avoid name collisions | EVAL entity.id = actorEntityId | LOOKUP JOIN ${lookupIndexName} ON entity.id @@ -102,7 +102,7 @@ export const buildLookupJoinEsql = (lookupIndexName: string): string => { | RENAME actorEntityType = entity.type | RENAME actorEntitySubType = entity.sub_type | RENAME actorHostIp = host.ip -| RENAME actorLookupEntityId = entity.id +| RENAME actorLookupEntityId = entity.id | EVAL entity.id = targetEntityId | LOOKUP JOIN ${lookupIndexName} ON entity.id diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.test.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.test.ts index 571f354dc0f98..d116d3a3cd097 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.test.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.test.ts @@ -8,7 +8,11 @@ import type { Logger } from '@kbn/core/server'; import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { getEntitiesLatestIndexName } from '@kbn/cloud-security-posture-common/utils/helpers'; -import { transformEntityTypeToIconAndShape, checkIfEntitiesIndexLookupMode } from './utils'; +import { + transformEntityTypeToIconAndShape, + checkIfEntitiesIndexLookupMode, + compareConnectorNodes, +} from './utils'; describe('utils', () => { describe('transformEntityTypeToIconAndShape', () => { @@ -179,4 +183,43 @@ describe('utils', () => { }); }); }); + + describe('compareConnectorNodes', () => { + it('should sort relationship nodes before label nodes', () => { + const rel = { shape: 'relationship', label: 'Owns' }; + const lbl = { shape: 'label', label: 'Action' }; + + expect(compareConnectorNodes(rel, lbl)).toBe(-1); + expect(compareConnectorNodes(lbl, rel)).toBe(1); + }); + + it('should sort alphabetically within the same shape type', () => { + const a = { shape: 'relationship', label: 'Accesses frequently' }; + const b = { shape: 'relationship', label: 'Owns' }; + + expect(compareConnectorNodes(a, b)).toBeLessThan(0); + expect(compareConnectorNodes(b, a)).toBeGreaterThan(0); + }); + + it('should return 0 for identical nodes', () => { + const node = { shape: 'label', label: 'Same' }; + + expect(compareConnectorNodes(node, node)).toBe(0); + }); + + it('should handle undefined values', () => { + expect(compareConnectorNodes(undefined, undefined)).toBe(0); + expect( + compareConnectorNodes({ shape: 'relationship', label: 'Owns' }, undefined) + ).toBeGreaterThan(0); + expect(compareConnectorNodes(undefined, { shape: 'label', label: 'Action' })).toBeLessThan(0); + }); + + it('should treat missing label as empty string', () => { + const a = { shape: 'label' }; + const b = { shape: 'label', label: 'Action' }; + + expect(compareConnectorNodes(a, b)).toBeLessThan(0); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.ts index 4c4945552bfd9..225937777014f 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/utils.ts @@ -77,3 +77,23 @@ export const checkIfEntitiesIndexLookupMode = async ( return false; } }; + +/** + * Comparator for sorting connector nodes: relationship nodes first, then label nodes, + * then alphabetically by label within each group. + * Accepts objects with at least { shape?: string; label?: string }. + */ +export const compareConnectorNodes = ( + a?: { shape?: string; label?: string }, + b?: { shape?: string; label?: string } +): number => { + const shapeA = a?.shape; + const shapeB = b?.shape; + + if (shapeA === 'relationship' && shapeB === 'label') return -1; + if (shapeA === 'label' && shapeB === 'relationship') return 1; + + const labelA = a?.label ?? ''; + const labelB = b?.label ?? ''; + return labelA.localeCompare(labelB); +}; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts index 156b3f087396f..3b40e8393e403 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts @@ -26,6 +26,7 @@ export interface GetGraphParams { end: string | number; esQuery?: EsQuery; entityIds?: EntityId[]; + pinnedIds?: string[]; }; showUnknownTarget: boolean; nodesLimit?: number; @@ -33,7 +34,16 @@ export interface GetGraphParams { export const getGraph = async ({ services: { esClient, logger }, - query: { originEventIds, spaceId = 'default', indexPatterns, start, end, esQuery, entityIds }, + query: { + originEventIds, + spaceId = 'default', + indexPatterns, + start, + end, + esQuery, + entityIds, + pinnedIds, + }, showUnknownTarget, nodesLimit, }: GetGraphParams): Promise> => { @@ -55,6 +65,7 @@ export const getGraph = async ({ indexPatterns, spaceId, esQuery, + pinnedIds, entityIds, }); diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/test/scout_cspm_agentless/.meta/ui/parallel.json b/x-pack/solutions/security/plugins/cloud_security_posture/test/scout_cspm_agentless/.meta/ui/parallel.json index a981727e2138d..9448c4f03bfdd 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/test/scout_cspm_agentless/.meta/ui/parallel.json +++ b/x-pack/solutions/security/plugins/cloud_security_posture/test/scout_cspm_agentless/.meta/ui/parallel.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-09T18:40:27.329Z", "sha1": "b083f9988949ad2c7283a13d601359f74fdd542a", "tests": [ { diff --git a/x-pack/solutions/security/plugins/entity_store/README.md b/x-pack/solutions/security/plugins/entity_store/README.md index 98788e8d7c585..0497ae10c9d6f 100755 --- a/x-pack/solutions/security/plugins/entity_store/README.md +++ b/x-pack/solutions/security/plugins/entity_store/README.md @@ -1,3 +1,62 @@ # Entity Store -Central place for Entities management and logs extraction +Central place for Entities management and logs extraction. + +## Entity Maintainers Framework + +The Entity Store plugin exposes an **Entity Maintainers Framework** so that other plugins can register recurring tasks that run in the context of the entity store. Registration is part of the plugin setup contract: consumers call `registerEntityMaintainer` during their plugin’s `setup` phase and supply a configuration object. + +### Setup contract and registration config + +From the setup contract: + +```ts +interface EntityStoreSetupContract { + registerEntityMaintainer: RegisterEntityMaintainer; +} +``` + +`RegisterEntityMaintainer` accepts a `RegisterEntityMaintainerConfig`: + +```ts +interface RegisterEntityMaintainerConfig { + id: string; + description?: string; + interval: string; + initialState: EntityMaintainerState; + run: EntityMaintainerTaskMethod; + setup?: EntityMaintainerTaskMethod; +} +``` + +- **id** - Unique identifier for the maintainer (used for task type and scheduling). +- **interval** - Cron-like interval at which the task runs (e.g. `5m`, `1h`). +- **initialState** - Initial state object for the maintainer, used on the first run before any `setup` or `run` has executed. +- **run** - Required. Called on every run (including the first). Must return the current state it manages. +- **setup** - Optional. If provided, it runs once before the first `run`. Useful for one-time initialization. + +### Scheduling and namespaces + +The framework schedules all registered maintainers when the Entity Store is installed for a given space. +The framework is **namespace aware**: each Kibana space gets its own task instance per maintainer (e.g. one task per `id` per namespace). Registration is global, scheduling is per namespace at install time. + +### Run and setup behavior + +- **run** is invoked on every execution at the configured interval. It receives a context (see below) and must return the **current state** it manages. That state is persisted and passed back in the context on the next run. +- **setup** is optional. When supplied, it runs a single time before the first **run**. It receives the same context shape and also returns state, that state becomes the initial state for the first **run**. If setup performs heavy work, the first iteration can be noticeably longer than subsequent ones. + +Both methods must return the state object they manage so the framework can store it and expose it in the context for the next iteration. + +### Callback context + +Both `run` and `setup` receive a single context argument with: + +- **status** - Object containing: + - **metadata** - Maintained by the framework: `namespace`, `runs` (execution count), `lastSuccessTimestamp`, `lastErrorTimestamp`. + - **state** - The state returned by the previous `run` (or by `setup` on the first run, or `initialState` before any execution). +- **abortController** - For cooperative cancellation if needed. +- **logger** - Scoped logger for the task. +- **fakeRequest** - Request-scoped utilities for the task execution environment. +- **esClient** - An Elasticsearch client scoped to the current context, using the permissions of the user who triggered the Entity Store plugin installation process. + +Consumers implement their maintenance logic in `run` (and optionally in `setup`) using this context and return the updated state so the framework can keep it for the next run. diff --git a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.test.ts b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.test.ts index 7b4c28dbe40fb..261372bce01ff 100644 --- a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.test.ts +++ b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getEuidDslFilterBasedOnDocument } from './dsl'; +import { getEuidDslFilterBasedOnDocument, getEuidDslDocumentsContainsIdFilter } from './dsl'; describe('getEuidDslFilterBasedOnDocument', () => { it('returns undefined when doc is falsy', () => { @@ -223,3 +223,59 @@ describe('getEuidDslFilterBasedOnDocument', () => { }); }); }); + +describe('getEuidDslDocumentsContainsIdFilter', () => { + it('user: returns should with all user identity fields', () => { + const result = getEuidDslDocumentsContainsIdFilter('user'); + + expect(result).toEqual({ + bool: { + should: [ + { exists: { field: 'user.entity.id' } }, + { exists: { field: 'user.id' } }, + { exists: { field: 'user.name' } }, + { exists: { field: 'user.email' } }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('host: returns should with all host identity fields', () => { + const result = getEuidDslDocumentsContainsIdFilter('host'); + + expect(result).toEqual({ + bool: { + should: [ + { exists: { field: 'host.entity.id' } }, + { exists: { field: 'host.id' } }, + { exists: { field: 'host.name' } }, + { exists: { field: 'host.hostname' } }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('service: returns should with all service identity fields', () => { + const result = getEuidDslDocumentsContainsIdFilter('service'); + + expect(result).toEqual({ + bool: { + should: [{ exists: { field: 'service.entity.id' } }, { exists: { field: 'service.name' } }], + minimum_should_match: 1, + }, + }); + }); + + it('generic: returns should with entity.id', () => { + const result = getEuidDslDocumentsContainsIdFilter('generic'); + + expect(result).toEqual({ + bool: { + should: [{ exists: { field: 'entity.id' } }], + minimum_should_match: 1, + }, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.ts b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.ts index 8d1536dba79bd..eb8ab1b34a7ab 100644 --- a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.ts +++ b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/dsl.ts @@ -10,6 +10,44 @@ import type { EntityType } from '../definitions/entity_schema'; import { getEntityDefinitionWithoutId } from '../definitions/registry'; import { getDocument, getFieldsToBeFilteredOn, getFieldsToBeFilteredOut } from './commons'; +/** + * Returns a DSL filter that matches documents containing at least one + * identity field for the given entity type. + * + * This is the DSL equivalent of {@link getEuidEsqlDocumentsContainsIdFilter}. + * Use it to pre-filter searches/aggregations to only documents that could + * resolve to an entity of the requested type. + * + * @example + * ```ts + * const filter = getEuidDslDocumentsContainsIdFilter('user'); + * // { + * // bool: { + * // should: [ + * // { exists: { field: 'user.entity.id' } }, + * // { exists: { field: 'user.id' } }, + * // { exists: { field: 'user.name' } }, + * // { exists: { field: 'user.email' } } + * // ], + * // minimum_should_match: 1 + * // } + * // } + * ``` + */ +export function getEuidDslDocumentsContainsIdFilter( + entityType: EntityType +): QueryDslQueryContainer { + const { identityField } = getEntityDefinitionWithoutId(entityType); + return { + bool: { + should: identityField.requiresOneOfFields.map((field) => ({ + exists: { field }, + })), + minimum_should_match: 1, + }, + }; +} + /** * Constructs an Elasticsearch DSL filter for the provided entity type and document. * diff --git a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/identity_fields.test.ts b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/identity_fields.test.ts new file mode 100644 index 0000000000000..a4f1c4a431326 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/identity_fields.test.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 { EntityType } from '../definitions/entity_schema'; +import { getEuidSourceFields } from './identity_fields'; + +describe('getEuidSourceFields', () => { + it('returns expected host identity invariants deduplicated', () => { + const result = getEuidSourceFields(EntityType.Values.host); + + expect(result.requiresOneOf).toEqual( + expect.arrayContaining(['host.entity.id', 'host.id', 'host.name', 'host.hostname']) + ); + expect(result.identitySourceFields).toHaveLength(new Set(result.identitySourceFields).size); + expect(result.identitySourceFields).toEqual( + expect.arrayContaining([ + 'host.entity.id', + 'host.id', + 'host.name', + 'host.domain', + 'host.hostname', + ]) + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/identity_fields.ts b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/identity_fields.ts new file mode 100644 index 0000000000000..0e41500b86cb8 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/identity_fields.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 type { EntityType } from '../definitions/entity_schema'; +import { getEntityDefinitionWithoutId } from '../definitions/registry'; +import { isEuidField } from './commons'; + +export interface IdentitySourceFields { + /** At least one must be present for identity to be valid. + * This can be used to filter documents before the entity ID is calculated. + */ + requiresOneOf: string[]; + /** All field names used in EUID composition, deduplicated + * This can be used to extract the ID fields from the document. + **/ + identitySourceFields: string[]; +} + +/** + * Returns the identity source field names for a given entity type and + * required fields for the entity ID. + * + * @param entityType - The entity type (e.g. 'host', 'user', 'service') + * @returns requiresOneOf and identitySourceFields from the entity definition + */ +export function getEuidSourceFields(entityType: EntityType): IdentitySourceFields { + const { + identityField: { requiresOneOfFields, euidFields }, + } = getEntityDefinitionWithoutId(entityType); + return { + requiresOneOf: requiresOneOfFields, + identitySourceFields: Array.from( + new Set( + euidFields.flatMap((composedField) => + composedField.filter(isEuidField).map((attr) => attr.field) + ) + ) + ), + }; +} diff --git a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/index.ts b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/index.ts index c28d63615c1d8..fc6ddbf3fa8cf 100644 --- a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/index.ts +++ b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/index.ts @@ -6,10 +6,11 @@ */ export { getEuidFromObject } from './memory'; -export { getEuidPainlessEvaluation } from './painless'; -export { getEuidDslFilterBasedOnDocument } from './dsl'; +export { getEuidPainlessEvaluation, getEuidPainlessRuntimeMapping } from './painless'; +export { getEuidDslFilterBasedOnDocument, getEuidDslDocumentsContainsIdFilter } from './dsl'; export { getEuidEsqlDocumentsContainsIdFilter, getEuidEsqlEvaluation, getEuidEsqlFilterBasedOnDocument, } from './esql'; +export { getEuidSourceFields, type IdentitySourceFields } from './identity_fields'; diff --git a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.test.ts b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.test.ts index e0c039b430c62..58bdd90d793ee 100644 --- a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.test.ts +++ b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.test.ts @@ -6,7 +6,7 @@ */ import { EntityType } from '../definitions/entity_schema'; -import { getEuidPainlessEvaluation } from './painless'; +import { getEuidPainlessEvaluation, getEuidPainlessRuntimeMapping } from './painless'; describe('getEuidPainlessEvaluation', () => { describe('snapshots per entity type', () => { @@ -18,3 +18,17 @@ describe('getEuidPainlessEvaluation', () => { }); }); }); + +describe('getEuidPainlessRuntimeMapping', () => { + Object.values(EntityType.Values).forEach((entityType) => { + it(`returns a keyword runtime mapping that wraps getEuidPainlessEvaluation for ${entityType}`, () => { + const returnScript = getEuidPainlessEvaluation(entityType); + const mapping = getEuidPainlessRuntimeMapping(entityType); + + expect(mapping.type).toBe('keyword'); + expect(mapping.script).toBeDefined(); + expect(mapping.script.source).toContain('emit(result)'); + expect(mapping.script.source).toContain(returnScript); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.ts b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.ts index 9f9d4fb5b099e..23c74c77bcec6 100644 --- a/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.ts +++ b/x-pack/solutions/security/plugins/entity_store/common/domain/euid/painless.ts @@ -10,9 +10,31 @@ import { getEntityDefinitionWithoutId } from '../definitions/registry'; import { isEuidField, isEuidSeparator } from './commons'; /** - * Constructs a Painless evaluation for the provided entity type to generate the entity id. + * Returns an Elasticsearch runtime keyword field mapping whose Painless script + * computes the typed EUID for the given entity type. + * + * Example usage: + * ```ts + * runtime_mappings: { 'user_id': getEuidPainlessRuntimeMapping('user') } + * ``` * - * To use in a runtime field, you can wrap the generation around a function and emit the value. + * @param entityType - The entity type string (e.g. 'host', 'user', 'generic') + * @returns A runtime keyword field mapping (type + script) for use in runtime_mappings. + */ +export function getEuidPainlessRuntimeMapping(entityType: EntityType): { + type: 'keyword'; + script: { source: string }; +} { + const returnScript = getEuidPainlessEvaluation(entityType); + const emitScript = `String euid_eval(def doc) { ${returnScript} } def result = euid_eval(doc); if (result != null) { emit(result); }`; + return { + type: 'keyword', + script: { source: emitScript }, + }; +} + +/** + * Constructs a Painless evaluation for the provided entity type to generate the entity id. * * Example usage: * ```ts diff --git a/x-pack/solutions/security/plugins/entity_store/common/index.ts b/x-pack/solutions/security/plugins/entity_store/common/index.ts index 4b4c3890ddaac..9ee2e1d37b31e 100644 --- a/x-pack/solutions/security/plugins/entity_store/common/index.ts +++ b/x-pack/solutions/security/plugins/entity_store/common/index.ts @@ -25,13 +25,18 @@ export const FF_ENABLE_ENTITY_STORE_V2 = 'securitySolution:entityStoreEnableV2'; export const euid = { getEuidFromObject: euidModule.getEuidFromObject, getEuidPainlessEvaluation: euidModule.getEuidPainlessEvaluation, + getEuidPainlessRuntimeMapping: euidModule.getEuidPainlessRuntimeMapping, getEuidDslFilterBasedOnDocument: euidModule.getEuidDslFilterBasedOnDocument, + getEuidDslDocumentsContainsIdFilter: euidModule.getEuidDslDocumentsContainsIdFilter, getEuidEsqlDocumentsContainsIdFilter: euidModule.getEuidEsqlDocumentsContainsIdFilter, getEuidEsqlEvaluation: euidModule.getEuidEsqlEvaluation, getEuidEsqlFilterBasedOnDocument: euidModule.getEuidEsqlFilterBasedOnDocument, + getEuidSourceFields: euidModule.getEuidSourceFields, }; export type { EntityType } from './domain/definitions/entity_schema'; +export type { IdentitySourceFields } from './domain/euid'; +export { ALL_ENTITY_TYPES } from './domain/definitions/entity_schema'; export type EntityStoreStatus = z.infer; export const EntityStoreStatus = z.enum([ diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts index 6861442d5a4bb..6218b4f89c1af 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts @@ -17,6 +17,7 @@ import type { ManagedEntityDefinition, } from '../../common/domain/definitions/entity_schema'; import { scheduleExtractEntityTask, stopExtractEntityTask } from '../tasks/extract_entity_task'; +import { scheduleEntityMaintainerTasks } from '../tasks/entity_maintainer'; import { installElasticsearchAssets, uninstallElasticsearchAssets } from './assets/install_assets'; import { EngineDescriptorTypeName, @@ -81,16 +82,26 @@ export class AssetManager { this.security = deps.security; } - public async initEntity( + public async init( request: KibanaRequest, - type: EntityType, + entityTypes: EntityType[], logExtractionParams?: LogExtractionBodyParams - ): Promise { - const installed = await this.install(type, logExtractionParams); - if (installed) { - await this.start(request, type); + ) { + try { + await Promise.all( + entityTypes.map((type) => this.initEntity(request, type, logExtractionParams)) + ); + + await scheduleEntityMaintainerTasks({ + logger: this.logger, + taskManager: this.taskManager, + namespace: this.namespace, + request, + }); + } catch (error) { + this.logger.error('Error during entity store init:', error); + throw error; } - return installed; } public async start(request: KibanaRequest, type: EntityType) { @@ -133,39 +144,7 @@ export class AssetManager { } } - public async install( - type: EntityType, - logExtractionParams?: LogExtractionBodyParams - ): Promise { - try { - const { engines } = await this.getStatus(); - if (engines.some((e) => e.type === type)) { - return false; - } - - this.logger.get(type).debug(`Installing assets for entity type: ${type}`); - const definition = getEntityDefinition(type, this.namespace); - const initialState: Partial = logExtractionParams ?? {}; - await Promise.all([ - this.engineDescriptorClient.init(type, initialState), - installElasticsearchAssets({ - esClient: this.esClient, - logger: this.logger, - definition, - namespace: this.namespace, - }), - ]); - await this.engineDescriptorClient.update(type, { status: ENGINE_STATUS.STARTED }); - this.logger.debug(`Installed definition: ${type}`); - - return true; - } catch (error) { - this.logger.error(`Error installing assets for entity type ${type}`, { error }); - throw error; - } - } - - public async uninstall(type: EntityType): Promise { + public async uninstall(type: EntityType) { try { const { engines } = await this.getStatus(); if (!engines.some((e) => e.type === type)) { @@ -211,6 +190,19 @@ export class AssetManager { } } + private async initEntity( + request: KibanaRequest, + type: EntityType, + logExtractionParams?: LogExtractionBodyParams + ): Promise { + const installed = await this.install(type, logExtractionParams); + if (installed) { + await this.start(request, type); + } + + return installed; + } + public async getPrivileges( request: KibanaRequest, additionalIndexPatterns: string[] = [] @@ -243,6 +235,38 @@ export class AssetManager { }); } + public async install( + type: EntityType, + logExtractionParams?: LogExtractionBodyParams + ): Promise { + try { + const { engines } = await this.getStatus(); + if (engines.some((e) => e.type === type)) { + return false; + } + + this.logger.get(type).debug(`Installing assets for entity type: ${type}`); + const definition = getEntityDefinition(type, this.namespace); + const initialState: Partial = logExtractionParams ?? {}; + await Promise.all([ + this.engineDescriptorClient.init(type, initialState), + installElasticsearchAssets({ + esClient: this.esClient, + logger: this.logger, + definition, + namespace: this.namespace, + }), + ]); + await this.engineDescriptorClient.update(type, { status: ENGINE_STATUS.STARTED }); + this.logger.debug(`Installed definition: ${type}`); + + return true; + } catch (error) { + this.logger.error(`Error installing assets for entity type ${type}`, { error }); + throw error; + } + } + private async getEngineWithComponents( engine: EngineDescriptor ): Promise { diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/constants.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/constants.ts similarity index 89% rename from x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/constants.ts rename to x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/constants.ts index cae2186edeccd..47590ec812f1f 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/constants.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/constants.ts @@ -6,9 +6,9 @@ */ import { z } from '@kbn/zod'; -import { TasksConfig } from '../../../tasks/config'; -import { EntityStoreTaskType } from '../../../tasks/constants'; -import { EntityType } from '../../../../common/domain/definitions/entity_schema'; +import { TasksConfig } from '../../../../tasks/config'; +import { EntityStoreTaskType } from '../../../../tasks/constants'; +import { EntityType } from '../../../../../common/domain/definitions/entity_schema'; export type EngineStatus = z.infer; export const EngineStatus = z.enum(['installing', 'started', 'stopped', 'updating', 'error']); @@ -37,7 +37,7 @@ export const LogExtractionState = z.object({ frequency: z .string() .regex(/[smdh]$/) - .default(TasksConfig[EntityStoreTaskType.Values.extractEntity].interval), + .default(TasksConfig[EntityStoreTaskType.Values.extractEntity].interval || '30s'), paginationTimestamp: z.string().optional(), paginationId: z.string().optional(), lastExecutionTimestamp: z.string().optional(), diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/index.ts similarity index 94% rename from x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor.ts rename to x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/index.ts index 466b471bbafe0..5f4c132c74387 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/index.ts @@ -10,11 +10,11 @@ import type { SavedObjectsFindResponse, } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsErrorHelpers, type Logger } from '@kbn/core/server'; -import type { EntityType } from '../../../../common/domain/definitions/entity_schema'; +import type { EntityType } from '../../../../../common/domain/definitions/entity_schema'; import type { EngineDescriptor } from './constants'; import { LogExtractionState, VersionState } from './constants'; -import { EngineDescriptorTypeName } from './engine_descriptor_type'; -import { ENGINE_STATUS } from '../../constants'; +import { EngineDescriptorTypeName } from './types'; +import { ENGINE_STATUS } from '../../../constants'; interface UpdateOptions { mergeAttributes?: boolean; diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor_type.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/types.ts similarity index 100% rename from x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor_type.ts rename to x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/types.ts diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts index b6612bf0fcb08..917065d32be34 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts @@ -5,6 +5,6 @@ * 2.0. */ -export * from './engine_descriptor_type'; +export * from './engine_descriptor/constants'; +export * from './engine_descriptor/types'; export * from './engine_descriptor'; -export * from './constants'; diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.test.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.test.ts index bdd14a49191d6..c505ce44e3e8a 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.test.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.test.ts @@ -546,6 +546,39 @@ describe('LogsExtractionClient', () => { }); }); + it('should filter out cross-cluster search (CCS) remote indices', async () => { + const mockEsqlResponse: ESQLSearchResponse = { + columns: [ + { name: '@timestamp', type: 'date' }, + { name: HASHED_ID_FIELD, type: 'keyword' }, + ], + values: [['2024-01-02T10:00:00.000Z', 'hash1']], + }; + + const mockDataView = { + getIndexPattern: jest + .fn() + .mockReturnValue('logs-*,remote_cluster:logs-*,other:filebeat-*,metrics-*'), + }; + + mockEngineDescriptorClient.findOrThrow.mockResolvedValue( + createMockEngineDescriptor('user') as Awaited< + ReturnType + > + ); + mockDataViewsService.get.mockResolvedValue(mockDataView as any); + mockExecuteEsqlQuery.mockResolvedValue(mockEsqlResponse); + mockIngestEntities.mockResolvedValue(undefined); + + const result = await client.extractLogs('user'); + + expect(result.success).toBe(true); + expect(result.success && result.scannedIndices).toContain('logs-*'); + expect(result.success && result.scannedIndices).toContain('metrics-*'); + expect(result.success && result.scannedIndices).not.toContain('remote_cluster:logs-*'); + expect(result.success && result.scannedIndices).not.toContain('other:filebeat-*'); + }); + it('should fallback to logs-* when data view is not found', async () => { const mockEsqlResponse: ESQLSearchResponse = { columns: [ diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.ts index 076123ade69a7..31fe3b5b162d2 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/logs_extraction_client.ts @@ -9,6 +9,7 @@ import type { Logger } from '@kbn/logging'; import moment from 'moment'; import { SavedObjectsErrorHelpers, type ElasticsearchClient } from '@kbn/core/server'; import type { DataViewsService } from '@kbn/data-views-plugin/common'; +import { isCCSRemoteIndexName } from '@kbn/es-query'; import type { EntityType, ManagedEntityDefinition, @@ -319,7 +320,7 @@ export class LogsExtractionClient { const cleanIndices = secSolDataView .getIndexPattern() .split(',') - .filter((index) => index !== alertsIndex); + .filter((index) => index !== alertsIndex && !isCCSRemoteIndexName(index)); indexPatterns.push(...cleanIndices); } catch (error) { this.logger.warn( diff --git a/x-pack/solutions/security/plugins/entity_store/server/infra/elasticsearch/ingest.ts b/x-pack/solutions/security/plugins/entity_store/server/infra/elasticsearch/ingest.ts index 2b58181ca78f6..90613a60f80da 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/infra/elasticsearch/ingest.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/infra/elasticsearch/ingest.ts @@ -9,6 +9,7 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import type { ESQLSearchResponse } from '@kbn/es-types'; const BATCH_SIZE = 5 * 1024 * 1024; // 5MB +const RETRY_ON_CONFLICT = 3; interface IngestEntitiesParams { esClient: ElasticsearchClient; @@ -86,7 +87,16 @@ export async function ingestEntities({ retries: 2, onDocument: (doc) => { const { _id, ...document } = doc; - return [{ index: { _index: targetIndex, _id: _id as string } }, document]; + return [ + { + update: { + _index: targetIndex, + _id: _id as string, + retry_on_conflict: RETRY_ON_CONFLICT, + }, + }, + { doc: document, doc_as_upsert: true }, + ]; }, onDrop: (dropped) => { // Log dropped documents but don't throw - allows bulk operation to continue diff --git a/x-pack/solutions/security/plugins/entity_store/server/plugin.ts b/x-pack/solutions/security/plugins/entity_store/server/plugin.ts index 3210c5acf5b4b..305dd5d2feb98 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/plugin.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/plugin.ts @@ -12,20 +12,22 @@ import type { EntityStoreRequestHandlerContext, EntityStoreSetupPlugins, EntityStoreStartPlugins, - PluginStartContract, - PluginSetupContract, + EntityStoreStartContract, + EntityStoreSetupContract, } from './types'; import { createRequestHandlerContext } from './request_context_factory'; import { PLUGIN_ID } from '../common'; import { registerTasks } from './tasks/register_tasks'; import { registerUiSettings } from './infra/feature_flags/register'; import { EngineDescriptorType } from './domain/definitions/saved_objects'; +import { registerEntityMaintainerTask } from './tasks/entity_maintainer'; +import type { RegisterEntityMaintainerConfig } from './tasks/entity_maintainer/types'; export class EntityStorePlugin implements Plugin< - PluginSetupContract, - PluginStartContract, + EntityStoreSetupContract, + EntityStoreStartContract, EntityStoreSetupPlugins, EntityStoreStartPlugins > @@ -38,7 +40,10 @@ export class EntityStorePlugin this.isServerless = initializerContext.env.packageInfo.buildFlavor === 'serverless'; } - public setup(core: EntityStoreCoreSetup, plugins: EntityStoreSetupPlugins) { + public setup( + core: EntityStoreCoreSetup, + plugins: EntityStoreSetupPlugins + ): EntityStoreSetupContract { plugins.taskManager.registerCanEncryptedSavedObjects(plugins.encryptedSavedObjects.canEncrypt); const router = core.http.createRouter(); @@ -61,11 +66,21 @@ export class EntityStorePlugin this.logger.debug('Registering ui settings'); registerUiSettings(core.uiSettings); - this.logger.debug('Registering saved objects type'); + this.logger.debug('Registering saved objects types'); core.savedObjects.registerType(EngineDescriptorType); + + return { + registerEntityMaintainer: (config: RegisterEntityMaintainerConfig) => + registerEntityMaintainerTask({ + taskManager: plugins.taskManager, + logger: this.logger, + config, + core, + }), + }; } - public start(core: CoreStart, plugins: EntityStoreStartPlugins) { + public start(core: CoreStart, plugins: EntityStoreStartPlugins): EntityStoreStartContract { this.logger.info('Initializing plugin'); plugins.taskManager.registerEncryptedSavedObjectsClient( diff --git a/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts b/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts index f48f2fb3e6975..1bb3bba4324bb 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts @@ -36,7 +36,6 @@ export async function createRequestHandlerContext({ const core = await context.core; const [, startPlugins] = await coreSetup.getStartServices(); const taskManagerStart = startPlugins.taskManager; - const namespace = startPlugins.spaces.spacesService.getSpaceId(request); const dataViewsService = await startPlugins.dataViews.dataViewsServiceFactory( diff --git a/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts b/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts index 35cc4b1c48146..843ebd1e72a09 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts @@ -48,7 +48,6 @@ export function registerInstall(router: EntityStorePluginRouter) { }, }); } - const { engines } = await assetManager.getStatus(); const installedTypes = new Set(engines.map((e) => e.type)); const toInstall = entityTypes.filter((type) => !installedTypes.has(type)); @@ -57,7 +56,7 @@ export function registerInstall(router: EntityStorePluginRouter) { return res.ok({ body: { ok: true } }); } - await Promise.all(toInstall.map((type) => assetManager.initEntity(req, type, params))); + await assetManager.init(req, toInstall, params); return res.created({ body: { ok: true } }); }) diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts index 5ac39042763be..3294b812f33a4 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts @@ -8,7 +8,8 @@ import type { IntervalSchedule, TaskRegisterDefinition } from '@kbn/task-manager-plugin/server'; import { EntityStoreTaskType } from './constants'; -type TaskScheduleConfig = Omit & IntervalSchedule; +type TaskScheduleConfig = Omit & + Partial; export interface EntityStoreTaskConfig extends TaskScheduleConfig { type: string; @@ -21,4 +22,8 @@ export const TasksConfig: Record = { timeout: '25s', interval: '30s', }, + [EntityStoreTaskType.Values.entityMaintainer]: { + title: 'Entity Store - Entity Maintainer Task', + type: 'entity_store:v2:entity_maintainer_task', + }, }; diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts index 508e0d05204d0..8b80ffaef2c33 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts @@ -8,4 +8,4 @@ import { z } from '@kbn/zod'; export type EntityStoreTaskType = z.infer; -export const EntityStoreTaskType = z.enum(['extractEntity']); +export const EntityStoreTaskType = z.enum(['extractEntity', 'entityMaintainer']); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers.test.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers.test.ts new file mode 100644 index 0000000000000..9cc5206c3ad0e --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers.test.ts @@ -0,0 +1,430 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggerMock } from '@kbn/logging-mocks'; +import type { KibanaRequest } from '@kbn/core/server'; +import { scheduleEntityMaintainerTasks, registerEntityMaintainerTask } from '.'; +import type { RegisterEntityMaintainerConfig } from './types'; +import { entityMaintainersRegistry } from './entity_maintainers_registry'; + +const mockEnsureScheduled = jest.fn(); +const mockRegisterTaskDefinitions = jest.fn(); +const mockCreateInternalRepository = jest.fn(); +const mockGetStartServices = jest.fn(); + +jest.mock('./entity_maintainers_registry', () => ({ + entityMaintainersRegistry: { + getAll: jest.fn(), + update: jest.fn(), + }, +})); + +function createMockDeps() { + const logger = loggerMock.create(); + (logger.get as jest.Mock) = jest.fn().mockReturnValue(logger); + const request = { headers: {} } as KibanaRequest; + const taskManagerStart = { + ensureScheduled: mockEnsureScheduled.mockResolvedValue(undefined), + }; + const taskManagerSetup = { + registerTaskDefinitions: mockRegisterTaskDefinitions.mockImplementation((defs) => defs), + }; + const mockEsClient = {}; + const start = { + savedObjects: { + createInternalRepository: mockCreateInternalRepository.mockReturnValue({}), + }, + elasticsearch: { + client: { + asScoped: () => ({ asCurrentUser: mockEsClient }), + }, + }, + }; + const core = { + getStartServices: mockGetStartServices.mockResolvedValue([start]), + }; + return { + logger, + request, + taskManagerStart, + taskManagerSetup, + core, + }; +} + +function createMockConfig( + overrides?: Partial +): RegisterEntityMaintainerConfig { + const defaultRun = jest.fn().mockResolvedValue({ foo: 'bar' }); + const { run = defaultRun, ...rest } = overrides ?? {}; + return { + id: 'test-maintainer', + interval: '5m', + initialState: {}, + run, + description: 'Test maintainer', + ...rest, + }; +} + +describe('entity_maintainer task', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('scheduleEntityMaintainerTasks', () => { + it('should call getAll and ensureScheduled for each task with correct id, taskType, and schedule', async () => { + const { logger, request, taskManagerStart } = createMockDeps(); + jest.mocked(entityMaintainersRegistry.getAll).mockReturnValue([ + { id: 'maintainer-a', interval: '1m' }, + { id: 'maintainer-b', interval: '5m' }, + ]); + + await scheduleEntityMaintainerTasks({ + logger, + taskManager: taskManagerStart as any, + namespace: 'default', + request, + }); + + expect(entityMaintainersRegistry.getAll).toHaveBeenCalledTimes(1); + expect(mockEnsureScheduled).toHaveBeenCalledTimes(2); + expect(mockEnsureScheduled).toHaveBeenNthCalledWith( + 1, + { + id: 'maintainer-a:default', + taskType: 'entity_store:v2:entity_maintainer_task:maintainer-a', + schedule: { interval: '1m' }, + state: { namespace: 'default' }, + params: {}, + }, + { request } + ); + expect(mockEnsureScheduled).toHaveBeenNthCalledWith( + 2, + { + id: 'maintainer-b:default', + taskType: 'entity_store:v2:entity_maintainer_task:maintainer-b', + schedule: { interval: '5m' }, + state: { namespace: 'default' }, + params: {}, + }, + { request } + ); + }); + + it('should propagate and log error when getAll throws', async () => { + const { logger, request, taskManagerStart } = createMockDeps(); + const err = new Error('getAll failed'); + jest.mocked(entityMaintainersRegistry.getAll).mockImplementation(() => { + throw err; + }); + + await expect( + scheduleEntityMaintainerTasks({ + logger, + taskManager: taskManagerStart as any, + namespace: 'default', + request, + }) + ).rejects.toThrow('getAll failed'); + + expect(mockEnsureScheduled).not.toHaveBeenCalled(); + }); + }); + + describe('registerEntityMaintainerTask', () => { + it('should register task definition with expected type and title', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const config = createMockConfig(); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + expect(mockRegisterTaskDefinitions).toHaveBeenCalledTimes(1); + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + expect(defs[taskType]).toBeDefined(); + expect(defs[taskType].title).toBe('Entity Store - Entity Maintainer Task'); + expect(defs[taskType].description).toBe('Test maintainer'); + }); + + it('should trigger the correct run method upon registration and scheduling', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn().mockResolvedValue({ key: 'value' }); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const runner = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: {}, + }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + }); + + await runner.run(); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + status: expect.objectContaining({ + state: {}, + }), + abortController: expect.any(AbortController), + logger: expect.anything(), + fakeRequest: expect.anything(), + esClient: expect.anything(), + }) + ); + }); + + it('should trigger all run methods when multiple registrations occur with single scheduling', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const runA = jest.fn().mockResolvedValue({ from: 'a' }); + const runB = jest.fn().mockResolvedValue({ from: 'b' }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config: createMockConfig({ id: 'maintainer-a', run: runA }), + core: core as any, + }); + await core.getStartServices(); + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config: createMockConfig({ id: 'maintainer-b', run: runB }), + core: core as any, + }); + await core.getStartServices(); + + expect(mockRegisterTaskDefinitions).toHaveBeenCalledTimes(2); + const defs1 = mockRegisterTaskDefinitions.mock.calls[0][0]; + const defs2 = mockRegisterTaskDefinitions.mock.calls[1][0]; + const runnerA = defs1['entity_store:v2:entity_maintainer_task:maintainer-a'].createTaskRunner( + { + taskInstance: { id: 'maintainer-a:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + } + ); + const runnerB = defs2['entity_store:v2:entity_maintainer_task:maintainer-b'].createTaskRunner( + { + taskInstance: { id: 'maintainer-b:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + } + ); + + await runnerA.run(); + await runnerB.run(); + + expect(runA).toHaveBeenCalledTimes(1); + expect(runB).toHaveBeenCalledTimes(1); + }); + + it('should execute setup method only once', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const setup = jest.fn().mockResolvedValue({ initialized: true }); + const run = jest.fn().mockResolvedValue({ synced: true }); + const config = createMockConfig({ setup, run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const fakeRequest = { headers: {} } as KibanaRequest; + + const runner1 = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest, + }); + await runner1.run(); + expect(setup).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(1); + + const runner2 = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: { + metadata: { + runs: 1, + lastSuccessTimestamp: new Date().toISOString(), + lastErrorTimestamp: null, + }, + state: { synced: true }, + }, + }, + abortController: new AbortController(), + fakeRequest, + }); + await runner2.run(); + expect(setup).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + }); + + it('should change state across lifecycle as run or setup change it', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const setup = jest.fn().mockResolvedValue({ setupState: 1 }); + const run = jest.fn().mockImplementation(({ status }) => { + const prev = status.state.runState ?? status.state.setupState ?? 0; + return Promise.resolve({ ...status.state, runState: prev + 1 }); + }); + const config = createMockConfig({ setup, run, initialState: { initial: 0 } }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const fakeRequest = { headers: {} } as KibanaRequest; + + const runner1 = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest, + }); + const result1 = await runner1.run(); + expect(result1.state.state.setupState).toBe(1); + expect(result1.state.state.runState).toBe(2); + + const runner2 = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: result1.state, + }, + abortController: new AbortController(), + fakeRequest, + }); + const result2 = await runner2.run(); + expect(result2.state.state.runState).toBe(3); + }); + + it('should populate lastErrorTimestamp when run throws', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn().mockRejectedValue(new Error('run failed')); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const runner = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + }); + + const result = await runner.run(); + + expect(result.state.metadata.lastErrorTimestamp).toBeDefined(); + expect(typeof result.state.metadata.lastErrorTimestamp).toBe('string'); + expect(result.state.metadata.runs).toBe(1); + }); + + it('should set status.metadata lastSuccessTimestamp and runs correctly', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn().mockResolvedValue({ done: true }); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const runner = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + }); + + const result = await runner.run(); + + expect(result.state.metadata.runs).toBe(1); + expect(result.state.metadata.lastSuccessTimestamp).toBeDefined(); + expect(typeof result.state.metadata.lastSuccessTimestamp).toBe('string'); + expect(result.state.metadata.lastErrorTimestamp).toBeNull(); + }); + + it('should return current state without calling run when fakeRequest is missing', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn(); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const currentState = { metadata: { runs: 2 }, state: { x: 1 } }; + const runner = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: currentState, + }, + abortController: new AbortController(), + fakeRequest: undefined, + }); + + const result = await runner.run(); + + expect(run).not.toHaveBeenCalled(); + expect(result.state.metadata.runs).toBe(currentState.metadata.runs); + expect(result.state.state).toEqual(currentState.state); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.test.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.test.ts new file mode 100644 index 0000000000000..b6c8d5dc6892b --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EntityMaintainersRegistry } from './entity_maintainers_registry'; + +describe('EntityMaintainersRegistry', () => { + let registry: EntityMaintainersRegistry; + + beforeEach(() => { + registry = new EntityMaintainersRegistry(); + }); + + describe('getAll', () => { + it('should return empty array when no entries have been added', () => { + expect(registry.getAll()).toEqual([]); + }); + }); + + describe('update', () => { + it('should add an entry and getAll returns it', () => { + registry.update({ id: 'maintainer-a', interval: '5m' }); + expect(registry.getAll()).toEqual([{ id: 'maintainer-a', interval: '5m' }]); + }); + + it('should add multiple entries and getAll returns all in map order', () => { + registry.update({ id: 'maintainer-a', interval: '1m' }); + registry.update({ id: 'maintainer-b', interval: '5m' }); + expect(registry.getAll()).toEqual([ + { id: 'maintainer-a', interval: '1m' }, + { id: 'maintainer-b', interval: '5m' }, + ]); + }); + + it('should overwrite entry when update is called with same id', () => { + registry.update({ id: 'maintainer-a', interval: '1m' }); + registry.update({ id: 'maintainer-a', interval: '10m' }); + expect(registry.getAll()).toEqual([{ id: 'maintainer-a', interval: '10m' }]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.ts new file mode 100644 index 0000000000000..0523e3e326777 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EntityMaintainerConfig, EntityMaintainerTaskEntry } from './types'; + +export class EntityMaintainersRegistry { + private readonly tasks = new Map(); + + update({ id, interval }: EntityMaintainerTaskEntry): void { + this.tasks.set(id, { interval }); + } + + getAll(): EntityMaintainerTaskEntry[] { + return Array.from(this.tasks.entries()).map(([id, { interval }]) => ({ + id, + interval, + })); + } +} + +export const entityMaintainersRegistry = new EntityMaintainersRegistry(); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/index.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/index.ts new file mode 100644 index 0000000000000..d04ac955fdfc7 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/index.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; +import type { Logger } from '@kbn/logging'; +import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; +import type { + EntityMaintainerStatus, + EntityMaintainerTaskMethod, + RegisterEntityMaintainerConfig, +} from './types'; +import { TasksConfig } from '../config'; +import { EntityStoreTaskType } from '../constants'; +import type { EntityStoreCoreSetup } from '../../types'; +import { entityMaintainersRegistry } from './entity_maintainers_registry'; + +function getTaskType(id: string): string { + return `${TasksConfig[EntityStoreTaskType.Values.entityMaintainer].type}:${id}`; +} + +function getTaskId(id: string, namespace: string): string { + return `${id}:${namespace}`; +} + +export async function scheduleEntityMaintainerTasks({ + logger, + taskManager, + namespace, + request, +}: { + logger: Logger; + taskManager: TaskManagerStartContract; + namespace: string; + request: KibanaRequest; +}): Promise { + try { + logger.debug(`Scheduling entity maintainer tasks`); + const tasks = entityMaintainersRegistry.getAll(); + for (const { id, interval } of tasks) { + await taskManager.ensureScheduled( + { + id: getTaskId(id, namespace), + taskType: getTaskType(id), + schedule: { interval }, + state: { namespace }, + params: {}, + }, + { request } + ); + } + } catch (err) { + logger.error(`Failed to schedule entity maintainer tasks: ${err?.message}`); + throw err; + } +} + +export function registerEntityMaintainerTask({ + taskManager, + logger, + config, + core, +}: { + taskManager: TaskManagerSetupContract; + logger: Logger; + config: RegisterEntityMaintainerConfig; + core: EntityStoreCoreSetup; +}): void { + logger.debug(`Registering entity maintainer task: ${config.id}`); + const { title } = TasksConfig[EntityStoreTaskType.Values.entityMaintainer]; + const { run, interval, initialState, description, id, setup } = config; + const type = getTaskType(id); + + entityMaintainersRegistry.update({ id, interval }); + + void core + .getStartServices() + .then(([start]) => { + taskManager.registerTaskDefinitions({ + [type]: { + title, + description, + createTaskRunner: ({ taskInstance, abortController, fakeRequest }) => ({ + run: async () => { + const currentStatus = taskInstance.state; + + if (!fakeRequest) { + logger.error(`No fake request found, skipping run`); + + return { + state: currentStatus, + }; + } + + const maintainerStatus: EntityMaintainerStatus = { + metadata: { + runs: currentStatus?.metadata?.runs || 0, + lastSuccessTimestamp: currentStatus?.metadata?.lastSuccessTimestamp || null, + lastErrorTimestamp: currentStatus?.metadata?.lastErrorTimestamp || null, + namespace: currentStatus?.namespace || currentStatus?.metadata?.namespace, + }, + state: currentStatus?.metadata?.runs ? currentStatus.state : initialState, + }; + + return await runEntityMaintainerTask({ + currentStatus: maintainerStatus, + fakeRequest, + logger: logger.get(taskInstance.id), + setup, + run, + abortController, + esClient: start.elasticsearch.client.asScoped(fakeRequest).asCurrentUser, + }); + }, + }), + }, + }); + }) + .catch((err) => { + logger.error(`Failed to register entity maintainer task: ${err?.message}`); + }); +} + +async function runEntityMaintainerTask({ + currentStatus, + fakeRequest, + logger, + setup, + run, + abortController, + esClient, +}: { + currentStatus: EntityMaintainerStatus; + fakeRequest: KibanaRequest; + logger: Logger; + setup?: EntityMaintainerTaskMethod; + run: EntityMaintainerTaskMethod; + abortController: AbortController; + esClient: ElasticsearchClient; +}): Promise<{ state: EntityMaintainerStatus }> { + try { + const isFirstRun = currentStatus.metadata.runs === 0; + if (isFirstRun && setup) { + logger.debug(`First run, executing setup`); + currentStatus.state = await setup({ + status: { ...currentStatus }, + abortController, + logger, + fakeRequest, + esClient, + }); + } + logger.debug(`Executing run`); + currentStatus.state = await run({ + status: { ...currentStatus }, + abortController, + logger, + fakeRequest, + esClient, + }); + currentStatus.metadata.lastSuccessTimestamp = new Date().toISOString(); + } catch (err) { + currentStatus.metadata.lastErrorTimestamp = new Date().toISOString(); + logger.debug(`Run failed - ${err?.message}`); + } finally { + currentStatus.metadata.runs++; + } + + return { + state: currentStatus, + }; +} diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/types.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/types.ts new file mode 100644 index 0000000000000..1344fd2016714 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/types.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 type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +export interface EntityMaintainerConfig { + interval: string; +} + +export interface EntityMaintainerTaskEntry { + id: string; + interval: string; +} + +export interface EntityMaintainerStatusMetadata { + namespace: string; + runs: number; + lastSuccessTimestamp: string | null; + lastErrorTimestamp: string | null; +} + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | EntityMaintainerState | JsonValue[]; +export interface EntityMaintainerState { + [key: string]: JsonValue; +} + +export interface EntityMaintainerStatus extends Record { + metadata: EntityMaintainerStatusMetadata; + state: EntityMaintainerState; +} + +interface EntityMaintainerTaskMethodContext { + status: EntityMaintainerStatus; + abortController: AbortController; + logger: Logger; + fakeRequest: KibanaRequest; + esClient: ElasticsearchClient; +} + +export type EntityMaintainerTaskMethod = ( + context: EntityMaintainerTaskMethodContext +) => Promise; + +export interface RegisterEntityMaintainerConfig { + id: string; + description?: string; + interval: string; + initialState: EntityMaintainerState; + run: EntityMaintainerTaskMethod; + setup?: EntityMaintainerTaskMethod; +} diff --git a/x-pack/solutions/security/plugins/entity_store/server/types.ts b/x-pack/solutions/security/plugins/entity_store/server/types.ts index f6fef06493639..6b8507b52ef29 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/types.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/types.ts @@ -26,6 +26,7 @@ import type { CoreSetup } from '@kbn/core/server'; import type { AssetManager } from './domain/asset_manager'; import type { FeatureFlags } from './infra/feature_flags'; import type { LogsExtractionClient } from './domain/logs_extraction_client'; +import type { RegisterEntityMaintainerConfig } from './tasks/entity_maintainer/types'; export interface EntityStoreSetupPlugins { taskManager: TaskManagerSetupContract; @@ -56,7 +57,12 @@ export type EntityStoreRequestHandlerContext = CustomRequestHandlerContext<{ export type EntityStorePluginRouter = IRouter; -export type PluginStartContract = void; -export type PluginSetupContract = void; +export type RegisterEntityMaintainer = (config: RegisterEntityMaintainerConfig) => void; -export type EntityStoreCoreSetup = CoreSetup; +export type EntityStoreStartContract = void; + +export interface EntityStoreSetupContract { + registerEntityMaintainer: RegisterEntityMaintainer; +} + +export type EntityStoreCoreSetup = CoreSetup; diff --git a/x-pack/solutions/security/plugins/entity_store/test/scout/.meta/api/standard.json b/x-pack/solutions/security/plugins/entity_store/test/scout/.meta/api/standard.json index f354c2ab9d995..a44c4cc1e6686 100644 --- a/x-pack/solutions/security/plugins/entity_store/test/scout/.meta/api/standard.json +++ b/x-pack/solutions/security/plugins/entity_store/test/scout/.meta/api/standard.json @@ -1,6 +1,5 @@ { - "lastModified": "2026-02-09T18:23:19.836Z", - "sha1": "692320d7f4f4dc96c4238fccc51866e0d02bca7b", + "sha1": "4669447614e389696e77c8c4e1c6a4b95b9d4f20", "tests": [ { "id": "0cdf5c38d17a5a2-75de561f5a0ef9b", @@ -14,7 +13,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts", - "line": 63, + "line": 60, "column": 10 } }, @@ -30,7 +29,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts", - "line": 83, + "line": 80, "column": 10 } }, @@ -46,7 +45,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts", - "line": 106, + "line": 103, "column": 10 } }, @@ -62,7 +61,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts", - "line": 127, + "line": 124, "column": 10 } }, @@ -78,7 +77,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts", - "line": 148, + "line": 145, "column": 10 } }, @@ -94,7 +93,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts", - "line": 173, + "line": 170, "column": 10 } }, @@ -110,13 +109,45 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts", - "line": 194, + "line": 191, "column": 10 } }, { - "id": "aad38721f43b8af-5bfc4f716888c3b", - "title": "Entity Store Logs Extraction Should extract properly generate euid for host", + "id": "5674044b3969a63-e635b79048cc29b", + "title": "Entity Store Logs Extraction with pagination (max 5 docs per page) Should extract properly extract host with pagination", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction_paginated.spec.ts", + "line": 58, + "column": 12 + } + }, + { + "id": "aad38721f43b8af-01476c159898b87", + "title": "Entity Store Logs Extraction Should extract properly extract host", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts", + "line": 57, + "column": 10 + } + }, + { + "id": "aad38721f43b8af-47aeaa90d58b3fc", + "title": "Entity Store Logs Extraction Should extract properly extract user", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -126,13 +157,13 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts", - "line": 56, + "line": 95, "column": 10 } }, { - "id": "aad38721f43b8af-048c71aa3be391b", - "title": "Entity Store Logs Extraction Should extract properly generate euid for user", + "id": "aad38721f43b8af-5a3f40022e3e5a3", + "title": "Entity Store Logs Extraction Should extract properly extract service", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -142,13 +173,13 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts", - "line": 92, + "line": 131, "column": 10 } }, { - "id": "aad38721f43b8af-8bb4848d40f00d1", - "title": "Entity Store Logs Extraction Should extract properly generate euid for service", + "id": "aad38721f43b8af-c0ef57416fddf15", + "title": "Entity Store Logs Extraction Should extract properly extract generic", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -158,13 +189,13 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts", - "line": 126, + "line": 167, "column": 10 } }, { - "id": "aad38721f43b8af-02c64c4a69bbebf", - "title": "Entity Store Logs Extraction Should extract properly generate euid for generic", + "id": "aad38721f43b8af-6c364c704ea9cc4", + "title": "Entity Store Logs Extraction Should properly handle field retention strategies", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -174,7 +205,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts", - "line": 160, + "line": 203, "column": 10 } }, @@ -190,7 +221,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/esql_translation.spec.ts", - "line": 57, + "line": 54, "column": 10 } }, @@ -206,7 +237,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/esql_translation.spec.ts", - "line": 78, + "line": 75, "column": 10 } }, @@ -222,7 +253,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/esql_translation.spec.ts", - "line": 102, + "line": 99, "column": 10 } }, @@ -238,7 +269,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/esql_translation.spec.ts", - "line": 123, + "line": 120, "column": 10 } }, @@ -254,7 +285,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/esql_translation.spec.ts", - "line": 144, + "line": 141, "column": 10 } }, @@ -270,7 +301,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/esql_translation.spec.ts", - "line": 171, + "line": 168, "column": 10 } }, @@ -286,7 +317,55 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/esql_translation.spec.ts", - "line": 192, + "line": 189, + "column": 10 + } + }, + { + "id": "66e498dbff280b4-e1ef6b799a84fce", + "title": "Entity Store install - privilege checks Should fail when user lacks permissions for source index patterns", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/install_privileges.spec.ts", + "line": 91, + "column": 10 + } + }, + { + "id": "66e498dbff280b4-62ddc6b33c80984", + "title": "Entity Store install - privilege checks Should fail when user lacks permissions for target index patterns", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/install_privileges.spec.ts", + "line": 117, + "column": 10 + } + }, + { + "id": "66e498dbff280b4-625d2f2ef49120d", + "title": "Entity Store install - privilege checks Should fail when user lacks permissions for entity store saved object descriptor", + "expectedStatus": "passed", + "tags": [ + "@local-stateful-classic", + "@cloud-stateful-classic", + "@local-serverless-security_complete", + "@cloud-serverless-security_complete" + ], + "location": { + "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/install_privileges.spec.ts", + "line": 145, "column": 10 } }, @@ -323,8 +402,8 @@ } }, { - "id": "519a3ae85face73-c3cbdcbfd207383", - "title": "Painless runtime field translation user: runtime field from getEuidPainlessEvaluation matches expected euid for all documents", + "id": "519a3ae85face73-8b7bdf39eb18b15", + "title": "Painless runtime field translation user: runtime field from getEuidPainlessRuntimeMapping matches expected euid for all documents", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -334,13 +413,13 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts", - "line": 67, + "line": 57, "column": 12 } }, { - "id": "519a3ae85face73-773b923ee6c2f0b", - "title": "Painless runtime field translation host: runtime field from getEuidPainlessEvaluation matches expected euid for all documents", + "id": "519a3ae85face73-ad3a8d357a613b4", + "title": "Painless runtime field translation host: runtime field from getEuidPainlessRuntimeMapping matches expected euid for all documents", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -350,13 +429,13 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts", - "line": 67, + "line": 57, "column": 12 } }, { - "id": "519a3ae85face73-6e42ad0c8fa3c72", - "title": "Painless runtime field translation service: runtime field from getEuidPainlessEvaluation matches expected euid for all documents", + "id": "519a3ae85face73-5add9fc2b06f046", + "title": "Painless runtime field translation service: runtime field from getEuidPainlessRuntimeMapping matches expected euid for all documents", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -366,13 +445,13 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts", - "line": 67, + "line": 57, "column": 12 } }, { - "id": "519a3ae85face73-8122dc2f482158b", - "title": "Painless runtime field translation generic: runtime field from getEuidPainlessEvaluation matches expected euid for all documents", + "id": "519a3ae85face73-ccc70f24e03541e", + "title": "Painless runtime field translation generic: runtime field from getEuidPainlessRuntimeMapping matches expected euid for all documents", "expectedStatus": "passed", "tags": [ "@local-stateful-classic", @@ -382,7 +461,7 @@ ], "location": { "file": "x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts", - "line": 67, + "line": 57, "column": 12 } }, diff --git a/x-pack/solutions/security/plugins/entity_store/test/scout/api/fixtures/helpers.ts b/x-pack/solutions/security/plugins/entity_store/test/scout/api/fixtures/helpers.ts index e5b11993f3206..cde696b231dc2 100644 --- a/x-pack/solutions/security/plugins/entity_store/test/scout/api/fixtures/helpers.ts +++ b/x-pack/solutions/security/plugins/entity_store/test/scout/api/fixtures/helpers.ts @@ -35,6 +35,7 @@ export const searchDocById = async (esClient: EsClient, id: string) => { await esClient.indices.refresh({ index: LATEST_INDEX }); return await esClient.search({ index: LATEST_INDEX, + version: true, query: { bool: { filter: { diff --git a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts index 5466f19b2d6e8..9890d0efcaaa7 100644 --- a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts +++ b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/dsl_translation.spec.ts @@ -14,7 +14,10 @@ import { UPDATES_INDEX, } from '../fixtures/constants'; import { FF_ENABLE_ENTITY_STORE_V2 } from '../../../../common'; -import { getEuidDslFilterBasedOnDocument } from '../../../../common/domain/euid/dsl'; +import { + getEuidDslFilterBasedOnDocument, + getEuidDslDocumentsContainsIdFilter, +} from '../../../../common/domain/euid/dsl'; function getTotal(hits: { total?: number | { value: number } }): number { const total = hits.total; @@ -208,4 +211,138 @@ apiTest.describe('DSL query translation', { tag: ENTITY_STORE_TAGS }, () => { }); } ); + + apiTest( + 'containsIdFilter: generic filter returns only docs with entity.id', + async ({ esClient }) => { + const dsl = getEuidDslDocumentsContainsIdFilter('generic'); + const result = await esClient.search({ + index: UPDATES_INDEX, + query: { ...dsl }, + size: 100, + }); + + const total = getTotal(result.hits); + expect(total).toBe(1); + expect(result.hits.hits).toHaveLength(1); + expect(result.hits.hits[0]._source).toMatchObject({ entity: { id: 'generic-id' } }); + } + ); + + apiTest( + 'containsIdFilter: service filter returns docs with service identity fields', + async ({ esClient }) => { + const dsl = getEuidDslDocumentsContainsIdFilter('service'); + const result = await esClient.search({ + index: UPDATES_INDEX, + query: { ...dsl }, + size: 100, + }); + + const total = getTotal(result.hits); + expect(total).toBe(2); + + const hasServiceEntityId = result.hits.hits.some((h) => { + const src = h._source as { service?: { entity?: { id?: string } } } | undefined; + return src?.service?.entity?.id === 'non-generated-service-id'; + }); + expect(hasServiceEntityId).toBe(true); + + const hasServiceName = result.hits.hits.some((h) => { + const src = h._source as { service?: { name?: string } } | undefined; + return src?.service?.name === 'service-name'; + }); + expect(hasServiceName).toBe(true); + } + ); + + apiTest( + 'containsIdFilter: user filter returns docs with any user identity field', + async ({ esClient }) => { + const dsl = getEuidDslDocumentsContainsIdFilter('user'); + const result = await esClient.search({ + index: UPDATES_INDEX, + query: { ...dsl }, + size: 100, + }); + + const total = getTotal(result.hits); + expect(total).toBeGreaterThan(0); + + const hasUserEntityId = result.hits.hits.some((h) => { + const src = h._source as { user?: { entity?: { id?: string } } } | undefined; + return src?.user?.entity?.id === 'non-generated-user'; + }); + expect(hasUserEntityId).toBe(true); + + const hasUserName = result.hits.hits.some((h) => { + const src = h._source as { user?: { name?: string } } | undefined; + return src?.user?.name === 'john.doe'; + }); + expect(hasUserName).toBe(true); + + const hasUserEmail = result.hits.hits.some((h) => { + const src = h._source as { user?: { email?: string } } | undefined; + return src?.user?.email === 'test@example.com'; + }); + expect(hasUserEmail).toBe(true); + + const hasUserId = result.hits.hits.some((h) => { + const src = h._source as { user?: { id?: string } } | undefined; + return src?.user?.id === 'user-101'; + }); + expect(hasUserId).toBe(true); + } + ); + + apiTest( + 'containsIdFilter: host filter returns docs with any host identity field', + async ({ esClient }) => { + const dsl = getEuidDslDocumentsContainsIdFilter('host'); + const result = await esClient.search({ + index: UPDATES_INDEX, + query: { ...dsl }, + size: 100, + }); + + const total = getTotal(result.hits); + expect(total).toBeGreaterThan(0); + + const hasHostEntityId = result.hits.hits.some((h) => { + const src = h._source as { host?: { entity?: { id?: string } } } | undefined; + return src?.host?.entity?.id === 'non-generated-host'; + }); + expect(hasHostEntityId).toBe(true); + + const hasHostId = result.hits.hits.some((h) => { + const src = h._source as { host?: { id?: string } } | undefined; + return src?.host?.id === 'host-123'; + }); + expect(hasHostId).toBe(true); + + const hasHostName = result.hits.hits.some((h) => { + const src = h._source as { host?: { name?: string } } | undefined; + return src?.host?.name === 'desktop-02'; + }); + expect(hasHostName).toBe(true); + } + ); + + apiTest( + 'containsIdFilter: does not match docs with no identity fields for the type', + async ({ esClient }) => { + const dsl = getEuidDslDocumentsContainsIdFilter('user'); + const result = await esClient.search({ + index: UPDATES_INDEX, + query: { ...dsl }, + size: 100, + }); + + const bareDocMatched = result.hits.hits.some((h) => { + const src = h._source as Record | undefined; + return !src?.user && !src?.entity?.id && Object.keys(src || {}).length <= 1; + }); + expect(bareDocMatched).toBe(false); + } + ); }); diff --git a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts index 4fd13790ee6f7..215272e0fd645 100644 --- a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts +++ b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/entity_extraction.spec.ts @@ -221,7 +221,9 @@ apiTest.describe('Entity Store Logs Extraction', { tag: ENTITY_STORE_TAGS }, () expect(firstExtractionResponse.body).toMatchObject({ count: 1 }); const beforeSubType = await searchDocById(esClient, 'user:latest-test'); + expect(beforeSubType.hits.hits).toHaveLength(1); + expect(beforeSubType.hits.hits[0]._version).toBe(1); expect(beforeSubType.hits.hits[0]._source).toMatchObject({ '@timestamp': '2026-02-13T11:00:00.000Z', 'entity.id': 'user:latest-test', @@ -253,6 +255,7 @@ apiTest.describe('Entity Store Logs Extraction', { tag: ENTITY_STORE_TAGS }, () const afterSubType = await searchDocById(esClient, 'user:latest-test'); expect(afterSubType.hits.hits).toHaveLength(1); + expect(afterSubType.hits.hits[0]._version).toBe(2); expect(afterSubType.hits.hits[0]._source).toMatchObject({ '@timestamp': '2026-02-13T11:01:00.000Z', 'entity.id': 'user:latest-test', @@ -318,6 +321,7 @@ apiTest.describe('Entity Store Logs Extraction', { tag: ENTITY_STORE_TAGS }, () const updatedSubType = await searchDocById(esClient, 'user:latest-test'); expect(updatedSubType.hits.hits).toHaveLength(1); + expect(updatedSubType.hits.hits[0]._version).toBe(3); expect(updatedSubType.hits.hits[0]._source).toMatchObject({ '@timestamp': '2026-02-13T11:02:04.000Z', 'entity.id': 'user:latest-test', @@ -349,6 +353,7 @@ apiTest.describe('Entity Store Logs Extraction', { tag: ENTITY_STORE_TAGS }, () const updatedLatestDomain = await searchDocById(esClient, 'user:latest-test'); expect(updatedLatestDomain.hits.hits).toHaveLength(1); + expect(updatedLatestDomain.hits.hits[0]._version).toBe(4); expect(updatedLatestDomain.hits.hits[0]._source).toMatchObject({ '@timestamp': '2026-02-13T11:03:00.000Z', 'entity.id': 'user:latest-test', diff --git a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts index 41551b98c15c5..fc106c661585f 100644 --- a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts +++ b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/painless_translation.spec.ts @@ -14,18 +14,10 @@ import { UPDATES_INDEX, } from '../fixtures/constants'; import { FF_ENABLE_ENTITY_STORE_V2 } from '../../../../common'; -import { getEuidPainlessEvaluation } from '../../../../common/domain/euid/painless'; +import { getEuidPainlessRuntimeMapping } from '../../../../common/domain/euid/painless'; import { getEuidFromObject } from '../../../../common/domain/euid/memory'; import { EntityType } from '../../../../common/domain/definitions/entity_schema'; -function toRuntimeFieldEmitScript(painless: string): string { - return `String euid_eval(def doc) { ${painless} } - def result = euid_eval(doc); - if (result != null) { - emit(result); - }`; -} - apiTest.describe('Painless runtime field translation', { tag: ENTITY_STORE_TAGS }, () => { let defaultHeaders: Record; @@ -63,22 +55,14 @@ apiTest.describe('Painless runtime field translation', { tag: ENTITY_STORE_TAGS for (const entityType of Object.values(EntityType.Values)) { apiTest( - `${entityType}: runtime field from getEuidPainlessEvaluation matches expected euid for all documents`, + `${entityType}: runtime field from getEuidPainlessRuntimeMapping matches expected euid for all documents`, async ({ esClient }) => { - const returnScript = getEuidPainlessEvaluation(entityType); - const emitScript = toRuntimeFieldEmitScript(returnScript); - const result = await esClient.search({ index: UPDATES_INDEX, body: { query: { match_all: {} }, runtime_mappings: { - entity_id: { - type: 'keyword', - script: { - source: emitScript, - }, - }, + entity_id: getEuidPainlessRuntimeMapping(entityType), }, size: 1000, fields: ['entity_id'], diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/common.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/common.ts index ff38124db9e44..65b2fe590eeb6 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/common.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/common.ts @@ -36,8 +36,10 @@ export const ScriptPathToExecutableSchema = schema.string({ validate: validateNonEmptyString, }); -export const ScriptTagsSchema = schema.arrayOf( - // @ts-expect-error TS2769: No overload matches this call. (due to now `oneOf()` type is defined) - schema.oneOf(Object.keys(SCRIPT_TAGS).map((osType) => schema.literal(osType))), - { minSize: 1, maxSize: Object.keys(SCRIPT_TAGS).length, validate: validateNoDuplicateValues } -); +export const getScriptsTagSchema = (type: 'patch' | 'post') => + // @ts-expect-error TS2769: No overload matches this call. (due to how `oneOf()` type is defined) + schema.arrayOf(schema.oneOf(Object.keys(SCRIPT_TAGS).map((osType) => schema.literal(osType))), { + minSize: type === 'patch' ? 0 : 1, + maxSize: Object.keys(SCRIPT_TAGS).length, + validate: validateNoDuplicateValues, + }); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/create_script.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/create_script.ts index 59bcf84f64f15..9e324b5b983cb 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/create_script.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/create_script.ts @@ -16,7 +16,7 @@ import { ScriptPathToExecutableSchema, ScriptPlatformSchema, ScriptRequiresInputSchema, - ScriptTagsSchema, + getScriptsTagSchema, } from './common'; export const CreateScriptRequestSchema = { @@ -29,7 +29,7 @@ export const CreateScriptRequestSchema = { instructions: schema.maybe(ScriptInstructionsSchema), example: schema.maybe(ScriptExampleSchema), pathToExecutable: schema.maybe(ScriptPathToExecutableSchema), - tags: schema.maybe(ScriptTagsSchema), + tags: schema.maybe(getScriptsTagSchema('post')), }), }; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/update_script.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/update_script.ts index b8a7b91f70c1b..f41fd47572b20 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/update_script.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/scripts_library/update_script.ts @@ -7,15 +7,11 @@ import { schema, type TypeOf } from '@kbn/config-schema'; import { - ScriptDescriptionSchema, - ScriptExampleSchema, ScriptFileSchema, - ScriptInstructionsSchema, ScriptNameSchema, - ScriptPathToExecutableSchema, ScriptPlatformSchema, ScriptRequiresInputSchema, - ScriptTagsSchema, + getScriptsTagSchema, } from './common'; import type { DeepMutable } from '../../../endpoint/types'; import { validateNonEmptyString } from '../schema_utils'; @@ -27,11 +23,11 @@ export const PatchUpdateScriptRequestSchema = { platform: schema.maybe(ScriptPlatformSchema), file: schema.maybe(ScriptFileSchema), requiresInput: schema.maybe(ScriptRequiresInputSchema), - description: schema.maybe(ScriptDescriptionSchema), - instructions: schema.maybe(ScriptInstructionsSchema), - example: schema.maybe(ScriptExampleSchema), - pathToExecutable: schema.maybe(ScriptPathToExecutableSchema), - tags: schema.maybe(ScriptTagsSchema), + description: schema.maybe(schema.string()), + instructions: schema.maybe(schema.string()), + example: schema.maybe(schema.string()), + pathToExecutable: schema.maybe(schema.string()), + tags: schema.maybe(getScriptsTagSchema('patch')), version: schema.maybe(schema.string({ minLength: 1, validate: validateNonEmptyString })), }, { diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/scripts_library.test.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/scripts_library.test.ts index 85eb355020c65..6ad49f7a2a9de 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/scripts_library.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/scripts_library.test.ts @@ -305,6 +305,20 @@ describe('Scripts library schemas', () => { ).toBeTruthy(); }); + it.each([ + { field: 'description', value: '' }, + { field: 'instructions', value: '' }, + { field: 'example', value: '' }, + { field: 'pathToExecutable', value: '' }, + { field: 'tags', value: [] }, + ])('should accept `$field` field with `$value` value', ({ field, value }) => { + expect( + PatchUpdateScriptRequestSchema.body.validate({ + [field]: value, + }) + ).toBeTruthy(); + }); + it.each` title | bodyPayload ---------- ------------- diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index 00a0ddb89e50e..a34d19e232a41 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -209,7 +209,7 @@ export const allowedExperimentalValues = Object.freeze({ /** * Enables the Automatic Migration of Splunk dashboards in Security Solution */ - splunkV2DashboardsEnabled: false, + splunkV2DashboardsEnabled: true, /** * Enables Detection Engine Health UI diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/new_agent_builder_attachment.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/new_agent_builder_attachment.tsx index 1ff08b2ea9fd0..758d12c869a84 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/new_agent_builder_attachment.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/new_agent_builder_attachment.tsx @@ -9,7 +9,6 @@ import type { EuiButtonColor } from '@elastic/eui'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui'; import React, { memo, useCallback } from 'react'; import type { EuiButtonEmptySizes } from '@elastic/eui/src/components/button/button_empty/button_empty'; -import { agentBuilderIconType } from '@kbn/agent-builder-plugin/public'; import type { AgentBuilderAddToChatTelemetry } from '../hooks/use_report_add_to_chat'; import { useReportAddToChat } from '../hooks/use_report_add_to_chat'; import * as i18n from './translations'; @@ -82,7 +81,11 @@ export const NewAgentBuilderAttachment = memo(function NewAgentBuilderAttachment > - + {i18n.ADD_TO_CHAT} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/components/rule_execution_status/rule_status_failed_callout.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/components/rule_execution_status/rule_status_failed_callout.tsx index 377bc33926f31..4619cb55e99f2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/components/rule_execution_status/rule_status_failed_callout.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/components/rule_execution_status/rule_status_failed_callout.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import { css } from '@emotion/react'; -import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; +import { EuiCallOut, EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { NewChat } from '@kbn/elastic-assistant'; import { FormattedDate } from '../../../../common/components/formatted_date'; @@ -78,7 +78,6 @@ const RuleStatusFailedCallOutComponent: React.FC =
= )} +
); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/step_about_rule_details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/step_about_rule_details/index.tsx index dc14b522db5cc..82e6c27f680ff 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/step_about_rule_details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/step_about_rule_details/index.tsx @@ -89,13 +89,13 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ {loading && ( <> - + )} {stepData != null && stepDataDetails != null && ( - + {toggleOptions.length > 0 && ( ; } const MyPanel = styled(EuiPanel)` @@ -23,7 +25,12 @@ const MyPanel = styled(EuiPanel)` MyPanel.displayName = 'MyPanel'; -const StepPanelComponent: React.FC = ({ children, loading, title }) => ( +const StepPanelComponent: React.FC = ({ + children, + loading, + title, + headerProps, +}) => ( {loading && ( = ({ children, loading, title data-test-subj="stepPanelProgress" /> )} - {title && } + {title && } {children} ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar_field/query_field.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar_field/query_field.tsx index 32feeca8080b7..241bfceaf927c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar_field/query_field.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/query_bar_field/query_field.tsx @@ -12,6 +12,7 @@ import deepEqual from 'fast-deep-equal'; import type { DataViewBase, Query } from '@kbn/es-query'; import type { SavedQuery } from '@kbn/data-plugin/public'; import { FilterManager } from '@kbn/data-plugin/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { OpenTimelineModal } from '../../../../timelines/components/open_timeline/open_timeline_modal'; import type { ActionTimelineToShow } from '../../../../timelines/components/open_timeline/types'; @@ -89,9 +90,8 @@ export const QueryBarField = ({ const [savedQuery, setSavedQuery] = useState(defaultSavedQuery); const [isSavedQueryFailedToLoad, setIsSavedQueryFailedToLoad] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - - const { uiSettings } = useKibana().services; - const [filterManager] = useState(new FilterManager(uiSettings)); + const kibanaServices = useKibana().services; + const [filterManager] = useState(new FilterManager(kibanaServices.uiSettings)); const savedQueryServices = useSavedQueryServices(); @@ -257,7 +257,7 @@ export const QueryBarField = ({ }; return ( - <> + ) : null} - + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 6616181ce62fd..422148dc31f5b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -8,12 +8,14 @@ /* eslint-disable complexity */ // TODO: Disabling complexity is temporary till this component is refactored as part of lists UI integration +import type { EuiResizeObserverProps } from '@elastic/eui'; import { EuiButtonIcon, EuiConfirmModal, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, + EuiResizeObserver, EuiSpacer, EuiToolTip, EuiWindowEvent, @@ -167,13 +169,6 @@ const StyledFullHeightContainer = styled.div` flex: 1 1 auto; `; -/** - * Sets min-height on tab container to minimize page hop when switching to tabs with less content - */ -const StyledMinHeightTabContainer = styled.div` - min-height: 800px; -`; - /** * Wrapper for the About, Definition and Schedule sections. * - Allows for overflow wrapping of extremely long text, that might otherwise break the layout. @@ -209,6 +204,16 @@ const defaultGroupingOptions = [ }, ]; +const DEFAULT_PANEL_HEADER_OPTIONS = { + border: true, + hideSubtitle: true, +} as const; + +/** + * Cutoff at which the About and Definition sections stack vertically to prevent content squishing (600px for About and 400px for Definition) + */ +const ABOUT_CONTENT_STACK_WIDTH_THRESHOLD = 1000; + const mapDispatchToProps = (dispatch: Dispatch) => ({ clearSelected: ({ id }: { id: string }) => dispatch(dataTableActions.clearSelected({ id })), clearEventsLoading: ({ id }: { id: string }) => @@ -296,6 +301,7 @@ export const RuleDetailsPage = connector( const { pollForSignalIndex } = useSignalHelpers(); const [rule, setRule] = useState(null); + const [shouldStackAboutContent, setShouldStackAboutContent] = useState(false); const isLoading = useMemo(() => ruleLoading && rule == null, [rule, ruleLoading]); const { starting: isStartingJobs, startMlJobs } = useStartMlJobs(); @@ -567,6 +573,14 @@ export const RuleDetailsPage = connector( [alertMergedFilters, refreshRule] ); + const onResize: EuiResizeObserverProps['onResize'] = useCallback( + (dimensions) => { + if (!dimensions) return; + setShouldStackAboutContent(dimensions.width < ABOUT_CONTENT_STACK_WIDTH_THRESHOLD); + }, + [setShouldStackAboutContent] + ); + const { isBulkDuplicateConfirmationVisible, showBulkDuplicateConfirmation, @@ -741,58 +755,88 @@ export const RuleDetailsPage = connector( {ruleError} - - +
- - - {rule !== null && ( - - )} - - - - - - {rule !== null && !isStartingJobs && ( - + + {(resizeRef) => ( + + + {rule !== null && ( + + )} + + + + + + {rule !== null && !isStartingJobs && ( + + )} + + + + + {rule != null && } + + + {hasActions && ( + + + + + )} - - - - - {rule != null && } - - - {hasActions && ( - - - - - - )} + + - - + )} + @@ -889,7 +933,7 @@ export const RuleDetailsPage = connector( - +
diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts index 8e159c94aca0e..a262e6c58af2c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts @@ -7,8 +7,17 @@ import type { DiffableAllFields } from '../../../../../common/api/detection_engine'; -export const DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['50%', '50%']; -export const LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['30%', '70%']; +/** + * We subtract 4px from each column width to account for the 8px gap between the columns + */ +export const DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = [ + 'calc(50% - 4px)', + 'calc(50% - 4px)', +]; +export const LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = [ + 'calc(30% - 4px)', + 'calc(70% - 4px)', +]; export const ABOUT_UPGRADE_FIELD_ORDER: Array = [ 'version', diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx index fbe13be06b484..5172993e46257 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx @@ -122,7 +122,7 @@ export const SeverityMappingItem = ({ severityMappingItem }: SeverityMappingItem
- + - + {ALERT_RISK_SCORE}
@@ -515,7 +515,7 @@ export const RuleAboutSection = ({ }); return ( -
+
({ useUserPrivileges: () => ({ timelinePrivileges: { read: true }, @@ -29,6 +37,13 @@ jest.mock('../../../../common/hooks/use_license', () => ({ const mockUseAttacksPrivileges = useAttacksPrivileges as jest.MockedFunction< typeof useAttacksPrivileges >; +const mockUseViewInAiAssistant = useViewInAiAssistant as jest.MockedFunction< + typeof useViewInAiAssistant +>; +const mockUseAttackViewInAiAssistantContextMenuItems = + useAttackViewInAiAssistantContextMenuItems as jest.MockedFunction< + typeof useAttackViewInAiAssistantContextMenuItems + >; const mockAttack = getMockAttackDiscoveryAlerts()[0]; function renderAttack(attack: AttackDiscoveryAlert) { @@ -47,6 +62,20 @@ describe('AttacksGroupTakeActionItems', () => { hasAttackIndexWrite: true, loading: false, }); + mockUseViewInAiAssistant.mockReturnValue({ + showAssistantOverlay: jest.fn(), + disabled: false, + promptContextId: 'prompt-context-id', + }); + mockUseAttackViewInAiAssistantContextMenuItems.mockReturnValue({ + items: [ + { + name: 'View in AI Assistant', + key: 'viewInAiAssistant', + 'data-test-subj': 'viewInAiAssistant', + }, + ], + }); }); describe('workflow items', () => { @@ -124,4 +153,20 @@ describe('AttacksGroupTakeActionItems', () => { expect(await findByText('Investigate in timeline')).toBeInTheDocument(); }); }); + + describe('view in ai assistant', () => { + it('should render the `View in AI Assistant` action item', async () => { + const { findByText } = renderAttack(mockAttack); + expect(await findByText('View in AI Assistant')).toBeInTheDocument(); + }); + + it('should not render the action item when hook returns no items', async () => { + mockUseAttackViewInAiAssistantContextMenuItems.mockReturnValue({ + items: [], + }); + + const { queryByText } = renderAttack(mockAttack); + expect(queryByText('View in AI Assistant')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx index b9d3b03208a0c..4a7de4abd2ab4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx @@ -23,6 +23,7 @@ import type { AttackWithWorkflowStatus } from '../../../hooks/attacks/bulk_actio import { useAttackTagsContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items'; import { useAttackInvestigateInTimelineContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items'; import { useAttackCaseContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items'; +import { useAttackViewInAiAssistantContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items'; interface AttacksGroupTakeActionItemsProps { attack: AttackDiscoveryAlert; @@ -112,6 +113,10 @@ export function AttacksGroupTakeActionItems({ title: attack.title, attacksWithCase, }); + const { items: viewInAiAssistantItems } = useAttackViewInAiAssistantContextMenuItems({ + attack, + closePopover, + }); const defaultPanel: EuiContextMenuPanelDescriptor = useMemo( () => ({ @@ -122,9 +127,17 @@ export function AttacksGroupTakeActionItems({ ...tagsItems, ...investigateInTimelineItems, ...casesItems, + ...viewInAiAssistantItems, ], }), - [workflowItems, assignItems, tagsItems, investigateInTimelineItems, casesItems] + [ + workflowItems, + assignItems, + tagsItems, + investigateInTimelineItems, + casesItems, + viewInAiAssistantItems, + ] ); const panels: EuiContextMenuPanelDescriptor[] = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx index 86002643b4fae..d527f0c16b17a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx @@ -237,7 +237,7 @@ export const TableSection = React.memo( (props) => { const attack = getAttack(props.selectedGroup, props.groupBucket); if (!attack) return ; - return ; + return ; }, [getAttack, statusFilter] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx new file mode 100644 index 0000000000000..8d02c9893f366 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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'; +import { useAssistantContext } from '@kbn/elastic-assistant'; +import { getAttackDiscoveryMarkdown } from '@kbn/elastic-assistant-common'; +import { getMockAttackDiscoveryAlerts } from '../../../../../attack_discovery/pages/mock/mock_attack_discovery_alerts'; +import { useAttackDiscoveryAttachment } from '../../../../../attack_discovery/pages/results/use_attack_discovery_attachment'; +import { useAgentBuilderAvailability } from '../../../../../agent_builder/hooks/use_agent_builder_availability'; +import { useReportAddToChat } from '../../../../../agent_builder/hooks/use_report_add_to_chat'; +import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; +import { useAttackViewInAiAssistantContextMenuItems } from './use_attack_view_in_ai_assistant_context_menu_items'; + +jest.mock('@kbn/elastic-assistant'); +jest.mock('@kbn/elastic-assistant-common', () => { + const actual = jest.requireActual('@kbn/elastic-assistant-common'); + + return { + ...actual, + getAttackDiscoveryMarkdown: jest.fn(), + }; +}); +jest.mock('../../../../../attack_discovery/pages/results/use_attack_discovery_attachment'); +jest.mock('../../../../../agent_builder/hooks/use_agent_builder_availability'); +jest.mock('../../../../../agent_builder/hooks/use_report_add_to_chat'); +jest.mock('../../../../../assistant/use_assistant_availability'); + +const mockUseAttackDiscoveryAttachment = useAttackDiscoveryAttachment as jest.MockedFunction< + typeof useAttackDiscoveryAttachment +>; +const mockUseAgentBuilderAvailability = useAgentBuilderAvailability as jest.MockedFunction< + typeof useAgentBuilderAvailability +>; +const mockUseReportAddToChat = useReportAddToChat as jest.MockedFunction; +const mockUseAssistantAvailability = useAssistantAvailability as jest.MockedFunction< + typeof useAssistantAvailability +>; +const mockUseAssistantContext = useAssistantContext as jest.MockedFunction< + typeof useAssistantContext +>; +const mockGetAttackDiscoveryMarkdown = getAttackDiscoveryMarkdown as jest.MockedFunction< + typeof getAttackDiscoveryMarkdown +>; + +const mockAttack = getMockAttackDiscoveryAlerts()[0]; +const mockRegisterPromptContext = jest.fn(); +const mockUnRegisterPromptContext = jest.fn(); +const mockShowAssistantOverlay = jest.fn(); + +describe('useAttackViewInAiAssistantContextMenuItems', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseAttackDiscoveryAttachment.mockReturnValue(jest.fn()); + mockUseAgentBuilderAvailability.mockReturnValue({ + hasAgentBuilderPrivilege: false, + isAgentChatExperienceEnabled: false, + hasValidAgentBuilderLicense: true, + isAgentBuilderEnabled: false, + }); + mockUseReportAddToChat.mockReturnValue(jest.fn()); + mockUseAssistantAvailability.mockReturnValue({ + hasAssistantPrivilege: true, + hasConnectorsAllPrivilege: true, + hasConnectorsReadPrivilege: true, + hasManageGlobalKnowledgeBase: true, + hasSearchAILakeConfigurations: true, + hasUpdateAIAssistantAnonymization: true, + isAssistantEnabled: true, + isAssistantVisible: true, + }); + mockUseAssistantContext.mockReturnValue({ + registerPromptContext: mockRegisterPromptContext, + unRegisterPromptContext: mockUnRegisterPromptContext, + showAssistantOverlay: mockShowAssistantOverlay, + } as unknown as ReturnType); + mockGetAttackDiscoveryMarkdown.mockReturnValue('test-markdown'); + }); + + it('should return "View in AI Assistant" when Agent Builder chat experience is disabled', () => { + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + }) + ); + + expect(result.current.items[0]?.name).toBe('View in AI Assistant'); + expect(result.current.items[0]?.key).toBe('viewInAiAssistant'); + expect(result.current.items[0]?.['data-test-subj']).toBe('viewInAiAssistant'); + }); + + it('should call closePopover and showAssistantOverlay on "View in AI Assistant" click', () => { + const closePopover = jest.fn(); + + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + closePopover, + }) + ); + + result.current.items[0]?.onClick?.({} as React.MouseEvent); + + expect(closePopover).toHaveBeenCalledTimes(1); + expect(mockUnRegisterPromptContext).toHaveBeenCalledWith(mockAttack.id); + expect(mockRegisterPromptContext).toHaveBeenCalledTimes(1); + expect(mockShowAssistantOverlay).toHaveBeenCalledWith({ + showOverlay: true, + promptContextId: mockAttack.id, + selectedConversation: { title: `${mockAttack.title} - ${mockAttack.id.slice(-5)}` }, + }); + expect(mockShowAssistantOverlay.mock.invocationCallOrder[0]).toBeLessThan( + closePopover.mock.invocationCallOrder[0] + ); + expect(mockUnRegisterPromptContext.mock.invocationCallOrder[0]).toBeLessThan( + mockRegisterPromptContext.mock.invocationCallOrder[0] + ); + expect(mockRegisterPromptContext.mock.invocationCallOrder[0]).toBeLessThan( + mockShowAssistantOverlay.mock.invocationCallOrder[0] + ); + }); + + it('should return "Add to chat" when Agent Builder chat experience is enabled and user has privilege', () => { + mockUseAgentBuilderAvailability.mockReturnValue({ + hasAgentBuilderPrivilege: true, + isAgentChatExperienceEnabled: true, + hasValidAgentBuilderLicense: true, + isAgentBuilderEnabled: true, + }); + + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + }) + ); + + expect(result.current.items).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "viewInAgentBuilder", + "disabled": false, + "key": "viewInAgentBuilder", + "name": "Add to chat", + "onClick": [Function], + }, + ] + `); + }); + + it('should disable "Add to chat" when license is invalid', () => { + mockUseAgentBuilderAvailability.mockReturnValue({ + hasAgentBuilderPrivilege: true, + isAgentChatExperienceEnabled: true, + hasValidAgentBuilderLicense: false, + isAgentBuilderEnabled: true, + }); + + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + }) + ); + + expect(result.current.items[0]?.disabled).toBe(true); + }); + + it('should return empty items when Agent Builder chat experience is enabled and user has no privilege', () => { + mockUseAgentBuilderAvailability.mockReturnValue({ + hasAgentBuilderPrivilege: false, + isAgentChatExperienceEnabled: true, + hasValidAgentBuilderLicense: true, + isAgentBuilderEnabled: false, + }); + + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + }) + ); + + expect(result.current.items).toEqual([]); + }); + + it('should call closePopover, reportAddToChat and openAgentBuilderFlyout on "Add to chat" click', () => { + const closePopover = jest.fn(); + const openAgentBuilderFlyout = jest.fn(); + const reportAddToChatClick = jest.fn(); + + mockUseAttackDiscoveryAttachment.mockReturnValue(openAgentBuilderFlyout); + mockUseReportAddToChat.mockReturnValue(reportAddToChatClick); + mockUseAgentBuilderAvailability.mockReturnValue({ + hasAgentBuilderPrivilege: true, + isAgentChatExperienceEnabled: true, + hasValidAgentBuilderLicense: true, + isAgentBuilderEnabled: true, + }); + + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + closePopover, + }) + ); + + result.current.items[0]?.onClick?.({} as React.MouseEvent); + + expect(closePopover).toHaveBeenCalledTimes(1); + expect(reportAddToChatClick).toHaveBeenCalledWith({ + pathway: 'attack_discovery_take_action', + attachments: ['alert'], + }); + expect(openAgentBuilderFlyout).toHaveBeenCalledTimes(1); + expect(reportAddToChatClick.mock.invocationCallOrder[0]).toBeLessThan( + closePopover.mock.invocationCallOrder[0] + ); + }); + + it('should disable "View in AI Assistant" when user has no assistant privilege', () => { + mockUseAssistantAvailability.mockReturnValue({ + hasAssistantPrivilege: false, + hasConnectorsAllPrivilege: true, + hasConnectorsReadPrivilege: true, + hasManageGlobalKnowledgeBase: true, + hasSearchAILakeConfigurations: true, + hasUpdateAIAssistantAnonymization: true, + isAssistantEnabled: true, + isAssistantVisible: true, + }); + + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + }) + ); + + expect(result.current.items[0]?.disabled).toBe(true); + }); + + it('registers prompt context with markdown getter and replacements', async () => { + const { result } = renderHook(() => + useAttackViewInAiAssistantContextMenuItems({ + attack: mockAttack, + }) + ); + + result.current.items[0]?.onClick?.({} as React.MouseEvent); + + const registeredContext = mockRegisterPromptContext.mock.calls[0][0]; + const promptContext = await registeredContext.getPromptContext(); + + expect(registeredContext).toMatchObject({ + id: mockAttack.id, + category: 'insight', + description: mockAttack.title, + replacements: mockAttack.replacements, + tooltip: null, + }); + expect(promptContext).toBe('test-markdown'); + expect(mockGetAttackDiscoveryMarkdown).toHaveBeenCalledWith({ + attackDiscovery: mockAttack, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx new file mode 100644 index 0000000000000..131bacc5047b6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.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 { useCallback, useMemo } from 'react'; +import { useAssistantContext } from '@kbn/elastic-assistant'; +import { + getAttackDiscoveryMarkdown, + type AttackDiscoveryAlert, +} from '@kbn/elastic-assistant-common'; +import type { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; +import { useReportAddToChat } from '../../../../../agent_builder/hooks/use_report_add_to_chat'; +import { useAgentBuilderAvailability } from '../../../../../agent_builder/hooks/use_agent_builder_availability'; +import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; +import { useAttackDiscoveryAttachment } from '../../../../../attack_discovery/pages/results/use_attack_discovery_attachment'; +import * as i18n from '../../../../../attack_discovery/pages/results/take_action/translations'; + +export interface UseAttackViewInAiAssistantContextMenuItemsProps { + /** + * The attack discovery object + */ + attack: AttackDiscoveryAlert; + /** + * Optional callback to close the containing popover menu + */ + closePopover?: () => void; +} + +export const useAttackViewInAiAssistantContextMenuItems = ({ + attack, + closePopover, +}: UseAttackViewInAiAssistantContextMenuItemsProps): { + items: EuiContextMenuPanelItemDescriptorEntry[]; +} => { + const { hasAssistantPrivilege } = useAssistantAvailability(); + const { registerPromptContext, showAssistantOverlay, unRegisterPromptContext } = + useAssistantContext(); + + const promptContextId = attack.id ?? null; + const viewInAiAssistantDisabled = !hasAssistantPrivilege || promptContextId == null; + + const onViewInAiAssistant = useCallback(() => { + if (promptContextId == null) { + return; + } + + const lastFive = attack.id ? ` - ${attack.id.slice(-5)}` : ''; + const conversationTitle = `${attack.title ?? ''}${lastFive}`; + + unRegisterPromptContext(promptContextId); + registerPromptContext({ + category: 'insight', + description: attack.title ?? '', + getPromptContext: async () => + getAttackDiscoveryMarkdown({ + attackDiscovery: attack, + // note: we do NOT want to replace the replacements here + }), + id: promptContextId, + replacements: attack.replacements, + tooltip: null, + }); + + showAssistantOverlay({ + showOverlay: true, + promptContextId, + selectedConversation: { title: conversationTitle }, + }); + }, [ + attack, + promptContextId, + registerPromptContext, + showAssistantOverlay, + unRegisterPromptContext, + ]); + + const { hasAgentBuilderPrivilege, isAgentChatExperienceEnabled, hasValidAgentBuilderLicense } = + useAgentBuilderAvailability(); + const openAgentBuilderFlyout = useAttackDiscoveryAttachment(attack, attack.replacements); + const reportAddToChatClick = useReportAddToChat(); + + const onViewInAgentBuilder = useCallback(() => { + reportAddToChatClick({ + pathway: 'attack_discovery_take_action', + attachments: ['alert'], + }); + openAgentBuilderFlyout(); + }, [openAgentBuilderFlyout, reportAddToChatClick]); + + const isAddToChatDisabled = !hasValidAgentBuilderLicense; + + const items = useMemo(() => { + if (isAgentChatExperienceEnabled) { + if (!hasAgentBuilderPrivilege) { + return []; + } + + return [ + { + name: i18n.ADD_TO_CHAT, + key: 'viewInAgentBuilder', + 'data-test-subj': 'viewInAgentBuilder', + disabled: isAddToChatDisabled, + onClick: () => { + onViewInAgentBuilder(); + closePopover?.(); + }, + }, + ]; + } + + return [ + { + name: i18n.VIEW_IN_AI_ASSISTANT, + key: 'viewInAiAssistant', + 'data-test-subj': 'viewInAiAssistant', + disabled: viewInAiAssistantDisabled, + onClick: () => { + onViewInAiAssistant(); + closePopover?.(); + }, + }, + ]; + }, [ + closePopover, + hasAgentBuilderPrivilege, + isAddToChatDisabled, + isAgentChatExperienceEnabled, + onViewInAgentBuilder, + onViewInAiAssistant, + viewInAiAssistantDisabled, + ]); + + return { items }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.test.tsx index f78766e593373..7cb2553bf4ddd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.test.tsx @@ -28,6 +28,37 @@ describe('HeaderButtons', () => { expect(getByTestId('siemMigrationsSelectMigrationButton')).toBeInTheDocument(); }); + it('marks only the option with the selected migration id as selected when names are duplicated', () => { + const [first, second] = getMigrationsStatsMock(); + const migrationsWithDuplicateNames = [ + { ...first, name: 'Same name', id: '1' }, + { ...second, name: 'Same name', id: '2' }, + ]; + + const { getByTestId } = render( + + + + ); + + const siemMigrationsSelectMigrationButton = getByTestId('siemMigrationsSelectMigrationButton'); + const comboBoxToggleListButton = within(siemMigrationsSelectMigrationButton).getByTestId( + 'comboBoxToggleListButton' + ); + fireEvent.click(comboBoxToggleListButton); + + const option1 = getByTestId('migrationSelectionOption-1'); + const option2 = getByTestId('migrationSelectionOption-2'); + + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'false'); + }); + it('calls onMigrationIdChange when a migration is selected', () => { const { getByTestId, getByText } = render( diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.tsx index 5efbca8c07309..74e46939144c9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/components/header_buttons/index.tsx @@ -31,6 +31,7 @@ export const SIEM_MIGRATIONS_SELECT_MIGRATION_BUTTON_ID = 'siemMigrationsSelectM const migrationStatsToComboBoxOption = ( stats: MigrationTaskStats ): EuiComboBoxOptionOption => ({ + key: stats.id, value: stats.id, label: stats.name, 'data-test-subj': `migrationSelectionOption-${stats.id}`, @@ -60,8 +61,10 @@ export const HeaderButtons: React.FC = React.memo( const migrationVendor = useMemo(() => selectedMigrationStats?.vendor, [selectedMigrationStats]); const selectedMigrationOption = useMemo>>(() => { - return selectedMigrationStats ? [migrationStatsToComboBoxOption(selectedMigrationStats)] : []; - }, [selectedMigrationStats]); + if (!selectedMigrationId) return []; + const selected = migrationOptions.find((opt) => opt.value === selectedMigrationId); + return selected ? [selected] : []; + }, [migrationOptions, selectedMigrationId]); const onChange = (selected: Array>) => { onMigrationIdChange(selected[0].value); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx index 277010473e6bd..7d17014569e40 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx @@ -47,6 +47,21 @@ const initialEnrichedColumns = getColumnHeaders( ); const initialEnrichedColumnsIds = initialEnrichedColumns.map((c) => c.id); +const mockAttackTimelineData = [ + { + ...mockTimelineData[0], + _id: 'attack-1', + data: [ + ...mockTimelineData[0].data, + { field: 'kibana.alert.attack_discovery.alert_ids', value: ['alert-1'] }, + { field: 'kibana.alert.rule.rule_type_id', value: ['attack-discovery'] }, + ], + ecs: { + ...mockTimelineData[0].ecs, + _index: 'attack-index', + }, + }, +]; type TestComponentProps = Partial> & { store?: ReturnType; @@ -120,6 +135,29 @@ describe('unified data table', () => { SPECIAL_TEST_TIMEOUT ); + it( + 'opens attack flyout when expanded row is an attack discovery alert', + async () => { + render(); + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + fireEvent.click(screen.getByTestId('docTableExpandToggleColumn')); + + await waitFor(() => { + expect(openFlyoutMock).toHaveBeenCalledWith({ + right: { + id: 'attack-details-right', + params: { + attackId: 'attack-1', + indexName: 'attack-index', + }, + }, + }); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + describe('custom cell rendering based on data Type', () => { it( 'should render source.ip as link', diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx index a8a450b5c319d..d40557d63f5e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx @@ -25,6 +25,7 @@ import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/tri import { JEST_ENVIRONMENT } from '../../../../../../common/constants'; import { useOnExpandableFlyoutClose } from '../../../../../flyout/shared/hooks/use_on_expandable_flyout_close'; import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/shared/constants/panel_keys'; +import { AttackDetailsRightPanelKey } from '../../../../../flyout/attack_details/constants/panel_keys'; import { selectTimelineById } from '../../../../store/selectors'; import { RowRendererCount } from '../../../../../../common/api/timeline'; import { EmptyComponent } from '../../../../../common/lib/cell_actions/helpers'; @@ -52,6 +53,7 @@ import { CustomTimelineDataGridBody } from './custom_timeline_data_grid_body'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; import { DocumentEventTypes } from '../../../../../common/lib/telemetry/types'; import { getTimelineRowTypeIndicator } from './get_row_indicator'; +import { isAttackDiscoveryRow } from './is_attack_discovery_row'; export const SAMPLE_SIZE_SETTING = 500; const DataGridMemoized = React.memo(UnifiedDataTable); @@ -175,15 +177,26 @@ export const TimelineDataTableComponent: React.FC = memo( const handleOnEventDetailPanelOpened = useCallback( (eventData: DataTableRecord & TimelineItem) => { + const isAttackRow = isAttackDiscoveryRow(eventData); + const indexName = eventData.ecs._index ?? ''; + const rightPanel = isAttackRow + ? { + id: AttackDetailsRightPanelKey, + params: { + attackId: eventData._id, + indexName, + }, + } + : { + id: DocumentDetailsRightPanelKey, + params: { + id: eventData._id, + indexName, + scopeId: timelineId, + }, + }; openFlyout({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: eventData._id, - indexName: eventData.ecs._index ?? '', - scopeId: timelineId, - }, - }, + right: rightPanel, }); telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutOpened, { location: timelineId, diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/is_attack_discovery_row.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/is_attack_discovery_row.ts new file mode 100644 index 0000000000000..1a231272a9e90 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/is_attack_discovery_row.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DataTableRecord } from '@kbn/discover-utils/types'; +import { + ATTACK_DISCOVERY_AD_HOC_RULE_TYPE_ID, + ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX, + ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID, +} from '@kbn/elastic-assistant-common'; +import { ALERT_RULE_TYPE_ID } from '@kbn/rule-data-utils'; +import type { TimelineItem } from '../../../../../../common/search_strategy'; + +const ATTACK_DISCOVERY_ALERT_IDS_FIELD = 'kibana.alert.attack_discovery.alert_ids'; +const ATTACK_DISCOVERY_RULE_TYPE_IDS = [ + ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID, + ATTACK_DISCOVERY_AD_HOC_RULE_TYPE_ID, +] as const; +const ATTACK_DISCOVERY_RULE_TYPES = new Set(ATTACK_DISCOVERY_RULE_TYPE_IDS); + +const getTimelineFieldValues = ( + eventData: DataTableRecord & TimelineItem, + fieldName: string +): string[] => { + const timelineField = eventData.data.find(({ field }) => field === fieldName); + return timelineField?.value?.filter((value): value is string => value != null) ?? []; +}; + +export const isAttackDiscoveryRow = (eventData: DataTableRecord & TimelineItem): boolean => { + const attackAlertIds = getTimelineFieldValues(eventData, ATTACK_DISCOVERY_ALERT_IDS_FIELD); + if (attackAlertIds.length > 0) { + return true; + } + + const ruleTypeIds = getTimelineFieldValues(eventData, ALERT_RULE_TYPE_ID); + if (ruleTypeIds.some((ruleTypeId) => ATTACK_DISCOVERY_RULE_TYPES.has(ruleTypeId))) { + return true; + } + + const indexName = eventData.ecs._index ?? ''; + return indexName.includes(ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts index fa4b1c056cf9f..d8064c09174b1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.ts @@ -48,10 +48,11 @@ export const getPrebuiltRulesAndTimelinesStatusRoute = ( }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); - const ctx = await context.resolve(['core', 'alerting']); + const ctx = await context.resolve(['core', 'alerting', 'securitySolution']); const savedObjectsClient = ctx.core.savedObjects.client; const rulesClient = await ctx.alerting.getRulesClient(); const ruleAssetsClient = createPrebuiltRuleAssetsClient(savedObjectsClient); + const mlAuthz = ctx.securitySolution.getMlAuthz(); try { const latestPrebuiltRules = await ruleAssetsClient.fetchLatestAssets(); @@ -70,8 +71,16 @@ export const getPrebuiltRulesAndTimelinesStatusRoute = ( await getExistingPrepackagedRules({ rulesClient, logger }) ); - const rulesToInstall = getRulesToInstall(latestPrebuiltRules, installedPrebuiltRules); - const rulesToUpdate = getRulesToUpdate(latestPrebuiltRules, installedPrebuiltRules); + const rulesToInstall = await getRulesToInstall( + latestPrebuiltRules, + installedPrebuiltRules, + mlAuthz + ); + const rulesToUpdate = await getRulesToUpdate( + latestPrebuiltRules, + installedPrebuiltRules, + mlAuthz + ); const frameworkRequest = await buildFrameworkRequest(context, request); const prebuiltTimelineStatus = await checkTimelinesStatus(frameworkRequest); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts index b9eaac82f3971..b638c55854d8f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/legacy_create_prepackaged_rules.ts @@ -44,6 +44,7 @@ export const legacyCreatePrepackagedRules = async ( const exceptionsListClient = context.getExceptionListClient() ?? exceptionsClient; const detectionRulesClient = context.getDetectionRulesClient(); const ruleAssetsClient = createPrebuiltRuleAssetsClient(savedObjectsClient); + const mlAuthz = context.getMlAuthz(); if (!siemClient || !rulesClient) { throw new PrepackagedRulesError('', 404); @@ -63,8 +64,16 @@ export const legacyCreatePrepackagedRules = async ( const installedPrebuiltRules = rulesToMap( await getExistingPrepackagedRules({ rulesClient, logger }) ); - const rulesToInstall = getRulesToInstall(latestPrebuiltRules, installedPrebuiltRules); - const rulesToUpdate = getRulesToUpdate(latestPrebuiltRules, installedPrebuiltRules); + const rulesToInstall = await getRulesToInstall( + latestPrebuiltRules, + installedPrebuiltRules, + mlAuthz + ); + const rulesToUpdate = await getRulesToUpdate( + latestPrebuiltRules, + installedPrebuiltRules, + mlAuthz + ); const ruleCreationResult = await createPrebuiltRules( detectionRulesClient, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.test.ts index fc14ab92bbcbb..3db75e52514d6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.test.ts @@ -7,37 +7,40 @@ import { getRulesToInstall } from './get_rules_to_install'; import { getRuleMock } from '../../routes/__mocks__/request_responses'; -import { getPrebuiltRuleMock } from '../mocks'; +import { getPrebuiltRuleMock, getPrebuiltRuleMockOfType } from '../mocks'; import { getQueryRuleParams } from '../../rule_schema/mocks'; import { rulesToMap } from './utils'; +import { buildMlAuthz, buildRestrictedMlAuthz } from '../../../machine_learning/__mocks__/authz'; describe('get_rules_to_install', () => { - test('should return empty array if both rule sets are empty', () => { - const update = getRulesToInstall([], rulesToMap([])); + const mockMlAuthz = buildMlAuthz(); + + test('should return empty array if both rule sets are empty', async () => { + const update = await getRulesToInstall([], rulesToMap([]), mockMlAuthz); expect(update).toEqual([]); }); - test('should return empty array if the two rule ids match', () => { + test('should return empty array if the two rule ids match', async () => { const ruleAsset = getPrebuiltRuleMock(); ruleAsset.rule_id = 'rule-1'; const installedRule = getRuleMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; - const update = getRulesToInstall([ruleAsset], rulesToMap([installedRule])); + const update = await getRulesToInstall([ruleAsset], rulesToMap([installedRule]), mockMlAuthz); expect(update).toEqual([]); }); - test('should return the rule to install if the id of the two rules do not match', () => { + test('should return the rule to install if the id of the two rules do not match', async () => { const ruleAsset = getPrebuiltRuleMock(); ruleAsset.rule_id = 'rule-1'; const installedRule = getRuleMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; - const update = getRulesToInstall([ruleAsset], rulesToMap([installedRule])); + const update = await getRulesToInstall([ruleAsset], rulesToMap([installedRule]), mockMlAuthz); expect(update).toEqual([ruleAsset]); }); - test('should return two rules to install if both the ids of the two rules do not match', () => { + test('should return two rules to install if both the ids of the two rules do not match', async () => { const ruleAsset1 = getPrebuiltRuleMock(); ruleAsset1.rule_id = 'rule-1'; @@ -46,11 +49,15 @@ describe('get_rules_to_install', () => { const installedRule = getRuleMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-3'; - const update = getRulesToInstall([ruleAsset1, ruleAsset2], rulesToMap([installedRule])); + const update = await getRulesToInstall( + [ruleAsset1, ruleAsset2], + rulesToMap([installedRule]), + mockMlAuthz + ); expect(update).toEqual([ruleAsset1, ruleAsset2]); }); - test('should return two rules of three to install if both the ids of the two rules do not match but the third does', () => { + test('should return two rules of three to install if both the ids of the two rules do not match but the third does', async () => { const ruleAsset1 = getPrebuiltRuleMock(); ruleAsset1.rule_id = 'rule-1'; @@ -62,10 +69,27 @@ describe('get_rules_to_install', () => { const installedRule = getRuleMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-3'; - const update = getRulesToInstall( + const update = await getRulesToInstall( [ruleAsset1, ruleAsset2, ruleAsset3], - rulesToMap([installedRule]) + rulesToMap([installedRule]), + mockMlAuthz ); expect(update).toEqual([ruleAsset1, ruleAsset2]); }); + + test('should exclude license-restricted rules from rules to install', async () => { + const queryRuleAsset = getPrebuiltRuleMock(); + queryRuleAsset.rule_id = 'rule-query'; + + const mlRuleAsset = getPrebuiltRuleMockOfType('machine_learning'); + + const mlAuthzRestrictingMlRules = buildRestrictedMlAuthz(); + + const update = await getRulesToInstall( + [queryRuleAsset, mlRuleAsset], + rulesToMap([]), + mlAuthzRestrictingMlRules + ); + expect(update).toEqual([queryRuleAsset]); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.ts index d2e6bc4acf7b4..a43587ed816a5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_install.ts @@ -5,12 +5,16 @@ * 2.0. */ +import type { MlAuthz } from '../../../machine_learning/authz'; import type { RuleAlertType } from '../../rule_schema'; import type { PrebuiltRuleAsset } from '../model/rule_assets/prebuilt_rule_asset'; +import { excludeLicenseRestrictedRules } from './utils'; -export const getRulesToInstall = ( +export const getRulesToInstall = async ( latestPrebuiltRules: PrebuiltRuleAsset[], - installedRules: Map + installedRules: Map, + mlAuthz: MlAuthz ) => { - return latestPrebuiltRules.filter((rule) => !installedRules.has(rule.rule_id)); + const uninstalledRules = latestPrebuiltRules.filter((rule) => !installedRules.has(rule.rule_id)); + return excludeLicenseRestrictedRules(uninstalledRules, mlAuthz); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.test.ts index 9cedfc101a932..d3e4d25b84c4e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.test.ts @@ -7,17 +7,20 @@ import { filterInstalledRules, getRulesToUpdate } from './get_rules_to_update'; import { getRuleMock } from '../../routes/__mocks__/request_responses'; -import { getPrebuiltRuleMock } from '../mocks'; +import { getPrebuiltRuleMock, getPrebuiltRuleMockOfType } from '../mocks'; import { getQueryRuleParams } from '../../rule_schema/mocks'; import { rulesToMap } from './utils'; +import { buildRestrictedMlAuthz, buildMlAuthz } from '../../../machine_learning/__mocks__/authz'; describe('get_rules_to_update', () => { - test('should return empty array if both rule sets are empty', () => { - const update = getRulesToUpdate([], rulesToMap([])); + const mockMlAuthz = buildMlAuthz(); + + test('should return empty array if both rule sets are empty', async () => { + const update = await getRulesToUpdate([], rulesToMap([]), mockMlAuthz); expect(update).toEqual([]); }); - test('should return empty array if the rule_id of the two rules do not match', () => { + test('should return empty array if the rule_id of the two rules do not match', async () => { const ruleAsset = getPrebuiltRuleMock(); ruleAsset.rule_id = 'rule-1'; ruleAsset.version = 2; @@ -25,11 +28,11 @@ describe('get_rules_to_update', () => { const installedRule = getRuleMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; installedRule.params.version = 1; - const update = getRulesToUpdate([ruleAsset], rulesToMap([installedRule])); + const update = await getRulesToUpdate([ruleAsset], rulesToMap([installedRule]), mockMlAuthz); expect(update).toEqual([]); }); - test('should return empty array if the version of file system rule is less than the installed version', () => { + test('should return empty array if the version of file system rule is less than the installed version', async () => { const ruleAsset = getPrebuiltRuleMock(); ruleAsset.rule_id = 'rule-1'; ruleAsset.version = 1; @@ -37,11 +40,11 @@ describe('get_rules_to_update', () => { const installedRule = getRuleMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 2; - const update = getRulesToUpdate([ruleAsset], rulesToMap([installedRule])); + const update = await getRulesToUpdate([ruleAsset], rulesToMap([installedRule]), mockMlAuthz); expect(update).toEqual([]); }); - test('should return empty array if the version of file system rule is the same as the installed version', () => { + test('should return empty array if the version of file system rule is the same as the installed version', async () => { const ruleAsset = getPrebuiltRuleMock(); ruleAsset.rule_id = 'rule-1'; ruleAsset.version = 1; @@ -49,11 +52,11 @@ describe('get_rules_to_update', () => { const installedRule = getRuleMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; - const update = getRulesToUpdate([ruleAsset], rulesToMap([installedRule])); + const update = await getRulesToUpdate([ruleAsset], rulesToMap([installedRule]), mockMlAuthz); expect(update).toEqual([]); }); - test('should return the rule to update if the version of file system rule is greater than the installed version', () => { + test('should return the rule to update if the version of file system rule is greater than the installed version', async () => { const ruleAsset = getPrebuiltRuleMock(); ruleAsset.rule_id = 'rule-1'; ruleAsset.version = 2; @@ -63,11 +66,11 @@ describe('get_rules_to_update', () => { installedRule.params.version = 1; installedRule.params.exceptionsList = []; - const update = getRulesToUpdate([ruleAsset], rulesToMap([installedRule])); + const update = await getRulesToUpdate([ruleAsset], rulesToMap([installedRule]), mockMlAuthz); expect(update).toEqual([ruleAsset]); }); - test('should return 1 rule out of 2 to update if the version of file system rule is greater than the installed version of just one', () => { + test('should return 1 rule out of 2 to update if the version of file system rule is greater than the installed version of just one', async () => { const ruleAsset = getPrebuiltRuleMock(); ruleAsset.rule_id = 'rule-1'; ruleAsset.version = 2; @@ -82,11 +85,15 @@ describe('get_rules_to_update', () => { installedRule2.params.version = 1; installedRule2.params.exceptionsList = []; - const update = getRulesToUpdate([ruleAsset], rulesToMap([installedRule1, installedRule2])); + const update = await getRulesToUpdate( + [ruleAsset], + rulesToMap([installedRule1, installedRule2]), + mockMlAuthz + ); expect(update).toEqual([ruleAsset]); }); - test('should return 2 rules out of 2 to update if the version of file system rule is greater than the installed version of both', () => { + test('should return 2 rules out of 2 to update if the version of file system rule is greater than the installed version of both', async () => { const ruleAsset1 = getPrebuiltRuleMock(); ruleAsset1.rule_id = 'rule-1'; ruleAsset1.version = 2; @@ -105,12 +112,41 @@ describe('get_rules_to_update', () => { installedRule2.params.version = 1; installedRule2.params.exceptionsList = []; - const update = getRulesToUpdate( + const update = await getRulesToUpdate( [ruleAsset1, ruleAsset2], - rulesToMap([installedRule1, installedRule2]) + rulesToMap([installedRule1, installedRule2]), + mockMlAuthz ); expect(update).toEqual([ruleAsset1, ruleAsset2]); }); + + test('should exclude license-restricted rules from rules to update', async () => { + const queryRuleAsset = getPrebuiltRuleMock(); + queryRuleAsset.rule_id = 'rule-query'; + queryRuleAsset.version = 2; + + const mlRuleAsset = getPrebuiltRuleMockOfType('machine_learning'); + mlRuleAsset.version = 2; + + const installedQueryRule = getRuleMock(getQueryRuleParams()); + installedQueryRule.params.ruleId = 'rule-query'; + installedQueryRule.params.version = 1; + installedQueryRule.params.exceptionsList = []; + + const installedMlRule = getRuleMock(getQueryRuleParams()); + installedMlRule.params.ruleId = mlRuleAsset.rule_id; + installedMlRule.params.version = 1; + installedMlRule.params.exceptionsList = []; + + const mlAuthzRestrictingMlRules = buildRestrictedMlAuthz(); + + const update = await getRulesToUpdate( + [queryRuleAsset, mlRuleAsset], + rulesToMap([installedQueryRule, installedMlRule]), + mlAuthzRestrictingMlRules + ); + expect(update).toEqual([queryRuleAsset]); + }); }); describe('filterInstalledRules', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.ts index dac43534e7420..f2c66d527ec5d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/get_rules_to_update.ts @@ -5,8 +5,10 @@ * 2.0. */ +import type { MlAuthz } from '../../../machine_learning/authz'; import type { RuleAlertType } from '../../rule_schema'; import type { PrebuiltRuleAsset } from '../model/rule_assets/prebuilt_rule_asset'; +import { excludeLicenseRestrictedRules } from './utils'; /** * Returns the rules to update by doing a compare to the rules from the file system against @@ -15,13 +17,15 @@ import type { PrebuiltRuleAsset } from '../model/rule_assets/prebuilt_rule_asset * @param latestPrebuiltRules The latest rules to check against installed * @param installedRules The installed rules */ -export const getRulesToUpdate = ( +export const getRulesToUpdate = async ( latestPrebuiltRules: PrebuiltRuleAsset[], - installedRules: Map + installedRules: Map, + mlAuthz: MlAuthz ) => { - return latestPrebuiltRules.filter((latestRule) => + const rulesToUpdate = latestPrebuiltRules.filter((latestRule) => filterInstalledRules(latestRule, installedRules) ); + return excludeLicenseRestrictedRules(rulesToUpdate, mlAuthz); }; /** diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index 5a50ed815a842..b45c47a729df3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -217,7 +217,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = const refresh = isPreview ? false : true; - ruleExecutionLogger.debug(`Starting Security Rule execution (interval: ${interval})`); + ruleExecutionLogger.debug(`Starting execution with interval: ${interval}`); await ruleExecutionLogger.logStatusChange({ newStatus: RuleExecutionStatusEnum.running, @@ -279,13 +279,13 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = if (SavedObjectsErrorHelpers.isNotFoundError(exc)) { await ruleExecutionLogger.logStatusChange({ newStatus: RuleExecutionStatusEnum.failed, - message: `Data View not found ${exc}`, + message: `Data view is not found.\nError: ${exc}`, userError: true, }); } else { await ruleExecutionLogger.logStatusChange({ newStatus: RuleExecutionStatusEnum.failed, - message: `Check for indices to search failed ${exc}`, + message: `Check for indices to search failed.\nError: ${exc}`, }); } @@ -589,12 +589,12 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = }); } else if (!(result.warningMessages.length > 0) && !(wrapperWarnings.length > 0)) { ruleExecutionLogger.debug('Security Rule execution completed'); - ruleExecutionLogger.debug( - `Finished indexing ${createdSignalsCount} alerts into ${ruleDataClient.indexNameWithNamespace( + ruleExecutionLogger.info( + `Alerts created: ${createdSignalsCount}\nFinished indexing ${createdSignalsCount} alerts into "${ruleDataClient.indexNameWithNamespace( spaceId - )} ${ + )}".${ !isEmpty(tuples) - ? `searched between date ranges ${JSON.stringify(tuples, null, 2)}` + ? ` Searched between date ranges: ${JSON.stringify(tuples, null, 2)}.` : '' }` ); @@ -614,7 +614,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = await ruleExecutionLogger.logStatusChange({ newStatus: RuleExecutionStatusEnum.failed, - message: `An error occurred during rule execution: message: "${errorMessage}"`, + message: `An error occurred during rule execution. ${errorMessage}`, userError: checkErrorDetails(errorMessage).isUserError, metrics: { searchDurations: result.searchAfterTimes, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts index 851635bf86bed..6522344945bca 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts @@ -106,7 +106,7 @@ export const buildAlertGroupFromSequence = ({ }) ); } catch (error) { - ruleExecutionLogger.error(error); + ruleExecutionLogger.debug(`Error transforming matched events to alerts\nError: ${error}`); return { shellAlert: undefined, buildingBlocks: [] }; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts index 87496501ce356..d4543d443ef76 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts @@ -98,7 +98,7 @@ export const eqlExecutor = async ({ tiebreakerField: ruleParams.tiebreakerField, }); - ruleExecutionLogger.debug(`EQL query request: ${JSON.stringify(request)}`); + ruleExecutionLogger.trace(`EQL query to execute\n${JSON.stringify(request)}`); const exceptionsWarning = getUnprocessedExceptionsWarnings(sharedParams.unprocessedExceptions); if (exceptionsWarning) { result.warningMessages.push(exceptionsWarning); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts index cd72345409595..58abb0f2647f3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts @@ -145,7 +145,7 @@ export const esqlExecutor = async ({ }; const hasLoggedRequestsReachedLimit = iteration >= 2; - ruleExecutionLogger.debug(`ES|QL query request: ${JSON.stringify(esqlRequest)}`); + ruleExecutionLogger.trace(`ES|QL query to execute\n${JSON.stringify(esqlRequest)}`); const exceptionsWarning = getUnprocessedExceptionsWarnings(unprocessedExceptions); if (exceptionsWarning) { result.warningMessages.push(exceptionsWarning); @@ -166,8 +166,8 @@ export const esqlExecutor = async ({ const esqlSearchDuration = performance.now() - esqlSignalSearchStart; result.searchAfterTimes.push(makeFloatString(esqlSearchDuration)); - ruleExecutionLogger.debug( - `ES|QL query request for ${iteration} iteration took: ${esqlSearchDuration}ms` + ruleExecutionLogger.trace( + `ES|QL query iteration\nIteration: ${iteration}. Search took: ${esqlSearchDuration}ms.` ); const results = response.values.map((row) => rowToDocument(response.columns, row)); @@ -235,9 +235,8 @@ export const esqlExecutor = async ({ maxNumberOfAlertsMultiplier: 1, }); - ruleExecutionLogger.debug( - `Created ${bulkCreateResult.createdItemsCount} alerts. Suppressed ${bulkCreateResult.suppressedItemsCount} alerts` - ); + ruleExecutionLogger.info(`Alerts created: ${bulkCreateResult.createdItemsCount}`); + ruleExecutionLogger.info(`Alerts suppressed: ${bulkCreateResult.suppressedItemsCount}`); updateExcludedDocuments({ excludedDocuments, @@ -268,7 +267,7 @@ export const esqlExecutor = async ({ }); addToSearchAfterReturn({ current: result, next: bulkCreateResult }); - ruleExecutionLogger.debug(`Created ${bulkCreateResult.createdItemsCount} alerts`); + ruleExecutionLogger.info(`Alerts created: ${bulkCreateResult.createdItemsCount}`); updateExcludedDocuments({ excludedDocuments, @@ -293,8 +292,8 @@ export const esqlExecutor = async ({ // no more results will be found if (response.values.length < size) { - ruleExecutionLogger.debug( - `End of search: Found ${response.values.length} results with page size ${size}` + ruleExecutionLogger.trace( + `End of search. Found ${response.values.length} results\nPage size ${size}.` ); break; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts index 524671096230f..6604973aa08e1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts @@ -71,7 +71,7 @@ export const bulkCreate = async ({ }); return enrichedAlerts; } catch (error) { - ruleExecutionLogger.error(`Alerts enrichment failed: ${error}`); + ruleExecutionLogger.error(`Error enriching alerts\nError: ${error}`); throw error; } finally { enrichmentsTimeFinish = performance.now(); @@ -91,10 +91,10 @@ export const bulkCreate = async ({ const end = performance.now(); - ruleExecutionLogger.debug(`Alerts bulk process took ${makeFloatString(end - start)} ms`); + ruleExecutionLogger.debug(`Bulk processing alerts took ${makeFloatString(end - start)}ms.`); if (!isEmpty(errors)) { - ruleExecutionLogger.warn(`Alerts bulk process finished with errors: ${JSON.stringify(errors)}`); + ruleExecutionLogger.warn(`Error bulk processing alerts\nError: ${JSON.stringify(errors)}`); return { errors: Object.keys(errors), success: false, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts index eeb79fdd24ee5..89349a9731aba 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts @@ -106,7 +106,7 @@ export const createEventSignal = async ({ loadFields: true, }); - ruleExecutionLogger.debug(`${ids?.length} matched signals found`); + ruleExecutionLogger.debug(`Matched events found: ${ids?.length}`); const enrichment = threatEnrichmentFactory({ signalIdToMatchedQueriesMap, @@ -138,12 +138,12 @@ export const createEventSignal = async ({ } else { createResult = await searchAfterAndBulkCreate(searchAfterBulkCreateParams); } - ruleExecutionLogger.debug( - `${ + ruleExecutionLogger.trace( + `Match checks completed\n${ currentEventList.length - } items have completed match checks and the total times to search were ${ + } items have completed match checks. Search times (ms): ${ createResult.searchAfterTimes.length !== 0 ? createResult.searchAfterTimes : '(unknown) ' - }ms` + }.` ); return createResult; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts index e5b20455e488f..d510e616c4e92 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts @@ -56,7 +56,7 @@ export const createThreatSignal = async ({ if (!threatFilter.query || threatFilter.query?.bool.should.length === 0) { // empty threat list and we do not want to return everything as being // a hit so opt to return the existing result. - ruleExecutionLogger.debug( + ruleExecutionLogger.trace( 'Indicator items are empty after filtering for missing data, returning without attempting a match' ); return currentResult; @@ -74,7 +74,7 @@ export const createThreatSignal = async ({ loadFields: true, }); - ruleExecutionLogger.debug( + ruleExecutionLogger.trace( `${threatFilter.query?.bool.should.length} indicator items are being checked for existence of matches` ); @@ -115,12 +115,12 @@ export const createThreatSignal = async ({ result = await searchAfterAndBulkCreate(searchAfterBulkCreateParams); } - ruleExecutionLogger.debug( - `${ + ruleExecutionLogger.trace( + `Match checks completed\n${ threatFilter.query?.bool.should.length - } items have completed match checks and the total times to search were ${ + } items have completed match checks. Search times (ms): ${ result.searchAfterTimes.length !== 0 ? result.searchAfterTimes : '(unknown) ' - }ms` + }.` ); return result; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts index f04996132e0dc..57319972eac48 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts @@ -77,7 +77,7 @@ export const createThreatSignals = async ({ }); const params = completeRule.ruleParams; - ruleExecutionLogger.debug('Indicator matching rule starting'); + ruleExecutionLogger.trace('Indicator matching rule starting'); const perPage = concurrentSearches * itemsPerSearch; const verifyExecutionCanProceed = buildExecutionIntervalValidator( completeRule.ruleConfig.schedule.interval @@ -185,7 +185,7 @@ export const createThreatSignals = async ({ while (list.hits.hits.length !== 0) { verifyExecutionCanProceed(); const chunks = chunk(chunkPage, list.hits.hits); - ruleExecutionLogger.debug(`${chunks.length} concurrent indicator searches are starting.`); + ruleExecutionLogger.trace(`${chunks.length} concurrent indicator searches are starting.`); const concurrentSearchesPerformed = chunks.map>(createSignal); const searchesPerformed = await Promise.all(concurrentSearchesPerformed); @@ -205,8 +205,8 @@ export const createThreatSignals = async ({ // allowed by elasticsearch. The sliced chunk is used in createSignal to generate // threat filters. chunkPage = maxClauseCountValue; - ruleExecutionLogger.warn( - `maxClauseCount error received from elasticsearch, setting IM rule page size to ${maxClauseCountValue}` + ruleExecutionLogger.debug( + `Max clause count error received from Elasticsearch. Setting rule page size to ${maxClauseCountValue}.` ); // only store results + errors that are not related to maxClauseCount @@ -231,11 +231,8 @@ export const createThreatSignals = async ({ results = combineConcurrentResults(results, searchesPerformed); } documentCount -= list.hits.hits.length; - ruleExecutionLogger.debug( - `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, - `search times of ${results.searchAfterTimes}ms,`, - `bulk create times ${results.bulkCreateTimes}ms,`, - `all successes are ${results.success}` + ruleExecutionLogger.trace( + `Alert candidates found: ${results.createdSignalsCount}.\nConcurrent indicator match searches completed. Search took: ${results.searchAfterTimes}ms. Bulk create times (ms): ${results.bulkCreateTimes}. Are all operations successful: ${results.success}.` ); // if alerts suppressed it means suppression enabled, so suppression alert limit should be applied (5 * max_signals) @@ -246,7 +243,7 @@ export const createThreatSignals = async ({ results.warningMessages.push(getMaxSignalsWarning()); } ruleExecutionLogger.debug( - `Indicator match has reached its max signals count ${params.maxSignals}. Additional documents not checked are ${documentCount}` + `Max alerts per run reached\n${params.maxSignals}. Additional ${documentCount} documents are not checked.` ); break; } else if ( @@ -257,15 +254,15 @@ export const createThreatSignals = async ({ ) { // warning should be already set ruleExecutionLogger.debug( - `Indicator match has reached its max signals count ${ + `Max alerts per run reached\nIndicator match has reached its max alerts count ${ MAX_SIGNALS_SUPPRESSION_MULTIPLIER * params.maxSignals - }. Additional documents not checked are ${documentCount}` + }. Additional ${documentCount} documents are not checked.` ); break; } - ruleExecutionLogger.debug(`Documents items left to check are ${documentCount}`); + ruleExecutionLogger.trace(`Documents items left to check: ${documentCount}`); if (maxClauseCountValue > Number.NEGATIVE_INFINITY) { - ruleExecutionLogger.debug(`Re-running search since we hit max clause count error`); + ruleExecutionLogger.trace(`Re-running search due to max clause count error`); // re-run search with smaller max clause count; list = await getDocumentList({ searchAfter: undefined }); @@ -280,8 +277,8 @@ export const createThreatSignals = async ({ const hasNegativeDateSort = sortIds?.some((val) => Number(val) < 0); if (hasNegativeDateSort) { - ruleExecutionLogger.debug( - `Negative date sort id value encountered: ${sortIds}. Threat search stopped.` + ruleExecutionLogger.trace( + `Negative date sort ID encountered\nValue: ${sortIds}. Threat search stopped.` ); break; @@ -382,8 +379,8 @@ export const createThreatSignals = async ({ await services.scopedClusterClient.asCurrentUser.closePointInTime({ id: threatPitId }); } catch (error) { // Don't fail due to a bad point in time closure. We have seen failures in e2e tests during nominal operations. - ruleExecutionLogger.warn( - `Error trying to close point in time: "${threatPitId}", it will expire within "${THREAT_PIT_KEEP_ALIVE}". Error is: "${error}"` + ruleExecutionLogger.debug( + `Error trying to close point in time\nPIT ID: "${threatPitId}". It will expire within "${THREAT_PIT_KEEP_ALIVE}". Error: "${error}".` ); } scheduleNotificationResponseActionsService({ @@ -391,6 +388,6 @@ export const createThreatSignals = async ({ signalsCount: results.createdSignalsCount, responseActions: completeRule.ruleParams.responseActions, }); - ruleExecutionLogger.debug('Indicator matching rule has completed'); + ruleExecutionLogger.trace('Indicator matching rule has completed'); return results; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_allowed_fields_for_terms_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_allowed_fields_for_terms_query.ts index 4e1c50c72745c..c7eef7e36eb79 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_allowed_fields_for_terms_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_allowed_fields_for_terms_query.ts @@ -69,7 +69,7 @@ export const getAllowedFieldsForTermQuery = async ({ ), }; } catch (e) { - ruleExecutionLogger.debug(`Can't get allowed fields for terms query: ${e}`); + ruleExecutionLogger.debug(`Error getting allowed fields for the terms query\nError: ${e}`); return allowedFieldsForTermsQuery; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_event_count.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_event_count.ts index 990620e47d841..28c080feb944c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_event_count.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_event_count.ts @@ -40,8 +40,8 @@ export const getEventList = async ({ throw new TypeError('perPage cannot exceed the size of 10000'); } - ruleExecutionLogger.debug( - `Querying the events items from the index: "${sharedParams.inputIndex}" with searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items` + ruleExecutionLogger.trace( + `Querying events\nIndex: "${sharedParams.inputIndex}", searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items.` ); const queryFilter = getQueryFilter({ @@ -75,7 +75,7 @@ export const getEventList = async ({ ruleExecutionLogger, }); - ruleExecutionLogger.debug(`Retrieved events items of size: ${searchResult.hits.hits.length}`); + ruleExecutionLogger.debug(`Events retrieved: ${searchResult.hits.hits.length}`); return searchResult; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_threat_list.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_threat_list.ts index ac27cd185dd5a..d2c4b21a05bc5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_threat_list.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/get_threat_list.ts @@ -54,7 +54,7 @@ export const getThreatList = async ({ }); ruleExecutionLogger.debug( - `Querying the indicator items from the index: "${threatIndex}" with searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items` + `Querying indicator items\nIndex: "${threatIndex}", searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items.` ); const response = await esClient.search< @@ -74,7 +74,7 @@ export const getThreatList = async ({ pit: { id: pitId }, }); - ruleExecutionLogger.debug(`Retrieved indicator items of size: ${response.hits.hits.length}`); + ruleExecutionLogger.debug(`Indicator items retrieved: ${response.hits.hits.length}`); reassignPitId(response.pit_id); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts index fedfec6707fdf..69e2a89a92184 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.test.ts @@ -98,10 +98,8 @@ describe('ml_executor', () => { isAlertSuppressionActive: true, scheduleNotificationResponseActionsService: mockScheduledNotificationResponseAction, }); - expect(ruleExecutionLogger.warn).toHaveBeenCalled(); - expect(ruleExecutionLogger.warn.mock.calls[0][0]).toContain( - 'Machine learning job(s) are not started' - ); + expect(ruleExecutionLogger.debug).toHaveBeenCalled(); + expect(ruleExecutionLogger.debug.mock.calls[0][0]).toContain('ML jobs are not started'); expect(result.warningMessages.length).toEqual(1); }); @@ -122,10 +120,8 @@ describe('ml_executor', () => { isAlertSuppressionActive: true, scheduleNotificationResponseActionsService: mockScheduledNotificationResponseAction, }); - expect(ruleExecutionLogger.warn).toHaveBeenCalled(); - expect(ruleExecutionLogger.warn.mock.calls[0][0]).toContain( - 'Machine learning job(s) are not started' - ); + expect(ruleExecutionLogger.debug).toHaveBeenCalled(); + expect(ruleExecutionLogger.debug.mock.calls[0][0]).toContain('ML jobs are not started'); expect(result.warningMessages.length).toEqual(1); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts index a38ea441c99f8..00f6770d26b73 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/ml/ml.ts @@ -81,19 +81,19 @@ export const mlExecutor = async ({ jobSummaries.some((job) => !isJobStarted(job.jobState, job.datafeedState)) ) { const warningMessage = [ - 'Machine learning job(s) are not started:', + 'ML jobs are not started', ...jobSummaries.map((job) => [ - `job id: "${job.id}"`, - `job name: "${job?.customSettings?.security_app_display_name ?? job.id}"`, - `job status: "${job.jobState}"`, - `datafeed status: "${job.datafeedState}"`, - ].join(', ') + `Job ID: "${job.id}"`, + `Job name: "${job?.customSettings?.security_app_display_name ?? job.id}"`, + `Job status: "${job.jobState}"`, + `Datafeed status: "${job.datafeedState}"`, + ].join('. ') ), - ].join(' '); + ].join('\n'); result.warningMessages.push(warningMessage); - ruleExecutionLogger.warn(warningMessage); + ruleExecutionLogger.debug(warningMessage); result.warning = true; } @@ -139,7 +139,7 @@ export const mlExecutor = async ({ const anomalyCount = filteredAnomalyHits.length; if (anomalyCount) { - ruleExecutionLogger.debug(`Found ${anomalyCount} signals from ML anomalies`); + ruleExecutionLogger.info(`Alerts from ML anomalies: ${anomalyCount}`); } if ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/multi_terms_composite.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/multi_terms_composite.ts index 7750daed86073..7b5e2fae86b44 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/multi_terms_composite.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/multi_terms_composite.ts @@ -284,8 +284,8 @@ export const multiTermsComposite = async ( } retryBatchSize = retryBatchSize / 2; - ruleExecutionLogger.warn( - `New terms query for multiple fields failed due to too many clauses in query: ${e.message}. Retrying #${retryCount} with ${retryBatchSize} for composite aggregation` + ruleExecutionLogger.debug( + `New terms query failed due to too many clauses\nError: ${e.message}. Retrying #${retryCount} with ${retryBatchSize} for composite aggregation.` ); throw e; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts index 4ce9fff66356a..500795e90d442 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts @@ -282,8 +282,8 @@ export const groupAndBulkCreate = async ({ ruleType: 'query', }); addToSearchAfterReturn({ current: toReturn, next: bulkCreateResult }); - sharedParams.ruleExecutionLogger.debug( - `created ${bulkCreateResult.createdItemsCount} signals` + sharedParams.ruleExecutionLogger.info( + `Alerts created: ${bulkCreateResult.createdItemsCount}` ); } else { const bulkCreateResult = await bulkCreate({ @@ -298,8 +298,8 @@ export const groupAndBulkCreate = async ({ suppressedItemsCount: getNumberOfSuppressedAlerts(bulkCreateResult.createdItems, []), }, }); - sharedParams.ruleExecutionLogger.debug( - `created ${bulkCreateResult.createdItemsCount} signals` + sharedParams.ruleExecutionLogger.info( + `Alerts created: ${bulkCreateResult.createdItemsCount}` ); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts index 2679f7bcf6d26..d8ef3eb59e925 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts @@ -83,7 +83,7 @@ export const bulkCreateWithSuppression = async < }); return enrichedAlerts; } catch (error) { - ruleExecutionLogger.error(`Alerts enrichment failed: ${error}`); + ruleExecutionLogger.error(`Error enriching alerts\nError: ${error}.`); throw error; } finally { enrichmentsTimeFinish = performance.now(); @@ -112,7 +112,7 @@ export const bulkCreateWithSuppression = async < const end = performance.now(); - ruleExecutionLogger.debug(`Alerts bulk process took ${makeFloatString(end - start)} ms`); + ruleExecutionLogger.debug(`Bulk processing alerts took ${makeFloatString(end - start)}ms.`); // query rule type suppression does not happen in memory, so we can't just count createdAlerts and suppressedAlerts // for this rule type we need to look into alerts suppression properties, extract those values and sum up @@ -124,7 +124,7 @@ export const bulkCreateWithSuppression = async < : suppressedAlerts.length; if (!isEmpty(errors)) { - ruleExecutionLogger.warn(`Alerts bulk process finished with errors: ${JSON.stringify(errors)}`); + ruleExecutionLogger.warn(`Error bulk processing alerts\nError: ${JSON.stringify(errors)}.`); return { errors: Object.keys(errors), success: false, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/create_set_to_filter_against.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/create_set_to_filter_against.ts index e419d13589a57..e5b518a57d8e8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/create_set_to_filter_against.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/create_set_to_filter_against.ts @@ -36,8 +36,8 @@ export const createSetToFilterAgainst = async ({ return acc; }, new Set()); - ruleExecutionLogger.debug( - `number of distinct values from ${field}: ${[...valuesFromSearchResultField].length}` + ruleExecutionLogger.trace( + `Distinct values from field: ${[...valuesFromSearchResultField].length}` ); const matchedListItems = await listClient.searchListItemByValues({ @@ -47,7 +47,7 @@ export const createSetToFilterAgainst = async ({ }); ruleExecutionLogger.debug( - `number of matched items from list with id ${listId}: ${matchedListItems.length}` + `Matched items from list: ${matchedListItems.length}\nList ID: "${listId}".` ); return new Set( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.test.ts index d5c566383ebba..ea79ac5b00a42 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.test.ts @@ -62,7 +62,7 @@ describe('filterEventsAgainstList', () => { expect(included.length).toEqual(4); expect(excluded.length).toEqual(0); expect(ruleExecutionLogger.debug.mock.calls[0][0]).toContain( - 'No exception items of type list found - return unfiltered events' + 'No exception items of type list found' ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.ts index 8a8f7d34aa3a8..f8629d8ba000b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/large_list_filters/filter_events_against_list.ts @@ -47,7 +47,9 @@ export const filterEventsAgainstList = async ({ ); if (!atLeastOneLargeValueList) { - ruleExecutionLogger.debug('No exception items of type list found - return unfiltered events'); + ruleExecutionLogger.debug( + 'No exception items of type list found\nReturning unfiltered events.' + ); return [events, []]; } @@ -74,7 +76,7 @@ export const filterEventsAgainstList = async ({ fieldAndSetTuples, }); ruleExecutionLogger.debug( - `Exception with id ${exceptionItem.id} filtered out ${nextExcludedEvents.length} events` + `Events filtered by exception: ${nextExcludedEvents.length}\nException ID: "${exceptionItem.id}".` ); return [nextIncludedEvents, [...excludedEvents, ...nextExcludedEvents]]; }, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/log_shard_failure.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/log_shard_failure.ts index a5110f7ea11bb..b846467ad3381 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/log_shard_failure.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/log_shard_failure.ts @@ -20,7 +20,7 @@ export const logShardFailures = ( isSequenceQuery, JSON.stringify(shardFailures) ); - ruleExecutionLogger.error(shardFailureMessage); + ruleExecutionLogger.error(`Shard failure\nError: ${shardFailureMessage}`); if (isSequenceQuery) { result.errors.push(shardFailureMessage); } else { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_factory.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_factory.ts index 14d6ec2931669..208cc7cfd72f7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_factory.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_factory.ts @@ -97,10 +97,10 @@ export const searchAfterAndBulkCreateFactory = async ({ while (toReturn.createdSignalsCount <= maxSignals) { const cycleNum = `cycle ${searchingIteration++}`; try { - ruleExecutionLogger.debug( - `[${cycleNum}] Searching events${ - sortIds ? ` after cursor ${JSON.stringify(sortIds)}` : '' - } in index pattern "${inputIndexPattern}"` + ruleExecutionLogger.trace( + `${cycleNum}: Searching events\nSearching events after cursor ${JSON.stringify( + sortIds + )} in index pattern "${inputIndexPattern}".` ); const searchAfterQuery = buildEventsSearchQuery({ @@ -152,17 +152,17 @@ export const searchAfterAndBulkCreateFactory = async ({ ); if (totalHits === 0 || searchResult.hits.hits.length === 0) { - ruleExecutionLogger.debug( - `[${cycleNum}] Found 0 events ${ - sortIds ? ` after cursor ${JSON.stringify(sortIds)}` : '' - }` + ruleExecutionLogger.trace( + `${cycleNum}: No results found\nFound 0 events after cursor ${JSON.stringify(sortIds)}.` ); break; } else { - ruleExecutionLogger.debug( - `[${cycleNum}] Found ${searchResult.hits.hits.length} of total ${totalHits} events${ - sortIds ? ` after cursor ${JSON.stringify(sortIds)}` : '' - }, last cursor ${JSON.stringify(lastSortIds)}` + ruleExecutionLogger.trace( + `${cycleNum}: Results found\nFound ${ + searchResult.hits.hits.length + } of total ${totalHits} events after cursor ${JSON.stringify( + sortIds + )}. Last cursor: ${JSON.stringify(lastSortIds)}.` ); } @@ -187,8 +187,8 @@ export const searchAfterAndBulkCreateFactory = async ({ toReturn, }); - ruleExecutionLogger.debug( - `[${cycleNum}] Created ${bulkCreateResult.createdItemsCount} alerts from ${enrichedEvents.length} events` + ruleExecutionLogger.trace( + `${cycleNum}: Created alerts from enriched events\nCreated ${bulkCreateResult.createdItemsCount} alerts from ${enrichedEvents.length} events.` ); sendAlertTelemetryEvents( @@ -212,13 +212,14 @@ export const searchAfterAndBulkCreateFactory = async ({ if (lastSortIds != null && lastSortIds.length !== 0 && !hasNegativeNumber) { sortIds = lastSortIds; } else { - ruleExecutionLogger.debug(`[${cycleNum}] Unable to fetch last event cursor`); + ruleExecutionLogger.trace(`${cycleNum}: Failed to fetch last event cursor`); break; } } catch (exc: unknown) { ruleExecutionLogger.error( - 'Unable to extract/process events or create alerts', - JSON.stringify(exc) + `${cycleNum}: Error extracting/processing events or creating alerts\nError: ${JSON.stringify( + exc + )}` ); return mergeReturns([ toReturn, @@ -229,7 +230,7 @@ export const searchAfterAndBulkCreateFactory = async ({ ]); } } - ruleExecutionLogger.debug(`Completed bulk indexing of ${toReturn.createdSignalsCount} alert`); + ruleExecutionLogger.debug(`Alerts created: ${toReturn.createdSignalsCount}`); if (isLoggedRequestsEnabled) { toReturn.loggedRequests = loggedRequests; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts index c3c4f8c4d5f23..6733556046dac 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts @@ -78,6 +78,6 @@ export function sendAlertTelemetryEvents( ); eventsTelemetry.sendAsync(TelemetryChannel.ENDPOINT_ALERTS, filtered); } catch (exc) { - ruleExecutionLogger.error(`Queuing telemetry events failed: ${exc}`); + ruleExecutionLogger.debug(`Error queuing telemetry events\nError: ${exc}`); } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/single_search_after.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/single_search_after.ts index 3bd45d5ab85e8..b1cb35418324e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/single_search_after.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/single_search_after.ts @@ -73,7 +73,7 @@ export const singleSearchAfter = async < loggedRequests, }; } catch (exc) { - ruleExecutionLogger.error(`Searching events operation failed: ${exc}`); + ruleExecutionLogger.error(`Error searching events\nError: ${exc}`); throw exc; } }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts index 6389df5b5eb4b..0bed3fa13d3d4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts @@ -434,7 +434,7 @@ export const getRuleRangeTuples = async ({ const intervalDuration = parseInterval(interval); if (intervalDuration == null) { ruleExecutionLogger.error( - `Failed to compute gap between rule runs: could not parse rule interval "${JSON.stringify( + `Error computing gap between rule runs\nError: could not parse rule interval "${JSON.stringify( interval )}"` ); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/machine_learning/__mocks__/authz.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/machine_learning/__mocks__/authz.ts index 33ab06ba67a8f..5631a792f71a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/machine_learning/__mocks__/authz.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/machine_learning/__mocks__/authz.ts @@ -11,4 +11,13 @@ export const mlServicesMock = mlPluginServerMock; const mockValidateRuleType = jest.fn().mockResolvedValue({ valid: true, message: undefined }); +const mockValidateInvalidRuleType = jest.fn().mockImplementation(async (type: string) => ({ + valid: type !== 'machine_learning', + message: type === 'machine_learning' ? 'ML rules require a platinum license' : undefined, +})); + export const buildMlAuthz = jest.fn().mockReturnValue({ validateRuleType: mockValidateRuleType }); + +export const buildRestrictedMlAuthz = jest + .fn() + .mockReturnValue({ validateRuleType: mockValidateInvalidRuleType }); diff --git a/x-pack/solutions/security/plugins/security_solution/test/scout/.meta/ui/parallel.json b/x-pack/solutions/security/plugins/security_solution/test/scout/.meta/ui/parallel.json index 7eda82d0fd9b7..697e13fc866eb 100644 --- a/x-pack/solutions/security/plugins/security_solution/test/scout/.meta/ui/parallel.json +++ b/x-pack/solutions/security/plugins/security_solution/test/scout/.meta/ui/parallel.json @@ -1,5 +1,4 @@ { - "lastModified": "2026-02-04T19:00:23.582Z", "sha1": "65f75ae3f6a21cc3a89a582d5f56d6ac595b91bc", "tests": [ { diff --git a/x-pack/solutions/security/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx b/x-pack/solutions/security/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx index 035864a12caa4..e8c2fb7cf77cd 100644 --- a/x-pack/solutions/security/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx +++ b/x-pack/solutions/security/plugins/session_view/public/components/process_tree_alerts_filter/index.test.tsx @@ -265,10 +265,11 @@ describe('ProcessTreeAlertsFiltersFilter component', () => { const filterButton = renderResult.getByTestId( 'sessionView:sessionViewAlertDetailsEmptyFilterButton' ); - await userEvent.click(filterButton); + await userEvent.click(filterButton, { pointerEventsCheck: 0 }); await userEvent.click( - renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-file') + renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-file'), + { pointerEventsCheck: 0 } ); expect(filterButton).toHaveTextContent('View: file alerts'); @@ -278,10 +279,11 @@ describe('ProcessTreeAlertsFiltersFilter component', () => { const filterButton = renderResult.getByTestId( 'sessionView:sessionViewAlertDetailsEmptyFilterButton' ); - await userEvent.click(filterButton); + await userEvent.click(filterButton, { pointerEventsCheck: 0 }); await userEvent.click( - renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-default') + renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-default'), + { pointerEventsCheck: 0 } ); expect(filterButton).toHaveTextContent(`View: ${DEFAULT_ALERT_FILTER_VALUE} alerts`); @@ -291,10 +293,11 @@ describe('ProcessTreeAlertsFiltersFilter component', () => { const filterButton = renderResult.getByTestId( 'sessionView:sessionViewAlertDetailsEmptyFilterButton' ); - await userEvent.click(filterButton); + await userEvent.click(filterButton, { pointerEventsCheck: 0 }); await userEvent.click( - renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-process') + renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-process'), + { pointerEventsCheck: 0 } ); expect(filterButton).toHaveTextContent('View: process alerts'); @@ -304,10 +307,11 @@ describe('ProcessTreeAlertsFiltersFilter component', () => { const filterButton = renderResult.getByTestId( 'sessionView:sessionViewAlertDetailsEmptyFilterButton' ); - await userEvent.click(filterButton); + await userEvent.click(filterButton, { pointerEventsCheck: 0 }); await userEvent.click( - renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-network') + renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-network'), + { pointerEventsCheck: 0 } ); expect(filterButton).toHaveTextContent('View: network alerts'); diff --git a/x-pack/solutions/security/test/api_integration/apis/cases/bulk_get_user_profiles.ts b/x-pack/solutions/security/test/api_integration/apis/cases/bulk_get_user_profiles.ts index 047bb8198838d..e387277f98c2b 100644 --- a/x-pack/solutions/security/test/api_integration/apis/cases/bulk_get_user_profiles.ts +++ b/x-pack/solutions/security/test/api_integration/apis/cases/bulk_get_user_profiles.ts @@ -30,7 +30,7 @@ export default ({ getService }: FtrProviderContext): void => { } with roles(s) ${user.roles.join()} can bulk get valid user profiles`, async () => { const suggestedProfiles = await suggestUserProfiles({ supertest: supertestWithoutAuth, - req: { name: user.username, owners: [owner], size: 1 }, + req: { name: user.username, owners: [owner] }, auth: { user, space: null }, }); @@ -43,8 +43,9 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user, space: null }, }); - expect(profiles.length).to.be(1); - expect(profiles[0].user.username).to.eql(user.username); + expect(profiles.length).to.be(10); + const found = profiles.find((profile) => profile.user.username === user.username); + expect(found !== undefined).to.be.ok(); }); } }); diff --git a/x-pack/solutions/security/test/api_integration/apis/cases/suggest_user_profiles.ts b/x-pack/solutions/security/test/api_integration/apis/cases/suggest_user_profiles.ts index e5ae00fe9a25c..6f8e50967c1ad 100644 --- a/x-pack/solutions/security/test/api_integration/apis/cases/suggest_user_profiles.ts +++ b/x-pack/solutions/security/test/api_integration/apis/cases/suggest_user_profiles.ts @@ -38,12 +38,13 @@ export default ({ getService }: FtrProviderContext): void => { } with roles(s) ${user.roles.join()} can retrieve user profile suggestions`, async () => { const profiles = await suggestUserProfiles({ supertest: supertestWithoutAuth, - req: { name: searchTerm, owners: [owner], size: 1 }, + req: { name: searchTerm, owners: [owner] }, auth: { user, space: null }, }); - expect(profiles.length).to.be(1); - expect(profiles[0].user.username).to.eql(searchTerm); + expect(profiles.length).to.be(10); + const found = profiles.find((profile) => profile.user.username === searchTerm); + expect(found !== undefined).to.be.ok(); }); } diff --git a/x-pack/solutions/security/test/cloud_security_posture_api/es_archives/logs_gcp_audit/data.json b/x-pack/solutions/security/test/cloud_security_posture_api/es_archives/logs_gcp_audit/data.json index 5b344a360752a..47f99fa8c3273 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_api/es_archives/logs_gcp_audit/data.json +++ b/x-pack/solutions/security/test/cloud_security_posture_api/es_archives/logs_gcp_audit/data.json @@ -1409,6 +1409,237 @@ } } +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "group-doc-1", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "entity": { + "id": "group-actor-1@example.com" + } + }, + "entity": { + "target": { + "id": "group-target-1" + } + }, + "source": { + "geo": { + "country_iso_code": "US" + } + }, + "event": { + "action": "test.pin.group", + "id": "group-evt-1", + "kind": "event", + "outcome": "success" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "group-doc-2", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "entity": { + "id": "group-actor-2@example.com" + } + }, + "entity": { + "target": { + "id": "group-target-2" + } + }, + "source": { + "geo": { + "country_iso_code": "US" + } + }, + "event": { + "action": "test.pin.group", + "id": "group-evt-2", + "kind": "event", + "outcome": "success" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "group-doc-3", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "entity": { + "id": "group-actor-3@example.com" + } + }, + "entity": { + "target": { + "id": "group-target-3" + } + }, + "source": { + "geo": { + "country_iso_code": "US" + } + }, + "event": { + "action": "test.pin.group", + "id": "group-evt-3", + "kind": "event", + "outcome": "success" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "group-doc-4", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "entity": { + "id": "group-actor-4@example.com" + } + }, + "entity": { + "target": { + "id": "group-target-4" + } + }, + "source": { + "geo": { + "country_iso_code": "US" + } + }, + "event": { + "action": "test.pin.group", + "id": "group-evt-4", + "kind": "event", + "outcome": "success" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "group-doc-5", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "entity": { + "id": "group-actor-5@example.com" + } + }, + "entity": { + "target": { + "id": "group-target-4" + } + }, + "source": { + "geo": { + "country_iso_code": "US" + } + }, + "event": { + "action": "test.pin.group", + "id": "group-evt-5", + "kind": "event", + "outcome": "success" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "group-doc-6", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "entity": { + "id": "group-actor-6@example.com" + } + }, + "entity": { + "target": { + "id": "group-target-5" + } + }, + "source": { + "geo": { + "country_iso_code": "US" + } + }, + "event": { + "action": "test.pin.group", + "id": "group-evt-6", + "kind": "event", + "outcome": "success" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "solo-doc-1", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "entity": { + "id": "solo-actor@example.com" + } + }, + "entity": { + "target": { + "id": "solo-target" + } + }, + "source": { + "geo": { + "country_iso_code": "US" + } + }, + "event": { + "action": "test.pin.solo", + "id": "solo-evt-1", + "kind": "event", + "outcome": "success" + } + } + } +} + { "type": "doc", "value": { @@ -1818,4 +2049,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts b/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts index 9ae2d6fa4b6e4..2322f96e39729 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts @@ -21,7 +21,7 @@ import type { EdgeDataModel, RelationshipNodeDataModel, } from '@kbn/cloud-security-posture-common/types/graph/latest'; -import { isLabelNode } from '@kbn/cloud-security-posture-graph/src/components/utils'; +import { isEntityNode, isLabelNode } from '@kbn/cloud-security-posture-graph/src/components/utils'; import { getEntitiesLatestIndexName } from '@kbn/cloud-security-posture-common/utils/helpers'; import type { FtrProviderContext } from '../ftr_provider_context'; import { @@ -207,6 +207,19 @@ export default function (providerContext: FtrProviderContext) { }, }).expect(result(400, logger)); }); + + it('should return 400 when pinnedIds exceeds max size', async () => { + const pinnedIds = Array.from({ length: 1025 }, (_, idx) => `id-${idx}`); + + await postGraph(supertest, { + query: { + pinnedIds, + originEventIds: [], + start: 'now-1d/d', + end: 'now/d', + }, + }).expect(result(400, logger)); + }); }); describe('Happy flows', () => { @@ -219,6 +232,12 @@ export default function (providerContext: FtrProviderContext) { 'x-pack/solutions/security/test/cloud_security_posture_api/es_archives/logs_gcp_audit' ); + try { + await spacesService.delete('foo'); + } catch (e) { + // Ignore if the space does not exist yet. + } + await spacesService.create({ id: 'foo', name: 'foo', @@ -263,6 +282,420 @@ export default function (providerContext: FtrProviderContext) { expect(response.body).not.to.have.property('messages'); }); + describe('Pinning', () => { + const groupAction = 'test.pin.group'; + const soloAction = 'test.pin.solo'; + const groupEventIds = [ + 'group-evt-1', + 'group-evt-2', + 'group-evt-3', + 'group-evt-4', + 'group-evt-5', + 'group-evt-6', + ]; + const soloEventId = 'solo-evt-1'; + const pinTestOriginEventIds = [...groupEventIds, soloEventId].map((id) => ({ + id, + isAlert: false, + })); + const groupedActorIds = [ + 'group-actor-1@example.com', + 'group-actor-2@example.com', + 'group-actor-3@example.com', + 'group-actor-4@example.com', + 'group-actor-5@example.com', + 'group-actor-6@example.com', + ]; + const groupedTargetIds = [ + 'group-target-1', + 'group-target-2', + 'group-target-3', + 'group-target-4', + 'group-target-5', + ]; + const soloActorId = 'solo-actor@example.com'; + const soloTargetId = 'solo-target'; + const groupActionQuery = { + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + bool: { + must: [], + filter: [ + { + match_phrase: { + 'event.action': groupAction, + }, + }, + ], + should: [], + must_not: [], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + should: [], + must_not: [], + }, + }; + const groupAndSoloQuery = { + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + bool: { + must: [], + filter: [ + { + match_phrase: { + 'event.action': groupAction, + }, + }, + ], + should: [], + must_not: [], + }, + }, + { + bool: { + must: [], + filter: [ + { + match_phrase: { + 'event.action': soloAction, + }, + }, + ], + should: [], + must_not: [], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + should: [], + must_not: [], + }, + }; + + it('should keep grouped and single entities without pinning', async () => { + // Render: actor group of 6 + solo actor; target group of 5 + solo target. + const response = await postGraph(supertest, { + query: { + indexPatterns: ['logs-*'], + originEventIds: pinTestOriginEventIds, + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + const entityNodes = response.body.nodes.filter(isEntityNode) as EntityNodeDataModel[]; + const actorNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'user' + ); + const targetNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'entity' + ); + + expect(actorNodes).to.have.length(2); + expect(targetNodes).to.have.length(2); + + const actorGroupNode = actorNodes.find( + (node) => (node.documentsData?.length ?? 0) === groupedActorIds.length + )!; + const actorGroupIds = (actorGroupNode.documentsData ?? []).map((doc) => doc.id).sort(); + expect(actorGroupIds).to.eql([...groupedActorIds].sort()); + + const soloActorNode = actorNodes.find((node) => (node.documentsData?.length ?? 0) === 1)!; + const soloActorIds = (soloActorNode.documentsData ?? []).map((doc) => doc.id); + expect(soloActorIds).to.eql([soloActorId]); + + const targetGroupNode = targetNodes.find( + (node) => (node.documentsData?.length ?? 0) === groupedTargetIds.length + )!; + const targetGroupIds = (targetGroupNode.documentsData ?? []).map((doc) => doc.id).sort(); + expect(targetGroupIds).to.eql([...groupedTargetIds].sort()); + + const soloTargetNode = targetNodes.find( + (node) => (node.documentsData?.length ?? 0) === 1 + )!; + const soloTargetIds = (soloTargetNode.documentsData ?? []).map((doc) => doc.id); + expect(soloTargetIds).to.eql([soloTargetId]); + }); + + it('should treat empty pinnedIds the same as missing pinnedIds', async () => { + const baseResponse = await postGraph(supertest, { + query: { + indexPatterns: ['logs-*'], + originEventIds: pinTestOriginEventIds, + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + const emptyPinnedResponse = await postGraph(supertest, { + query: { + pinnedIds: [], + indexPatterns: ['logs-*'], + originEventIds: pinTestOriginEventIds, + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + const normalizeNodes = (nodes: NodeDataModel[]) => + [...nodes].sort((a, b) => a.id.localeCompare(b.id)); + const normalizeEdges = (edges: EdgeDataModel[]) => + [...edges].sort((a, b) => a.id.localeCompare(b.id)); + + expect(normalizeNodes(emptyPinnedResponse.body.nodes)).to.eql( + normalizeNodes(baseResponse.body.nodes) + ); + expect(normalizeEdges(emptyPinnedResponse.body.edges)).to.eql( + normalizeEdges(baseResponse.body.edges) + ); + expect(emptyPinnedResponse.body.messages).to.eql(baseResponse.body.messages); + }); + + it('should extract pinned actors and targets from grouped nodes', async () => { + // Render: 3 single actors + 1 actor group; 3 single targets + 1 target group. + const response = await postGraph(supertest, { + query: { + pinnedIds: [ + 'group-actor-1@example.com', + 'group-actor-2@example.com', + 'group-target-3', + ], + indexPatterns: ['logs-*'], + originEventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + esQuery: groupActionQuery, + }, + }).expect(result(200)); + + const entityNodes = response.body.nodes.filter(isEntityNode) as EntityNodeDataModel[]; + const actorNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'user' + ); + const targetNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'entity' + ); + + expect(actorNodes).to.have.length(4); + expect(targetNodes).to.have.length(4); + + const actorGroupNode = actorNodes.find( + (node) => (node.documentsData?.length ?? 0) === 3 + )!; + const actorGroupIds = (actorGroupNode.documentsData ?? []).map((doc) => doc.id).sort(); + expect(actorGroupIds).to.eql( + [ + 'group-actor-4@example.com', + 'group-actor-5@example.com', + 'group-actor-6@example.com', + ].sort() + ); + + const targetGroupNode = targetNodes.find( + (node) => (node.documentsData?.length ?? 0) === 2 + )!; + const targetGroupIds = (targetGroupNode.documentsData ?? []).map((doc) => doc.id).sort(); + expect(targetGroupIds).to.eql(['group-target-4', 'group-target-5']); + + const actorSingleIds = actorNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => node.documentsData?.[0]?.id) + .sort(); + const targetSingleIds = targetNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => node.documentsData?.[0]?.id) + .sort(); + + expect(actorSingleIds).to.eql( + [ + 'group-actor-1@example.com', + 'group-actor-2@example.com', + 'group-actor-3@example.com', + ].sort() + ); + expect(targetSingleIds).to.eql( + ['group-target-1', 'group-target-2', 'group-target-3'].sort() + ); + + const actorGroupIndex = response.body.nodes.indexOf(actorGroupNode); + const targetGroupIndex = response.body.nodes.indexOf(targetGroupNode); + const actorSingleIndexes = actorNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => response.body.nodes.indexOf(node)); + const targetSingleIndexes = targetNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => response.body.nodes.indexOf(node)); + + // Verify pinned singles are returned before their remaining group node. + expect(Math.max(...actorSingleIndexes)).to.be.lessThan(actorGroupIndex); + expect(Math.max(...targetSingleIndexes)).to.be.lessThan(targetGroupIndex); + }); + + it('should not change already single entities when pinned alongside grouped ones', async () => { + // Render: solo actor + pinned actor + grouped actors (5); solo target + pinned target + grouped targets (4). + const response = await postGraph(supertest, { + query: { + pinnedIds: ['solo-actor@example.com', 'group-actor-1@example.com'], + indexPatterns: ['logs-*'], + originEventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + esQuery: groupAndSoloQuery, + }, + }).expect(result(200)); + + const entityNodes = response.body.nodes.filter(isEntityNode) as EntityNodeDataModel[]; + const actorNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'user' + ); + const targetNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'entity' + ); + + expect(actorNodes).to.have.length(3); + expect(targetNodes).to.have.length(3); + + const actorGroupNode = actorNodes.find( + (node) => (node.documentsData?.length ?? 0) === 5 + )!; + const actorGroupIds = (actorGroupNode.documentsData ?? []).map((doc) => doc.id).sort(); + expect(actorGroupIds).to.eql( + [ + 'group-actor-2@example.com', + 'group-actor-3@example.com', + 'group-actor-4@example.com', + 'group-actor-5@example.com', + 'group-actor-6@example.com', + ].sort() + ); + + const targetGroupNode = targetNodes.find( + (node) => (node.documentsData?.length ?? 0) === 4 + )!; + const targetGroupIds = (targetGroupNode.documentsData ?? []).map((doc) => doc.id).sort(); + expect(targetGroupIds).to.eql( + ['group-target-2', 'group-target-3', 'group-target-4', 'group-target-5'].sort() + ); + + const actorSingleIds = actorNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => node.documentsData?.[0]?.id) + .sort(); + const targetSingleIds = targetNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => node.documentsData?.[0]?.id) + .sort(); + + expect(actorSingleIds).to.eql([soloActorId, 'group-actor-1@example.com'].sort()); + expect(targetSingleIds).to.eql([soloTargetId, 'group-target-1'].sort()); + }); + + it('should pin an event by document id and pin related entities', async () => { + // Render: event is pinned by doc id, so its actor/target become single nodes; remaining entities stay grouped. + const response = await postGraph(supertest, { + query: { + pinnedIds: ['group-doc-1'], + indexPatterns: ['logs-*'], + originEventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + esQuery: groupActionQuery, + }, + }).expect(result(200)); + + const entityNodes = response.body.nodes.filter(isEntityNode) as EntityNodeDataModel[]; + const actorNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'user' + ); + const targetNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'entity' + ); + + expect(actorNodes).to.have.length(2); + expect(targetNodes).to.have.length(2); + + const actorSingleIds = actorNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => node.documentsData?.[0]?.id) + .sort(); + const targetSingleIds = targetNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => node.documentsData?.[0]?.id) + .sort(); + + expect(actorSingleIds).to.eql(['group-actor-1@example.com']); + expect(targetSingleIds).to.eql(['group-target-1']); + + const actorGroupNode = actorNodes.find( + (node) => (node.documentsData?.length ?? 0) === groupedActorIds.length - 1 + )!; + const targetGroupNode = targetNodes.find( + (node) => (node.documentsData?.length ?? 0) === groupedTargetIds.length - 1 + )!; + const actorGroupIndex = response.body.nodes.indexOf(actorGroupNode); + const targetGroupIndex = response.body.nodes.indexOf(targetGroupNode); + const actorSingleIndexes = actorNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => response.body.nodes.indexOf(node)); + const targetSingleIndexes = targetNodes + .filter((node) => (node.documentsData?.length ?? 0) === 1) + .map((node) => response.body.nodes.indexOf(node)); + + // Verify pinned singles are returned before their remaining group node. + expect(Math.max(...actorSingleIndexes)).to.be.lessThan(actorGroupIndex); + expect(Math.max(...targetSingleIndexes)).to.be.lessThan(targetGroupIndex); + }); + + it('should ignore pinnedIds that do not exist', async () => { + // Render: actor group of 6 + target group of 5 (no extra singles). + const response = await postGraph(supertest, { + query: { + pinnedIds: ['non-existent-entity-id'], + indexPatterns: ['logs-*'], + originEventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + esQuery: groupActionQuery, + }, + }).expect(result(200)); + + const entityNodes = response.body.nodes.filter(isEntityNode) as EntityNodeDataModel[]; + const actorNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'user' + ); + const targetNodes = entityNodes.filter( + (node) => node.documentsData?.[0]?.entity?.ecsParentField === 'entity' + ); + + expect(actorNodes).to.have.length(1); + expect(targetNodes).to.have.length(1); + + const actorGroupIds = (actorNodes[0].documentsData ?? []).map((doc) => doc.id).sort(); + const targetGroupIds = (targetNodes[0].documentsData ?? []).map((doc) => doc.id).sort(); + + expect(actorGroupIds).to.eql([...groupedActorIds].sort()); + expect(targetGroupIds).to.eql([...groupedTargetIds].sort()); + }); + }); + it('should return a graph with nodes and edges by actor', async () => { const response = await postGraph(supertest, { query: { diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts index 92840e0732dc1..5ddaa364c4ace 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts @@ -26,6 +26,8 @@ export const testSubjectIds = { 'cloudSecurityGraphGraphInvestigationShowActionsOnEntity', GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_ITEM_ID: 'cloudSecurityGraphGraphInvestigationShowEntityDetails', + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID: + 'cloudSecurityGraphGraphInvestigationShowEntityRelationships', GRAPH_LABEL_EXPAND_POPOVER_TEST_ID: 'cloudSecurityGraphGraphInvestigationGraphLabelExpandPopover', GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID: 'cloudSecurityGraphGraphInvestigationShowEventsWithThisAction', @@ -47,6 +49,7 @@ export const testSubjectIds = { GRAPH_CALLOUT_TEST_ID: 'cloudSecurityGraphGraphInvestigationCallout', GRAPH_NODE_ENTITY_DETAILS_ID: 'cloudSecurityGraphGraphInvestigationEntityNodeDetails', GRAPH_NODE_ENTITY_TAG_TEXT_ID: 'cloudSecurityGraphGraphInvestigationTagText', + GRAPH_NODE_ENTITY_TAG_COUNT_ID: 'cloudSecurityGraphGraphInvestigationTagCount', GROUPED_ITEM_TITLE_TEST_ID_LINK: 'GraphGroupedNodePreviewPanelGroupedItemTitleLink', GROUPED_ITEM_TITLE_TEST_ID_TEXT: 'GraphGroupedNodePreviewPanelGroupedItemTitleText', GROUPED_ITEM_ACTOR_TEST_ID: 'GraphGroupedNodePreviewPanelGroupedItemActor', diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/data.json b/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/data.json index b0c6500b71530..cf7bf1a443e18 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/data.json +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/data.json @@ -467,11 +467,174 @@ "@timestamp": "2025-07-20T17:26:09.361Z", "cloud": { "account": { - "id": "your-project-id", - "name": "your-project-name" + "id": "relationships-test-account", + "name": "relationships-test-project" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS IAM" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "relationships-test-user", + "name": "Relationships Test User", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_aws_iam_user-default-2025.07.16-000005", + "sub_type": "AWS IAM User", + "type": "Identity", + "relationships": { + "Owns": ["relationships-target-host-1", "relationships-target-host-2"] + } + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "user": { + "id": "relationships-test-user", + "name": "Relationships Test User" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "14", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "relationships-test-account", + "name": "relationships-test-project" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS EC2" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "relationships-target-host-1", + "name": "Relationships Target Host 1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-compute_aws_ec2-default-2025.07.16-000005", + "sub_type": "AWS EC2 Instance", + "type": "Host" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "host": { + "id": "relationships-target-host-1", + "name": "Relationships Target Host 1" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "15", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "relationships-test-account", + "name": "relationships-test-project" + }, + "provider": "aws", + "region": "us-west-2", + "service": { + "name": "AWS EC2" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "relationships-target-host-2", + "name": "Relationships Target Host 2", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-compute_aws_ec2-default-2025.07.16-000005", + "sub_type": "AWS EC2 Instance", + "type": "Host" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "host": { + "id": "relationships-target-host-2", + "name": "Relationships Target Host 2" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "16", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "relationships-test-account", + "name": "relationships-test-project" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS EC2" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "relationships-target-service", + "name": "Relationships Target Service", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-compute_aws_ec2-default-2025.07.16-000005", + "sub_type": "AWS Lambda", + "type": "Service" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "service": { + "id": "relationships-target-service", + "name": "Relationships Target Service" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "17", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "my-gcp-project-123456", + "name": "My GCP Project" }, "provider": "gcp", - "region": "global", + "project": { + "id": "my-gcp-project", + "name": "My GCP Project" + }, + "region": "us-central1", "service": { "name": "GCP IAM" } @@ -480,18 +643,18 @@ "EngineMetadata": { "Type": "generic" }, - "id": "mv-expand-test-actor@example.com", - "name": "MvExpandTestActor", - "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_gcp_iam_user-default-2025.07.16-000005", - "sub_type": "GCP IAM User", - "type": "Identity" + "id": "gcp-admin-user@my-gcp-project.iam.gserviceaccount.com", + "name": "GCP Admin User", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_gcp_service_account-default-2025.07.16-000005", + "sub_type": "GCP Service Account", + "type": "Service Account" }, "event": { "ingested": "2025-07-20T17:27:13.583Z" }, "user": { - "id": "mv-expand-test-actor@example.com", - "name": "MvExpandTestActor" + "id": "gcp-admin-user@my-gcp-project.iam.gserviceaccount.com", + "name": "GCP Admin User" } } } @@ -500,17 +663,21 @@ { "type": "doc", "value": { - "id": "14", + "id": "18", "index": ".entities.v2.latest.security_default", "source": { "@timestamp": "2025-07-20T17:26:09.361Z", "cloud": { "account": { - "id": "your-project-id", - "name": "your-project-name" + "id": "my-gcp-project-123456", + "name": "My GCP Project" }, "provider": "gcp", - "region": "global", + "project": { + "id": "my-gcp-project", + "name": "My GCP Project" + }, + "region": "us-central1", "service": { "name": "GCP IAM" } @@ -519,18 +686,22 @@ "EngineMetadata": { "Type": "generic" }, - "id": "mv-expand-target-identity", - "name": "MvExpandTargetIdentity", - "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_gcp_iam_user-default-2025.07.16-000005", - "sub_type": "GCP IAM User", - "type": "Identity" + "id": "data-pipeline@my-gcp-project.iam.gserviceaccount.com", + "name": "data-pipeline Service Account", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_gcp_service_account-default-2025.07.16-000005", + "sub_type": "GCP Service Account", + "type": "Service Account", + "relationships": { + "Owns": ["projects/my-gcp-project/zones/us-central1-a/instances/web-server-prod-1", "projects/my-gcp-project/zones/us-east1-b/instances/api-gateway-prod-1", "projects/my-gcp-project/zones/us-west1-a/instances/db-server-prod-1"], + "Communicates_with": ["projects/my-gcp-project/zones/us-central1-a/instances/web-server-prod-1", "projects/my-gcp-project/zones/us-east1-b/instances/api-gateway-prod-1"] + } }, "event": { "ingested": "2025-07-20T17:27:13.583Z" }, "user": { - "id": "mv-expand-target-identity", - "name": "MvExpandTargetIdentity" + "id": "data-pipeline@my-gcp-project.iam.gserviceaccount.com", + "name": "data-pipeline Service Account" } } } @@ -539,7 +710,136 @@ { "type": "doc", "value": { - "id": "15", + "id": "19", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "my-gcp-project-123456", + "name": "My GCP Project" + }, + "provider": "gcp", + "project": { + "id": "my-gcp-project", + "name": "My GCP Project" + }, + "region": "us-central1-a", + "service": { + "name": "GCP Compute Engine" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "projects/my-gcp-project/zones/us-central1-a/instances/web-server-prod-1", + "name": "web-server-prod-1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-compute_gcp_compute_instance-default-2025.07.16-000005", + "sub_type": "GCP Compute Instance", + "type": "Host" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "host": { + "id": "projects/my-gcp-project/zones/us-central1-a/instances/web-server-prod-1", + "name": "web-server-prod-1" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "20", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "my-gcp-project-123456", + "name": "My GCP Project" + }, + "provider": "gcp", + "project": { + "id": "my-gcp-project", + "name": "My GCP Project" + }, + "region": "us-east1-b", + "service": { + "name": "GCP Compute Engine" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "projects/my-gcp-project/zones/us-east1-b/instances/api-gateway-prod-1", + "name": "api-gateway-prod-1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-compute_gcp_compute_instance-default-2025.07.16-000005", + "sub_type": "GCP Compute Instance", + "type": "Host" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "host": { + "id": "projects/my-gcp-project/zones/us-east1-b/instances/api-gateway-prod-1", + "name": "api-gateway-prod-1" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "21", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "my-gcp-project-123456", + "name": "My GCP Project" + }, + "provider": "gcp", + "project": { + "id": "my-gcp-project", + "name": "My GCP Project" + }, + "region": "us-west1-a", + "service": { + "name": "GCP Compute Engine" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "projects/my-gcp-project/zones/us-west1-a/instances/db-server-prod-1", + "name": "db-server-prod-1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-compute_gcp_compute_instance-default-2025.07.16-000005", + "sub_type": "GCP Compute Instance", + "type": "Host" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "host": { + "id": "projects/my-gcp-project/zones/us-west1-a/instances/db-server-prod-1", + "name": "db-server-prod-1" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "22", "index": ".entities.v2.latest.security_default", "source": { "@timestamp": "2025-07-20T17:26:09.361Z", @@ -549,28 +849,567 @@ "name": "your-project-name" }, "provider": "gcp", - "region": "us-central1", + "region": "global", "service": { - "name": "GCP Storage" + "name": "GCP IAM" } }, "entity": { "EngineMetadata": { "Type": "generic" }, - "id": "mv-expand-target-storage", - "name": "MvExpandTargetStorage", - "source": ".ds-logs-cloud_asset_inventory.asset_inventory-storage_gcp_storage_bucket-default-2025.07.16-000005", - "sub_type": "GCP Storage Bucket", - "type": "Storage" + "id": "mv-expand-test-actor@example.com", + "name": "MvExpandTestActor", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_gcp_iam_user-default-2025.07.16-000005", + "sub_type": "GCP IAM User", + "type": "Identity" }, "event": { "ingested": "2025-07-20T17:27:13.583Z" }, - "cloud.storage": { - "bucket": { - "name": "mv-expand-target-storage" - } + "user": { + "id": "mv-expand-test-actor@example.com", + "name": "MvExpandTestActor" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "23", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "your-project-id", + "name": "your-project-name" + }, + "provider": "gcp", + "region": "global", + "service": { + "name": "GCP IAM" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "mv-expand-target-identity", + "name": "MvExpandTargetIdentity", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_gcp_iam_user-default-2025.07.16-000005", + "sub_type": "GCP IAM User", + "type": "Identity" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "user": { + "id": "mv-expand-target-identity", + "name": "MvExpandTargetIdentity" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "24", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "your-project-id", + "name": "your-project-name" + }, + "provider": "gcp", + "region": "us-central1", + "service": { + "name": "GCP Storage" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "mv-expand-target-storage", + "name": "MvExpandTargetStorage", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-storage_gcp_storage_bucket-default-2025.07.16-000005", + "sub_type": "GCP Storage Bucket", + "type": "Storage" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "cloud.storage": { + "bucket": { + "name": "mv-expand-target-storage" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "25", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS IAM" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-root-user", + "name": "Hierarchy Root User", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_aws_iam_user-default-2025.07.16-000005", + "sub_type": "AWS IAM User", + "type": "Identity", + "relationships": { + "Owns": ["rel-hierarchy-host-1", "rel-hierarchy-service-1", "rel-hierarchy-identity-1"] + } + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "user": { + "id": "rel-hierarchy-root-user", + "name": "Hierarchy Root User" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "26", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS EC2" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-host-1", + "name": "Hierarchy Host 1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-compute_aws_ec2_instance-default-2025.07.16-000005", + "sub_type": "AWS EC2 Instance", + "type": "Host", + "relationships": { + "Communicates_with": ["rel-hierarchy-storage-1", "rel-hierarchy-storage-2"] + } + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "host": { + "id": "rel-hierarchy-host-1", + "name": "Hierarchy Host 1" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "27", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS Lambda" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-service-1", + "name": "Hierarchy Service 1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-service_aws_lambda-default-2025.07.16-000005", + "sub_type": "AWS Lambda Function", + "type": "Service", + "relationships": { + "Communicates_with": ["rel-hierarchy-database-1", "rel-hierarchy-database-2"] + } + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "service": { + "id": "rel-hierarchy-service-1", + "name": "Hierarchy Service 1" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "28", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS IAM" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-identity-1", + "name": "Hierarchy Identity 1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_aws_iam_role-default-2025.07.16-000005", + "sub_type": "AWS IAM Role", + "type": "Identity", + "relationships": { + "Communicates_with": ["rel-hierarchy-network-1", "rel-hierarchy-network-2"], + "Supervises": ["rel-hierarchy-delegate-1"], + "Depends_on": ["rel-hierarchy-delegate-1"] + } + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "user": { + "id": "rel-hierarchy-identity-1", + "name": "Hierarchy Identity 1" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "29", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS S3" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-storage-1", + "name": "Hierarchy Storage Bucket 1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-storage_aws_s3_bucket-default-2025.07.16-000005", + "sub_type": "AWS S3 Bucket", + "type": "Storage" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "30", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS S3" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-storage-2", + "name": "Hierarchy Storage Bucket 2", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-storage_aws_s3_bucket-default-2025.07.16-000005", + "sub_type": "AWS S3 Bucket", + "type": "Storage" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "31", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS RDS" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-database-1", + "name": "Hierarchy Database 1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-database_aws_rds-default-2025.07.16-000005", + "sub_type": "AWS RDS Instance", + "type": "Database" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "32", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS RDS" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-database-2", + "name": "Hierarchy Database 2", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-database_aws_rds-default-2025.07.16-000005", + "sub_type": "AWS RDS Instance", + "type": "Database" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "33", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS VPC" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-network-1", + "name": "Hierarchy Network VPC 1", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-network_aws_vpc-default-2025.07.16-000005", + "sub_type": "AWS VPC", + "type": "Networking" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "34", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS VPC" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-network-2", + "name": "Hierarchy Network VPC 2", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-network_aws_vpc-default-2025.07.16-000005", + "sub_type": "AWS VPC", + "type": "Networking" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "35", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-east-1", + "service": { + "name": "AWS Organizations" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-delegate-1", + "name": "Hierarchy Delegate Agent", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-identity_aws_organizations_admin-default-2025.07.16-000005", + "sub_type": "AWS Organizations Admin", + "type": "User" + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "user": { + "id": "rel-hierarchy-delegate-1", + "name": "Hierarchy Delegate Agent" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "rel-hierarchy-external-caller", + "index": ".entities.v2.latest.security_default", + "source": { + "@timestamp": "2025-07-20T17:26:09.361Z", + "cloud": { + "account": { + "id": "rel-hierarchy-account", + "name": "Relationship Hierarchy Test" + }, + "provider": "aws", + "region": "us-west-2", + "service": { + "name": "AWS Lambda" + } + }, + "entity": { + "EngineMetadata": { + "Type": "generic" + }, + "id": "rel-hierarchy-external-caller", + "name": "Hierarchy External Caller", + "source": ".ds-logs-cloud_asset_inventory.asset_inventory-service_aws_lambda-default-2025.07.16-000005", + "sub_type": "AWS Lambda Function", + "type": "Service", + "relationships": { + "Communicates_with": ["rel-hierarchy-identity-1"] + } + }, + "event": { + "ingested": "2025-07-20T17:27:13.583Z" + }, + "service": { + "id": "rel-hierarchy-external-caller", + "name": "Hierarchy External Caller" } } } diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/mappings.json b/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/mappings.json index 3c786241c9741..482e5c56c45f6 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/mappings.json +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2/mappings.json @@ -4,7 +4,7 @@ "aliases": { "entities-generic-latest": {} }, - "index": ".entities.v2.latest.security_generic_default", + "index": ".entities.v2.latest.security_default", "settings": { "index": { "mode": "lookup" @@ -416,6 +416,25 @@ }, "url": { "type": "keyword" + }, + "relationships": { + "properties": { + "Accesses_frequently": { + "type": "keyword" + }, + "Communicates_with": { + "type": "keyword" + }, + "Depends_on": { + "type": "keyword" + }, + "Owns": { + "type": "keyword" + }, + "Supervises": { + "type": "keyword" + } + } } } }, diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json b/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json index 5315c7e1317a3..0697152bc2bf8 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json @@ -1622,3 +1622,411 @@ } } } + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "relationships-event-1", + "index": ".ds-logs-gcp.audit-default-2024.09.12-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "user": { + "name": "relationships-test-user", + "entity": { + "id": "relationships-test-user" + } + }, + "cloud": { + "account": { + "id": "relationships-test-account" + }, + "provider": "aws" + }, + "ecs": { + "version": "8.11.0" + }, + "event": { + "action": "AssumeRole", + "agent_id_status": "missing", + "category": [ + "authentication" + ], + "id": "relationships-event-id-12345", + "ingested": "2024-10-07T17:47:35Z", + "kind": "event", + "outcome": "success", + "provider": "gcp", + "type": [ + "access" + ] + }, + "log": { + "level": "INFO", + "logger": "gcp" + }, + "related": { + "user": [ + "relationships-test-user" + ] + }, + "service": { + "target": { + "entity": { + "id": "relationships-target-service" + } + }, + "name": "sts.amazonaws.com" + }, + "tags": [], + "user_agent": { + "device": { + "name": "Other" + }, + "name": "AWS SDK", + "original": "aws-sdk-go/1.44.0" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "multi-relationships-event-1", + "index": ".ds-logs-gcp.audit-default-2024.09.12-000001", + "source": { + "@timestamp": "2024-09-01T12:35:00.789Z", + "user": { + "name": "gcp-admin-user@my-gcp-project.iam.gserviceaccount.com", + "entity": { + "id": "gcp-admin-user@my-gcp-project.iam.gserviceaccount.com" + } + }, + "service": { + "target": { + "entity": { + "id": "data-pipeline@my-gcp-project.iam.gserviceaccount.com" + } + } + }, + "cloud": { + "account": { + "id": "my-gcp-project-123456" + }, + "provider": "gcp", + "project": { + "id": "my-gcp-project", + "name": "My GCP Project" + }, + "region": "us-central1" + }, + "ecs": { + "version": "8.11.0" + }, + "event": { + "action": "google.iam.admin.v1.SetIamPolicy", + "agent_id_status": "missing", + "category": [ + "iam" + ], + "id": "multi-relationships-event-id-12345", + "ingested": "2024-10-07T17:47:35Z", + "kind": "event", + "outcome": "success", + "provider": "gcp", + "type": [ + "admin" + ] + }, + "log": { + "level": "INFO", + "logger": "gcp" + }, + "related": { + "user": [ + "gcp-admin-user@my-gcp-project.iam.gserviceaccount.com" + ] + }, + "tags": [], + "user_agent": { + "device": { + "name": "Other" + }, + "name": "google-cloud-sdk", + "original": "google-cloud-sdk gcloud/450.0.0" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "multi-relationships-event-2", + "index": ".ds-logs-gcp.audit-default-2024.09.12-000001", + "source": { + "@timestamp": "2024-09-01T12:40:00.789Z", + "service": { + "entity": { + "id": "data-pipeline@my-gcp-project.iam.gserviceaccount.com" + }, + "target": { + "entity": { + "id": "projects/my-gcp-project/zones/us-west1-a/instances/db-server-prod-1" + } + } + }, + "cloud": { + "account": { + "id": "my-gcp-project-123456" + }, + "provider": "gcp", + "project": { + "id": "my-gcp-project", + "name": "My GCP Project" + }, + "region": "us-central1" + }, + "ecs": { + "version": "8.11.0" + }, + "event": { + "action": "google.storage.buckets.update", + "agent_id_status": "missing", + "category": [ + "file" + ], + "id": "multi-relationships-event-id-67890", + "ingested": "2024-10-07T17:47:35Z", + "kind": "event", + "outcome": "success", + "provider": "gcp", + "type": [ + "change" + ] + }, + "log": { + "level": "INFO", + "logger": "gcp" + }, + "related": { + "user": [ + "data-pipeline@my-gcp-project.iam.gserviceaccount.com" + ] + }, + "tags": [], + "user_agent": { + "device": { + "name": "Other" + }, + "name": "google-cloud-sdk", + "original": "google-cloud-sdk gcloud/450.0.0" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "rel-hierarchy-event-ftr-1", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:45:00.789Z", + "user": { + "name": "Hierarchy Root User", + "entity": { + "id": "rel-hierarchy-root-user" + }, + "target": { + "entity": { + "id": "rel-hierarchy-identity-1" + } + } + }, + "host": { + "target": { + "entity": { + "id": "rel-hierarchy-host-1" + } + } + }, + "cloud": { + "account": { + "id": "rel-hierarchy-account" + }, + "provider": "gcp", + "region": "us-east1" + }, + "ecs": { + "version": "8.11.0" + }, + "event": { + "action": "google.iam.admin.v1.UpdatePolicy", + "agent_id_status": "missing", + "category": [ + "iam" + ], + "id": "rel-hierarchy-event-id-ftr-12345", + "ingested": "2024-10-07T17:49:00Z", + "kind": "event", + "outcome": "success", + "provider": "gcp", + "type": [ + "change" + ] + }, + "log": { + "level": "INFO", + "logger": "gcp" + }, + "related": { + "user": [ + "rel-hierarchy-root-user" + ] + }, + "tags": [], + "user_agent": { + "device": { + "name": "Other" + }, + "name": "gcloud", + "original": "gcloud/450.0.0" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "rel-hierarchy-event-ftr-2", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:46:00.789Z", + "user": { + "name": "Hierarchy Identity 1", + "entity": { + "id": "rel-hierarchy-identity-1" + } + }, + "entity": { + "target": { + "id": "rel-hierarchy-delegate-1" + } + }, + "cloud": { + "account": { + "id": "rel-hierarchy-account" + }, + "provider": "gcp", + "region": "us-east1" + }, + "ecs": { + "version": "8.11.0" + }, + "event": { + "action": "google.admin.reports.v1.AuditLog", + "agent_id_status": "missing", + "category": [ + "iam" + ], + "id": "rel-hierarchy-event-id-ftr-22222", + "ingested": "2024-10-07T17:49:00Z", + "kind": "event", + "outcome": "success", + "provider": "gcp", + "type": [ + "info" + ] + }, + "log": { + "level": "INFO", + "logger": "gcp" + }, + "related": { + "user": [ + "rel-hierarchy-identity-1" + ] + }, + "tags": [], + "user_agent": { + "device": { + "name": "Other" + }, + "name": "gcloud", + "original": "gcloud/450.0.0" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "rel-hierarchy-event-ftr-3", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:47:00.789Z", + "user": { + "name": "Hierarchy Identity 1", + "entity": { + "id": "rel-hierarchy-identity-1" + } + }, + "entity": { + "target": { + "id": "rel-hierarchy-delegate-1" + } + }, + "cloud": { + "account": { + "id": "rel-hierarchy-account" + }, + "provider": "gcp", + "region": "us-east1" + }, + "ecs": { + "version": "8.11.0" + }, + "event": { + "action": "google.admin.directory.v1.UserOperation", + "agent_id_status": "missing", + "category": [ + "iam" + ], + "id": "rel-hierarchy-event-id-ftr-33333", + "ingested": "2024-10-07T17:49:00Z", + "kind": "event", + "outcome": "success", + "provider": "gcp", + "type": [ + "change" + ] + }, + "log": { + "level": "INFO", + "logger": "gcp" + }, + "related": { + "user": [ + "rel-hierarchy-identity-1" + ] + }, + "tags": [], + "user_agent": { + "device": { + "name": "Other" + }, + "name": "gcloud", + "original": "gcloud/450.0.0" + } + } + } +} diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/expanded_flyout_graph.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/expanded_flyout_graph.ts index 0a2d9636d1c13..5598eaf80ecaa 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/expanded_flyout_graph.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/expanded_flyout_graph.ts @@ -22,6 +22,7 @@ const { GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID, GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID, GRAPH_NODE_POPOVER_SHOW_ENTITY_DETAILS_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID, GRAPH_LABEL_EXPAND_POPOVER_TEST_ID, GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID, GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENT_DETAILS_ITEM_ID, @@ -37,6 +38,7 @@ const { GRAPH_CALLOUT_TEST_ID, GRAPH_NODE_ENTITY_DETAILS_ID, GRAPH_NODE_ENTITY_TAG_TEXT_ID, + GRAPH_NODE_ENTITY_TAG_COUNT_ID, GROUPED_ITEM_TITLE_TEST_ID_TEXT, GROUPED_ITEM_TITLE_TEST_ID_LINK, GROUPED_ITEM_TEST_ID, @@ -102,14 +104,24 @@ export class ExpandedFlyoutGraph extends GenericFtrService { + async getNodesByIdCount(nodeId: string): Promise { await this.waitGraphIsLoaded(); const graph = await this.testSubjects.find(GRAPH_INVESTIGATION_TEST_ID); await graph.scrollIntoView(); const nodes = await graph.findAllByCssSelector( `.react-flow__nodes .react-flow__node[data-id="${nodeId}"]` ); - expect(nodes.length).to.be(1); + return nodes.length; + } + + async assertNodeExists(nodeId: string): Promise { + const count = await this.getNodesByIdCount(nodeId); + expect(count).to.be(1); + } + + async assertNodeDoesNotExist(nodeId: string): Promise { + const count = await this.getNodesByIdCount(nodeId); + expect(count).to.be(0); } async clickOnNodeExpandButton( @@ -139,6 +151,11 @@ export class ExpandedFlyoutGraph extends GenericFtrService { + await this.clickOnNodeExpandButton(nodeId); + await this.testSubjects.click(GRAPH_NODE_POPOVER_SHOW_ENTITY_RELATIONSHIPS_ITEM_ID); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } async hideActionsOnEntity(nodeId: string): Promise { await this.clickOnNodeExpandButton(nodeId); @@ -204,7 +221,9 @@ export class ExpandedFlyoutGraph extends GenericFtrService { + await this.showSearchBar(); await this.testSubjects.click(`${GRAPH_INVESTIGATION_TEST_ID} > addFilter`); + await this.testSubjects.existOrFail('filter-0', { timeout: 5000 }); await this.filterBar.createFilter(filter); await this.testSubjects.scrollIntoView('saveFilter'); await this.testSubjects.clickWhenNotDisabled('saveFilter'); @@ -281,6 +300,13 @@ export class ExpandedFlyoutGraph extends GenericFtrService { + const node = await this.selectNode(nodeId); + const countWrapper = await node.findByTestSubject(GRAPH_NODE_ENTITY_TAG_COUNT_ID); + const countText = await countWrapper.getVisibleText(); + expect(parseInt(countText, 10)).to.be(expectedCount); + } + async assertNodeEntityDetails(nodeId: string, expectedDetails: string): Promise { const node = await this.selectNode(nodeId); const detailsElement = await node.findByTestSubject(GRAPH_NODE_ENTITY_DETAILS_ID); diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/alerts_flyout.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/alerts_flyout.ts index affbb076c62c5..047b3c971098f 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/alerts_flyout.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/alerts_flyout.ts @@ -481,7 +481,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro logger, retry, entitiesIndex: '.entities.v2.latest.security_*', - expectedCount: 15, + expectedCount: 36, }); }); diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/entity_preview_flyout.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/entity_preview_flyout.ts index 30ac26fbed546..6cbfb9238d898 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/entity_preview_flyout.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/entity_preview_flyout.ts @@ -178,7 +178,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro logger, retry, entitiesIndex: getEntitiesLatestIndexName(), - expectedCount: 15, + expectedCount: 36, }); }); diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/events_flyout.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/events_flyout.ts index 113cdfca47bad..f0ac42b4a0406 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/events_flyout.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/events_flyout.ts @@ -99,6 +99,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro 0, 'user.entity.id: admin@example.com OR user.target.entity.id: admin@example.com' ); + await expandedFlyoutGraph.expectFilterPreviewEquals( 0, 'user.entity.id: admin@example.com OR user.target.entity.id: admin@example.com' @@ -119,6 +120,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro await expandedFlyoutGraph.showEventsOfSameAction( 'label(google.iam.admin.v1.CreateRole)ln(d417ea74f69263353ca1f98e8269b8a6)oe(1)oa(0)' ); + await expandedFlyoutGraph.expectFilterTextEquals( 0, 'user.entity.id: admin@example.com OR user.target.entity.id: admin@example.com OR related.entity: admin@example.com OR event.action: google.iam.admin.v1.CreateRole' @@ -517,7 +519,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro logger, retry, entitiesIndex: '.entities.v2.latest.security_*', - expectedCount: 15, + expectedCount: 36, }); }); @@ -528,6 +530,314 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro }); runEnrichmentTests(); + + describe('Entity Relationships', () => { + it('expanded flyout - event with service target and entity relationships', async () => { + // Navigate to events page with the SetIamPolicy event + // Note: getFlyoutFilter uses document id, not event.id + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('multi-relationships-event-1')}` + ); + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + await networkEventsPage.flyout.assertGraphPreviewVisible(); + + // Expected nodes: + // - 1 actor node (gcp-admin-user) + // - 1 service target node (data-pipeline) + // - 1 label node (SetIamPolicy action) + const expectedNodes = 3; + await networkEventsPage.flyout.assertGraphNodesNumber(expectedNodes); + + await expandedFlyoutGraph.expandGraph(); + await expandedFlyoutGraph.waitGraphIsLoaded(); + await expandedFlyoutGraph.assertGraphNodesNumber(expectedNodes); + // Verify actor node + const actorNodeId = 'gcp-admin-user@my-gcp-project.iam.gserviceaccount.com'; + await expandedFlyoutGraph.assertNodeEntityTag(actorNodeId, 'Service Account'); + await expandedFlyoutGraph.assertNodeEntityDetails(actorNodeId, 'GCP Admin User'); + + // Verify service target node + const serviceTargetNodeId = 'data-pipeline@my-gcp-project.iam.gserviceaccount.com'; + await expandedFlyoutGraph.assertNodeEntityTag(serviceTargetNodeId, 'Service Account'); + await expandedFlyoutGraph.assertNodeEntityDetails( + serviceTargetNodeId, + 'data-pipeline Service Account' + ); + + await expandedFlyoutGraph.showEntityDetails( + 'data-pipeline@my-gcp-project.iam.gserviceaccount.com' + ); + await expandedFlyoutGraph.assertPreviewPopoverIsOpen(); + + await expandedFlyoutGraph.closePreviewSection(); + + await expandedFlyoutGraph.showEntityRelationships( + 'data-pipeline@my-gcp-project.iam.gserviceaccount.com' + ); + + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + await expandedFlyoutGraph.dismissCallout(); + + // Expected nodes: + // - 1 actor node (gcp-admin-user) + // - 1 service target node (data-pipeline) + // - 1 label node (SetIamPolicy action) + // - 2 relationship nodes (Owns, Communicates_with) + // - 2 grouped target nodes (Owns targets: 3 items, Communicates_with targets: 2 items) + const expectedNodesWithRelationships = 7; + await expandedFlyoutGraph.assertGraphNodesNumber(expectedNodesWithRelationships); + + const communicatesWithRelationshipNodeId = + 'rel(data-pipeline@my-gcp-project.iam.gserviceaccount.com-Communicates_with)'; + await expandedFlyoutGraph.assertNodeExists(communicatesWithRelationshipNodeId); + + const ownsRelationshipNodeId = + 'rel(data-pipeline@my-gcp-project.iam.gserviceaccount.com-Owns)'; + await expandedFlyoutGraph.assertNodeExists(ownsRelationshipNodeId); + + const communicatesWithIdRelationshipTargetNodeId = 'd4f3b950f4345da123745ee6c3806cf1'; + await expandedFlyoutGraph.assertNodeEntityTag( + communicatesWithIdRelationshipTargetNodeId, + 'Host' + ); + await expandedFlyoutGraph.assertNodeEntityDetails( + communicatesWithIdRelationshipTargetNodeId, + 'GCP Compute Instance' + ); + await expandedFlyoutGraph.assertNodeEntityTagCount( + communicatesWithIdRelationshipTargetNodeId, + 2 + ); + + const ownsIdRelationshipTargetNodeId = '6cf356f3b9190616a3d11bd98e0acdfd'; + await expandedFlyoutGraph.assertNodeEntityTag(ownsIdRelationshipTargetNodeId, 'Host'); + await expandedFlyoutGraph.assertNodeEntityDetails( + ownsIdRelationshipTargetNodeId, + 'GCP Compute Instance' + ); + await expandedFlyoutGraph.assertNodeEntityTagCount(ownsIdRelationshipTargetNodeId, 3); + + // Click on "Show this entity's actions" for data-pipeline entity + // This should reveal another event where data-pipeline is the actor + await expandedFlyoutGraph.showActionsByEntity(serviceTargetNodeId); + + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + + // Expected nodes after showing entity's actions: + // - 1 actor node (gcp-admin-user) + // - 1 service target node (data-pipeline) - now also an actor + // - 1 label node (SetIamPolicy action) + // - 2 relationship nodes (Owns, Communicates_with) + // - 2 grouped target nodes (Owns targets: 3 items, Communicates_with targets: 2 items) + // - 1 new label node (google.storage.buckets.update action) + // - 1 new target node (db-server-prod-1) + const expectedNodesWithActions = 9; + await expandedFlyoutGraph.assertGraphNodesNumber(expectedNodesWithActions); + + const eventTargetNodeId = + 'projects/my-gcp-project/zones/us-west1-a/instances/db-server-prod-1'; + await expandedFlyoutGraph.assertNodeEntityTag(eventTargetNodeId, 'Host'); + await expandedFlyoutGraph.assertNodeEntityDetails( + eventTargetNodeId, + 'db-server-prod-1' + ); + + // assrt that that existing grouped target nodes still exist + await expandedFlyoutGraph.assertNodeEntityTag( + communicatesWithIdRelationshipTargetNodeId, + 'Host' + ); + await expandedFlyoutGraph.assertNodeEntityDetails( + communicatesWithIdRelationshipTargetNodeId, + 'GCP Compute Instance' + ); + await expandedFlyoutGraph.assertNodeEntityTagCount( + communicatesWithIdRelationshipTargetNodeId, + 2 + ); + + await expandedFlyoutGraph.assertNodeEntityTag(ownsIdRelationshipTargetNodeId, 'Host'); + await expandedFlyoutGraph.assertNodeEntityDetails( + ownsIdRelationshipTargetNodeId, + 'GCP Compute Instance' + ); + await expandedFlyoutGraph.assertNodeEntityTagCount(ownsIdRelationshipTargetNodeId, 3); + }); + + it('expanded flyout - hierarchical relationships with grouped targets and event', async () => { + // Test scenario matching the API integration test: + // - Root user owns 3 entities (Host, Service, Identity) - each with different type + // - Each of those 3 entities communicates_with 2 entities of the same type (grouped) + // - Identity-1 also supervises AND depends_on a delegate entity (different type: User) + // - Root user performs an action (event) targeting host-1 and identity-1 + // - Identity-1 performs 2 different actions targeting delegate-1 (these get stacked) + // - External-caller has Communicates_with relationship targeting identity-1 + + // Navigate to the event - include all 3 events + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('rel-hierarchy-event-ftr-1')}` + ); + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + await networkEventsPage.flyout.assertGraphPreviewVisible(); + + await expandedFlyoutGraph.expandGraph(); + await expandedFlyoutGraph.waitGraphIsLoaded(); + + // Expected nodes: + // - 1 actor node: root user + // - 1 label node: for the event action (google.iam.admin.v1.UpdatePolicy) + // - 2 target nodes: host-1 and identity-1 + // Total: 4 nodes + const expectedTotalNodes = 4; + await expandedFlyoutGraph.assertGraphNodesNumber(expectedTotalNodes); + + await expandedFlyoutGraph.dismissCallout(); + + // Verify root user (actor) node + const rootNodeId = 'rel-hierarchy-root-user'; + await expandedFlyoutGraph.assertNodeEntityTag(rootNodeId, 'Identity'); + await expandedFlyoutGraph.assertNodeEntityDetails(rootNodeId, 'Hierarchy Root User'); + + // Verify intermediate identity node + const hostNodeId = 'rel-hierarchy-identity-1'; + await expandedFlyoutGraph.assertNodeEntityTag(hostNodeId, 'Identity'); + await expandedFlyoutGraph.assertNodeEntityDetails(hostNodeId, 'Hierarchy Identity 1'); + + // Verify intermediate host node + const serviceNodeId = 'rel-hierarchy-host-1'; + await expandedFlyoutGraph.assertNodeEntityTag(serviceNodeId, 'Host'); + await expandedFlyoutGraph.assertNodeEntityDetails(serviceNodeId, 'Hierarchy Host 1'); + + await expandedFlyoutGraph.showEntityRelationships('rel-hierarchy-root-user'); + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + + const expectedNodesWithSingleOwnsRelationship = 6; + await expandedFlyoutGraph.assertGraphNodesNumber( + expectedNodesWithSingleOwnsRelationship + ); + + const ownsRelationshipNodeId = 'rel(rel-hierarchy-root-user-Owns)'; + await expandedFlyoutGraph.assertNodeExists(ownsRelationshipNodeId); + + await expandedFlyoutGraph.showEntityRelationships(hostNodeId); + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + + await expandedFlyoutGraph.showEntityRelationships(serviceNodeId); + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + + await expandedFlyoutGraph.showEntityRelationships('rel-hierarchy-service-1'); + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + + // Expected nodes with multiple relationships: + // - 9 entity nodes: root + 3 intermediate + 3 grouped targets + 1 delegate + 1 external-caller + // - 7 relationship nodes: 1 Owns + 4 Communicates_with + 1 Supervises + 1 Depends_on + // - 1 label node: UpdatePolicy (not stacked) + // - 1 group node: for stacking Supervises and Depends_on (same source-target pair) + const expectedNodesWithMultipleRelationships = 18; + await expandedFlyoutGraph.assertGraphNodesNumber( + expectedNodesWithMultipleRelationships + ); + + // rel-hierarchy-identity-1 entity has the following relationships: + // - Supervises: rel-hierarchy-delegate-1 + // - Depends_on: rel-hierarchy-delegate-1 + // - Communicates_with: rel-hierarchy-network-1, rel-hierarchy-network-2 + // Supervises and Depends_on share the same target (delegate-1), so they are stacked in a group + const supervisesRelationshipNodeId = 'rel(rel-hierarchy-identity-1-Supervises)'; + await expandedFlyoutGraph.assertNodeExists(supervisesRelationshipNodeId); + + const dependsOnRelationshipNodeId = 'rel(rel-hierarchy-identity-1-Depends_on)'; + await expandedFlyoutGraph.assertNodeExists(dependsOnRelationshipNodeId); + + const communicatesWithRelationshipNodeId = + 'rel(rel-hierarchy-identity-1-Communicates_with)'; + await expandedFlyoutGraph.assertNodeExists(communicatesWithRelationshipNodeId); + + const delegateTargetNodeId = 'rel-hierarchy-delegate-1'; + await expandedFlyoutGraph.assertNodeEntityTag(delegateTargetNodeId, 'User'); + await expandedFlyoutGraph.assertNodeEntityDetails( + delegateTargetNodeId, + 'Hierarchy Delegate Agent' + ); + + const communicatesWithIdRelationshipTargetNodeId = '3ed488a2068243098af41d666693f341'; + await expandedFlyoutGraph.assertNodeEntityTag( + communicatesWithIdRelationshipTargetNodeId, + 'Networking' + ); + await expandedFlyoutGraph.assertNodeEntityDetails( + communicatesWithIdRelationshipTargetNodeId, + 'AWS VPC' + ); + + await expandedFlyoutGraph.showEntityDetails(communicatesWithIdRelationshipTargetNodeId); + // check the preview panel grouped items rendered correctly + await networkEventsPage.flyout.assertPreviewPanelIsOpen('group'); + await networkEventsPage.flyout.assertPreviewPanelGroupedItemsNumber(2); + await expandedFlyoutGraph.assertPreviewPanelGroupedItemTitleLinkNumber(2); + + await expandedFlyoutGraph.closePreviewSection(); + + const communicatesWithHostRelationshipNodeId = + 'rel(rel-hierarchy-host-1-Communicates_with)'; + await expandedFlyoutGraph.assertNodeExists(communicatesWithHostRelationshipNodeId); + + const communicatesWithStorageRelationshipNodeId = + 'rel(rel-hierarchy-service-1-Communicates_with)'; + await expandedFlyoutGraph.assertNodeExists(communicatesWithStorageRelationshipNodeId); + + // Verify external-caller entity and its relationship + const externalCallerNodeId = 'rel-hierarchy-external-caller'; + await expandedFlyoutGraph.assertNodeEntityTag(externalCallerNodeId, 'Service'); + await expandedFlyoutGraph.assertNodeEntityDetails( + externalCallerNodeId, + 'Hierarchy External Caller' + ); + + const externalCallerRelationshipNodeId = + 'rel(rel-hierarchy-external-caller-Communicates_with)'; + await expandedFlyoutGraph.assertNodeExists(externalCallerRelationshipNodeId); + // Show actions by identity-1 to see the two new events (AuditLog and UserOperation) + await expandedFlyoutGraph.showActionsByEntity(hostNodeId); + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + + // Expected nodes after showing entity's actions: + // Previous 18 nodes + 2 new label nodes (join existing relationship group) = 20 + const expectedNodesAfterShowingActions = 20; + await expandedFlyoutGraph.assertGraphNodesNumber(expectedNodesAfterShowingActions); + + await expandedFlyoutGraph.assertNodeEntityTag(delegateTargetNodeId, 'User'); + + await expandedFlyoutGraph.showEntityRelationships('rel-hierarchy-identity-1'); + await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); + + // Verify the group node exists that contains 2 label and 2 relationship nodes + // (stacked together because they share the same source-target pair: identity-1 → delegate-1) + const stackedGroupNodeId = + 'grp(30fe1a3db6add7620bc17c65035dd458088ccd164c434ad513a790fa9abc1575)'; + await expandedFlyoutGraph.assertNodeExists(stackedGroupNodeId); + + // hide entity relationships + await expandedFlyoutGraph.assertNodeDoesNotExist(supervisesRelationshipNodeId); + await expandedFlyoutGraph.assertNodeDoesNotExist(dependsOnRelationshipNodeId); + await expandedFlyoutGraph.assertNodeDoesNotExist(communicatesWithRelationshipNodeId); + + await expandedFlyoutGraph.assertNodeExists(delegateTargetNodeId); + await expandedFlyoutGraph.assertNodeDoesNotExist( + communicatesWithIdRelationshipTargetNodeId + ); + }); + }); }); }); }); diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/rules/rules_counters.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/rules/rules_counters.ts index 954880c5e11e3..8c7f6562c4d0b 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/pages/rules/rules_counters.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/pages/rules/rules_counters.ts @@ -135,6 +135,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect((await disabledRulesCounter.getVisibleText()).includes('0')).to.be(true); // disable rule 1.1.1 (k8s findings mock contains a findings from that rule) + await rule.rulePage.closeToasts(); await rule.rulePage.clickEnableRulesRowSwitchButton(0); await pageObjects.header.waitUntilLoadingHasFinished(); expect((await disabledRulesCounter.getVisibleText()).includes('1')).to.be(true); @@ -143,10 +144,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect((await postureScoreCounter.getVisibleText()).includes('0%')).to.be(true); // enable rule back + await rule.rulePage.closeToasts(); await rule.rulePage.clickEnableRulesRowSwitchButton(0); }); it('Clicking the disabled rules button shows enables the disabled filter', async () => { + await rule.rulePage.closeToasts(); await rule.rulePage.clickEnableRulesRowSwitchButton(0); await pageObjects.header.waitUntilLoadingHasFinished(); @@ -162,6 +165,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const isEmptyStateVisible = await rule.rulePage.getCountersEmptyState(); expect(isEmptyStateVisible).to.be(true); + await rule.rulePage.closeToasts(); await rule.rulePage.clickEnableRulesRowSwitchButton(0); }); }); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/esql/trial_license_complete_tier/esql.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/esql/trial_license_complete_tier/esql.ts index 154ed800c685e..f27e3532a3b03 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/esql/trial_license_complete_tier/esql.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/esql/trial_license_complete_tier/esql.ts @@ -64,8 +64,7 @@ export default ({ getService }: FtrProviderContext) => { */ const internalIdPipe = (id: string) => `| where id=="${id}"`; - // Failing: See https://github.com/elastic/kibana/issues/235895 - describe.skip('@ess @serverless ES|QL rule type', () => { + describe('@ess @serverless ES|QL rule type', () => { before(async () => { await esArchiver.load( 'x-pack/solutions/security/test/fixtures/es_archives/security_solution/ecs_compliant' @@ -1712,14 +1711,13 @@ export default ({ getService }: FtrProviderContext) => { expect(alertsResponse.hits.hits).toHaveLength(4); }); - // flaky test: https://github.com/elastic/kibana/issues/235895 - it.skip('should generate alerts over multiple pages from different indices but same event id for mv_expand when number alerts exceeds max signal', async () => { + it('should generate alerts over multiple pages from different indices but same event id for mv_expand when number alerts exceeds max signal', async () => { const id = uuidv4(); const rule: EsqlRuleCreateProps = { ...getCreateEsqlRulesSchemaMock(`rule-${id}`, true), query: `from ecs_compliant, ecs_compliant_synthetic_source metadata _id, _index ${internalIdPipe( id - )} | mv_expand agent.name | sort @timestamp asc`, + )} | mv_expand agent.name | sort @timestamp asc, _index asc`, // sort by timestamp and index to ensure deterministic results, see https://github.com/elastic/kibana/issues/253849 from: '2020-10-28T05:15:00.000Z', to: '2020-10-28T06:00:00.000Z', interval: '45m', @@ -1733,7 +1731,7 @@ export default ({ getService }: FtrProviderContext) => { }; await Promise.all( - ['ecs_compliant', 'ecs_compliant_synthetic_source'].map((index) => + ['ecs_compliant', 'ecs_compliant_synthetic_source'].map((index, i) => es.index({ index, id, diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/perform_installation/perform_installation.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/perform_installation/perform_installation.ts index c19c9c5d8488d..cca668e78d49e 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/perform_installation/perform_installation.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/perform_installation/perform_installation.ts @@ -13,7 +13,9 @@ import { createRuleAssetSavedObjectOfType, deleteAllPrebuiltRuleAssets, installPrebuiltRules, + installPrebuiltRulesAndTimelines, } from '../../../../utils'; +import { createMlRuleThroughAlertingEndpoint } from '../utils'; export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); @@ -75,5 +77,44 @@ export default ({ getService }: FtrProviderContext): void => { ], }); }); + + describe('legacy (PUT /api/detection_engine/rules/prepackaged)', () => { + it('ML rules are silently excluded from installation', async () => { + const mlRuleAsset = createRuleAssetSavedObjectOfType('machine_learning'); + const nonMlRuleAsset = createRuleAssetSavedObjectOfType('query'); + await createPrebuiltRuleAssetSavedObjects(es, [mlRuleAsset, nonMlRuleAsset]); + + const response = await installPrebuiltRulesAndTimelines(es, supertest); + + expect(response.rules_installed).toBe(1); + expect(response.rules_updated).toBe(0); + }); + + it('ML rules are silently excluded from updates', async () => { + const queryRuleAsset = createRuleAssetSavedObjectOfType('query', { + rule_id: 'query-rule', + version: 1, + }); + await createPrebuiltRuleAssetSavedObjects(es, [queryRuleAsset]); + await installPrebuiltRulesAndTimelines(es, supertest); + + await createMlRuleThroughAlertingEndpoint(supertest, { ruleId: 'ml-rule', version: 1 }); + + const targetQueryRuleAsset = createRuleAssetSavedObjectOfType('query', { + rule_id: 'query-rule', + version: 2, + }); + const targetMlRuleAsset = createRuleAssetSavedObjectOfType('machine_learning', { + rule_id: 'ml-rule', + version: 2, + }); + await createPrebuiltRuleAssetSavedObjects(es, [targetQueryRuleAsset, targetMlRuleAsset]); + + const response = await installPrebuiltRulesAndTimelines(es, supertest); + + expect(response.rules_installed).toBe(0); + expect(response.rules_updated).toBe(1); + }); + }); }); }; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/status/status.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/status/status.ts index eaddf656931d1..aec417cf3a5d9 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/status/status.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/ml_disabled/status/status.ts @@ -12,6 +12,7 @@ import { createPrebuiltRuleAssetSavedObjects, createRuleAssetSavedObjectOfType, deleteAllPrebuiltRuleAssets, + getPrebuiltRulesAndTimelinesStatus, getPrebuiltRulesStatus, } from '../../../../utils'; import { createMlRuleThroughAlertingEndpoint } from '../utils'; @@ -65,5 +66,34 @@ export default ({ getService }: FtrProviderContext): void => { }, }); }); + + describe('legacy (GET /api/detection_engine/rules/prepackaged/_status)', () => { + it('ML rules are not counted towards installable rules', async () => { + const mlRuleAsset = createRuleAssetSavedObjectOfType('machine_learning', { version: 1 }); + await createPrebuiltRuleAssetSavedObjects(es, [mlRuleAsset]); + + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + + expect(statusResponse.rules_not_installed).toBe(0); + }); + + it('ML rules are not counted towards upgradable rules', async () => { + await createMlRuleThroughAlertingEndpoint(supertest, { + ruleId: 'ml-rule', + version: 1, + }); + + const targetMlRuleAsset = createRuleAssetSavedObjectOfType('machine_learning', { + rule_id: 'ml-rule', + version: 2, + }); + await createPrebuiltRuleAssetSavedObjects(es, [targetMlRuleAsset]); + + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); + + expect(statusResponse.rules_not_updated).toBe(0); + expect(statusResponse.rules_installed).toBe(1); + }); + }); }); }; diff --git a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts index 8bb907204fbf7..339a1c0490c43 100644 --- a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts +++ b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts @@ -37,8 +37,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // https://github.com/elastic/kibana/pull/190690 // fails after missing `awaits` were added - // Flaky: See https://github.com/elastic/kibana/issues/241493 - describe('Case View', function () { + // Flaky: See https://github.com/elastic/kibana/issues/253931 + describe.skip('Case View', function () { before(async () => { await svlCommonPage.loginWithPrivilegedRole(); }); diff --git a/yarn.lock b/yarn.lock index d01d17b2a8776..2c11724850cb1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -452,52 +452,6 @@ tslib "^2.6.2" uuid "^9.0.1" -"@aws-sdk/client-sesv2@^3.839.0": - version "3.908.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-sesv2/-/client-sesv2-3.908.0.tgz#c4b15fe75a61f93eda95ff2e490853b18d24bbba" - integrity sha512-UfY1u1/dO0T1rmpCb7yzpoO5RZ4tQt+n1H0aLWG/QTQJR5rNraa3A2E1rqdMQKLEUaKoaOHUKdfriHsdkTyRYA== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "3.908.0" - "@aws-sdk/credential-provider-node" "3.908.0" - "@aws-sdk/middleware-host-header" "3.901.0" - "@aws-sdk/middleware-logger" "3.901.0" - "@aws-sdk/middleware-recursion-detection" "3.901.0" - "@aws-sdk/middleware-user-agent" "3.908.0" - "@aws-sdk/region-config-resolver" "3.901.0" - "@aws-sdk/signature-v4-multi-region" "3.908.0" - "@aws-sdk/types" "3.901.0" - "@aws-sdk/util-endpoints" "3.901.0" - "@aws-sdk/util-user-agent-browser" "3.907.0" - "@aws-sdk/util-user-agent-node" "3.908.0" - "@smithy/config-resolver" "^4.3.0" - "@smithy/core" "^3.15.0" - "@smithy/fetch-http-handler" "^5.3.1" - "@smithy/hash-node" "^4.2.0" - "@smithy/invalid-dependency" "^4.2.0" - "@smithy/middleware-content-length" "^4.2.0" - "@smithy/middleware-endpoint" "^4.3.1" - "@smithy/middleware-retry" "^4.4.1" - "@smithy/middleware-serde" "^4.2.0" - "@smithy/middleware-stack" "^4.2.0" - "@smithy/node-config-provider" "^4.3.0" - "@smithy/node-http-handler" "^4.3.0" - "@smithy/protocol-http" "^5.3.0" - "@smithy/smithy-client" "^4.7.1" - "@smithy/types" "^4.6.0" - "@smithy/url-parser" "^4.2.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.0" - "@smithy/util-defaults-mode-node" "^4.2.1" - "@smithy/util-endpoints" "^3.2.0" - "@smithy/util-middleware" "^4.2.0" - "@smithy/util-retry" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" - tslib "^2.6.2" - "@aws-sdk/client-sso@3.879.0": version "3.879.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.879.0.tgz#44b7bcc051af7e89ffff7346bd5f5b0672b48390" @@ -584,25 +538,6 @@ fast-xml-parser "5.2.5" tslib "^2.6.2" -"@aws-sdk/core@3.908.0": - version "3.908.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.908.0.tgz#efd691b34ea3ba413f82ec27ce5fa9db4f9d386d" - integrity sha512-okl6FC2cQT1Oidvmnmvyp/IEvqENBagKO0ww4YV5UtBkf0VlhAymCWkZqhovtklsqgq0otag2VRPAgnrMt6nVQ== - dependencies: - "@aws-sdk/types" "3.901.0" - "@aws-sdk/xml-builder" "3.901.0" - "@smithy/core" "^3.15.0" - "@smithy/node-config-provider" "^4.3.0" - "@smithy/property-provider" "^4.2.0" - "@smithy/protocol-http" "^5.3.0" - "@smithy/signature-v4" "^5.3.0" - "@smithy/smithy-client" "^4.7.1" - "@smithy/types" "^4.6.0" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-middleware" "^4.2.0" - "@smithy/util-utf8" "^4.2.0" - tslib "^2.6.2" - "@aws-sdk/credential-provider-env@3.879.0": version "3.879.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.879.0.tgz#8de1561de6de585bffb8b7ff13ec7a88cb696de6" @@ -649,7 +584,7 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/credential-provider-node@3.879.0", "@aws-sdk/credential-provider-node@3.883.0", "@aws-sdk/credential-provider-node@3.908.0", "@aws-sdk/credential-provider-node@^3.750.0": +"@aws-sdk/credential-provider-node@3.879.0", "@aws-sdk/credential-provider-node@3.883.0", "@aws-sdk/credential-provider-node@^3.750.0": version "3.879.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.879.0.tgz#379a7edadd8fdfe72fe768d44ee323871b80a2f9" integrity sha512-FYaAqJbnSTrVL2iZkNDj2hj5087yMv2RN2GA8DJhe7iOJjzhzRojrtlfpWeJg6IhK0sBKDH+YXbdeexCzUJvtA== @@ -735,16 +670,6 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/middleware-host-header@3.901.0": - version "3.901.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.901.0.tgz#e6b3a6706601d93949ca25167ecec50c40e3d9de" - integrity sha512-yWX7GvRmqBtbNnUW7qbre3GvZmyYwU0WHefpZzDTYDoNgatuYq6LgUIQ+z5C04/kCRoFkAFrHag8a3BXqFzq5A== - dependencies: - "@aws-sdk/types" "3.901.0" - "@smithy/protocol-http" "^5.3.0" - "@smithy/types" "^4.6.0" - tslib "^2.6.2" - "@aws-sdk/middleware-logger@3.876.0": version "3.876.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz#16ee45f7bcd887badc8f12d80eef9ba18a0ac97c" @@ -754,15 +679,6 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/middleware-logger@3.901.0": - version "3.901.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.901.0.tgz#30562184bd0b6a90d30f2d6d58ef5054300f2652" - integrity sha512-UoHebjE7el/tfRo8/CQTj91oNUm+5Heus5/a4ECdmWaSCHCS/hXTsU3PTTHAY67oAQR8wBLFPfp3mMvXjB+L2A== - dependencies: - "@aws-sdk/types" "3.901.0" - "@smithy/types" "^4.6.0" - tslib "^2.6.2" - "@aws-sdk/middleware-recursion-detection@3.873.0": version "3.873.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz#1f9086542800d355d85332acea7accf1856e408b" @@ -773,37 +689,6 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/middleware-recursion-detection@3.901.0": - version "3.901.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.901.0.tgz#8492bd83aeee52f4e1b4194a81d044f46acf8c5b" - integrity sha512-Wd2t8qa/4OL0v/oDpCHHYkgsXJr8/ttCxrvCKAt0H1zZe2LlRhY9gpDVKqdertfHrHDj786fOvEQA28G1L75Dg== - dependencies: - "@aws-sdk/types" "3.901.0" - "@aws/lambda-invoke-store" "^0.0.1" - "@smithy/protocol-http" "^5.3.0" - "@smithy/types" "^4.6.0" - tslib "^2.6.2" - -"@aws-sdk/middleware-sdk-s3@3.908.0": - version "3.908.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.908.0.tgz#e65cf8668270162e469c74da726e023fbe5bd135" - integrity sha512-23MbAOHsGaD0kTVMVLumaIM1f9vtDImIn2lSvPullbjFHKS4XxfrKuPumtKDzl8gzcux+98XnmfDRKH0fzkOUA== - dependencies: - "@aws-sdk/core" "3.908.0" - "@aws-sdk/types" "3.901.0" - "@aws-sdk/util-arn-parser" "3.893.0" - "@smithy/core" "^3.15.0" - "@smithy/node-config-provider" "^4.3.0" - "@smithy/protocol-http" "^5.3.0" - "@smithy/signature-v4" "^5.3.0" - "@smithy/smithy-client" "^4.7.1" - "@smithy/types" "^4.6.0" - "@smithy/util-config-provider" "^4.2.0" - "@smithy/util-middleware" "^4.2.0" - "@smithy/util-stream" "^4.5.0" - "@smithy/util-utf8" "^4.2.0" - tslib "^2.6.2" - "@aws-sdk/middleware-user-agent@3.879.0": version "3.879.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.879.0.tgz#e207d6ae2a82059d843200d92a2f7ccbaa3cbc67" @@ -830,19 +715,6 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/middleware-user-agent@3.908.0": - version "3.908.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.908.0.tgz#aa4827cf1c5290b5d9d44ac39f467735fd199b04" - integrity sha512-R0ePEOku72EvyJWy/D0Z5f/Ifpfxa0U9gySO3stpNhOox87XhsILpcIsCHPy0OHz1a7cMoZsF6rMKSzDeCnogQ== - dependencies: - "@aws-sdk/core" "3.908.0" - "@aws-sdk/types" "3.901.0" - "@aws-sdk/util-endpoints" "3.901.0" - "@smithy/core" "^3.15.0" - "@smithy/protocol-http" "^5.3.0" - "@smithy/types" "^4.6.0" - tslib "^2.6.2" - "@aws-sdk/middleware-websocket@3.873.0": version "3.873.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-websocket/-/middleware-websocket-3.873.0.tgz#07c0b790552022841c22d992c7a20cbaaa1edd2e" @@ -959,30 +831,6 @@ "@smithy/util-middleware" "^4.0.5" tslib "^2.6.2" -"@aws-sdk/region-config-resolver@3.901.0": - version "3.901.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.901.0.tgz#6673eeda4ecc0747f93a084e876cab71431a97ca" - integrity sha512-7F0N888qVLHo4CSQOsnkZ4QAp8uHLKJ4v3u09Ly5k4AEStrSlFpckTPyUx6elwGL+fxGjNE2aakK8vEgzzCV0A== - dependencies: - "@aws-sdk/types" "3.901.0" - "@smithy/node-config-provider" "^4.3.0" - "@smithy/types" "^4.6.0" - "@smithy/util-config-provider" "^4.2.0" - "@smithy/util-middleware" "^4.2.0" - tslib "^2.6.2" - -"@aws-sdk/signature-v4-multi-region@3.908.0": - version "3.908.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.908.0.tgz#05690b946b11b9a2663012aac503ed3e6f3109bc" - integrity sha512-8OodflIzZM2GVuCGiGK6hqwsbfHRDl4kQcEYzHRg9p91H4h5Y876DPvLRkwM7pSC7LKUL0XkKWWVVjwJbp6/Ig== - dependencies: - "@aws-sdk/middleware-sdk-s3" "3.908.0" - "@aws-sdk/types" "3.901.0" - "@smithy/protocol-http" "^5.3.0" - "@smithy/signature-v4" "^5.3.0" - "@smithy/types" "^4.6.0" - tslib "^2.6.2" - "@aws-sdk/token-providers@3.879.0": version "3.879.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.879.0.tgz#7c2806f23dc740853da6fe6b7d8a76ef19d4b428" @@ -1017,7 +865,7 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/types@3.901.0", "@aws-sdk/types@^3.222.0": +"@aws-sdk/types@^3.222.0": version "3.901.0" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.901.0.tgz#b5a2e26c7b3fb3bbfe4c7fc24873646992a1c56c" integrity sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg== @@ -1025,13 +873,6 @@ "@smithy/types" "^4.6.0" tslib "^2.6.2" -"@aws-sdk/util-arn-parser@3.893.0": - version "3.893.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.893.0.tgz#fcc9b792744b9da597662891c2422dda83881d8d" - integrity sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA== - dependencies: - tslib "^2.6.2" - "@aws-sdk/util-endpoints@3.879.0": version "3.879.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.879.0.tgz#e30c15beede883d327dbd290c47512d6d700a2e9" @@ -1043,17 +884,6 @@ "@smithy/util-endpoints" "^3.0.7" tslib "^2.6.2" -"@aws-sdk/util-endpoints@3.901.0": - version "3.901.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.901.0.tgz#be6296739d0f446b89a3f497c3a85afeb6cddd92" - integrity sha512-5nZP3hGA8FHEtKvEQf4Aww5QZOkjLW1Z+NixSd+0XKfHvA39Ah5sZboScjLx0C9kti/K3OGW1RCx5K9Zc3bZqg== - dependencies: - "@aws-sdk/types" "3.901.0" - "@smithy/types" "^4.6.0" - "@smithy/url-parser" "^4.2.0" - "@smithy/util-endpoints" "^3.2.0" - tslib "^2.6.2" - "@aws-sdk/util-format-url@3.873.0": version "3.873.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-format-url/-/util-format-url-3.873.0.tgz#b376610ee5fb06386501bf360556d3690854c06f" @@ -1081,16 +911,6 @@ bowser "^2.11.0" tslib "^2.6.2" -"@aws-sdk/util-user-agent-browser@3.907.0": - version "3.907.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.907.0.tgz#96b621c66530c061fbc51f5bf4931e64429927d4" - integrity sha512-Hus/2YCQmtCEfr4Ls88d07Q99Ex59uvtktiPTV963Q7w7LHuIT/JBjrbwNxtSm2KlJR9PHNdqxwN+fSuNsMGMQ== - dependencies: - "@aws-sdk/types" "3.901.0" - "@smithy/types" "^4.6.0" - bowser "^2.11.0" - tslib "^2.6.2" - "@aws-sdk/util-user-agent-node@3.879.0": version "3.879.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.879.0.tgz#e14001b5fd08d14dab2dd12d08ecd1322ec99615" @@ -1113,17 +933,6 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/util-user-agent-node@3.908.0": - version "3.908.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.908.0.tgz#37d0397a3dac1705693b4cad5fe79da5bd04e46d" - integrity sha512-l6AEaKUAYarcEy8T8NZ+dNZ00VGLs3fW2Cqu1AuPENaSad0/ahEU+VU7MpXS8FhMRGPgplxKVgCTLyTY0Lbssw== - dependencies: - "@aws-sdk/middleware-user-agent" "3.908.0" - "@aws-sdk/types" "3.901.0" - "@smithy/node-config-provider" "^4.3.0" - "@smithy/types" "^4.6.0" - tslib "^2.6.2" - "@aws-sdk/xml-builder@3.873.0": version "3.873.0" resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.873.0.tgz#b5a3acfdeecfc1b7fee8a7773cb2a45590eb5701" @@ -1132,20 +941,6 @@ "@smithy/types" "^4.3.2" tslib "^2.6.2" -"@aws-sdk/xml-builder@3.901.0": - version "3.901.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.901.0.tgz#3cd2e3929cefafd771c8bd790ec6965faa1be49d" - integrity sha512-pxFCkuAP7Q94wMTNPAwi6hEtNrp/BdFf+HOrIEeFQsk4EoOmpKY3I6S+u6A9Wg295J80Kh74LqDWM22ux3z6Aw== - dependencies: - "@smithy/types" "^4.6.0" - fast-xml-parser "5.2.5" - tslib "^2.6.2" - -"@aws/lambda-invoke-store@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz#92d792a7dda250dfcb902e13228f37a81be57c8f" - integrity sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw== - "@axe-core/playwright@4.11.0": version "4.11.0" resolved "https://registry.yarnpkg.com/@axe-core/playwright/-/playwright-4.11.0.tgz#64beab80764c1f3f0ec4ac21f9b2c2d7df508958" @@ -2705,7 +2500,7 @@ resolved "https://registry.yarnpkg.com/@elastic/filesaver/-/filesaver-1.1.2.tgz#1998ffb3cd89c9da4ec12a7793bfcae10e30c77a" integrity sha512-YZbSufYFBhAj+S2cJgiKALoxIJevqXN2MSr6Yqr42rJdaPuM31cj6pUDwflkql1oDjupqD9la+MfxPFjXI1JFQ== -"@elastic/kibana-d3-color@npm:@elastic/kibana-d3-color@2.0.1", "d3-color@1 - 2", "d3-color@npm:@elastic/kibana-d3-color@2.0.1": +"@elastic/kibana-d3-color@npm:@elastic/kibana-d3-color@2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@elastic/kibana-d3-color/-/kibana-d3-color-2.0.1.tgz#f83b9c2fea09273a918659de04d5e8098c82f65c" integrity sha512-YZ8hV2bWNyYi833Yj3UWczmTxdHzmo/Xc2IVkNXr/ZqtkrTDlTLysCyJm7SfAt9iBy6EVRGWTn8cPz8QOY6Ixw== @@ -6357,6 +6152,10 @@ version "0.0.0" uid "" +"@kbn/cps-server-utils@link:src/platform/packages/shared/kbn-cps-server-utils": + version "0.0.0" + uid "" + "@kbn/cps-utils@link:src/platform/packages/shared/kbn-cps-utils": version "0.0.0" uid "" @@ -6409,10 +6208,6 @@ version "0.0.0" uid "" -"@kbn/dashboard-enhanced-plugin@link:x-pack/platform/plugins/shared/dashboard_enhanced": - version "0.0.0" - uid "" - "@kbn/dashboard-markdown@link:src/platform/plugins/shared/dashboard_markdown": version "0.0.0" uid "" @@ -6733,10 +6528,6 @@ version "0.0.0" uid "" -"@kbn/embeddable-enhanced-plugin@link:x-pack/platform/plugins/shared/embeddable_enhanced": - version "0.0.0" - uid "" - "@kbn/embeddable-examples-plugin@link:examples/embeddable_examples": version "0.0.0" uid "" @@ -6905,6 +6696,10 @@ version "0.0.0" uid "" +"@kbn/eval-kql@link:src/platform/packages/shared/kbn-eval-kql": + version "0.0.0" + uid "" + "@kbn/evals-suite-agent-builder@link:x-pack/platform/packages/shared/agent-builder/kbn-evals-suite-agent-builder": version "0.0.0" uid "" @@ -7121,10 +6916,6 @@ version "0.0.0" uid "" -"@kbn/flyout-ui@link:src/platform/packages/shared/kbn-flyout-ui": - version "0.0.0" - uid "" - "@kbn/foo-plugin@link:x-pack/platform/test/ui_capabilities/common/plugins/foo_plugin": version "0.0.0" uid "" @@ -8389,6 +8180,10 @@ version "0.0.0" uid "" +"@kbn/response-ops-scheduling-types@link:x-pack/platform/packages/shared/response-ops/scheduling-types": + version "0.0.0" + uid "" + "@kbn/response-stream-plugin@link:examples/response_stream": version "0.0.0" uid "" @@ -8709,10 +8504,6 @@ version "0.0.0" uid "" -"@kbn/security-solution-common@link:src/platform/packages/shared/kbn-security-solution-common": - version "0.0.0" - uid "" - "@kbn/security-solution-connectors@link:x-pack/solutions/security/packages/connectors": version "0.0.0" uid "" @@ -8733,10 +8524,6 @@ version "0.0.0" uid "" -"@kbn/security-solution-flyout@link:src/platform/packages/shared/kbn-security-solution-flyout": - version "0.0.0" - uid "" - "@kbn/security-solution-navigation@link:x-pack/solutions/security/packages/navigation": version "0.0.0" uid "" @@ -8941,6 +8728,10 @@ version "0.0.0" uid "" +"@kbn/shared-ux-ai-components@link:src/platform/packages/shared/shared-ux/ai-components": + version "0.0.0" + uid "" + "@kbn/shared-ux-avatar-solution@link:src/platform/packages/shared/shared-ux/avatar/solution": version "0.0.0" uid "" @@ -9289,6 +9080,10 @@ version "0.0.0" uid "" +"@kbn/synthetics-forge@link:x-pack/solutions/observability/packages/kbn-synthetics-forge": + version "0.0.0" + uid "" + "@kbn/synthetics-plugin@link:x-pack/solutions/observability/plugins/synthetics": version "0.0.0" uid "" @@ -12516,7 +12311,7 @@ "@smithy/util-base64" "^4.3.0" tslib "^2.6.2" -"@smithy/hash-node@^4.0.5", "@smithy/hash-node@^4.2.0": +"@smithy/hash-node@^4.0.5": version "4.2.0" resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.0.tgz#d2de380cb88a3665d5e3f5bbe901cfb46867c74f" integrity sha512-ugv93gOhZGysTctZh9qdgng8B+xO0cj+zN0qAZ+Sgh7qTQGPOJbMdIuyP89KNfUyfAqFSNh5tMvC+h2uCpmTtA== @@ -12526,7 +12321,7 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" -"@smithy/invalid-dependency@^4.0.5", "@smithy/invalid-dependency@^4.2.0": +"@smithy/invalid-dependency@^4.0.5": version "4.2.0" resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.0.tgz#749c741c1b01bcdb12c0ec24701db655102f6ea7" integrity sha512-ZmK5X5fUPAbtvRcUPtk28aqIClVhbfcmfoS4M7UQBTnDdrNxhsrxYVv0ZEl5NaPSyExsPWqL4GsPlRvtlwg+2A== @@ -12548,7 +12343,7 @@ dependencies: tslib "^2.6.2" -"@smithy/middleware-content-length@^4.0.5", "@smithy/middleware-content-length@^4.2.0": +"@smithy/middleware-content-length@^4.0.5": version "4.2.0" resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.0.tgz#bf1bea6e7c0e35e8c6d4825880e4cfa903cbd501" integrity sha512-6ZAnwrXFecrA4kIDOcz6aLBhU5ih2is2NdcZtobBDSdSHtE9a+MThB5uqyK4XXesdOCvOcbCm2IGB95birTSOQ== @@ -12571,7 +12366,7 @@ "@smithy/util-middleware" "^4.2.0" tslib "^2.6.2" -"@smithy/middleware-retry@^4.1.20", "@smithy/middleware-retry@^4.1.22", "@smithy/middleware-retry@^4.4.1": +"@smithy/middleware-retry@^4.1.20", "@smithy/middleware-retry@^4.1.22": version "4.4.1" resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.4.1.tgz#6986ee827053986848f7ece835887c7a28c3d49a" integrity sha512-wXxS4ex8cJJteL0PPQmWYkNi9QKDWZIpsndr0wZI2EL+pSSvA/qqxXU60gBOJoIc2YgtZSWY/PE86qhKCCKP1w== @@ -12672,7 +12467,7 @@ "@smithy/types" "^4.12.0" tslib "^2.6.2" -"@smithy/signature-v4@^5.1.3", "@smithy/signature-v4@^5.3.0": +"@smithy/signature-v4@^5.1.3": version "5.3.0" resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.0.tgz#05d459cc4ec8f9d7300bb6b488cccedf2b73b7fb" integrity sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g== @@ -12731,7 +12526,7 @@ dependencies: tslib "^2.6.2" -"@smithy/util-body-length-node@^4.0.0", "@smithy/util-body-length-node@^4.2.1": +"@smithy/util-body-length-node@^4.0.0": version "4.2.1" resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz#79c8a5d18e010cce6c42d5cbaf6c1958523e6fec" integrity sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA== @@ -12761,7 +12556,7 @@ dependencies: tslib "^2.6.2" -"@smithy/util-defaults-mode-browser@^4.0.27", "@smithy/util-defaults-mode-browser@^4.0.29", "@smithy/util-defaults-mode-browser@^4.3.0": +"@smithy/util-defaults-mode-browser@^4.0.27", "@smithy/util-defaults-mode-browser@^4.0.29": version "4.3.0" resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.0.tgz#ea03c444da5b4080d2280b754c5f93d5ce884fc1" integrity sha512-H4MAj8j8Yp19Mr7vVtGgi7noJjvjJbsKQJkvNnLlrIFduRFT5jq5Eri1k838YW7rN2g5FTnXpz5ktKVr1KVgPQ== @@ -12771,7 +12566,7 @@ "@smithy/types" "^4.6.0" tslib "^2.6.2" -"@smithy/util-defaults-mode-node@^4.0.27", "@smithy/util-defaults-mode-node@^4.0.29", "@smithy/util-defaults-mode-node@^4.2.1": +"@smithy/util-defaults-mode-node@^4.0.27", "@smithy/util-defaults-mode-node@^4.0.29": version "4.2.1" resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.1.tgz#e605d031d0de42db19d9e0458a6acd1eb58120ae" integrity sha512-PuDcgx7/qKEMzV1QFHJ7E4/MMeEjaA7+zS5UNcHCLPvvn59AeZQ0DSDGMpqC2xecfa/1cNGm4l8Ec/VxCuY7Ug== @@ -12784,7 +12579,7 @@ "@smithy/types" "^4.6.0" tslib "^2.6.2" -"@smithy/util-endpoints@^3.0.7", "@smithy/util-endpoints@^3.2.0", "@smithy/util-endpoints@^3.2.8": +"@smithy/util-endpoints@^3.0.7", "@smithy/util-endpoints@^3.2.8": version "3.2.8" resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz#5650bda2adac989ff2e562606088c5de3dcb1b36" integrity sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw== @@ -14592,12 +14387,11 @@ dependencies: undici-types "~6.21.0" -"@types/nodemailer@7.0.4": - version "7.0.4" - resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-7.0.4.tgz#1a38d8bce85f7da066cc7fd6f57090bc741860f0" - integrity sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow== +"@types/nodemailer@7.0.6": + version "7.0.6" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-7.0.6.tgz#cbeb4bdf0c8457f43eeb0caee5000248b650c42c" + integrity sha512-+aRevAyUOCUjvCpq+KSJOjxRNxNRqw7jp8AvT2H4As1BfQA6hBTJ2x65DYu7Cre3h5PQZhMky3bc9zsBCXdvGA== dependencies: - "@aws-sdk/client-sesv2" "^3.839.0" "@types/node" "*" "@types/normalize-package-data@^2.4.0": @@ -16955,9 +16749,9 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== bn.js@^5.0.0, bn.js@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" - integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + version "5.2.3" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.3.tgz#16a9e409616b23fef3ccbedb8d42f13bff80295e" + integrity sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w== body-parser@1.20.3: version "1.20.3" @@ -19053,6 +18847,11 @@ d3-collection@^1.0.7: resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== +"d3-color@1 - 2", "d3-color@npm:@elastic/kibana-d3-color@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@elastic/kibana-d3-color/-/kibana-d3-color-2.0.1.tgz#f83b9c2fea09273a918659de04d5e8098c82f65c" + integrity sha512-YZ8hV2bWNyYi833Yj3UWczmTxdHzmo/Xc2IVkNXr/ZqtkrTDlTLysCyJm7SfAt9iBy6EVRGWTn8cPz8QOY6Ixw== + "d3-color@1 - 3", d3-color@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" @@ -20111,10 +19910,10 @@ dotenv@16.4.7: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26" integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== -dotenv@17.2.3, dotenv@^17.2.3: - version "17.2.3" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2" - integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w== +dotenv@17.2.4, dotenv@^17.2.3: + version "17.2.4" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.2.4.tgz#b5a0e2f015e0be287a5330a90bf0e804606504c2" + integrity sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw== dotenv@^16.0.2, dotenv@^16.5.0: version "16.5.0" @@ -21504,12 +21303,12 @@ fast-uri@^3.0.1: resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.3.tgz#892a1c91802d5d7860de728f18608a0573142241" integrity sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw== -fast-xml-parser@5.2.5, fast-xml-parser@5.3.4, fast-xml-parser@^4.5.0: - version "5.3.4" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz#06f39aafffdbc97bef0321e626c7ddd06a043ecf" - integrity sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA== +fast-xml-parser@5.2.5, fast-xml-parser@5.3.6, fast-xml-parser@^4.5.0: + version "5.3.6" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz#85a69117ca156b1b3c52e426495b6de266cb6a4b" + integrity sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA== dependencies: - strnum "^2.1.0" + strnum "^2.1.2" fastest-levenshtein@1.0.16, fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16: version "1.0.16" @@ -32379,7 +32178,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -32397,6 +32196,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -32489,7 +32297,14 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -32544,10 +32359,10 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strnum@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.1.tgz#cf2a6e0cf903728b8b2c4b971b7e36b4e82d46ab" - integrity sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw== +strnum@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a" + integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ== stubborn-fs@^1.2.5: version "1.2.5" @@ -35301,7 +35116,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -35327,6 +35142,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"