diff --git a/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml b/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml new file mode 100644 index 00000000000000..f6cb21abdb6821 --- /dev/null +++ b/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml @@ -0,0 +1,58 @@ +# https://buildkite.com/elastic/kibana-elasticsearch-serverless-verify-and-promote/ +agents: + queue: kibana-default + +steps: + - label: "Annotate runtime parameters" + command: | + buildkite-agent annotate --context es-serverless-image --style info "ES Serverless image: $ES_SERVERLESS_IMAGE" + buildkite-agent annotate --context kibana-commit --style info "Kibana build hash: $BUILDKITE_BRANCH / $BUILDKITE_COMMIT" + + - group: "(:kibana: x :elastic:) Trigger Kibana Serverless suite" + if: "build.env('SKIP_VERIFICATION') != '1' && build.env('SKIP_VERIFICATION') != 'true'" + steps: + - label: "Pre-Build" + command: .buildkite/scripts/lifecycle/pre_build.sh + key: pre-build + timeout_in_minutes: 10 + agents: + queue: kibana-default + + - label: "Build Kibana Distribution and Plugins" + command: .buildkite/scripts/steps/build_kibana.sh + agents: + queue: n2-16-spot + key: build + depends_on: pre-build + if: "build.env('KIBANA_BUILD_ID') == null || build.env('KIBANA_BUILD_ID') == ''" + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - label: "Pick Test Group Run Order" + command: .buildkite/scripts/steps/test/pick_test_group_run_order.sh + agents: + queue: kibana-default + env: + FTR_CONFIGS_SCRIPT: 'TEST_ES_SERVERLESS_IMAGE=$ES_SERVERLESS_IMAGE .buildkite/scripts/steps/test/ftr_configs.sh' + FTR_CONFIG_PATTERNS: '**/test_serverless/**' + LIMIT_CONFIG_TYPE: 'functional' + retry: + automatic: + - exit_status: '*' + limit: 1 + + - wait: ~ + + - label: ":arrow_up::elastic::arrow_up: Promote docker image" + command: .buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh $ES_SERVERLESS_IMAGE + + - wait: ~ + + - label: 'Post-Build' + command: .buildkite/scripts/lifecycle/post_build.sh + timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh b/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh new file mode 100755 index 00000000000000..c6bf1738fe1447 --- /dev/null +++ b/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +BASE_ES_SERVERLESS_REPO=docker.elastic.co/elasticsearch-ci/elasticsearch-serverless +TARGET_IMAGE=docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified + +ES_SERVERLESS_BUCKET=kibana-ci-es-serverless-images +MANIFEST_FILE_NAME=latest-verified.json + +SOURCE_IMAGE_OR_TAG=$1 +if [[ $SOURCE_IMAGE_OR_TAG =~ :[a-zA-Z_-]+$ ]]; 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_ES_SERVERLESS_REPO:$SOURCE_IMAGE_OR_TAG" +fi + +echo "--- Promoting ${SOURCE_IMAGE_OR_TAG} to ':latest-verified'" + +echo "Re-tagging $SOURCE_IMAGE -> $TARGET_IMAGE" + +echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co +docker pull "$SOURCE_IMAGE" +docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" +docker push "$TARGET_IMAGE" + +ORIG_IMG_DATA=$(docker inspect "$SOURCE_IMAGE") +ELASTIC_COMMIT_HASH=$(echo $ORIG_IMG_DATA | jq -r '.[].Config.Labels["org.opencontainers.image.revision"]') + +docker logout docker.elastic.co + +echo "Image push to $TARGET_IMAGE successful." +echo "Promotion successful! Henceforth, thou shall be named Sir $TARGET_IMAGE" + +MANIFEST_UPLOAD_PATH="Skipped" +if [[ "$UPLOAD_MANIFEST" =~ ^(1|true)$ && "$SOURCE_IMAGE_OR_TAG" =~ ^git-[0-9a-fA-F]{12}$ ]]; then + echo "--- Uploading latest-verified manifest to GCS" + cat << EOT >> $MANIFEST_FILE_NAME +{ + "build_url": "$BUILDKITE_BUILD_URL", + "kibana_commit": "$BUILDKITE_COMMIT", + "kibana_branch": "$BUILDKITE_BRANCH", + "elasticsearch_serverless_tag": "$SOURCE_IMAGE_OR_TAG", + "elasticsearch_serverless_image_url: "$SOURCE_IMAGE", + "elasticsearch_serverless_commit": "TODO: this currently can't be decided", + "elasticsearch_commit": "$ELASTIC_COMMIT_HASH", + "created_at": "`date`", + "timestamp": "`FORCE_COLOR=0 node -p 'Date.now()'`" +} +EOT + + gsutil -h "Cache-Control:no-cache, max-age=0, no-transform" \ + cp $MANIFEST_FILE_NAME "gs://$ES_SERVERLESS_BUCKET/$MANIFEST_FILE_NAME" + gsutil acl ch -u AllUsers:R "gs://$ES_SERVERLESS_BUCKET/$MANIFEST_FILE_NAME" + MANIFEST_UPLOAD_PATH="$MANIFEST_FILE_NAME" + +elif [[ "$UPLOAD_MANIFEST" =~ ^(1|true)$ ]]; then + echo "--- Skipping upload of latest-verified manifest to GCS, ES Serverless build tag is not pointing to a hash" +elif [[ "$SOURCE_IMAGE_OR_TAG" =~ ^git-[0-9a-fA-F]{12}$ ]]; then + echo "--- Skipping upload of latest-verified manifest to GCS, flag was not provided" +fi + +echo "--- Annotating build with info" +cat << EOT | buildkite-agent annotate --style "success" +

Promotion successful!

+
New image: $TARGET_IMAGE +
Source image: $SOURCE_IMAGE +
Kibana commit: $BUILDKITE_COMMIT +
Elasticsearch commit: $ELASTIC_COMMIT_HASH +
Manifest file: $MANIFEST_UPLOAD_PATH +EOT diff --git a/docs/api-generated/connectors/connector-apis-passthru.asciidoc b/docs/api-generated/connectors/connector-apis-passthru.asciidoc index a8b429e0b5ae47..b68b4e4ca648fc 100644 --- a/docs/api-generated/connectors/connector-apis-passthru.asciidoc +++ b/docs/api-generated/connectors/connector-apis-passthru.asciidoc @@ -1015,6 +1015,7 @@ Any modifications made to this file will be overwritten.
  • config_properties_servicenow - Connector request properties for a ServiceNow ITSM connector
  • config_properties_servicenow_itom - Connector request properties for a ServiceNow ITSM connector
  • config_properties_swimlane - Connector request properties for a Swimlane connector
  • +
  • config_properties_torq - Connector request properties for a Torq connector
  • config_properties_webhook - Connector request properties for a Webhook connector
  • config_properties_xmatters - Connector request properties for an xMatters connector
  • connector_response_properties - Connector response properties
  • @@ -1035,6 +1036,7 @@ Any modifications made to this file will be overwritten.
  • connector_response_properties_swimlane - Connector response properties for a Swimlane connector
  • connector_response_properties_teams - Connector response properties for a Microsoft Teams connector
  • connector_response_properties_tines - Connector response properties for a Tines connector
  • +
  • connector_response_properties_torq - Connector response properties for a Torq connector
  • connector_response_properties_webhook - Connector response properties for a Webhook connector
  • connector_response_properties_xmatters - Connector response properties for an xMatters connector
  • connector_types - Connector types
  • @@ -1056,6 +1058,7 @@ Any modifications made to this file will be overwritten.
  • create_connector_request_swimlane - Create Swimlane connector request
  • create_connector_request_teams - Create Microsoft Teams connector request
  • create_connector_request_tines - Create Tines connector request
  • +
  • create_connector_request_torq - Create Torq connector request
  • create_connector_request_webhook - Create Webhook connector request
  • create_connector_request_xmatters - Create xMatters connector request
  • features -
  • @@ -1106,6 +1109,7 @@ Any modifications made to this file will be overwritten.
  • secrets_properties_slack_webhook - Connector secrets properties for a Webhook Slack connector
  • secrets_properties_swimlane - Connector secrets properties for a Swimlane connector
  • secrets_properties_teams - Connector secrets properties for a Microsoft Teams connector
  • +
  • secrets_properties_torq - Connector secrets properties for a Torq connector
  • secrets_properties_webhook - Connector secrets properties for a Webhook connector
  • secrets_properties_xmatters - Connector secrets properties for an xMatters connector
  • updateConnector_400_response -
  • @@ -1124,6 +1128,7 @@ Any modifications made to this file will be overwritten.
  • update_connector_request_slack_webhook - Update Slack connector request
  • update_connector_request_swimlane - Update Swimlane connector request
  • update_connector_request_teams - Update Microsoft Teams connector request
  • +
  • update_connector_request_torq - Update Torq connector request
  • update_connector_request_webhook - Update Webhook connector request
  • update_connector_request_xmatters - Update xMatters connector request
  • @@ -1529,6 +1534,13 @@ Any modifications made to this file will be overwritten.
    mappings (optional)
    Connector_mappings_properties_for_a_Swimlane_connector
    +
    +

    config_properties_torq - Connector request properties for a Torq connector Up

    +
    Defines properties for connectors when type is .torq.
    +
    +
    webhookIntegrationUrl
    String The endpoint URL of the Elastic Security integration in Torq.
    +
    +

    config_properties_webhook - Connector request properties for a Webhook connector Up

    Defines properties for connectors when type is .webhook.
    @@ -1842,6 +1854,22 @@ Any modifications made to this file will be overwritten.
    is_missing_secrets (optional)
    Boolean Indicates whether secrets are missing for the connector. Secrets configuration properties vary depending on the connector type.
    is_preconfigured
    Boolean Indicates whether it is a preconfigured connector. If true, the config and is_missing_secrets properties are omitted from the response.
    is_system_action (optional)
    Boolean Indicates whether the connector is used for system actions.
    +
    name
    String The display name for the connector.
    +
    + +
    +

    connector_response_properties_torq - Connector response properties for a Torq connector Up

    +
    +
    +
    config
    +
    connector_type_id
    String The type of connector.
    +
    Enum:
    +
    .torq
    +
    id
    String The identifier for the connector.
    +
    is_deprecated
    Boolean Indicates whether the connector type is deprecated.
    +
    is_missing_secrets (optional)
    Boolean Indicates whether secrets are missing for the connector. Secrets configuration properties vary depending on the connector type.
    +
    is_preconfigured
    Boolean Indicates whether it is a preconfigured connector. If true, the config and is_missing_secrets properties are omitted from the response.
    +
    is_system_action (optional)
    Boolean Indicates whether the connector is used for system actions.
    name
    String The display name for the connector.
    @@ -2093,6 +2121,18 @@ Any modifications made to this file will be overwritten.
    secrets
    map[String, oas_any_type_not_mapped] Defines secrets for connectors when type is .tines.
    +
    +

    create_connector_request_torq - Create Torq connector request Up

    +
    The Torq connector uses a Torq webhook to trigger workflows with Kibana actions.
    +
    +
    config
    +
    connector_type_id
    String The type of connector.
    +
    Enum:
    +
    .torq
    +
    name
    String The display name for the connector.
    +
    secrets
    +
    +

    create_connector_request_webhook - Create Webhook connector request Up

    The Webhook connector uses axios to send a POST or PUT request to a web service.
    @@ -2560,6 +2600,13 @@ Any modifications made to this file will be overwritten.
    webhookUrl
    String The URL of the incoming webhook. If you are using the xpack.actions.allowedHosts setting, add the hostname to the allowed hosts.
    +
    +

    secrets_properties_torq - Connector secrets properties for a Torq connector Up

    +
    Defines secrets for connectors when type is .torq.
    +
    +
    token
    String The secret of the webhook authentication header.
    +
    +

    secrets_properties_webhook - Connector secrets properties for a Webhook connector Up

    Defines secrets for connectors when type is .webhook.
    @@ -2718,6 +2765,15 @@ Any modifications made to this file will be overwritten.
    secrets
    secrets_properties_teams
    +
    +

    update_connector_request_torq - Update Torq connector request Up

    +
    +
    +
    config
    +
    name
    String The display name for the connector.
    +
    secrets
    +
    +

    update_connector_request_webhook - Update Webhook connector request Up

    diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index bbaba806386e27..28aa218f3b6c8e 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -7,10 +7,18 @@ Connectors provide a central place to store connection information for services [cols="2"] |=== +a| <> + +| Send a request to D3 Security. + a| <> | Send email from your server. +a| <> + +| Send a request to OpenAI. + a| <> | Create an incident in {ibm-r}. @@ -63,6 +71,10 @@ a| <> | Send events to a Tines Story. +a| <> + +| Trigger a Torq workflow. + a| <> | Send a request to a web service. @@ -75,18 +87,6 @@ a| <> | Send actionable alerts to on-call xMatters resources. -a| <> - -| Trigger a Torq workflow. - -a| <> - -| Send a request to OpenAI. - -a| <> - -| Send a request to D3 Security. - |=== [NOTE] diff --git a/docs/management/connectors/action-types/torq.asciidoc b/docs/management/connectors/action-types/torq.asciidoc index 39ef2585e62b9c..7b4b9712adcb98 100644 --- a/docs/management/connectors/action-types/torq.asciidoc +++ b/docs/management/connectors/action-types/torq.asciidoc @@ -3,6 +3,10 @@ ++++ Torq ++++ +:frontmatter-description: Add a connector that can use Torq to trigger workflows. +:frontmatter-tags-products: [kibana] +:frontmatter-tags-content-type: [how-to] +:frontmatter-tags-user-goals: [configure] The Torq connector uses a Torq webhook to trigger workflows with Kibana actions. @@ -27,34 +31,6 @@ Torq endpoint URL:: Endpoint URL (webhook) of the Elastic Security integration y Torq authentication header secret:: Secret of the webhook authentication header. -[float] -[[preconfigured-torq-configuration]] -=== Create preconfigured connectors - -If you are running {kib} on-prem, you can define connectors by -adding `xpack.actions.preconfigured` settings to your `kibana.yml` file. -For example: - -[source,yaml] --- -xpack.actions.preconfigured: - my-torq: - name: preconfigured-torq-connector-type - actionTypeId: .torq - config: - webhookIntegrationUrl: https://hooks.torq.io/v1/somehook - secrets: - token: mytorqtoken --- - -Config defines information for the connector type. - -`webhookIntegrationUrl`:: An address that corresponds to **Torq endpoint URL**. - -Secrets defines sensitive information for the connector type. - -`token`:: A string that corresponds to **Torq authentication header secret**. - [float] [[torq-action-configuration]] === Test connectors diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 63de5aca67dca3..a30bc26ca2511c 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -1,4 +1,6 @@ +include::action-types/d3security.asciidoc[leveloffset=+1] include::action-types/email.asciidoc[leveloffset=+1] +include::action-types/gen-ai.asciidoc[leveloffset=+1] include::action-types/resilient.asciidoc[leveloffset=+1] include::action-types/index.asciidoc[leveloffset=+1] include::action-types/jira.asciidoc[leveloffset=+1] @@ -16,6 +18,4 @@ include::action-types/torq.asciidoc[leveloffset=+1] include::action-types/webhook.asciidoc[leveloffset=+1] include::action-types/cases-webhook.asciidoc[leveloffset=+1] include::action-types/xmatters.asciidoc[leveloffset=+1] -include::action-types/gen-ai.asciidoc[leveloffset=+1] -include::action-types/d3security.asciidoc[leveloffset=+1] include::pre-configured-connectors.asciidoc[leveloffset=+1] diff --git a/docs/management/connectors/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc index 72f5f78f6e728c..1fc679facf4237 100644 --- a/docs/management/connectors/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -113,11 +113,13 @@ Index names must start with `kibana-alert-history-` to take advantage of the pre * <> * <> * <> +* <> * <> * <> * <> * <> * <> +* <> * <> * <> * <> @@ -528,6 +530,26 @@ xpack.actions.preconfigured: <3> Field mappings for properties such as the alert identifer, severity, and rule name. <4> The API authentication token for HTTP basic authentication. NOTE: This value should be stored in the <>. +[float] +[[preconfigured-torq-configuration]] +==== Torq connectors + +The following example creates a <>: + +[source,yaml] +-- +xpack.actions.preconfigured: + my-torq: + name: preconfigured-torq-connector-type + actionTypeId: .torq + config: + webhookIntegrationUrl: https://hooks.torq.io/v1/somehook <1> + secrets: + token: mytorqtoken <2> +-- +<1> The endpoint URL of the Elastic Security integration in Torq. +<2> The secret of the webhook authentication header. + [float] [[preconfigured-webhook-configuration]] ==== Webhook connectors diff --git a/docs/management/upgrade-assistant.asciidoc b/docs/management/upgrade-assistant.asciidoc index a44afa4474a7b6..ed9093ded2846d 100644 --- a/docs/management/upgrade-assistant.asciidoc +++ b/docs/management/upgrade-assistant.asciidoc @@ -26,4 +26,4 @@ The Upgrade assistant pulls information about deprecations from the following so * Elasticsearch deprecation logs * Kibana deprecations API -For more information about the API's the Upgraed assistant provides, refer to <>. +For more information about Upgrade Assistant APIs, refer to <>. diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c9880bdade4dc5..b3cd5777edd69c 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -432,6 +432,9 @@ For an <>, specifies whether it uses HT `xpack.actions.preconfigured..config.viewIncidentUrl`:: For a <>, specifies a URL string with either the external service ID or external service title Mustache variable to view a case in the external system. +`xpack.actions.preconfigured..config.webhookIntegrationUrl`:: +For a <>, specifies the endpoint URL of the Elastic Security integration in Torq. + `xpack.actions.preconfigured..name`:: The name of the preconfigured connector. @@ -492,6 +495,7 @@ A token secret that varies by connector: -- * For a <>, specifies the D3 Security token. * For a <>, specifies the Slack bot user OAuth token. +* For a <>, specifies the secret of the webhook authentication header. -- `xpack.actions.preconfigured..secrets.user`:: diff --git a/packages/kbn-dev-cli-runner/src/flags.ts b/packages/kbn-dev-cli-runner/src/flags.ts index 595205c3e03335..d7b352333ae1b7 100644 --- a/packages/kbn-dev-cli-runner/src/flags.ts +++ b/packages/kbn-dev-cli-runner/src/flags.ts @@ -27,6 +27,7 @@ export interface FlagOptions { allowUnexpected?: boolean; guessTypesForUnexpectedFlags?: boolean; help?: string; + examples?: string; alias?: { [key: string]: string | string[] }; boolean?: string[]; string?: string[]; @@ -47,6 +48,7 @@ export function mergeFlagOptions(global: FlagOptions = {}, local: FlagOptions = }, help: local.help, + examples: local.examples, allowUnexpected: !!(global.allowUnexpected || local.allowUnexpected), guessTypesForUnexpectedFlags: !!(global.allowUnexpected || local.allowUnexpected), diff --git a/packages/kbn-dev-cli-runner/src/help.ts b/packages/kbn-dev-cli-runner/src/help.ts index a7dc17aa43f17f..f3e0e2c78e97f2 100644 --- a/packages/kbn-dev-cli-runner/src/help.ts +++ b/packages/kbn-dev-cli-runner/src/help.ts @@ -36,11 +36,13 @@ export function getHelp({ usage, flagHelp, defaultLogLevel, + examples, }: { description?: string; usage?: string; flagHelp?: string; defaultLogLevel?: string; + examples?: string; }) { const optionHelp = joinAndTrimLines( dedent(flagHelp || ''), @@ -48,13 +50,17 @@ export function getHelp({ GLOBAL_FLAGS ); + const examplesHelp = examples ? joinAndTrimLines('Examples:', examples) : ''; + return ` ${dedent(usage || '') || DEFAULT_GLOBAL_USAGE} ${indent(dedent(description || 'Runs a dev task'), 2)} Options: - ${indent(optionHelp, 4)}\n\n`; + ${indent(optionHelp, 4)} +${examplesHelp ? `\n ${indent(examplesHelp, 4)}` : ''} +`; } export function getCommandLevelHelp({ diff --git a/packages/kbn-dev-cli-runner/src/run.ts b/packages/kbn-dev-cli-runner/src/run.ts index 08457caaebfd41..2ef90c2e2c27a8 100644 --- a/packages/kbn-dev-cli-runner/src/run.ts +++ b/packages/kbn-dev-cli-runner/src/run.ts @@ -50,6 +50,7 @@ export async function run(fn: RunFn, options: RunOptions = {}) { usage: options.usage, flagHelp: options.flags?.help, defaultLogLevel: options.log?.defaultLevel, + examples: options.flags?.examples, }); if (flags.help) { diff --git a/packages/kbn-es/src/cli_commands/serverless.ts b/packages/kbn-es/src/cli_commands/serverless.ts index 7ee4f08fb94fee..c8b3018e6f6696 100644 --- a/packages/kbn-es/src/cli_commands/serverless.ts +++ b/packages/kbn-es/src/cli_commands/serverless.ts @@ -13,9 +13,8 @@ import { getTimeReporter } from '@kbn/ci-stats-reporter'; import { Cluster } from '../cluster'; import { - SERVERLESS_REPO, - SERVERLESS_TAG, - SERVERLESS_IMG, + ES_SERVERLESS_REPO_ELASTICSEARCH, + ES_SERVERLESS_DEFAULT_IMAGE, DEFAULT_PORT, ServerlessOptions, } from '../utils'; @@ -28,9 +27,8 @@ export const serverless: Command = { return dedent` Options: - --tag Image tag of ES serverless to run from ${SERVERLESS_REPO} [default: ${SERVERLESS_TAG}] - --image Full path of ES serverless image to run, has precedence over tag. [default: ${SERVERLESS_IMG}] - + --tag Image tag of ES serverless to run from ${ES_SERVERLESS_REPO_ELASTICSEARCH} + --image Full path of ES serverless image to run, has precedence over tag. [default: ${ES_SERVERLESS_DEFAULT_IMAGE}] --background Start ES serverless without attaching to the first node's logs --basePath Path to the directory where the ES cluster will store data --clean Remove existing file system object store before running @@ -39,14 +37,14 @@ export const serverless: Command = { --ssl Enable HTTP SSL on the ES cluster --skipTeardown If this process exits, leave the ES cluster running in the background --waitForReady Wait for the ES cluster to be ready to serve requests - + -E Additional key=value settings to pass to ES -F Absolute paths for files to mount into containers Examples: - es serverless --tag git-fec36430fba2-x86_64 - es serverless --image docker.elastic.co/repo:tag + es serverless --tag git-fec36430fba2-x86_64 # loads ${ES_SERVERLESS_REPO_ELASTICSEARCH}:git-fec36430fba2-x86_64 + es serverless --image docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified `; }, run: async (defaults = {}) => { diff --git a/packages/kbn-es/src/utils/docker.test.ts b/packages/kbn-es/src/utils/docker.test.ts index d48cddd6fdb6d1..08edc2a17521df 100644 --- a/packages/kbn-es/src/utils/docker.test.ts +++ b/packages/kbn-es/src/utils/docker.test.ts @@ -23,7 +23,7 @@ import { runDockerContainer, runServerlessCluster, runServerlessEsNode, - SERVERLESS_IMG, + ES_SERVERLESS_DEFAULT_IMAGE, setupServerlessVolumes, stopServerlessCluster, teardownServerlessClusterSync, @@ -451,7 +451,7 @@ describe('runServerlessEsNode()', () => { const node = { params: ['--env', 'foo=bar', '--volume', 'foo/bar'], name: 'es01', - image: SERVERLESS_IMG, + image: ES_SERVERLESS_DEFAULT_IMAGE, }; test('should call the correct Docker command', async () => { @@ -462,7 +462,7 @@ describe('runServerlessEsNode()', () => { expect(execa.mock.calls[0][0]).toEqual('docker'); expect(execa.mock.calls[0][1]).toEqual( expect.arrayContaining([ - SERVERLESS_IMG, + ES_SERVERLESS_DEFAULT_IMAGE, ...node.params, '--name', node.name, @@ -530,7 +530,9 @@ describe('teardownServerlessClusterSync()', () => { teardownServerlessClusterSync(log, defaultOptions); expect(execa.commandSync.mock.calls).toHaveLength(2); - expect(execa.commandSync.mock.calls[0][0]).toEqual(expect.stringContaining(SERVERLESS_IMG)); + expect(execa.commandSync.mock.calls[0][0]).toEqual( + expect.stringContaining(ES_SERVERLESS_DEFAULT_IMAGE) + ); expect(execa.commandSync.mock.calls[1][0]).toEqual(`docker kill ${nodes.join(' ')}`); }); diff --git a/packages/kbn-es/src/utils/docker.ts b/packages/kbn-es/src/utils/docker.ts index 00a1d7ce9dc545..5ed22e094e6f8c 100644 --- a/packages/kbn-es/src/utils/docker.ts +++ b/packages/kbn-es/src/utils/docker.ts @@ -38,9 +38,12 @@ import { import { SYSTEM_INDICES_SUPERUSER } from './native_realm'; import { waitUntilClusterReady } from './wait_until_cluster_ready'; -interface BaseOptions { - tag?: string; +interface ImageOptions { image?: string; + tag?: string; +} + +interface BaseOptions extends ImageOptions { port?: number; ssl?: boolean; /** Kill running cluster before starting a new cluster */ @@ -106,9 +109,10 @@ export const DOCKER_REPO = `${DOCKER_REGISTRY}/elasticsearch/elasticsearch`; export const DOCKER_TAG = `${pkg.version}-SNAPSHOT`; export const DOCKER_IMG = `${DOCKER_REPO}:${DOCKER_TAG}`; -export const SERVERLESS_REPO = `${DOCKER_REGISTRY}/elasticsearch-ci/elasticsearch-serverless`; -export const SERVERLESS_TAG = 'latest'; -export const SERVERLESS_IMG = `${SERVERLESS_REPO}:${SERVERLESS_TAG}`; +export const ES_SERVERLESS_REPO_KIBANA = `${DOCKER_REGISTRY}/kibana-ci/elasticsearch-serverless`; +export const ES_SERVERLESS_REPO_ELASTICSEARCH = `${DOCKER_REGISTRY}/elasticsearch-ci/elasticsearch-serverless`; +export const ES_SERVERLESS_LATEST_VERIFIED_TAG = 'latest-verified'; +export const ES_SERVERLESS_DEFAULT_IMAGE = `${ES_SERVERLESS_REPO_KIBANA}:${ES_SERVERLESS_LATEST_VERIFIED_TAG}`; // See for default cluster settings // https://github.com/elastic/elasticsearch-serverless/blob/main/serverless-build-tools/src/main/kotlin/elasticsearch.serverless-run.gradle.kts @@ -275,7 +279,12 @@ export function resolveDockerImage({ image, repo, defaultImg, -}: (ServerlessOptions | DockerOptions) & { repo: string; defaultImg: string }) { +}: { + tag?: string; + image?: string; + repo: string; + defaultImg: string; +}) { if (image) { if (!image.includes(DOCKER_REGISTRY)) { throw createCliError( @@ -525,11 +534,12 @@ export async function setupServerlessVolumes(log: ToolingLog, options: Serverles /** * Resolve the Serverless ES image based on defaults and CLI options */ -function getServerlessImage(options: ServerlessOptions) { +function getServerlessImage({ image, tag }: ImageOptions) { return resolveDockerImage({ - ...options, - repo: SERVERLESS_REPO, - defaultImg: SERVERLESS_IMG, + image, + tag, + repo: ES_SERVERLESS_REPO_ELASTICSEARCH, + defaultImg: ES_SERVERLESS_DEFAULT_IMAGE, }); } @@ -573,7 +583,10 @@ function getESClient(clientOptions: ClientOptions): Client { * Runs an ES Serverless Cluster through Docker */ export async function runServerlessCluster(log: ToolingLog, options: ServerlessOptions) { - const image = getServerlessImage(options); + const image = getServerlessImage({ + image: options.image, + tag: options.tag, + }); await setupDocker({ log, image, options }); const volumeCmd = await setupServerlessVolumes(log, options); @@ -686,8 +699,13 @@ export function teardownServerlessClusterSync(log: ToolingLog, options: Serverle /** * Resolve the Elasticsearch image based on defaults and CLI options */ -function getDockerImage(options: DockerOptions) { - return resolveDockerImage({ ...options, repo: DOCKER_REPO, defaultImg: DOCKER_IMG }); +function getDockerImage({ image, tag }: ImageOptions) { + return resolveDockerImage({ + image, + tag, + repo: DOCKER_REPO, + defaultImg: DOCKER_IMG, + }); } /** @@ -713,7 +731,10 @@ export async function runDockerContainer(log: ToolingLog, options: DockerOptions let image; if (!options.dockerCmd) { - image = getDockerImage(options); + image = getDockerImage({ + image: options.image, + tag: options.tag, + }); await setupDocker({ log, image, options }); } diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_cards.test.tsx.snap b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_cards.test.tsx.snap index ba2969ab4a0b3c..f971ca2c40bbb1 100644 --- a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_cards.test.tsx.snap +++ b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_cards.test.tsx.snap @@ -18,14 +18,20 @@ exports[`guide cards snapshots should render all cards 1`] = ` activeFilter="all" card={ Object { - "icon": "vector", - "navigateTo": Object { - "appId": "enterpriseSearchVectorSearch", - }, + "guideId": "databaseSearch", + "icon": "database", "order": 1, "solution": "search", - "telemetryId": "onboarding--search--vector", - "title": "Set up vector search", + "telemetryId": "onboarding--search--database", + "title": , + } + } + />, } } guidesState={Array []} @@ -44,14 +50,14 @@ exports[`guide cards snapshots should render all cards 1`] = ` activeFilter="all" card={ Object { - "icon": "magnifyWithPlus", + "icon": "vector", "navigateTo": Object { - "appId": "enterpriseSearchAISearch", + "appId": "enterpriseSearchVectorSearch", }, "order": 4, "solution": "search", - "telemetryId": "onboarding--search--semantic", - "title": "Build a semantic search experience", + "telemetryId": "onboarding--search--vector", + "title": "Set up vector search", } } guidesState={Array []} @@ -70,20 +76,14 @@ exports[`guide cards snapshots should render all cards 1`] = ` activeFilter="all" card={ Object { - "guideId": "appSearch", - "icon": "wrench", + "icon": "magnifyWithPlus", + "navigateTo": Object { + "appId": "enterpriseSearchAISearch", + }, "order": 7, "solution": "search", - "telemetryId": "onboarding--search--application", - "title": , - } - } - />, + "telemetryId": "onboarding--search--ai", + "title": "Build an AI-powered search experience", } } guidesState={Array []} @@ -102,12 +102,20 @@ exports[`guide cards snapshots should render all cards 1`] = ` activeFilter="all" card={ Object { - "guideId": "websiteSearch", - "icon": "search", + "guideId": "appSearch", + "icon": "wrench", "order": 10, "solution": "search", - "telemetryId": "onboarding--search--website", - "title": "Add search to my website", + "telemetryId": "onboarding--search--application", + "title": , + } + } + />, } } guidesState={Array []} @@ -126,20 +134,12 @@ exports[`guide cards snapshots should render all cards 1`] = ` activeFilter="all" card={ Object { - "guideId": "databaseSearch", - "icon": "database", + "guideId": "websiteSearch", + "icon": "search", "order": 13, "solution": "search", - "telemetryId": "onboarding--search--database", - "title": , - } - } - />, + "telemetryId": "onboarding--search--website", + "title": "Add search to my website", } } guidesState={Array []} diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/guide_cards.constants.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/guide_cards.constants.tsx index c624f31d2848a2..f570f5ea3b87b2 100644 --- a/packages/kbn-guided-onboarding/src/components/landing_page/guide_cards.constants.tsx +++ b/packages/kbn-guided-onboarding/src/components/landing_page/guide_cards.constants.tsx @@ -31,6 +31,22 @@ export interface GuideCardConstants { } export const guideCards: GuideCardConstants[] = [ + { + solution: 'search', + icon: 'database', + title: ( + , + }} + /> + ), + guideId: 'databaseSearch', + telemetryId: 'onboarding--search--database', + order: 1, + }, { solution: 'search', icon: 'vector', @@ -41,19 +57,19 @@ export const guideCards: GuideCardConstants[] = [ appId: 'enterpriseSearchVectorSearch', }, telemetryId: 'onboarding--search--vector', - order: 1, + order: 4, }, { solution: 'search', icon: 'magnifyWithPlus', title: i18n.translate('guidedOnboardingPackage.gettingStarted.cards.aiSearch.title', { - defaultMessage: 'Build a semantic search experience', + defaultMessage: 'Build an AI-powered search experience', }), navigateTo: { appId: 'enterpriseSearchAISearch', }, - telemetryId: 'onboarding--search--semantic', - order: 4, + telemetryId: 'onboarding--search--ai', + order: 7, }, { solution: 'search', @@ -69,7 +85,7 @@ export const guideCards: GuideCardConstants[] = [ ), guideId: 'appSearch', telemetryId: 'onboarding--search--application', - order: 7, + order: 10, }, { solution: 'search', @@ -79,22 +95,6 @@ export const guideCards: GuideCardConstants[] = [ }), guideId: 'websiteSearch', telemetryId: 'onboarding--search--website', - order: 10, - }, - { - solution: 'search', - icon: 'database', - title: ( - , - }} - /> - ), - guideId: 'databaseSearch', - telemetryId: 'onboarding--search--database', order: 13, }, { diff --git a/packages/kbn-test/src/es/es_test_config.ts b/packages/kbn-test/src/es/es_test_config.ts index c31728b0fd7d8d..26827558b7bd3c 100644 --- a/packages/kbn-test/src/es/es_test_config.ts +++ b/packages/kbn-test/src/es/es_test_config.ts @@ -27,6 +27,10 @@ class EsTestConfig { return process.env.TEST_ES_FROM || 'snapshot'; } + getESServerlessImage() { + return process.env.TEST_ES_SERVERLESS_IMAGE; + } + getTransportPort() { return process.env.TEST_ES_TRANSPORT_PORT || '9300-9400'; } diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index 3cd90d21d1d917..3c63960bdc0e57 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -70,6 +70,10 @@ export interface CreateTestEsClusterOptions { */ esArgs?: string[]; esFrom?: string; + esServerlessOptions?: { + image?: string; + tag?: string; + }; esJavaOpts?: string; /** * License to run your cluster under. Keep in mind that a `trial` license @@ -164,6 +168,7 @@ export function createTestEsCluster< writeLogsToPath, basePath = Path.resolve(REPO_ROOT, '.es'), esFrom = esTestConfig.getBuildFrom(), + esServerlessOptions, dataArchive, nodes = [{ name: 'node-01' }], esArgs: customEsArgs = [], @@ -236,9 +241,11 @@ export function createTestEsCluster< } else if (esFrom === 'snapshot') { installPath = (await firstNode.installSnapshot(config)).installPath; } else if (esFrom === 'serverless') { - return await firstNode.runServerless({ + await firstNode.runServerless({ basePath, esArgs: customEsArgs, + image: esServerlessOptions?.image, + tag: esServerlessOptions?.tag, port, clean: true, background: true, @@ -247,6 +254,7 @@ export function createTestEsCluster< kill: true, // likely don't need this but avoids any issues where the ESS cluster wasn't cleaned up waitForReady: true, }); + return; } else if (Path.isAbsolute(esFrom)) { installPath = esFrom; } else { @@ -275,9 +283,9 @@ export function createTestEsCluster< }); } - nodeStartPromises.push(async () => { + nodeStartPromises.push(() => { log.info(`[es] starting node ${node.name} on port ${nodePort}`); - return await this.nodes[i].start(installPath, { + return this.nodes[i].start(installPath, { password: config.password, esArgs: assignArgs(esArgs, overriddenArgs), esJavaOpts, @@ -292,7 +300,7 @@ export function createTestEsCluster< }); } - await Promise.all(extractDirectoryPromises.map(async (extract) => await extract())); + await Promise.all(extractDirectoryPromises.map((extract) => extract())); for (const start of nodeStartPromises) { await start(); } diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index f9c83161b521b6..d298a1c1abaa46 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -12,11 +12,12 @@ import getPort from 'get-port'; import { REPO_ROOT } from '@kbn/repo-info'; import type { ArtifactLicense } from '@kbn/es'; import type { Config } from '../../functional_test_runner'; -import { createTestEsCluster } from '../../es'; +import { createTestEsCluster, esTestConfig } from '../../es'; interface RunElasticsearchOptions { log: ToolingLog; esFrom?: string; + esServerlessImage?: string; config: Config; onEarlyExit?: (msg: string) => void; logsDir?: string; @@ -32,6 +33,7 @@ type EsConfig = ReturnType; function getEsConfig({ config, esFrom = config.get('esTestCluster.from'), + esServerlessImage, }: RunElasticsearchOptions) { const ssl = !!config.get('esTestCluster.ssl'); const license: ArtifactLicense = config.get('esTestCluster.license'); @@ -50,6 +52,8 @@ function getEsConfig({ const serverless: boolean = config.get('serverless'); const files: string[] | undefined = config.get('esTestCluster.files'); + const esServerlessOptions = getESServerlessOptions(esServerlessImage, config); + return { ssl, license, @@ -57,6 +61,7 @@ function getEsConfig({ esJavaOpts, isSecurityEnabled, esFrom, + esServerlessOptions, port, password, dataArchive, @@ -129,6 +134,7 @@ async function startEsNode({ clusterName: `cluster-${name}`, esArgs: config.esArgs, esFrom: config.esFrom, + esServerlessOptions: config.esServerlessOptions, esJavaOpts: config.esJavaOpts, license: config.license, password: config.password, @@ -153,3 +159,23 @@ async function startEsNode({ return cluster; } + +function getESServerlessOptions(esServerlessImageFromArg: string | undefined, config: Config) { + const esServerlessImageUrlOrTag = + esServerlessImageFromArg || + esTestConfig.getESServerlessImage() || + (config.has('esTestCluster.esServerlessImage') && + config.get('esTestCluster.esServerlessImage')); + + if (esServerlessImageUrlOrTag) { + if (esServerlessImageUrlOrTag.includes(':')) { + return { + image: esServerlessImageUrlOrTag, + }; + } else { + return { + tag: esServerlessImageUrlOrTag, + }; + } + } +} diff --git a/packages/kbn-test/src/functional_tests/run_tests/cli.ts b/packages/kbn-test/src/functional_tests/run_tests/cli.ts index 19a003dd973cfc..40a711ca9f6c28 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/cli.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/cli.ts @@ -26,6 +26,7 @@ export function runTestsCli() { { description: `Run Functional Tests`, usage: ` + Usage: node scripts/functional_tests --help node scripts/functional_tests [--config [--config ...]] node scripts/functional_tests [options] [-- --] diff --git a/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts b/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts index 0c9dae3a25794f..77399605b29f4d 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts @@ -42,6 +42,7 @@ describe('parse runTest flags', () => { ], "dryRun": false, "esFrom": undefined, + "esServerlessImage": undefined, "esVersion": , "grep": undefined, "installDir": undefined, diff --git a/packages/kbn-test/src/functional_tests/run_tests/flags.ts b/packages/kbn-test/src/functional_tests/run_tests/flags.ts index f4dd6beb26e804..3ba86999d38029 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/flags.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/flags.ts @@ -23,6 +23,7 @@ export const FLAG_OPTIONS: FlagOptions = { 'config', 'journey', 'esFrom', + 'esServerlessImage', 'kibana-install-dir', 'grep', 'include-tag', @@ -37,6 +38,7 @@ export const FLAG_OPTIONS: FlagOptions = { --config Define a FTR config that should be executed. Can be specified multiple times --journey Define a Journey that should be executed. Can be specified multiple times --esFrom Build Elasticsearch from source or run snapshot or serverless. Default: $TEST_ES_FROM or "snapshot" + --esServerlessImage When 'esFrom' is "serverless", this argument will be interpreted either as a tag within the ES Serverless repo, OR a full docker image path. --include-tag Tags that suites must include to be run, can be included multiple times --exclude-tag Tags that suites must NOT include to be run, can be included multiple times --include Files that must included to be run, can be included multiple times @@ -50,6 +52,13 @@ export const FLAG_OPTIONS: FlagOptions = { --updateSnapshots Replace inline and file snapshots with whatever is generated from the test --updateAll, -u Replace both baseline screenshots and snapshots `, + examples: ` +Run the latest verified, kibana-compatible ES Serverless image: + node scripts/functional_tests --config ./config.ts --esFrom serverless --esServerlessImage docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified + +Run with a specific ES Serverless tag from the docker.elastic.co/elasticsearch-ci/elasticsearch-serverless repo: + node scripts/functional_tests --config ./config.ts --esFrom serverless --esServerlessImage git-fec36430fba2 + `, }; export function parseFlags(flags: FlagsReader) { @@ -75,6 +84,7 @@ export function parseFlags(flags: FlagsReader) { ? Path.resolve(REPO_ROOT, 'data/ftr_servers_logs', uuidV4()) : undefined, esFrom: flags.enum('esFrom', ['snapshot', 'source', 'serverless']), + esServerlessImage: flags.string('esServerlessImage'), installDir: flags.path('kibana-install-dir'), grep: flags.string('grep'), suiteTags: { diff --git a/packages/shared-ux/page/kibana_template/impl/src/__snapshots__/page_template.test.tsx.snap b/packages/shared-ux/page/kibana_template/impl/src/__snapshots__/page_template.test.tsx.snap index c57c90acbdcf72..09421e4cb5dd71 100644 --- a/packages/shared-ux/page/kibana_template/impl/src/__snapshots__/page_template.test.tsx.snap +++ b/packages/shared-ux/page/kibana_template/impl/src/__snapshots__/page_template.test.tsx.snap @@ -3,7 +3,7 @@ exports[`KibanaPageTemplate render basic template 1`] = `
    <_EuiPageHeader diff --git a/packages/shared-ux/page/kibana_template/impl/src/page_template_inner.tsx b/packages/shared-ux/page/kibana_template/impl/src/page_template_inner.tsx index f9b9dcd247de62..5da29ba797041c 100644 --- a/packages/shared-ux/page/kibana_template/impl/src/page_template_inner.tsx +++ b/packages/shared-ux/page/kibana_template/impl/src/page_template_inner.tsx @@ -68,7 +68,8 @@ export const KibanaPageTemplateInner: FC = ({ // the following props can be removed to allow the template to auto-handle // the fixed header and banner heights. offset={0} - minHeight={0} + minHeight={header ? 'calc(100vh - var(--euiFixedHeadersOffset, 0))' : 0} + grow={header ? false : undefined} {...rest} > {sideBar} diff --git a/src/dev/build/tasks/fleet/download_elastic_gpg_key.ts b/src/dev/build/tasks/fleet/download_elastic_gpg_key.ts index 483a342ba300ec..6cd0b351c4d31a 100644 --- a/src/dev/build/tasks/fleet/download_elastic_gpg_key.ts +++ b/src/dev/build/tasks/fleet/download_elastic_gpg_key.ts @@ -13,9 +13,9 @@ import { ToolingLog } from '@kbn/tooling-log'; import { downloadToDisk } from '../../lib'; const ARTIFACTS_URL = 'https://artifacts.elastic.co/'; -const GPG_KEY_NAME = 'GPG-KEY-elasticsearch'; +const GPG_KEY_NAME = 'GPG-KEY-elasticsearch.sha1'; const GPG_KEY_SHA512 = - '62a567354286deb02baf5fc6b82ddf6c7067898723463da9ae65b132b8c6d6f064b2874e390885682376228eed166c1c82fe7f11f6c9a69f0c157029c548fa3d'; + '84ee193cc337344d9a7da9021daf3f5ede83f5f1ab049d169f3634921529dcd096abf7a91eec7f26f3a6913e5e38f88f69a5e2ce79ad155d46edc75705a648c6'; export async function downloadElasticGpgKey(pkgDir: string, log: ToolingLog) { const gpgKeyUrl = ARTIFACTS_URL + GPG_KEY_NAME; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap index bd4e658b04a99a..2cd43886807516 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap @@ -1,160 +1,166 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`isCloudEnabled is false should not render instruction toggle when ON_PREM_ELASTIC_CLOUD instructions are not provided 1`] = ` - -
    - - - +
    + -
    - + "prepend": [Function], + } + } + description="tutorial used to drive jest tests" + notices={null} + title="jest test tutorial" + /> + + +
    + + `; exports[`isCloudEnabled is false should render ON_PREM instructions with instruction toggle 1`] = ` - -
    - +
    + - - - + + + + + + - - - -
    - +
    + + `; exports[`should render ELASTIC_CLOUD instructions when isCloudEnabled is true 1`] = ` - -
    - - - +
    + -
    - + "prepend": [Function], + } + } + description="tutorial used to drive jest tests" + iconType="logoApache" + notices={null} + title="jest test tutorial" + /> + + +
    + + `; diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.js b/src/plugins/home/public/application/components/tutorial/tutorial.js index de222dbe6155d4..620da42169c358 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.js @@ -18,7 +18,7 @@ import * as StatusCheckStates from './status_check_states'; import { injectI18n, FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { getServices } from '../../kibana_services'; -import { KibanaPageTemplate } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; const INSTRUCTIONS_TYPE = { ELASTIC_CLOUD: 'elasticCloud', @@ -360,7 +360,7 @@ class TutorialUi extends React.Component { render() { let content; if (this.state.notFound) { - content = ( + return ( {content}; + return ( + + {content} + + ); } } diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index cde8b86f5df22f..c5a4c1b27fbab3 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -16,7 +16,7 @@ import { SampleDataTab } from '@kbn/home-sample-data-tab'; import { i18n } from '@kbn/i18n'; import { Synopsis } from './synopsis'; import { getServices } from '../kibana_services'; -import { KibanaPageTemplate } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { getTutorials } from '../load_tutorials'; const SAMPLE_DATA_TAB_ID = 'sampleData'; @@ -257,7 +257,7 @@ class TutorialDirectoryUi extends React.Component { rightSideItems: headerLinks ? [headerLinks] : [], }} > - {this.renderTabContent()} + {this.renderTabContent()}
    ); } diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index f92739d0454390..7954559206bb75 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -54,17 +54,13 @@ export { POSITIONS, WEIGHTS, TOOLBAR_BUTTON_SIZES, ToolbarButton } from './toolb export { reactRouterNavigate, reactRouterOnClickHandler } from './react_router_navigate'; export type { - KibanaPageTemplateProps, NoDataPageActions, NoDataPageActionsProps, NoDataPageProps, ElasticAgentCardProps, } from './page_template'; export { - KibanaPageTemplate, KibanaPageTemplateSolutionNavAvatar, - NO_DATA_PAGE_MAX_WIDTH, - NO_DATA_PAGE_TEMPLATE_PROPS, NO_DATA_RECOMMENDED, NoDataPage, ElasticAgentCard, diff --git a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap deleted file mode 100644 index 4dea9549670f30..00000000000000 --- a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap +++ /dev/null @@ -1,440 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`KibanaPageTemplate render basic template 1`] = ` -
    -
    -
    -
    -
    -
    -
    -

    - test -

    -
    -
    -
    -
    -
    - test -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -`; - -exports[`KibanaPageTemplate render custom empty prompt only 1`] = ` - - - custom test - - } - /> - -`; - -exports[`KibanaPageTemplate render custom empty prompt with page header 1`] = ` - - - custom test - - } - /> - -`; - -exports[`KibanaPageTemplate render default empty prompt 1`] = ` - -`; - -exports[`KibanaPageTemplate render noDataContent 1`] = ` - -`; - -exports[`KibanaPageTemplate render solutionNav 1`] = ` -
    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    -

    - test -

    -
    -
    -
    -
    -
    - test -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -`; diff --git a/src/plugins/kibana_react/public/page_template/index.ts b/src/plugins/kibana_react/public/page_template/index.ts index fda644a2847974..65a5db433593af 100644 --- a/src/plugins/kibana_react/public/page_template/index.ts +++ b/src/plugins/kibana_react/public/page_template/index.ts @@ -6,9 +6,5 @@ * Side Public License, v 1. */ -export type { KibanaPageTemplateProps } from './page_template'; -export { KibanaPageTemplate } from './page_template'; export { KibanaPageTemplateSolutionNavAvatar, KibanaPageTemplateSolutionNav } from './solution_nav'; export * from './no_data_page'; -export { withSolutionNav } from './with_solution_nav'; -export { NO_DATA_PAGE_MAX_WIDTH, NO_DATA_PAGE_TEMPLATE_PROPS } from './util'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/index.ts b/src/plugins/kibana_react/public/page_template/no_data_page/index.ts index b5a11722dd3975..55661ad6f14f75 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/index.ts +++ b/src/plugins/kibana_react/public/page_template/no_data_page/index.ts @@ -8,4 +8,3 @@ export * from './no_data_page'; export * from './no_data_card'; -export * from './no_data_config_page'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx deleted file mode 100644 index 0bdde400213984..00000000000000 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { NoDataConfigPage, NoDataConfigPageWithSolutionNavBar } from './no_data_config_page'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx deleted file mode 100644 index cae591f571c793..00000000000000 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import { EuiPageTemplate_Deprecated as EuiPageTemplate } from '@elastic/eui'; -import React from 'react'; -import { NoDataPage } from '../no_data_page'; -import { withSolutionNav } from '../../with_solution_nav'; -import { KibanaPageTemplateProps } from '../../page_template'; -import { getClasses, NO_DATA_PAGE_TEMPLATE_PROPS } from '../../util'; - -export const NoDataConfigPage = (props: KibanaPageTemplateProps) => { - const { className, noDataConfig, ...rest } = props; - - if (!noDataConfig) { - return null; - } - - const template = NO_DATA_PAGE_TEMPLATE_PROPS.template; - const classes = getClasses(template, className); - - return ( - - - - ); -}; - -export const NoDataConfigPageWithSolutionNavBar = withSolutionNav(NoDataConfigPage); diff --git a/src/plugins/kibana_react/public/page_template/page_template.scss b/src/plugins/kibana_react/public/page_template/page_template.scss deleted file mode 100644 index d94daec56235f8..00000000000000 --- a/src/plugins/kibana_react/public/page_template/page_template.scss +++ /dev/null @@ -1,22 +0,0 @@ -.kbnPageTemplate__pageSideBar { - overflow: hidden; - // Temporary hack till the sizing is changed directly in EUI - min-width: 248px; - - @include euiCanAnimate { - transition: min-width $euiAnimSpeedFast $euiAnimSlightResistance; - } - - &.kbnPageTemplate__pageSideBar--shrink { - min-width: $euiSizeXXL; - } - - .kbnPageTemplate--centeredBody & { - border-bottom: $euiBorderThin; - - @include euiBreakpoint('m', 'l', 'xl') { - border-bottom: none; - border-right: $euiBorderThin; - } - } -} diff --git a/src/plugins/kibana_react/public/page_template/page_template.test.tsx b/src/plugins/kibana_react/public/page_template/page_template.test.tsx deleted file mode 100644 index aff6082902a34e..00000000000000 --- a/src/plugins/kibana_react/public/page_template/page_template.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { shallow, render } from 'enzyme'; -import { KibanaPageTemplate, KibanaPageTemplateProps } from './page_template'; -import { EuiEmptyPrompt } from '@elastic/eui'; -import { KibanaPageTemplateSolutionNavProps } from './solution_nav'; - -const navItems: KibanaPageTemplateSolutionNavProps['items'] = [ - { - name: 'Ingest', - id: '1', - items: [ - { - name: 'Ingest Node Pipelines', - id: '1.1', - }, - { - name: 'Logstash Pipelines', - id: '1.2', - }, - { - name: 'Beats Central Management', - id: '1.3', - }, - ], - }, - { - name: 'Data', - id: '2', - items: [ - { - name: 'Index Management', - id: '2.1', - }, - { - name: 'Index Lifecycle Policies', - id: '2.2', - }, - { - name: 'Snapshot and Restore', - id: '2.3', - }, - ], - }, -]; - -const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = { - solution: 'Elastic', - actions: { - elasticAgent: {}, - beats: {}, - custom: {}, - }, - docsLink: 'test', -}; - -describe('KibanaPageTemplate', () => { - test('render default empty prompt', () => { - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - }); - - test('render custom empty prompt only', () => { - const component = shallow( - - custom test} /> - - ); - expect(component).toMatchSnapshot(); - }); - - test('render custom empty prompt with page header', () => { - const component = shallow( - - custom test} /> - - ); - expect(component).toMatchSnapshot(); - }); - - test('render basic template', () => { - const component = render( - - ); - expect(component).toMatchSnapshot(); - }); - - test('render solutionNav', () => { - const component = render( - - ); - expect(component).toMatchSnapshot(); - expect(component.find('div.kbnPageTemplate__pageSideBar').length).toBe(1); - }); - - test('render noDataContent', () => { - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - }); - - test('render sidebar classes', () => { - const component = shallow( - - ); - expect(component.html().includes('kbnPageTemplate__pageSideBar customClass')).toBe(true); - }); -}); diff --git a/src/plugins/kibana_react/public/page_template/page_template.tsx b/src/plugins/kibana_react/public/page_template/page_template.tsx deleted file mode 100644 index 42ba9d18735876..00000000000000 --- a/src/plugins/kibana_react/public/page_template/page_template.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import './page_template.scss'; - -import React, { FunctionComponent } from 'react'; -import { EuiPageTemplateProps_Deprecated } from '@elastic/eui'; -import { KibanaPageTemplateSolutionNavProps } from './solution_nav'; - -import { - NoDataPageProps, - NoDataConfigPage, - NoDataConfigPageWithSolutionNavBar, -} from './no_data_page'; -import { KibanaPageTemplateInner, KibanaPageTemplateWithSolutionNav } from './page_template_inner'; - -/** - * A thin wrapper around EuiPageTemplate with a few Kibana specific additions - * @deprecated Use `KibanaPageTemplateProps` from `@kbn/shared-ux-page-kibana-template-types`. - */ -export type KibanaPageTemplateProps = EuiPageTemplateProps_Deprecated & { - /** - * Changes the template type depending on other props provided. - * With `pageHeader` only: Uses `centeredBody` and fills an EuiEmptyPrompt with `pageHeader` info. - * With `children` only: Uses `centeredBody` - * With `pageHeader` and `children`: Uses `centeredContent` - */ - isEmptyState?: boolean; - /** - * Quick creation of EuiSideNav. Hooks up mobile instance too - */ - solutionNav?: KibanaPageTemplateSolutionNavProps; - /** - * Accepts a configuration object, that when provided, ignores pageHeader and children and instead - * displays Agent, Beats, and custom cards to direct users to the right ingest location - */ - noDataConfig?: NoDataPageProps; -}; - -/** @deprecated Use `KibanaPageTemplate` from `@kbn/shared-ux-page-kibana-template`. */ -export const KibanaPageTemplate: FunctionComponent = ({ - template, - className, - children, - solutionNav, - noDataConfig, - ...rest -}) => { - /** - * If passing the custom template of `noDataConfig` - */ - if (noDataConfig && solutionNav) { - return ( - - ); - } - - if (noDataConfig) { - return ( - - ); - } - - if (solutionNav) { - return ( - - ); - } - - return ( - - ); -}; diff --git a/src/plugins/kibana_react/public/page_template/page_template_inner.tsx b/src/plugins/kibana_react/public/page_template/page_template_inner.tsx deleted file mode 100644 index 001cea5c26a23d..00000000000000 --- a/src/plugins/kibana_react/public/page_template/page_template_inner.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { FunctionComponent } from 'react'; - -import { EuiEmptyPrompt, EuiPageTemplate_Deprecated as EuiPageTemplate } from '@elastic/eui'; -import { withSolutionNav } from './with_solution_nav'; -import { KibanaPageTemplateProps } from './page_template'; -import { getClasses } from './util'; - -type Props = KibanaPageTemplateProps; - -/** - * A thin wrapper around EuiPageTemplate with a few Kibana specific additions - */ -export const KibanaPageTemplateInner: FunctionComponent = ({ - template, - className, - pageHeader, - children, - isEmptyState, - ...rest -}) => { - /** - * An easy way to create the right content for empty pages - */ - const emptyStateDefaultTemplate = 'centeredBody'; - if (isEmptyState && pageHeader && !children) { - template = template ?? emptyStateDefaultTemplate; - const { iconType, pageTitle, description, rightSideItems } = pageHeader; - pageHeader = undefined; - children = ( - {pageTitle} : undefined} - body={description ?

    {description}

    : undefined} - actions={rightSideItems} - /> - ); - } else if (isEmptyState && pageHeader && children) { - template = template ?? 'centeredContent'; - } else if (isEmptyState && !pageHeader) { - template = template ?? emptyStateDefaultTemplate; - } - - const classes = getClasses(template, className); - - return ( - - {children} - - ); -}; - -export const KibanaPageTemplateWithSolutionNav = withSolutionNav(KibanaPageTemplateInner); diff --git a/src/plugins/kibana_react/public/page_template/util/constants.ts b/src/plugins/kibana_react/public/page_template/util/constants.ts deleted file mode 100644 index 159a6d0d8d4c15..00000000000000 --- a/src/plugins/kibana_react/public/page_template/util/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { KibanaPageTemplateProps } from '../page_template'; - -export const NO_DATA_PAGE_MAX_WIDTH = 950; - -export const NO_DATA_PAGE_TEMPLATE_PROPS: KibanaPageTemplateProps = { - restrictWidth: NO_DATA_PAGE_MAX_WIDTH, - template: 'centeredBody', - pageContentProps: { - hasShadow: false, - color: 'transparent', - }, -}; diff --git a/src/plugins/kibana_react/public/page_template/util/index.ts b/src/plugins/kibana_react/public/page_template/util/index.ts index adfefdf8345664..06edc43d70d570 100644 --- a/src/plugins/kibana_react/public/page_template/util/index.ts +++ b/src/plugins/kibana_react/public/page_template/util/index.ts @@ -7,4 +7,3 @@ */ export { getClasses } from './presentation'; -export * from './constants'; diff --git a/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx b/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx deleted file mode 100644 index 842573b9d8de4d..00000000000000 --- a/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { ComponentType, useState } from 'react'; -import classNames from 'classnames'; -import { useIsWithinBreakpoints } from '@elastic/eui'; -import { EuiPageSideBarProps_Deprecated as EuiPageSideBarProps } from '@elastic/eui/src/components/page/page_side_bar'; -import { KibanaPageTemplateSolutionNav, KibanaPageTemplateSolutionNavProps } from './solution_nav'; -import { KibanaPageTemplateProps } from '.'; - -// https://reactjs.org/docs/higher-order-components.html#convention-wrap-the-display-name-for-easy-debugging -function getDisplayName(Component: ComponentType) { - return Component.displayName || Component.name || 'UnnamedComponent'; -} - -type SolutionNavProps = KibanaPageTemplateProps & { - solutionNav: KibanaPageTemplateSolutionNavProps; -}; - -const SOLUTION_NAV_COLLAPSED_KEY = 'solutionNavIsCollapsed'; - -export const withSolutionNav = (WrappedComponent: ComponentType) => { - const WithSolutionNav = (props: SolutionNavProps) => { - const isMediumBreakpoint = useIsWithinBreakpoints(['m']); - const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); - const [isSideNavOpenOnDesktop, setisSideNavOpenOnDesktop] = useState( - !JSON.parse(String(localStorage.getItem(SOLUTION_NAV_COLLAPSED_KEY))) - ); - const { solutionNav, ...propagatedProps } = props; - const { children, isEmptyState, template } = propagatedProps; - const toggleOpenOnDesktop = () => { - setisSideNavOpenOnDesktop(!isSideNavOpenOnDesktop); - // Have to store it as the opposite of the default we want - localStorage.setItem(SOLUTION_NAV_COLLAPSED_KEY, JSON.stringify(isSideNavOpenOnDesktop)); - }; - const sideBarClasses = classNames( - 'kbnPageTemplate__pageSideBar', - { - 'kbnPageTemplate__pageSideBar--shrink': - isMediumBreakpoint || (isLargerBreakpoint && !isSideNavOpenOnDesktop), - }, - props.pageSideBarProps?.className - ); - - const templateToUse = isEmptyState && !template ? 'centeredContent' : template; - - const pageSideBar = ( - - ); - const pageSideBarProps = { - paddingSize: 'none', - ...props.pageSideBarProps, - className: sideBarClasses, - } as EuiPageSideBarProps; // needed because for some reason 'none' is not recognized as a valid value for paddingSize - return ( - - {children} - - ); - }; - WithSolutionNav.displayName = `WithSolutionNavBar(${getDisplayName(WrappedComponent)})`; - return WithSolutionNav; -}; diff --git a/x-pack/packages/ml/trained_models_utils/index.ts b/x-pack/packages/ml/trained_models_utils/index.ts index ba67911f3f8aba..22b808bdc7b5ee 100644 --- a/x-pack/packages/ml/trained_models_utils/index.ts +++ b/x-pack/packages/ml/trained_models_utils/index.ts @@ -14,4 +14,10 @@ export { type DeploymentState, type SupportedPytorchTasksType, type TrainedModelType, + ELASTIC_MODEL_DEFINITIONS, + type ElasticModelId, + type ModelDefinition, + type ModelDefinitionResponse, + type ElserVersion, + type GetElserOptions, } from './src/constants/trained_models'; diff --git a/x-pack/packages/ml/trained_models_utils/src/constants/trained_models.ts b/x-pack/packages/ml/trained_models_utils/src/constants/trained_models.ts index 86b88f51a66c42..4580330119ddd9 100644 --- a/x-pack/packages/ml/trained_models_utils/src/constants/trained_models.ts +++ b/x-pack/packages/ml/trained_models_utils/src/constants/trained_models.ts @@ -46,8 +46,9 @@ export const BUILT_IN_MODEL_TAG = 'prepackaged'; export const ELASTIC_MODEL_TAG = 'elastic'; -export const ELASTIC_MODEL_DEFINITIONS = { +export const ELASTIC_MODEL_DEFINITIONS: Record = Object.freeze({ '.elser_model_1': { + version: 1, config: { input: { field_names: ['text_field'], @@ -57,7 +58,49 @@ export const ELASTIC_MODEL_DEFINITIONS = { defaultMessage: 'Elastic Learned Sparse EncodeR v1 (Tech Preview)', }), }, -} as const; + '.elser_model_2_SNAPSHOT': { + version: 2, + default: true, + config: { + input: { + field_names: ['text_field'], + }, + }, + description: i18n.translate('xpack.ml.trainedModels.modelsList.elserV2Description', { + defaultMessage: 'Elastic Learned Sparse EncodeR v2 (Tech Preview)', + }), + }, + '.elser_model_2_linux-x86_64_SNAPSHOT': { + version: 2, + os: 'Linux', + arch: 'amd64', + config: { + input: { + field_names: ['text_field'], + }, + }, + description: i18n.translate('xpack.ml.trainedModels.modelsList.elserV2x86Description', { + defaultMessage: + 'Elastic Learned Sparse EncodeR v2, optimized for linux-x86_64 (Tech Preview)', + }), + }, +} as const); + +export interface ModelDefinition { + version: number; + config: object; + description: string; + os?: string; + arch?: string; + default?: boolean; + recommended?: boolean; +} + +export type ModelDefinitionResponse = ModelDefinition & { + name: string; +}; + +export type ElasticModelId = keyof typeof ELASTIC_MODEL_DEFINITIONS; export const MODEL_STATE = { ...DEPLOYMENT_STATE, @@ -66,3 +109,9 @@ export const MODEL_STATE = { } as const; export type ModelState = typeof MODEL_STATE[keyof typeof MODEL_STATE] | null; + +export type ElserVersion = 1 | 2; + +export interface GetElserOptions { + version?: ElserVersion; +} diff --git a/x-pack/plugins/actions/docs/openapi/bundled.json b/x-pack/plugins/actions/docs/openapi/bundled.json index f79bb3ca9c0fe4..4039ab6b5a0deb 100644 --- a/x-pack/plugins/actions/docs/openapi/bundled.json +++ b/x-pack/plugins/actions/docs/openapi/bundled.json @@ -111,6 +111,9 @@ { "$ref": "#/components/schemas/create_connector_request_tines" }, + { + "$ref": "#/components/schemas/create_connector_request_torq" + }, { "$ref": "#/components/schemas/create_connector_request_webhook" }, @@ -389,6 +392,9 @@ { "$ref": "#/components/schemas/create_connector_request_tines" }, + { + "$ref": "#/components/schemas/create_connector_request_torq" + }, { "$ref": "#/components/schemas/create_connector_request_webhook" }, @@ -508,6 +514,9 @@ { "$ref": "#/components/schemas/update_connector_request_teams" }, + { + "$ref": "#/components/schemas/update_connector_request_torq" + }, { "$ref": "#/components/schemas/update_connector_request_webhook" }, @@ -2829,6 +2838,66 @@ } } }, + "config_properties_torq": { + "title": "Connector request properties for a Torq connector", + "description": "Defines properties for connectors when type is `.torq`.", + "type": "object", + "required": [ + "webhookIntegrationUrl" + ], + "properties": { + "webhookIntegrationUrl": { + "description": "The endpoint URL of the Elastic Security integration in Torq.", + "type": "string" + } + } + }, + "secrets_properties_torq": { + "title": "Connector secrets properties for a Torq connector", + "description": "Defines secrets for connectors when type is `.torq`.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "description": "The secret of the webhook authentication header.", + "type": "string" + } + } + }, + "create_connector_request_torq": { + "title": "Create Torq connector request", + "description": "The Torq connector uses a Torq webhook to trigger workflows with Kibana actions.\n", + "type": "object", + "required": [ + "config", + "connector_type_id", + "name", + "secrets" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/config_properties_torq" + }, + "connector_type_id": { + "type": "string", + "description": "The type of connector.", + "enum": [ + ".torq" + ], + "example": ".torq" + }, + "name": { + "type": "string", + "description": "The display name for the connector.", + "example": "my-connector" + }, + "secrets": { + "$ref": "#/components/schemas/secrets_properties_torq" + } + } + }, "config_properties_webhook": { "title": "Connector request properties for a Webhook connector", "description": "Defines properties for connectors when type is `.webhook`.", @@ -3776,6 +3845,50 @@ } } }, + "connector_response_properties_torq": { + "title": "Connector response properties for a Torq connector", + "type": "object", + "required": [ + "config", + "connector_type_id", + "id", + "is_deprecated", + "is_preconfigured", + "name" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/config_properties_torq" + }, + "connector_type_id": { + "type": "string", + "description": "The type of connector.", + "enum": [ + ".torq" + ] + }, + "id": { + "type": "string", + "description": "The identifier for the connector." + }, + "is_deprecated": { + "$ref": "#/components/schemas/is_deprecated" + }, + "is_missing_secrets": { + "$ref": "#/components/schemas/is_missing_secrets" + }, + "is_preconfigured": { + "$ref": "#/components/schemas/is_preconfigured" + }, + "is_system_action": { + "$ref": "#/components/schemas/is_system_action" + }, + "name": { + "type": "string", + "description": "The display name for the connector." + } + } + }, "connector_response_properties_webhook": { "title": "Connector response properties for a Webhook connector", "type": "object", @@ -3919,6 +4032,9 @@ { "$ref": "#/components/schemas/connector_response_properties_tines" }, + { + "$ref": "#/components/schemas/connector_response_properties_torq" + }, { "$ref": "#/components/schemas/connector_response_properties_webhook" }, @@ -4221,6 +4337,27 @@ } } }, + "update_connector_request_torq": { + "title": "Update Torq connector request", + "type": "object", + "required": [ + "config", + "name", + "secrets" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/config_properties_torq" + }, + "name": { + "type": "string", + "description": "The display name for the connector." + }, + "secrets": { + "$ref": "#/components/schemas/secrets_properties_torq" + } + } + }, "update_connector_request_webhook": { "title": "Update Webhook connector request", "type": "object", @@ -4286,6 +4423,7 @@ ".swimlane", ".teams", ".tines", + ".torq", ".webhook", ".xmatters" ], diff --git a/x-pack/plugins/actions/docs/openapi/bundled.yaml b/x-pack/plugins/actions/docs/openapi/bundled.yaml index ed97fb7f31233f..9d1842c4dace9a 100644 --- a/x-pack/plugins/actions/docs/openapi/bundled.yaml +++ b/x-pack/plugins/actions/docs/openapi/bundled.yaml @@ -55,6 +55,7 @@ paths: - $ref: '#/components/schemas/create_connector_request_swimlane' - $ref: '#/components/schemas/create_connector_request_teams' - $ref: '#/components/schemas/create_connector_request_tines' + - $ref: '#/components/schemas/create_connector_request_torq' - $ref: '#/components/schemas/create_connector_request_webhook' - $ref: '#/components/schemas/create_connector_request_xmatters' discriminator: @@ -208,6 +209,7 @@ paths: - $ref: '#/components/schemas/create_connector_request_swimlane' - $ref: '#/components/schemas/create_connector_request_teams' - $ref: '#/components/schemas/create_connector_request_tines' + - $ref: '#/components/schemas/create_connector_request_torq' - $ref: '#/components/schemas/create_connector_request_webhook' - $ref: '#/components/schemas/create_connector_request_xmatters' discriminator: @@ -264,6 +266,7 @@ paths: - $ref: '#/components/schemas/update_connector_request_slack_webhook' - $ref: '#/components/schemas/update_connector_request_swimlane' - $ref: '#/components/schemas/update_connector_request_teams' + - $ref: '#/components/schemas/update_connector_request_torq' - $ref: '#/components/schemas/update_connector_request_webhook' - $ref: '#/components/schemas/update_connector_request_xmatters' examples: @@ -1916,6 +1919,51 @@ components: example: my-connector secrets: $ref: '#/components/schemas/secrets_properties_tines' + config_properties_torq: + title: Connector request properties for a Torq connector + description: Defines properties for connectors when type is `.torq`. + type: object + required: + - webhookIntegrationUrl + properties: + webhookIntegrationUrl: + description: The endpoint URL of the Elastic Security integration in Torq. + type: string + secrets_properties_torq: + title: Connector secrets properties for a Torq connector + description: Defines secrets for connectors when type is `.torq`. + type: object + required: + - token + properties: + token: + description: The secret of the webhook authentication header. + type: string + create_connector_request_torq: + title: Create Torq connector request + description: | + The Torq connector uses a Torq webhook to trigger workflows with Kibana actions. + type: object + required: + - config + - connector_type_id + - name + - secrets + properties: + config: + $ref: '#/components/schemas/config_properties_torq' + connector_type_id: + type: string + description: The type of connector. + enum: + - .torq + example: .torq + name: + type: string + description: The display name for the connector. + example: my-connector + secrets: + $ref: '#/components/schemas/secrets_properties_torq' config_properties_webhook: title: Connector request properties for a Webhook connector description: Defines properties for connectors when type is `.webhook`. @@ -2626,6 +2674,38 @@ components: name: type: string description: The display name for the connector. + connector_response_properties_torq: + title: Connector response properties for a Torq connector + type: object + required: + - config + - connector_type_id + - id + - is_deprecated + - is_preconfigured + - name + properties: + config: + $ref: '#/components/schemas/config_properties_torq' + connector_type_id: + type: string + description: The type of connector. + enum: + - .torq + id: + type: string + description: The identifier for the connector. + is_deprecated: + $ref: '#/components/schemas/is_deprecated' + is_missing_secrets: + $ref: '#/components/schemas/is_missing_secrets' + is_preconfigured: + $ref: '#/components/schemas/is_preconfigured' + is_system_action: + $ref: '#/components/schemas/is_system_action' + name: + type: string + description: The display name for the connector. connector_response_properties_webhook: title: Connector response properties for a Webhook connector type: object @@ -2711,6 +2791,7 @@ components: - $ref: '#/components/schemas/connector_response_properties_swimlane' - $ref: '#/components/schemas/connector_response_properties_teams' - $ref: '#/components/schemas/connector_response_properties_tines' + - $ref: '#/components/schemas/connector_response_properties_torq' - $ref: '#/components/schemas/connector_response_properties_webhook' - $ref: '#/components/schemas/connector_response_properties_xmatters' discriminator: @@ -2922,6 +3003,21 @@ components: description: The display name for the connector. secrets: $ref: '#/components/schemas/secrets_properties_teams' + update_connector_request_torq: + title: Update Torq connector request + type: object + required: + - config + - name + - secrets + properties: + config: + $ref: '#/components/schemas/config_properties_torq' + name: + type: string + description: The display name for the connector. + secrets: + $ref: '#/components/schemas/secrets_properties_torq' update_connector_request_webhook: title: Update Webhook connector request type: object @@ -2975,6 +3071,7 @@ components: - .swimlane - .teams - .tines + - .torq - .webhook - .xmatters example: .server-log diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/config_properties_torq.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/config_properties_torq.yaml new file mode 100644 index 00000000000000..06808a37a75fca --- /dev/null +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/config_properties_torq.yaml @@ -0,0 +1,9 @@ +title: Connector request properties for a Torq connector +description: Defines properties for connectors when type is `.torq`. +type: object +required: + - webhookIntegrationUrl +properties: + webhookIntegrationUrl: + description: The endpoint URL of the Elastic Security integration in Torq. + type: string \ No newline at end of file diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml index 334fe3fa5cdb32..edef270fd75ae5 100644 --- a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml @@ -18,6 +18,7 @@ oneOf: - $ref: 'connector_response_properties_swimlane.yaml' - $ref: 'connector_response_properties_teams.yaml' - $ref: 'connector_response_properties_tines.yaml' + - $ref: 'connector_response_properties_torq.yaml' - $ref: 'connector_response_properties_webhook.yaml' - $ref: 'connector_response_properties_xmatters.yaml' discriminator: diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties_torq.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties_torq.yaml new file mode 100644 index 00000000000000..135d5e9db6cb44 --- /dev/null +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties_torq.yaml @@ -0,0 +1,31 @@ +title: Connector response properties for a Torq connector +type: object +required: + - config + - connector_type_id + - id + - is_deprecated + - is_preconfigured + - name +properties: + config: + $ref: 'config_properties_torq.yaml' + connector_type_id: + type: string + description: The type of connector. + enum: + - .torq + id: + type: string + description: The identifier for the connector. + is_deprecated: + $ref: 'is_deprecated.yaml' + is_missing_secrets: + $ref: 'is_missing_secrets.yaml' + is_preconfigured: + $ref: 'is_preconfigured.yaml' + is_system_action: + $ref: 'is_system_action.yaml' + name: + type: string + description: The display name for the connector. diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml index 2bbc9f5dabac45..687648acd7141f 100644 --- a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml @@ -20,6 +20,7 @@ enum: - .swimlane - .teams - .tines + - .torq - .webhook - .xmatters example: .server-log \ No newline at end of file diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/create_connector_request_torq.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/create_connector_request_torq.yaml new file mode 100644 index 00000000000000..934f9c9c1b395f --- /dev/null +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/create_connector_request_torq.yaml @@ -0,0 +1,24 @@ +title: Create Torq connector request +description: > + The Torq connector uses a Torq webhook to trigger workflows with Kibana actions. +type: object +required: + - config + - connector_type_id + - name + - secrets +properties: + config: + $ref: 'config_properties_torq.yaml' + connector_type_id: + type: string + description: The type of connector. + enum: + - .torq + example: .torq + name: + type: string + description: The display name for the connector. + example: my-connector + secrets: + $ref: 'secrets_properties_torq.yaml' \ No newline at end of file diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/secrets_properties_torq.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/secrets_properties_torq.yaml new file mode 100644 index 00000000000000..ab79a0f672b425 --- /dev/null +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/secrets_properties_torq.yaml @@ -0,0 +1,9 @@ +title: Connector secrets properties for a Torq connector +description: Defines secrets for connectors when type is `.torq`. +type: object +required: + - token +properties: + token: + description: The secret of the webhook authentication header. + type: string \ No newline at end of file diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/update_connector_request_torq.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/update_connector_request_torq.yaml new file mode 100644 index 00000000000000..f82de22f3e27b0 --- /dev/null +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/update_connector_request_torq.yaml @@ -0,0 +1,14 @@ +title: Update Torq connector request +type: object +required: + - config + - name + - secrets +properties: + config: + $ref: 'config_properties_torq.yaml' + name: + type: string + description: The display name for the connector. + secrets: + $ref: 'secrets_properties_torq.yaml' \ No newline at end of file diff --git a/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector.yaml b/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector.yaml index f32def50706b2f..62f2e1821eb8fd 100644 --- a/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector.yaml +++ b/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector.yaml @@ -34,6 +34,7 @@ post: - $ref: '../components/schemas/create_connector_request_swimlane.yaml' - $ref: '../components/schemas/create_connector_request_teams.yaml' - $ref: '../components/schemas/create_connector_request_tines.yaml' + - $ref: '../components/schemas/create_connector_request_torq.yaml' - $ref: '../components/schemas/create_connector_request_webhook.yaml' - $ref: '../components/schemas/create_connector_request_xmatters.yaml' discriminator: diff --git a/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector@{connectorid}.yaml b/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector@{connectorid}.yaml index c6a2447c251a20..ad93b5076639ad 100644 --- a/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector@{connectorid}.yaml +++ b/x-pack/plugins/actions/docs/openapi/paths/s@{spaceid}@api@actions@connector@{connectorid}.yaml @@ -118,6 +118,7 @@ post: - $ref: '../components/schemas/create_connector_request_swimlane.yaml' - $ref: '../components/schemas/create_connector_request_teams.yaml' - $ref: '../components/schemas/create_connector_request_tines.yaml' + - $ref: '../components/schemas/create_connector_request_torq.yaml' - $ref: '../components/schemas/create_connector_request_webhook.yaml' - $ref: '../components/schemas/create_connector_request_xmatters.yaml' discriminator: @@ -176,6 +177,7 @@ put: - $ref: '../components/schemas/update_connector_request_swimlane.yaml' - $ref: '../components/schemas/update_connector_request_teams.yaml' # - $ref: '../components/schemas/update_connector_request_tines.yaml' + - $ref: '../components/schemas/update_connector_request_torq.yaml' - $ref: '../components/schemas/update_connector_request_webhook.yaml' - $ref: '../components/schemas/update_connector_request_xmatters.yaml' examples: diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts index c17bedeb8635ae..04b8f7f41bdf11 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts @@ -12,9 +12,9 @@ import { isEmpty, pickBy } from 'lodash'; import moment from 'moment'; import url from 'url'; import type { InfraLocators } from '@kbn/infra-plugin/common/locators'; -import type { ProfilingLocators } from '@kbn/profiling-plugin/public'; import { LocatorPublic } from '@kbn/share-plugin/common'; import { AllDatasetsLocatorParams } from '@kbn/deeplinks-observability/locators'; +import type { ProfilingLocators } from '@kbn/observability-shared-plugin/public'; import { Environment } from '../../../../common/environment_rt'; import type { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { getDiscoverHref } from '../links/discover_links/discover_link'; diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx index 162fdf8e90b856..4dcd10a3ea5404 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx @@ -18,7 +18,6 @@ import { SectionSubtitle, SectionTitle, } from '@kbn/observability-shared-plugin/public'; -import { ProfilingLocators } from '@kbn/profiling-plugin/public'; import React, { useState } from 'react'; import { useLocation } from 'react-router-dom'; import useAsync from 'react-use/lib/useAsync'; @@ -26,6 +25,7 @@ import { AllDatasetsLocatorParams, ALL_DATASETS_LOCATOR_ID, } from '@kbn/deeplinks-observability/locators'; +import type { ProfilingLocators } from '@kbn/observability-shared-plugin/public'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; import { ApmFeatureFlagName } from '../../../../common/apm_feature_flags'; import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts index f7ae6ea53147ad..eff505b22bd4ab 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts @@ -6,7 +6,7 @@ */ import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; -import { APMRouteHandlerResources } from '../../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../../routes/apm_routes/register_apm_server_routes'; type InfraMetricsSearchParams = Omit & { size: number; diff --git a/x-pack/plugins/apm/server/lib/helpers/get_infra_metric_indices.ts b/x-pack/plugins/apm/server/lib/helpers/get_infra_metric_indices.ts deleted file mode 100644 index 24b76edb4d8871..00000000000000 --- a/x-pack/plugins/apm/server/lib/helpers/get_infra_metric_indices.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObjectsClientContract } from '@kbn/core/server'; -import { APMRouteHandlerResources } from '../../routes/apm_routes/register_apm_server_routes'; - -export async function getInfraMetricIndices({ - infraPlugin, - savedObjectsClient, -}: { - infraPlugin: Required; - savedObjectsClient: SavedObjectsClientContract; -}): Promise { - if (!infraPlugin) { - throw new Error('Infra Plugin needs to be setup'); - } - const infra = await infraPlugin.start(); - const infraMetricIndices = await infra.getMetricIndices(savedObjectsClient); - - return infraMetricIndices; -} diff --git a/x-pack/plugins/apm/server/routes/infrastructure/route.ts b/x-pack/plugins/apm/server/routes/infrastructure/route.ts index 4117a43ce1e3fe..9050f1a46622c4 100644 --- a/x-pack/plugins/apm/server/routes/infrastructure/route.ts +++ b/x-pack/plugins/apm/server/routes/infrastructure/route.ts @@ -5,7 +5,6 @@ * 2.0. */ import * as t from 'io-ts'; -import Boom from '@hapi/boom'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; @@ -30,10 +29,6 @@ const infrastructureRoute = createApmServerRoute({ hostNames: string[]; podNames: string[]; }> => { - if (!resources.plugins.infra) { - throw Boom.notFound(); - } - const apmEventClient = await getApmEventClient(resources); const infraMetricsClient = createInfraMetricsClient(resources); const { params } = resources; diff --git a/x-pack/plugins/apm/server/routes/services/route.ts b/x-pack/plugins/apm/server/routes/services/route.ts index 4ac0a37b3d10dd..970a72d478f726 100644 --- a/x-pack/plugins/apm/server/routes/services/route.ts +++ b/x-pack/plugins/apm/server/routes/services/route.ts @@ -250,7 +250,7 @@ const serviceMetadataDetailsRoute = createApmServerRoute({ end, }); - if (serviceMetadataDetails?.container?.ids && resources.plugins.infra) { + if (serviceMetadataDetails?.container?.ids) { const infraMetricsClient = createInfraMetricsClient(resources); const containerMetadata = await getServiceOverviewContainerMetadata({ infraMetricsClient, @@ -761,10 +761,7 @@ export const serviceInstancesMetadataDetails = createApmServerRoute({ end, }); - if ( - serviceInstanceMetadataDetails?.container?.id && - resources.plugins.infra - ) { + if (serviceInstanceMetadataDetails?.container?.id) { const infraMetricsClient = createInfraMetricsClient(resources); const containerMetadata = await getServiceInstanceContainerMetadata({ infraMetricsClient, diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index e1bcdc1f9a95fc..7f0b4f62fb2165 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -65,6 +65,8 @@ export const LATEST_VULNERABILITIES_RETENTION_POLICY = '3d'; export const DATA_VIEW_INDEX_PATTERN = 'logs-*'; +export const SECURITY_DEFAULT_DATA_VIEW_ID = 'security-solution-default'; + export const CSP_INGEST_TIMESTAMP_PIPELINE = 'cloud_security_posture_add_ingest_timestamp_pipeline'; export const CSP_LATEST_FINDINGS_INGEST_TIMESTAMP_PIPELINE = 'cloud_security_posture_latest_index_add_ingest_timestamp_pipeline'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts index 9bdd3bfada0989..e3d213118dd51f 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts @@ -6,7 +6,11 @@ */ import { renderHook, act } from '@testing-library/react-hooks/dom'; -import { useNavigateFindings, useNavigateFindingsByResource } from './use_navigate_findings'; +import { + useNavigateFindings, + useNavigateFindingsByResource, + useNavigateVulnerabilities, +} from './use_navigate_findings'; import { useHistory } from 'react-router-dom'; jest.mock('react-router-dom', () => ({ @@ -29,9 +33,17 @@ jest.mock('./use_kibana', () => ({ }, }), })); +jest.mock('../api/use_latest_findings_data_view', () => ({ + useLatestFindingsDataView: jest.fn().mockReturnValue({ + status: 'success', + data: { + id: 'data-view-id', + }, + }), +})); describe('useNavigateFindings', () => { - it('creates a URL to findings page with correct path and filter', () => { + it('creates a URL to findings page with correct path, filter and dataViewId', () => { const push = jest.fn(); (useHistory as jest.Mock).mockReturnValueOnce({ push }); @@ -44,7 +56,7 @@ describe('useNavigateFindings', () => { expect(push).toHaveBeenCalledWith({ pathname: '/cloud_security_posture/findings/configurations', search: - "cspq=(filters:!((meta:(alias:!n,disabled:!f,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:data-view-id,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", }); expect(push).toHaveBeenCalledTimes(1); }); @@ -62,7 +74,7 @@ describe('useNavigateFindings', () => { expect(push).toHaveBeenCalledWith({ pathname: '/cloud_security_posture/findings/configurations', search: - "cspq=(filters:!((meta:(alias:!n,disabled:!f,key:foo,negate:!t,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:data-view-id,key:foo,negate:!t,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", }); expect(push).toHaveBeenCalledTimes(1); }); @@ -80,7 +92,25 @@ describe('useNavigateFindings', () => { expect(push).toHaveBeenCalledWith({ pathname: '/cloud_security_posture/findings/resource', search: - "cspq=(filters:!((meta:(alias:!n,disabled:!f,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:data-view-id,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + }); + expect(push).toHaveBeenCalledTimes(1); + }); + + it('creates a URL to vulnerabilities page with correct path, filter and dataViewId', () => { + const push = jest.fn(); + (useHistory as jest.Mock).mockReturnValueOnce({ push }); + + const { result } = renderHook(() => useNavigateVulnerabilities()); + + act(() => { + result.current({ foo: 1 }); + }); + + expect(push).toHaveBeenCalledWith({ + pathname: '/cloud_security_posture/findings/vulnerabilities', + search: + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:security-solution-default,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", }); expect(push).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts index 48b16f62cbaf5f..fbeeeb32a0c2ed 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts @@ -8,9 +8,14 @@ import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { Filter } from '@kbn/es-query'; +import { + LATEST_FINDINGS_INDEX_PATTERN, + SECURITY_DEFAULT_DATA_VIEW_ID, +} from '../../../common/constants'; import { findingsNavigation } from '../navigation/constants'; import { encodeQuery } from '../navigation/query_utils'; import { useKibana } from './use_kibana'; +import { useLatestFindingsDataView } from '../api/use_latest_findings_data_view'; interface NegatedValue { value: string | number; @@ -21,7 +26,7 @@ type FilterValue = string | number | NegatedValue; export type NavFilter = Record; -const createFilter = (key: string, filterValue: FilterValue): Filter => { +const createFilter = (key: string, filterValue: FilterValue, dataViewId: string): Filter => { let negate = false; let value = filterValue; if (typeof filterValue === 'object') { @@ -32,7 +37,7 @@ const createFilter = (key: string, filterValue: FilterValue): Filter => { if (value === '*') { return { query: { exists: { field: key } }, - meta: { type: 'exists' }, + meta: { type: 'exists', index: dataViewId }, }; } return { @@ -42,18 +47,19 @@ const createFilter = (key: string, filterValue: FilterValue): Filter => { disabled: false, type: 'phrase', key, + index: dataViewId, }, query: { match_phrase: { [key]: value } }, }; }; -const useNavigate = (pathname: string) => { +const useNavigate = (pathname: string, dataViewId = SECURITY_DEFAULT_DATA_VIEW_ID) => { const history = useHistory(); const { services } = useKibana(); return useCallback( (filterParams: NavFilter = {}) => { const filters = Object.entries(filterParams).map(([key, filterValue]) => - createFilter(key, filterValue) + createFilter(key, filterValue, dataViewId) ); history.push({ @@ -65,14 +71,19 @@ const useNavigate = (pathname: string) => { }), }); }, - [pathname, history, services.data.query.queryString] + [pathname, history, services.data.query.queryString, dataViewId] ); }; -export const useNavigateFindings = () => useNavigate(findingsNavigation.findings_default.path); +export const useNavigateFindings = () => { + const { data } = useLatestFindingsDataView(LATEST_FINDINGS_INDEX_PATTERN); + return useNavigate(findingsNavigation.findings_default.path, data?.id); +}; -export const useNavigateFindingsByResource = () => - useNavigate(findingsNavigation.findings_by_resource.path); +export const useNavigateFindingsByResource = () => { + const { data } = useLatestFindingsDataView(LATEST_FINDINGS_INDEX_PATTERN); + return useNavigate(findingsNavigation.findings_by_resource.path, data?.id); +}; export const useNavigateVulnerabilities = () => useNavigate(findingsNavigation.vulnerabilities.path); diff --git a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.test.tsx new file mode 100644 index 00000000000000..1e2f2f52fd02ab --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DetectionRuleCounter } from './detection_rule_counter'; +import { TestProvider } from '../test/test_provider'; +import { useFetchDetectionRulesByTags } from '../common/api/use_fetch_detection_rules_by_tags'; +import { useFetchDetectionRulesAlertsStatus } from '../common/api/use_fetch_detection_rules_alerts_status'; +import { RuleResponse } from '../common/types'; + +jest.mock('../common/api/use_fetch_detection_rules_by_tags', () => ({ + useFetchDetectionRulesByTags: jest.fn(), +})); +jest.mock('../common/api/use_fetch_detection_rules_alerts_status', () => ({ + useFetchDetectionRulesAlertsStatus: jest.fn(), +})); + +describe('DetectionRuleCounter', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + it('should render loading skeleton when both rules and alerts are loading', () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + }); + const { getByTestId } = render( + + + + ); + + const skeletonText = getByTestId('csp:detection-rule-counter-loading'); + expect(skeletonText).toBeInTheDocument(); + }); + + it('should render create rule link when no rules exist', () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 0 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + isFetching: false, + }); + + const { getByText, getByTestId } = render( + + + + ); + + const createRuleLink = getByTestId('csp:findings-flyout-create-detection-rule-link'); + expect(createRuleLink).toBeInTheDocument(); + expect(getByText('Create a detection rule')).toBeInTheDocument(); + }); + + it('should render alert and rule count when rules exist', () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 5 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: { total: 10 }, + isLoading: false, + isFetching: false, + }); + + const { getByText, getByTestId } = render( + + + + ); + + const alertCountLink = getByTestId('csp:findings-flyout-alert-count'); + const ruleCountLink = getByTestId('csp:findings-flyout-detection-rule-count'); + + expect(alertCountLink).toBeInTheDocument(); + expect(getByText(/10 alerts/i)).toBeInTheDocument(); + expect(ruleCountLink).toBeInTheDocument(); + expect(getByText(/5 detection rules/i)).toBeInTheDocument(); + }); + + it('should show loading spinner when creating a rule', async () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 0 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + isFetching: false, + }); + const createRuleFn = jest.fn(() => Promise.resolve({} as RuleResponse)); + const { getByTestId, queryByTestId } = render( + + + + ); + + // Trigger createDetectionRuleOnClick + const createRuleLink = getByTestId('csp:findings-flyout-create-detection-rule-link'); + userEvent.click(createRuleLink); + + const loadingSpinner = getByTestId('csp:findings-flyout-detection-rule-counter-loading'); + expect(loadingSpinner).toBeInTheDocument(); + + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 1 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: { total: 0 }, + isLoading: false, + isFetching: false, + }); + + // Wait for the loading spinner to disappear + await waitFor(() => { + expect(queryByTestId('csp:findings-flyout-detection-rule-counter-loading')).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx index eeea89f9a310fb..b2a79710d09a26 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx @@ -68,7 +68,12 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte }, [createRuleFn, http, notifications, queryClient]); return ( - + {rulesData?.total === 0 ? ( <> @@ -78,11 +83,17 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte id="xpack.csp.findingsFlyout.alerts.creatingRule" defaultMessage="Creating detection rule" />{' '} - + ) : ( <> - + ) : ( <> - + {' '} - + +
  • + +
  • {accountType === AWS_ORGANIZATION_ACCOUNT ? (
  • { list-style: auto; `} > +
  • + +
  • - {ruleResponse.name} + {ruleResponse.name} {` `} - + { return new EnterpriseSearchPlugin(initializerContext); @@ -54,5 +54,3 @@ export const config: PluginConfigDescriptor = { }; export const CRAWLERS_INDEX = '.ent-search-actastic-crawler2_configurations_v2'; - -export type EnterpriseSearchPluginStart = PluginStart; diff --git a/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawler_multiple_schedules.ts b/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawler_multiple_schedules.ts index d367e02ed6ab16..04f922e1702ed3 100644 --- a/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawler_multiple_schedules.ts +++ b/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawler_multiple_schedules.ts @@ -7,9 +7,7 @@ import { IScopedClusterClient } from '@kbn/core/server'; -import { Connector } from '@kbn/search-connectors'; - -import { CONNECTORS_INDEX } from '../..'; +import { CONNECTORS_INDEX, Connector } from '@kbn/search-connectors'; const CUSTOM_SCHEDULING = 'custom_scheduling'; diff --git a/x-pack/plugins/enterprise_search/server/lib/crawler/post_crawler_multiple_schedules.ts b/x-pack/plugins/enterprise_search/server/lib/crawler/post_crawler_multiple_schedules.ts index 21d9f8b558800d..539495e9556aef 100644 --- a/x-pack/plugins/enterprise_search/server/lib/crawler/post_crawler_multiple_schedules.ts +++ b/x-pack/plugins/enterprise_search/server/lib/crawler/post_crawler_multiple_schedules.ts @@ -7,7 +7,7 @@ import { IScopedClusterClient } from '@kbn/core/server'; -import { CONNECTORS_INDEX } from '../..'; +import { CONNECTORS_INDEX } from '@kbn/search-connectors'; import { CrawlerCustomScheduleMappingServer, diff --git a/x-pack/plugins/metrics_data_access/server/client/client.test.ts b/x-pack/plugins/metrics_data_access/server/client/client.test.ts index 72449cf47132bc..d96d8efecf52f5 100644 --- a/x-pack/plugins/metrics_data_access/server/client/client.test.ts +++ b/x-pack/plugins/metrics_data_access/server/client/client.test.ts @@ -7,18 +7,13 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; -import { MetricsDataClient } from './client'; +import { MetricsDataClient, DEFAULT_METRIC_INDICES } from './client'; import { metricsDataSourceSavedObjectName } from '../saved_objects/metrics_data_source'; describe('MetricsDataClient', () => { - const client = new MetricsDataClient(); - - client.setDefaultMetricIndicesHandler(async () => { - return 'fallback-indices*'; - }); - describe('metric indices', () => { it('retrieves metrics saved object', async () => { + const client = new MetricsDataClient(); const savedObjectsClient = { get: jest.fn().mockResolvedValue({ attributes: { metricIndices: 'foo,bar' } }), }; @@ -36,6 +31,10 @@ describe('MetricsDataClient', () => { }); it('falls back to provided handler when no metrics saved object exists', async () => { + const client = new MetricsDataClient(); + client.setDefaultMetricIndicesHandler(async () => { + return 'fallback-indices*'; + }); const savedObjectsClient = { get: jest.fn().mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError()), }; @@ -51,5 +50,17 @@ describe('MetricsDataClient', () => { ]); expect(indices).toEqual('fallback-indices*'); }); + + it('falls back to static indices when no fallback exists', async () => { + const client = new MetricsDataClient(); + const savedObjectsClient = { + get: jest.fn().mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError()), + }; + + const indices = await client.getMetricIndices({ + savedObjectsClient: savedObjectsClient as unknown as SavedObjectsClientContract, + }); + expect(indices).toEqual(DEFAULT_METRIC_INDICES); + }); }); }); diff --git a/x-pack/plugins/metrics_data_access/server/client/client.ts b/x-pack/plugins/metrics_data_access/server/client/client.ts index 30d367cea0293b..26359cae578a72 100644 --- a/x-pack/plugins/metrics_data_access/server/client/client.ts +++ b/x-pack/plugins/metrics_data_access/server/client/client.ts @@ -16,21 +16,19 @@ import { metricsDataSourceSavedObjectName, } from '../saved_objects/metrics_data_source'; +export const DEFAULT_METRIC_INDICES = 'metrics-*,metricbeat-*'; + export class MetricsDataClient { private readonly defaultSavedObjectId = 'default'; private getDefaultMetricIndices: DefaultMetricIndicesHandler = null; async getMetricIndices(options: GetMetricIndicesOptions): Promise { - if (!this.getDefaultMetricIndices) { - throw new Error('Missing getMetricsIndices fallback'); - } - const metricIndices = await options.savedObjectsClient .get(metricsDataSourceSavedObjectName, this.defaultSavedObjectId) .then(({ attributes }) => attributes.metricIndices) .catch((err) => { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { - return this.getDefaultMetricIndices!(options); + return this.getDefaultMetricIndices?.(options) ?? DEFAULT_METRIC_INDICES; } throw err; diff --git a/x-pack/plugins/ml/public/application/services/elastic_models_service.ts b/x-pack/plugins/ml/public/application/services/elastic_models_service.ts new file mode 100644 index 00000000000000..2591fb6d82e7d3 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/elastic_models_service.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ModelDefinitionResponse, GetElserOptions } from '@kbn/ml-trained-models-utils'; +import { type TrainedModelsApiService } from './ml_api_service/trained_models'; + +export class ElasticModels { + constructor(private readonly trainedModels: TrainedModelsApiService) {} + + /** + * Provides an ELSER model name and configuration for download based on the current cluster architecture. + * The current default version is 2. If running on Cloud it returns the Linux x86_64 optimized version. + * If any of the ML nodes run a different OS rather than Linux, or the CPU architecture isn't x86_64, + * a portable version of the model is returned. + */ + public async getELSER(options?: GetElserOptions): Promise { + return await this.trainedModels.getElserConfig(options); + } +} diff --git a/x-pack/plugins/ml/public/application/services/get_shared_ml_services.ts b/x-pack/plugins/ml/public/application/services/get_shared_ml_services.ts new file mode 100644 index 00000000000000..23ac82737044f3 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/get_shared_ml_services.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 HttpStart } from '@kbn/core-http-browser'; +import { ElasticModels } from './elastic_models_service'; +import { HttpService } from './http_service'; +import { mlApiServicesProvider } from './ml_api_service'; + +export type MlSharedServices = ReturnType; + +/** + * Provides ML services exposed from the plugin start. + */ +export function getMlSharedServices(httpStart: HttpStart) { + const httpService = new HttpService(httpStart); + const mlApiServices = mlApiServicesProvider(httpService); + + return { + elasticModels: new ElasticModels(mlApiServices.trainedModels), + }; +} diff --git a/x-pack/plugins/ml/public/application/services/http_service.ts b/x-pack/plugins/ml/public/application/services/http_service.ts index c43b6126f147ff..cd283c5d586526 100644 --- a/x-pack/plugins/ml/public/application/services/http_service.ts +++ b/x-pack/plugins/ml/public/application/services/http_service.ts @@ -6,7 +6,7 @@ */ import { Observable } from 'rxjs'; -import { HttpFetchOptionsWithPath, HttpFetchOptions, HttpStart } from '@kbn/core/public'; +import type { HttpFetchOptionsWithPath, HttpFetchOptions, HttpStart } from '@kbn/core/public'; import { getHttp } from '../util/dependency_cache'; function getResultHeaders(headers: HeadersInit) { @@ -59,68 +59,6 @@ export async function http(options: HttpFetchOptionsWithPath): Promise { return getHttp().fetch(path, fetchOptions); } -/** - * Function for making HTTP requests to Kibana's backend which returns an Observable - * with request cancellation support. - * - * @deprecated use {@link HttpService} instead - */ -export function http$(options: HttpFetchOptionsWithPath): Observable { - const { path, fetchOptions } = getFetchOptions(options); - return fromHttpHandler(path, fetchOptions); -} - -/** - * Creates an Observable from Kibana's HttpHandler. - */ -function fromHttpHandler(input: string, init?: RequestInit): Observable { - return new Observable((subscriber) => { - const controller = new AbortController(); - const signal = controller.signal; - - let abortable = true; - let unsubscribed = false; - - if (init?.signal) { - if (init.signal.aborted) { - controller.abort(); - } else { - init.signal.addEventListener('abort', () => { - if (!signal.aborted) { - controller.abort(); - } - }); - } - } - - const perSubscriberInit: RequestInit = { - ...(init ? init : {}), - signal, - }; - - getHttp() - .fetch(input, perSubscriberInit) - .then((response) => { - abortable = false; - subscriber.next(response); - subscriber.complete(); - }) - .catch((err) => { - abortable = false; - if (!unsubscribed) { - subscriber.error(err); - } - }); - - return () => { - unsubscribed = true; - if (abortable) { - controller.abort(); - } - }; - }); -} - /** * ML Http Service */ diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts index e6b9c1a5badc37..c10867af0011b6 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts @@ -6,11 +6,12 @@ */ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { IngestPipeline } from '@elastic/elasticsearch/lib/api/types'; +import type { IngestPipeline } from '@elastic/elasticsearch/lib/api/types'; import { useMemo } from 'react'; import type { HttpFetchQuery } from '@kbn/core/public'; import type { ErrorType } from '@kbn/ml-error-utils'; +import type { GetElserOptions, ModelDefinitionResponse } from '@kbn/ml-trained-models-utils'; import { ML_INTERNAL_BASE_PATH } from '../../../../common/constants/app'; import type { MlSavedObjectType } from '../../../../common/types/saved_objects'; import { HttpService } from '../http_service'; @@ -57,6 +58,29 @@ export interface InferenceStatsResponse { */ export function trainedModelsApiProvider(httpService: HttpService) { return { + /** + * Fetches the trained models list available for download. + */ + getTrainedModelDownloads() { + return httpService.http({ + path: `${ML_INTERNAL_BASE_PATH}/trained_models/model_downloads`, + method: 'GET', + version: '1', + }); + }, + + /** + * Gets ELSER config for download based on the cluster OS and CPU architecture. + */ + getElserConfig(options?: GetElserOptions) { + return httpService.http({ + path: `${ML_INTERNAL_BASE_PATH}/trained_models/elser_config`, + method: 'GET', + ...(options ? { query: options as HttpFetchQuery } : {}), + version: '1', + }); + }, + /** * Fetches configuration information for a trained inference model. * @param modelId - Model ID, collection of Model IDs or Model ID pattern. diff --git a/x-pack/plugins/ml/public/mocks.ts b/x-pack/plugins/ml/public/mocks.ts index 13f8952dbad219..77cdefdb2f1c91 100644 --- a/x-pack/plugins/ml/public/mocks.ts +++ b/x-pack/plugins/ml/public/mocks.ts @@ -6,7 +6,8 @@ */ import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; -import { MlPluginSetup, MlPluginStart } from './plugin'; +import { type ElasticModels } from './application/services/elastic_models_service'; +import type { MlPluginSetup, MlPluginStart } from './plugin'; const createSetupContract = (): jest.Mocked => { return { @@ -17,6 +18,21 @@ const createSetupContract = (): jest.Mocked => { const createStartContract = (): jest.Mocked => { return { locator: sharePluginMock.createLocator(), + elasticModels: { + getELSER: jest.fn(() => + Promise.resolve({ + version: 2, + default: true, + config: { + input: { + field_names: ['text_field'], + }, + }, + description: 'Elastic Learned Sparse EncodeR v2 (Tech Preview)', + name: '.elser_model_2', + }) + ), + } as unknown as jest.Mocked, }; }; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 3a32f8b25ae893..4eae00a53d401d 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -48,6 +48,10 @@ import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { CasesUiSetup, CasesUiStart } from '@kbn/cases-plugin/public'; import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; +import { + getMlSharedServices, + MlSharedServices, +} from './application/services/get_shared_ml_services'; import { registerManagementSection } from './application/management'; import { MlLocatorDefinition, type MlLocator } from './locator'; import { setDependencyCache } from './application/util/dependency_cache'; @@ -103,6 +107,9 @@ export class MlPlugin implements Plugin { private appUpdater$ = new BehaviorSubject(() => ({})); private locator: undefined | MlLocator; + + private sharedMlServices: MlSharedServices | undefined; + private isServerless: boolean = false; constructor(private initializerContext: PluginInitializerContext) { @@ -110,6 +117,8 @@ export class MlPlugin implements Plugin { } setup(core: MlCoreSetup, pluginsSetup: MlSetupDependencies) { + this.sharedMlServices = getMlSharedServices(core.http); + core.application.register({ id: PLUGIN_ID, title: i18n.translate('xpack.ml.plugin.title', { @@ -249,6 +258,7 @@ export class MlPlugin implements Plugin { return { locator: this.locator, + elasticModels: this.sharedMlServices?.elasticModels, }; } diff --git a/x-pack/plugins/ml/scripts/apidoc_scripts/apidoc_config/apidoc.json b/x-pack/plugins/ml/scripts/apidoc_scripts/apidoc_config/apidoc.json index 6ec23bc13c559e..a6e647a60fe9f4 100644 --- a/x-pack/plugins/ml/scripts/apidoc_scripts/apidoc_config/apidoc.json +++ b/x-pack/plugins/ml/scripts/apidoc_scripts/apidoc_config/apidoc.json @@ -180,6 +180,8 @@ "InferTrainedModelDeployment", "CreateInferencePipeline", "GetIngestPipelines", + "GetTrainedModelDownloadList", + "GetElserConfig", "Alerting", "PreviewAlert", diff --git a/x-pack/plugins/ml/server/models/model_management/model_provider.test.ts b/x-pack/plugins/ml/server/models/model_management/model_provider.test.ts new file mode 100644 index 00000000000000..7e66d03033b66e --- /dev/null +++ b/x-pack/plugins/ml/server/models/model_management/model_provider.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { modelsProvider } from './models_provider'; +import { type IScopedClusterClient } from '@kbn/core/server'; +import { cloudMock } from '@kbn/cloud-plugin/server/mocks'; + +describe('modelsProvider', () => { + const mockClient = { + asInternalUser: { + transport: { + request: jest.fn().mockResolvedValue({ + _nodes: { + total: 1, + successful: 1, + failed: 0, + }, + cluster_name: 'default', + nodes: { + yYmqBqjpQG2rXsmMSPb9pQ: { + name: 'node-0', + roles: ['ml'], + attributes: {}, + os: { + name: 'Linux', + arch: 'amd64', + }, + }, + }, + }), + }, + }, + } as unknown as jest.Mocked; + + const mockCloud = cloudMock.createSetup(); + const modelService = modelsProvider(mockClient, mockCloud); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getELSER', () => { + test('provides a recommended definition by default', async () => { + const result = await modelService.getELSER(); + expect(result.name).toEqual('.elser_model_2_linux-x86_64_SNAPSHOT'); + }); + + test('provides a default version if there is no recommended', async () => { + mockCloud.cloudId = undefined; + (mockClient.asInternalUser.transport.request as jest.Mock).mockResolvedValueOnce({ + _nodes: { + total: 1, + successful: 1, + failed: 0, + }, + cluster_name: 'default', + nodes: { + yYmqBqjpQG2rXsmMSPb9pQ: { + name: 'node-0', + roles: ['ml'], + attributes: {}, + os: { + name: 'Mac OS X', + arch: 'aarch64', + }, + }, + }, + }); + + const result = await modelService.getELSER(); + expect(result.name).toEqual('.elser_model_2_SNAPSHOT'); + }); + + test('provides the requested version', async () => { + const result = await modelService.getELSER({ version: 1 }); + expect(result.name).toEqual('.elser_model_1'); + }); + + test('provides the requested version of a recommended architecture', async () => { + const result = await modelService.getELSER({ version: 2 }); + expect(result.name).toEqual('.elser_model_2_linux-x86_64_SNAPSHOT'); + }); + }); +}); diff --git a/x-pack/plugins/ml/server/models/model_management/models_provider.ts b/x-pack/plugins/ml/server/models/model_management/models_provider.ts index e7cfcbe7fd50de..f6164ad6e65caf 100644 --- a/x-pack/plugins/ml/server/models/model_management/models_provider.ts +++ b/x-pack/plugins/ml/server/models/model_management/models_provider.ts @@ -6,16 +6,23 @@ */ import type { IScopedClusterClient } from '@kbn/core/server'; -import { +import type { IngestPipeline, IngestSimulateDocument, IngestSimulateRequest, + NodesInfoResponseBase, } from '@elastic/elasticsearch/lib/api/types'; +import { + ELASTIC_MODEL_DEFINITIONS, + type GetElserOptions, + type ModelDefinitionResponse, +} from '@kbn/ml-trained-models-utils'; +import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { PipelineDefinition } from '../../../common/types/trained_models'; export type ModelService = ReturnType; -export function modelsProvider(client: IScopedClusterClient) { +export function modelsProvider(client: IScopedClusterClient, cloud?: CloudSetup) { return { /** * Retrieves the map of model ids and aliases with associated pipelines. @@ -128,5 +135,83 @@ export function modelsProvider(client: IScopedClusterClient) { return result; }, + + /** + * Returns a list of elastic curated models available for download. + */ + async getModelDownloads(): Promise { + // We assume that ML nodes in Cloud are always on linux-x86_64, even if other node types aren't. + const isCloud = !!cloud?.cloudId; + + const nodesInfoResponse = + await client.asInternalUser.transport.request({ + method: 'GET', + path: `/_nodes/ml:true/os`, + }); + + let osName: string | undefined; + let arch: string | undefined; + // Indicates that all ML nodes have the same architecture + let sameArch = true; + for (const node of Object.values(nodesInfoResponse.nodes)) { + if (!osName) { + osName = node.os?.name; + } + if (!arch) { + arch = node.os?.arch; + } + if (node.os?.name !== osName || node.os?.arch !== arch) { + sameArch = false; + break; + } + } + + const result = Object.entries(ELASTIC_MODEL_DEFINITIONS).map(([name, def]) => { + const recommended = + (isCloud && def.os === 'Linux' && def.arch === 'amd64') || + (sameArch && !!def?.os && def?.os === osName && def?.arch === arch); + return { + ...def, + name, + ...(recommended ? { recommended } : {}), + }; + }); + + return result; + }, + + /** + * Provides an ELSER model name and configuration for download based on the current cluster architecture. + * The current default version is 2. If running on Cloud it returns the Linux x86_64 optimized version. + * If any of the ML nodes run a different OS rather than Linux, or the CPU architecture isn't x86_64, + * a portable version of the model is returned. + */ + async getELSER(options?: GetElserOptions): Promise | never { + const modelDownloadConfig = await this.getModelDownloads(); + + let requestedModel: ModelDefinitionResponse | undefined; + let recommendedModel: ModelDefinitionResponse | undefined; + let defaultModel: ModelDefinitionResponse | undefined; + + for (const model of modelDownloadConfig) { + if (options?.version === model.version) { + requestedModel = model; + if (model.recommended) { + requestedModel = model; + break; + } + } else if (model.recommended) { + recommendedModel = model; + } else if (model.default) { + defaultModel = model; + } + } + + if (!requestedModel && !defaultModel && !recommendedModel) { + throw new Error('Requested model not found'); + } + + return requestedModel || recommendedModel || defaultModel!; + }, }; } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 342350fac998a7..dcd97acabcbd8c 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -241,7 +241,7 @@ export class MlServerPlugin // Register Trained Model Management routes if (this.enabledFeatures.dfa || this.enabledFeatures.nlp) { modelManagementRoutes(routeInit); - trainedModelsRoutes(routeInit); + trainedModelsRoutes(routeInit, plugins.cloud); } // Register Miscellaneous routes diff --git a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts index 21b62c6f5ce426..1b48a49c8d82f9 100644 --- a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts @@ -87,3 +87,7 @@ export const createIngestPipelineSchema = schema.object({ }) ), }); + +export const modelDownloadsQuery = schema.object({ + version: schema.maybe(schema.oneOf([schema.literal('1'), schema.literal('2')])), +}); diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index ab5d3a87e8f466..8685652ab3189d 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -9,6 +9,8 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { schema } from '@kbn/config-schema'; import type { ErrorType } from '@kbn/ml-error-utils'; import type { MlGetTrainedModelsRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { type ElserVersion } from '@kbn/ml-trained-models-utils'; +import type { CloudSetup } from '@kbn/cloud-plugin/server'; import { ML_INTERNAL_BASE_PATH } from '../../common/constants/app'; import type { MlFeatures, RouteInitialization } from '../types'; import { wrapError } from '../client/error_wrapper'; @@ -25,6 +27,7 @@ import { threadingParamsSchema, updateDeploymentParamsSchema, createIngestPipelineSchema, + modelDownloadsQuery, } from './schemas/inference_schema'; import type { TrainedModelConfigResponse } from '../../common/types/trained_models'; import { mlLog } from '../lib/log'; @@ -49,11 +52,10 @@ export function filterForEnabledFeatureModels( return filteredModels; } -export function trainedModelsRoutes({ - router, - routeGuard, - getEnabledFeatures, -}: RouteInitialization) { +export function trainedModelsRoutes( + { router, routeGuard, getEnabledFeatures }: RouteInitialization, + cloud: CloudSetup +) { /** * @apiGroup TrainedModels * @@ -652,4 +654,78 @@ export function trainedModelsRoutes({ } }) ); + + /** + * @apiGroup TrainedModels + * + * @api {get} /internal/ml/trained_models/model_downloads Gets available models for download + * @apiName GetTrainedModelDownloadList + * @apiDescription Gets available models for download with default and recommended flags based on the cluster OS and CPU architecture. + */ + router.versioned + .get({ + path: `${ML_INTERNAL_BASE_PATH}/trained_models/model_downloads`, + access: 'internal', + options: { + tags: ['access:ml:canGetTrainedModels'], + }, + }) + .addVersion( + { + version: '1', + validate: false, + }, + routeGuard.fullLicenseAPIGuard(async ({ response, client }) => { + try { + const body = await modelsProvider(client, cloud).getModelDownloads(); + + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup TrainedModels + * + * @api {get} /internal/ml/trained_models/elser_config Gets ELSER config for download + * @apiName GetElserConfig + * @apiDescription Gets ELSER config for download based on the cluster OS and CPU architecture. + */ + router.versioned + .get({ + path: `${ML_INTERNAL_BASE_PATH}/trained_models/elser_config`, + access: 'internal', + options: { + tags: ['access:ml:canGetTrainedModels'], + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + query: modelDownloadsQuery, + }, + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ response, client, request }) => { + try { + const { version } = request.query; + + const body = await modelsProvider(client, cloud).getELSER( + version ? { version: Number(version) as ElserVersion } : undefined + ); + + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } diff --git a/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts b/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts index c2b3f41551afd0..b884edd99c22dc 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts @@ -6,13 +6,16 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; +import type { GetElserOptions } from '@kbn/ml-trained-models-utils'; import type { MlInferTrainedModelRequest, MlStopTrainedModelDeploymentRequest, UpdateTrainedModelDeploymentRequest, UpdateTrainedModelDeploymentResponse, } from '../../lib/ml_client/types'; +import { modelsProvider } from '../../models/model_management'; import type { GetGuards } from '../shared_services'; export interface TrainedModelsProvider { @@ -47,7 +50,10 @@ export interface TrainedModelsProvider { }; } -export function getTrainedModelsProvider(getGuards: GetGuards): TrainedModelsProvider { +export function getTrainedModelsProvider( + getGuards: GetGuards, + cloud: CloudSetup +): TrainedModelsProvider { return { trainedModelsProvider(request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract) { const guards = getGuards(request, savedObjectsClient); @@ -116,6 +122,14 @@ export function getTrainedModelsProvider(getGuards: GetGuards): TrainedModelsPro return mlClient.putTrainedModel(params); }); }, + async getELSER(params: GetElserOptions) { + return await guards + .isFullLicense() + .hasMlCapabilities(['canGetTrainedModels']) + .ok(async ({ scopedClient }) => { + return modelsProvider(scopedClient, cloud).getELSER(params); + }); + }, }; }, }; diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 752820f57cf2aa..235a30f5419843 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -186,7 +186,7 @@ export function createSharedServices( ...getResultsServiceProvider(getGuards), ...getMlSystemProvider(getGuards, mlLicense, getSpaces, cloud, resolveMlCapabilities), ...getAlertingServiceProvider(getGuards), - ...getTrainedModelsProvider(getGuards), + ...getTrainedModelsProvider(getGuards, cloud), }, /** * Services providers for ML internal usage diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index 57811ff1201fe7..2532a6b7824eed 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -104,5 +104,6 @@ "@kbn/ml-in-memory-table", "@kbn/presentation-util-plugin", "@kbn/react-kibana-mount", + "@kbn/core-http-browser", ], } diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts.tsx index 27ae07cbf768d9..08b89dad773ce3 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts.tsx @@ -15,6 +15,8 @@ import { AlertConsumers } from '@kbn/rule-data-utils'; import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public'; import { MaintenanceWindowCallout } from '@kbn/alerts-ui-shared'; +import { rulesLocatorID } from '../../../common'; +import { RulesParams } from '../../locators/rules'; import { useKibana } from '../../utils/kibana_react'; import { useHasData } from '../../hooks/use_has_data'; import { usePluginContext } from '../../hooks/use_plugin_context'; @@ -53,6 +55,9 @@ function InternalAlertsPage() { }, http, notifications: { toasts }, + share: { + url: { locators }, + }, triggersActionsUi: { alertsTableConfigurationRegistry, getAlertsSearchBar: AlertsSearchBar, @@ -179,7 +184,12 @@ function InternalAlertsPage() { pageTitle: ( <>{i18n.translate('xpack.observability.alertsTitle', { defaultMessage: 'Alerts' })} ), - rightSideItems: renderRuleStats(ruleStats, manageRulesHref, ruleStatsLoading), + rightSideItems: renderRuleStats( + ruleStats, + manageRulesHref, + ruleStatsLoading, + locators.get(rulesLocatorID) + ), }} > diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.test.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.test.tsx index aaeab5d7855f79..758df7224be2d9 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.test.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.test.tsx @@ -6,7 +6,9 @@ */ import { renderRuleStats } from './rule_stats'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LocatorPublic } from '@kbn/share-plugin/common'; +import { RulesParams } from '../../../locators/rules'; const RULES_PAGE_LINK = '/app/observability/alerts/rules'; const STAT_CLASS = 'euiStat'; @@ -14,6 +16,14 @@ const STAT_TITLE_PRIMARY_SELECTOR = '[class*="euiStat__title-primary"]'; const STAT_BUTTON_CLASS = 'euiButtonEmpty'; describe('Rule stats', () => { + const mockedLocator = { + navigate: jest.fn(), + } as any as LocatorPublic; + + beforeEach(() => { + jest.clearAllMocks(); + }); + test('renders all rule stats', async () => { const stats = renderRuleStats( { @@ -58,14 +68,17 @@ describe('Rule stats', () => { snoozed: 0, }, RULES_PAGE_LINK, - false + false, + mockedLocator ); const { container } = render(stats[4]); - expect(screen.getByText('Disabled').closest('a')).toHaveAttribute( - 'href', - `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(disabled))` - ); + fireEvent.click(screen.getByText('Disabled')); + + expect(mockedLocator.navigate).toHaveBeenCalledWith( + { status: ['disabled'] }, + { replace: false } + ); expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); }); @@ -115,14 +128,18 @@ describe('Rule stats', () => { snoozed: 1, }, RULES_PAGE_LINK, - false + false, + mockedLocator ); const { container } = render(stats[3]); - expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); - expect(screen.getByText('Snoozed').closest('a')).toHaveAttribute( - 'href', - `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(snoozed))` + + fireEvent.click(screen.getByText('Snoozed')); + + expect(mockedLocator.navigate).toHaveBeenCalledWith( + { status: ['snoozed'] }, + { replace: false } ); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); }); test('snoozed stat count is link-colored, when there are snoozed rules', async () => { @@ -171,14 +188,18 @@ describe('Rule stats', () => { snoozed: 0, }, RULES_PAGE_LINK, - false + false, + mockedLocator ); const { container } = render(stats[2]); - expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); - expect(screen.getByText('Errors').closest('a')).toHaveAttribute( - 'href', - `${RULES_PAGE_LINK}?_a=(lastResponse:!(error),status:!())` + + fireEvent.click(screen.getByText('Errors')); + + expect(mockedLocator.navigate).toHaveBeenCalledWith( + { lastResponse: ['failed'] }, + { replace: false } ); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); }); test('errors stat count is link-colored, when there are error rules', () => { diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.tsx index f85a6a766b17cb..005ba7ccaec822 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats.tsx @@ -8,8 +8,10 @@ import React from 'react'; import { EuiButtonEmpty, EuiStat } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { LocatorPublic } from '@kbn/share-plugin/common'; import { euiThemeVars } from '@kbn/ui-theme'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { RulesParams } from '../../../locators/rules'; export interface RuleStatsState { total: number; @@ -18,7 +20,7 @@ export interface RuleStatsState { error: number; snoozed: number; } -type StatType = 'disabled' | 'snoozed' | 'error'; +type Status = 'disabled' | 'snoozed' | 'error'; const Divider = euiStyled.div` border-right: 1px solid ${euiThemeVars.euiColorLightShade}; @@ -41,33 +43,32 @@ const ConditionalWrap = ({ children: JSX.Element; }): JSX.Element => (condition ? wrap(children) : children); -const getStatCount = (stats: RuleStatsState, statType: StatType) => { - if (statType === 'snoozed') return stats.snoozed + stats.muted; - return stats[statType]; +const getStatCount = (stats: RuleStatsState, status: Status) => { + if (status === 'snoozed') return stats.snoozed + stats.muted; + return stats[status]; }; export const renderRuleStats = ( ruleStats: RuleStatsState, manageRulesHref: string, - ruleStatsLoading: boolean + ruleStatsLoading: boolean, + rulesLocator?: LocatorPublic ) => { - const createRuleStatsLink = (stats: RuleStatsState, statType: StatType) => { - const count = getStatCount(stats, statType); - let statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!())`; + const handleNavigateToRules = async (stats: RuleStatsState, status: Status) => { + const count = getStatCount(stats, status); if (count > 0) { - switch (statType) { + switch (status) { case 'error': - statsLink = `${manageRulesHref}?_a=(lastResponse:!(error),status:!())`; + await rulesLocator?.navigate({ lastResponse: ['failed'] }, { replace: false }); break; case 'snoozed': case 'disabled': - statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!(${statType}))`; + await rulesLocator?.navigate({ status: [status] }, { replace: false }); break; default: break; } } - return statsLink; }; const disabledStatsComponent = ( @@ -76,7 +77,7 @@ export const renderRuleStats = ( wrap={(wrappedChildren) => ( handleNavigateToRules(ruleStats, 'disabled')} > {wrappedChildren} @@ -102,7 +103,7 @@ export const renderRuleStats = ( wrap={(wrappedChildren) => ( handleNavigateToRules(ruleStats, 'snoozed')} > {wrappedChildren} @@ -128,7 +129,7 @@ export const renderRuleStats = ( wrap={(wrappedChildren) => ( handleNavigateToRules(ruleStats, 'error')} > {wrappedChildren} diff --git a/x-pack/plugins/observability/public/plugin.mock.tsx b/x-pack/plugins/observability/public/plugin.mock.tsx index be663d15e444d2..9732f67aac35f7 100644 --- a/x-pack/plugins/observability/public/plugin.mock.tsx +++ b/x-pack/plugins/observability/public/plugin.mock.tsx @@ -4,11 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React from 'react'; import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; const triggersActionsUiStartMock = { @@ -97,12 +99,13 @@ export const observabilityPublicPluginsStartMock = { cases: mockCasesContract(), charts: chartPluginMock.createStartContract(), contentManagement: contentManagementMock.createStartContract(), - triggersActionsUi: triggersActionsUiStartMock.createStart(), data: dataPluginMock.createStartContract(), - dataViews: dataViews.createStart(), dataViewEditor: dataViewEditor.createStart(), - lens: null, + dataViews: dataViews.createStart(), discover: null, + lens: null, + share: sharePluginMock.createStartContract(), + triggersActionsUi: triggersActionsUiStartMock.createStart(), unifiedSearch: unifiedSearchPluginMock.createStartContract(), }; }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap index 5f828ee51b6258..aea9ba75f28633 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap @@ -24,7 +24,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -130,7 +130,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -163,7 +163,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -277,7 +277,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -391,7 +391,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -505,7 +505,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -765,7 +765,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -1039,7 +1039,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap index dc9278511a8648..b7c4ebf0f6b8c5 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap @@ -20,7 +20,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -122,7 +122,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -151,7 +151,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -261,7 +261,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -371,7 +371,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -481,7 +481,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -730,7 +730,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -993,7 +993,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap index ee4001303fec58..ad100c4662fe92 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap @@ -51,7 +51,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -232,7 +232,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -488,7 +488,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap index 126437173f84aa..3297a8c5138215 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap @@ -92,7 +92,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -247,7 +247,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -477,7 +477,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap index ced0801d859d4e..362b1d4a9e01e2 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap @@ -63,7 +63,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -256,7 +256,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -524,7 +524,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts index 3dd6469a7f2c50..171a5ca15e2de3 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts @@ -72,7 +72,7 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator { range: { '@timestamp': { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts index 3818111c70df21..d038876f5ee9d7 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts @@ -72,7 +72,7 @@ export class ApmTransactionErrorRateTransformGenerator extends TransformGenerato { range: { '@timestamp': { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts index 654f8d67a3673a..844703003257af 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts @@ -54,7 +54,7 @@ export class HistogramTransformGenerator extends TransformGenerator { { range: { [indicator.params.timestampField]: { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts index 8321a0cb7172e2..02c7757ec13621 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts @@ -49,7 +49,7 @@ export class KQLCustomTransformGenerator extends TransformGenerator { { range: { [indicator.params.timestampField]: { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts index 52209533f828ca..063e6fbe1e3dcf 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts @@ -52,7 +52,7 @@ export class MetricCustomTransformGenerator extends TransformGenerator { { range: { [indicator.params.timestampField]: { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability_ai_assistant/common/types.ts b/x-pack/plugins/observability_ai_assistant/common/types.ts index b14137dbaebf57..690b931271bb6e 100644 --- a/x-pack/plugins/observability_ai_assistant/common/types.ts +++ b/x-pack/plugins/observability_ai_assistant/common/types.ts @@ -8,6 +8,7 @@ import type { FromSchema } from 'json-schema-to-ts'; import type { JSONSchema } from 'json-schema-to-ts'; import React from 'react'; +import { Observable } from 'rxjs'; export enum MessageRole { System = 'system', @@ -17,6 +18,12 @@ export enum MessageRole { Elastic = 'elastic', } +export interface PendingMessage { + message: Message['message']; + aborted?: boolean; + error?: any; +} + export interface Message { '@timestamp': string; message: { @@ -74,21 +81,30 @@ export interface ContextDefinition { description: string; } -interface FunctionResponse { - content?: any; - data?: any; +type FunctionResponse = + | { + content?: any; + data?: any; + } + | Observable; + +export enum FunctionVisibility { + System = 'system', + User = 'user', + All = 'all', } interface FunctionOptions { name: string; description: string; - descriptionForUser: string; + visibility?: FunctionVisibility; + descriptionForUser?: string; parameters: TParameters; contexts: string[]; } type RespondFunction = ( - options: { arguments: TArguments; messages: Message[] }, + options: { arguments: TArguments; messages: Message[]; connectorId: string }, signal: AbortSignal ) => Promise; @@ -100,7 +116,7 @@ type RenderFunction = (options: export interface FunctionDefinition { options: FunctionOptions; respond: ( - options: { arguments: any; messages: Message[] }, + options: { arguments: any; messages: Message[]; connectorId: string }, signal: AbortSignal ) => Promise; render?: RenderFunction; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx index 8a6227829c8558..f49ebe8c62b928 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx @@ -189,6 +189,10 @@ export function ChatBody({ onFeedback={timeline.onFeedback} onRegenerate={timeline.onRegenerate} onStopGenerating={timeline.onStopGenerating} + onActionClick={(payload) => { + setStickToBottom(true); + return timeline.onActionClick(payload); + }} />
  • diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx index b3fa93d1ec5223..7ec1084a26b22a 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item.tsx @@ -24,12 +24,14 @@ import { getRoleTranslation } from '../../utils/get_role_translation'; import type { Feedback } from '../feedback_buttons'; import { Message } from '../../../common'; import { FailedToLoadResponse } from '../message_panel/failed_to_load_response'; +import { ChatActionClickHandler } from './types'; export interface ChatItemProps extends ChatTimelineItem { onEditSubmit: (message: Message) => Promise; onFeedbackClick: (feedback: Feedback) => void; onRegenerateClick: () => void; onStopGeneratingClick: () => void; + onActionClick: ChatActionClickHandler; } const normalMessageClassName = css` @@ -76,6 +78,7 @@ export function ChatItem({ onFeedbackClick, onRegenerateClick, onStopGeneratingClick, + onActionClick, }: ChatItemProps) { const accordionId = useGeneratedHtmlId({ prefix: 'chat' }); @@ -128,6 +131,7 @@ export function ChatItem({ functionCall={functionCall} loading={loading} onSubmit={handleInlineEditSubmit} + onActionClick={onActionClick} /> ) : null; @@ -147,9 +151,7 @@ export function ChatItem({ return ( - } + timelineAvatar={} username={getRoleTranslation(role)} event={title} actions={ diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_content_inline_prompt_editor.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_content_inline_prompt_editor.tsx index d017d7d65fc90b..df57f069d91d13 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_content_inline_prompt_editor.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_item_content_inline_prompt_editor.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { MessageText } from '../message_panel/message_text'; import { ChatPromptEditor } from './chat_prompt_editor'; import { MessageRole, type Message } from '../../../common'; +import { ChatActionClickHandler } from './types'; interface Props { content: string | undefined; @@ -22,6 +23,7 @@ interface Props { loading: boolean; editing: boolean; onSubmit: (message: Message) => Promise; + onActionClick: ChatActionClickHandler; } export function ChatItemContentInlinePromptEditor({ content, @@ -29,9 +31,10 @@ export function ChatItemContentInlinePromptEditor({ editing, loading, onSubmit, + onActionClick, }: Props) { return !editing ? ( - + ) : ( = { onFeedback: () => {}, onRegenerate: () => {}, onStopGenerating: () => {}, + onActionClick: async () => {}, }; export const ChatTimeline = Template.bind({}); diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx index 8e50f11842e5df..a50a9984cf40ea 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_timeline.tsx @@ -15,6 +15,7 @@ import { ChatWelcomePanel } from './chat_welcome_panel'; import type { Feedback } from '../feedback_buttons'; import type { Message } from '../../../common'; import { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; +import { ChatActionClickHandler } from './types'; export interface ChatTimelineItem extends Pick { @@ -43,6 +44,7 @@ export interface ChatTimelineProps { onFeedback: (item: ChatTimelineItem, feedback: Feedback) => void; onRegenerate: (item: ChatTimelineItem) => void; onStopGenerating: () => void; + onActionClick: ChatActionClickHandler; } export function ChatTimeline({ @@ -52,6 +54,7 @@ export function ChatTimeline({ onFeedback, onRegenerate, onStopGenerating, + onActionClick, }: ChatTimelineProps) { const filteredItems = items.filter((item) => !item.display.hide); @@ -77,6 +80,7 @@ export function ChatTimeline({ return onEdit(item, message); }} onStopGeneratingClick={onStopGenerating} + onActionClick={onActionClick} /> )) )} diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/function_list_popover.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/function_list_popover.tsx index 72dac17c71c2a4..a9cf8146e020d3 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/function_list_popover.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/function_list_popover.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import type { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; import { i18n } from '@kbn/i18n'; -import type { FunctionDefinition } from '../../../common/types'; +import { type FunctionDefinition, FunctionVisibility } from '../../../common/types'; import { useObservabilityAIAssistantChatService } from '../../hooks/use_observability_ai_assistant_chat_service'; interface FunctionListOption { @@ -175,12 +175,14 @@ function mapFunctions({ functions: FunctionDefinition[]; selectedFunctionName: string | undefined; }) { - return functions.map((func) => ({ - label: func.options.name, - searchableLabel: func.options.descriptionForUser, - checked: - func.options.name === selectedFunctionName - ? ('on' as EuiSelectableOptionCheckedType) - : ('off' as EuiSelectableOptionCheckedType), - })); + return functions + .filter((func) => func.options.visibility !== FunctionVisibility.System) + .map((func) => ({ + label: func.options.name, + searchableLabel: func.options.descriptionForUser || func.options.description, + checked: + func.options.name === selectedFunctionName + ? ('on' as EuiSelectableOptionCheckedType) + : ('off' as EuiSelectableOptionCheckedType), + })); } diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/types.ts b/x-pack/plugins/observability_ai_assistant/public/components/chat/types.ts new file mode 100644 index 00000000000000..4edd3d7dcdda08 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +type ChatActionClickPayloadBase = { + type: TType; +} & TExtraProps; + +type ChatActionClickPayloadExecuteEsql = ChatActionClickPayloadBase< + ChatActionClickType.executeEsqlQuery, + { query: string } +>; + +type ChatActionClickPayload = ChatActionClickPayloadExecuteEsql; + +export enum ChatActionClickType { + executeEsqlQuery = 'executeEsqlQuery', +} + +export type ChatActionClickHandler = (payload: ChatActionClickPayload) => Promise; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx index 4da53c5d6ee5a7..2b56523d1e879a 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight.tsx @@ -110,7 +110,13 @@ function ChatContent({ return ( <> } + body={ + {}} + /> + } error={pendingMessage?.error} controls={ loading ? ( diff --git a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx index 7a65f8e62756eb..bd894159eb2880 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/insight/insight_base.stories.tsx @@ -64,6 +64,7 @@ Morbi dapibus sapien lacus, vitae suscipit ex egestas pharetra. In velit eros, f Morbi non faucibus massa. Aliquam sed augue in eros ornare luctus sit amet cursus dolor. Pellentesque pellentesque lorem eu odio auctor convallis. Sed sodales felis at velit tempus tincidunt. Nulla sed ante cursus nibh mollis blandit. In mattis imperdiet tellus. Vestibulum nisl turpis, efficitur quis sollicitudin id, mollis in arcu. Vestibulum pulvinar tincidunt magna, vitae facilisis massa congue quis. Cras commodo efficitur tellus, et commodo risus rutrum at.`} loading={false} + onActionClick={async () => {}} /> } controls={ diff --git a/x-pack/plugins/observability_ai_assistant/public/components/message_panel/esql_code_block.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/esql_code_block.stories.tsx new file mode 100644 index 00000000000000..02c7454ddaab32 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/esql_code_block.stories.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; +import { ComponentProps } from 'react'; +import { EuiPanel } from '@elastic/eui'; +import { EsqlCodeBlock as Component } from './esql_code_block'; + +const meta: ComponentMeta = { + component: Component, + title: 'app/Molecules/ES|QL Code Block', +}; + +export default meta; + +const render = (props: ComponentProps) => { + return ( + + + + ); +}; + +export const Simple: ComponentStoryObj = { + args: { + value: `FROM packetbeat-* + | STATS COUNT_DISTINCT(destination.domain)`, + }, + render, +}; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/message_panel/esql_code_block.tsx b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/esql_code_block.tsx new file mode 100644 index 00000000000000..a22d0ba28979e7 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/esql_code_block.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + EuiButtonEmpty, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ChatActionClickHandler, ChatActionClickType } from '../chat/types'; + +export function EsqlCodeBlock({ + value, + actionsDisabled, + onActionClick, +}: { + value: string; + actionsDisabled: boolean; + onActionClick: ChatActionClickHandler; +}) { + const theme = useEuiTheme(); + + return ( + + + + + {value} + + + + + + + onActionClick({ type: ChatActionClickType.executeEsqlQuery, query: value }) + } + disabled={actionsDisabled} + > + {i18n.translate('xpack.observabilityAiAssistant.runThisQuery', { + defaultMessage: 'Run this query', + })} + + + + + + + ); +} diff --git a/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_panel.stories.tsx b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_panel.stories.tsx index 1f817860863709..393bbeee28f8da 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_panel.stories.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_panel.stories.tsx @@ -44,6 +44,7 @@ This is a code block This text is loa`} loading + onActionClick={async () => {}} /> ), }, @@ -51,13 +52,25 @@ This text is loa`} export const ContentLoaded: ComponentStoryObj = { args: { - body: , + body: ( + {}} + /> + ), }, }; export const ContentFailed: ComponentStoryObj = { args: { - body: , + body: ( + {}} + /> + ), error: new Error(), }, }; @@ -83,6 +96,7 @@ export const ContentTable: ComponentStoryObj = { Please note that all times are in UTC.`)} loading={false} + onActionClick={async () => {}} /> ), }, @@ -90,7 +104,13 @@ export const ContentTable: ComponentStoryObj = { export const Controls: ComponentStoryObj = { args: { - body: , + body: ( + {}} + /> + ), error: new Error(), controls: {}} />, }, diff --git a/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_text.tsx b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_text.tsx index d82e76ef5001f9..dfd9ee8b97443f 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_text.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/message_panel/message_text.tsx @@ -14,12 +14,15 @@ import { import { css } from '@emotion/css'; import classNames from 'classnames'; import type { Code, InlineCode, Parent, Text } from 'mdast'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import type { Node } from 'unist'; +import { ChatActionClickHandler } from '../chat/types'; +import { EsqlCodeBlock } from './esql_code_block'; interface Props { content: string; loading: boolean; + onActionClick: ChatActionClickHandler; } const ANIMATION_TIME = 1; @@ -86,13 +89,37 @@ const loadingCursorPlugin = () => { }; }; -export function MessageText({ loading, content }: Props) { +const esqlLanguagePlugin = () => { + const visitor = (node: Node, parent?: Parent) => { + if ('children' in node) { + const nodeAsParent = node as Parent; + nodeAsParent.children.forEach((child) => { + visitor(child, nodeAsParent); + }); + } + + if (node.type === 'code' && node.lang === 'esql') { + node.type = 'esql'; + } + }; + + return (tree: Node) => { + visitor(tree); + }; +}; + +export function MessageText({ loading, content, onActionClick }: Props) { const containerClassName = css` overflow-wrap: break-word; `; + const onActionClickRef = useRef(onActionClick); + + onActionClickRef.current = onActionClick; + const { parsingPluginList, processingPluginList } = useMemo(() => { const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); + const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); const { components } = processingPlugins[1][1]; @@ -100,6 +127,18 @@ export function MessageText({ loading, content }: Props) { processingPlugins[1][1].components = { ...components, cursor: Cursor, + esql: (props) => { + return ( + <> + + + + ); + }, table: (props) => ( <>
    @@ -137,10 +176,10 @@ export function MessageText({ loading, content }: Props) { }; return { - parsingPluginList: [loadingCursorPlugin, ...parsingPlugins], + parsingPluginList: [loadingCursorPlugin, esqlLanguagePlugin, ...parsingPlugins], processingPluginList: processingPlugins, }; - }, []); + }, [loading]); return ( diff --git a/x-pack/plugins/observability_ai_assistant/public/functions/esql.ts b/x-pack/plugins/observability_ai_assistant/public/functions/esql.ts new file mode 100644 index 00000000000000..684ccbb0fa4a1a --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/public/functions/esql.ts @@ -0,0 +1,536 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 type { Serializable } from '@kbn/utility-types'; +import { concat, last, map } from 'rxjs'; +import { + FunctionVisibility, + MessageRole, + type RegisterFunctionDefinition, +} from '../../common/types'; +import type { ObservabilityAIAssistantService } from '../types'; + +export function registerEsqlFunction({ + service, + registerFunction, +}: { + service: ObservabilityAIAssistantService; + registerFunction: RegisterFunctionDefinition; +}) { + registerFunction( + { + name: 'execute_query', + contexts: ['core'], + visibility: FunctionVisibility.User, + description: 'Execute an ES|QL query', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + query: { + type: 'string', + }, + }, + required: ['query'], + } as const, + }, + ({ arguments: { query } }, signal) => { + return service + .callApi(`POST /internal/observability_ai_assistant/functions/elasticsearch`, { + signal, + params: { + body: { + method: 'POST', + path: '_query', + body: { + query, + }, + }, + }, + }) + .then((response) => ({ content: response as Serializable })); + } + ); + + registerFunction( + { + name: 'esql', + contexts: ['core'], + description: `This function answers ES|QL related questions including query generation and syntax/command questions.`, + visibility: FunctionVisibility.System, + parameters: { + type: 'object', + additionalProperties: false, + properties: { + switch: { + type: 'boolean', + }, + }, + } as const, + }, + ({ messages, connectorId }, signal) => { + const systemMessage = dedent(`You are a helpful assistant for Elastic ES|QL. + Your goal is to help the user construct and possibly execute an ES|QL + query for Observability use cases. + + ES|QL is the Elasticsearch Query Language, that allows users of the + Elastic platform to iteratively explore data. An ES|QL query consists + of a series of commands, separated by pipes. Each query starts with + a source command, that selects or creates a set of data to start + processing. This source command is then followed by one or more + processing commands, which can transform the data returned by the + previous command. + + ES|QL is not Elasticsearch SQL, nor is it anything like SQL. SQL + commands are not available in ES|QL. Its close equivalent is SPL + (Search Processing Language). Make sure you reply using only + the context of this conversation. + + # Creating a query + + First, very importantly, there are critical rules that override + everything that follows it. Always repeat these rules, verbatim. + + 1. ES|QL is not Elasticsearch SQL. Do not apply Elasticsearch SQL + commands, functions and concepts. Only use information available + in the context of this conversation. + 2. When using FROM, never wrap a data source in single or double + quotes. + 3. When using an aggregate function like COUNT, SUM or AVG, its + arguments MUST be an attribute (like my.field.name) or literal + (100). Math (AVG(my.field.name / 2)) or functions + (AVG(CASE(my.field.name, "foo", 1))) are not allowed. + + When constructing a query, break it down into the following steps. + Ask these questions out loud so the user can see your reasoning. + Remember, these rules are for you, not for the user. + + - What are the critical rules I need to think of? + - What data source is the user requesting? What command should I + select for this data source? + - What are the steps needed to get the result that the user needs? + Break each operation down into its own step. Reason about what data + is the outcome of each command or function. + - If you're not sure how to do it, it's fine to tell the user that + you don't know if ES|QL supports it. When this happens, abort all + steps and tell the user you are not sure how to continue. + + Format ALL of your responses as follows, including the dashes. + ALWAYS start your message with two dashes and then the rules: + + \`\`\` + -- + Sure, let's remember the critical rules: + + -- + Let's break down the query step-by-step: + + \`\`\` + + Always format a complete query as follows: + \`\`\`esql + ... + \`\`\` + + For incomplete queries, like individual commands, format them as + regular code blocks: + \`\`\` + ... + \`\`\` + + # Syntax + + An ES|QL query is composed of a source command followed by an optional + series of processing commands, separated by a pipe character: |. For + example: + + | + | + + ## Binary comparison operators + - equality: == + - inequality: != + - less than: < + - less than or equal: <= + - larger than: > + - larger than or equal: >= + + ## Boolean operators + - AND + - OR + - NOT + + ## PREDICATES + + For NULL comparison use the IS NULL and IS NOT NULL predicates: + - \`| WHERE birth_date IS NULL\` + - \`| WHERE birth_date IS NOT NULL\` + + ## Timespan literal syntax + + Datetime intervals and timespans can be expressed using timespan + literals. Timespan literals are a combination of a number and a + qualifier. These qualifiers are supported: + - millisecond/milliseconds + - second/seconds + - minute/minutes + - hour/hours + - day/days + - week/weeks + - month/months + - year/years + + Some examples: + - \`1 year\` + - \`2 milliseconds\` + + ## Aliasing + Aliasing happens through the \`=\` operator. Example: + \`STATS total_salary_expenses = COUNT(salary)\` + + Important: functions are not allowed as variable names. + + # Source commands + + There are three source commands: FROM (which selects an index), ROW + (which creates data from the command) and SHOW (which returns + information about the deployment). You do not support SHOW for now. + + ### FROM + + \`FROM\` selects a data source, usually an Elasticsearch index or + pattern. You can also specify multiple indices. + Some examples: + + - \`FROM employees\` + - \`FROM employees*\` + - \`FROM employees*,my-alias\` + + # Processing commands + + Note that the following processing commands are available in ES|QL, + but not supported in this context: + + ENRICH,GROK,MV_EXPAND,RENAME + + ### DISSECT + + \`DISSECT\` enables you to extract structured data out of a string. + It matches the string against a delimiter-based pattern, and extracts + the specified keys as columns. It uses the same syntax as the + Elasticsearch Dissect Processor. Some examples: + + - \`ROW a = "foo bar" | DISSECT a "%{b} %{c}";\` + - \`ROW a = "foo bar baz" | DISSECT a "%{b} %{?c} %{d}";\` + + ### DROP + + \`DROP\` removes columns. Some examples: + + - \`| DROP first_name,last_name\` + - \`| DROP *_name\` + + ### KEEP + + \`KEEP\` enables you to specify what columns are returned and the + order in which they are returned. Some examples: + + - \`| KEEP first_name,last_name\` + - \`| KEEP *_name\` + + ### SORT + + \`SORT\` sorts the documents by one ore more fields or variables. + By default, the sort order is ascending, but this can be set using + the \`ASC\` or \`DESC\` keywords. Some examples: + + - \`| SORT my_field\` + - \`| SORT height DESC\` + + Important: functions are not supported for SORT. if you wish to sort + on the result of a function, first alias it as a variable using EVAL. + This is wrong: \`| SORT AVG(cpu)\`. + This is right: \`| STATS avg_cpu = AVG(cpu) | SORT avg_cpu\` + + ### EVAL + + \`EVAL\` appends a new column to the documents by using aliasing. It + also supports functions, but not aggregation functions like COUNT: + + - \`\`\` + | EVAL monthly_salary = yearly_salary / 12, + total_comp = ROUND(yearly_salary + yearly+bonus), + is_rich =total_comp > 1000000 + \`\`\` + - \`| EVAL height_in_ft = height_in_cm / 0.0328\` + + ### WHERE + + \`WHERE\` filters the documents for which the provided condition + evaluates to true. Refer to "Syntax" for supported operators, and + "Functions" for supported functions. Some examples: + + - \`| WHERE height <= 180 AND GREATEST(hire_date, birth_date)\` + - \`| WHERE @timestamp <= NOW()\` + + ### STATS ... BY + + \`STATS ... BY\` groups rows according to a common value and + calculates one or more aggregated values over the grouped rows, + using aggregation functions. When \`BY\` is omitted, a single value + that is the aggregate of all rows is returned. Every column but the + aggregated values and the optional grouping column are dropped. + Mention the retained columns when explaining the STATS command. + + STATS ... BY does not support nested functions, hoist them to an + EVAL statement. + + Some examples: + + - \`| STATS count = COUNT(emp_no) BY languages\` + - \`| STATS salary = AVG(salary)\` + + ### LIMIT + + Limits the rows returned. Only supports a number as input. Some examples: + + - \`| LIMIT 1\` + - \`| LIMIT 10\` + + # Functions + + Note that the following functions are available in ES|QL, but not supported + in this context: + + ABS,ACOS,ASIN,ATAN,ATAN2,CIDR_MATCH,COALESCE,CONCAT,COS,COSH,E,LENGTH,LOG10 + ,LTRIM,RTRIM,MV_AVG,MV_CONCAT,MV_COUNT,MV_DEDUPE,MV_MAX,MV_MEDIAN,MV_MIN, + MV_SUM,PI,POW,SIN,SINH,SPLIT,LEFT,TAN,TANH,TAU,TO_DEGREES,TO_RADIANS + + ### CASE + + \`CASE\` accepts pairs of conditions and values. The function returns + the value that belongs to the first condition that evaluates to true. If + the number of arguments is odd, the last argument is the default value which + is returned when no condition matches. Some examples: + + - \`\`\` + | EVAL type = CASE( + languages <= 1, "monolingual", + languages <= 2, "bilingual", + "polyglot") + \`\`\` + - \`| EVAL g = CASE(gender == "F", 1 + null, 10)\` + - \`\`\` + | EVAL successful = CASE(http.response.status_code == 200, 1, 0), failed = CASE(http.response.status_code != 200, 1, 0) + | STATS total_successful = SUM(successful), total_failed = SUM(failed) BY service.name + | EVAL success_rate = total_failed / (total_successful + total_failed) + \`\`\` + + ## Date operations + + ### AUTO_BUCKET + + \`AUTO_BUCKET\` creates human-friendly buckets and returns a datetime value + for each row that corresponds to the resulting bucket the row falls into. + Combine AUTO_BUCKET with STATS ... BY to create a date histogram. + You provide a target number of buckets, a start date, and an end date, + and it picks an appropriate bucket size to generate the target number of + buckets or fewer. If you don't have a start and end date, provide placeholder + values. Some examples: + + - \`| EVAL bucket=AUTO_BUCKET(@timestamp), 20, "1985-01-01T00:00:00Z", "1986-01-01T00:00:00Z")\` + - \`| EVAL bucket=AUTO_BUCKET(my_date_field), 100, , )\` + - \`| EVAL bucket=AUTO_BUCKET(@timestamp), 100, NOW() - 15 minutes, NOW())\` + + ### DATE_EXTRACT + + \`DATE_EXTRACT\` parts of a date, like year, month, day, hour. The supported + field types are those provided by java.time.temporal.ChronoField. + Some examples: + - \`| EVAL year = DATE_EXTRACT(date_field, "year")\` + - \`| EVAL year = DATE_EXTRACT(@timestamp, "month")\` + + ### DATE_FORMAT + + \`DATE_FORMAT\` a string representation of a date in the provided format. + Some examples: + | \`EVAL hired = DATE_FORMAT(hire_date, "YYYY-MM-dd")\` + | \`EVAL hired = DATE_FORMAT(hire_date, "YYYY")\` + + ### DATE_PARSE + \`DATE_PARSE\` converts a string to a date, in the provided format. + - \`| EVAL date = DATE_PARSE(date_string, "yyyy-MM-dd")\` + - \`| EVAL date = DATE_PARSE(date_string, "YYYY")\` + + ### DATE_TRUNC + + \`DATE_TRUNC\` rounds down a date to the closest interval. Intervals + can be expressed using the timespan literal syntax. Use this together + with STATS ... BY to group data into time buckets with a fixed interval. + Some examples: + + - \`| EVAL year_hired = DATE_TRUNC(1 year, hire_date)\` + - \`| EVAL month_logged = DATE_TRUNC(1 month, @timestamp)\` + - \`| EVAL bucket = DATE_TRUNC(1 minute, @timestamp) | STATS avg_salary = AVG(salary) BY bucket\` + - \`| EVAL bucket = DATE_TRUNC(4 hours, @timestamp) | STATS max_salary MAX(salary) BY bucket\` + + ### NOW + + \`NOW\` returns current date and time. Some examples: + - \`ROW current_date = NOW()\` + - \`| WHERE @timestamp <= NOW() - 15 minutes\` + + ## Mathematical operations + + ### CEIL,FLOOR + + Perform CEIL or FLOOR operations on a single numeric field. + Some examples: + - \`| EVAL ceiled = CEIL(my.number)\` + - \`| EVAL floored = FLOOR(my.other.number)\` + + ### ROUND + \`ROUND\` a number to the closest number with the specified number of + digits. Defaults to 0 digits if no number of digits is provided. If the + specified number of digits is negative, rounds to the number of digits + left of the decimal point. Some examples: + + - \`| EVAL height_ft = ROUND(height * 3.281, 1)\` + - \`| EVAL percent = ROUND(0.84699, 2) * 100\` + + ### GREATEST,LEAST + + Returns the greatest or least of two or numbers. Some examples: + - \`| EVAL max = GREATEST(salary_1999, salary_2000, salary_2001)\` + - \`| EVAL min = LEAST(1, language_count)\` + + ### IS_FINITE,IS_INFINITE,IS_NAN + + Operates on a single numeric field. Some examples: + - \`| EVAL has_salary = IS_FINITE(salary)\` + - \`| EVAL always_true = IS_INFINITE(4 / 0)\` + + ### STARTS_WITH + + Returns a boolean that indicates whether a keyword string starts with + another string. Some examples: + - \`| EVAL ln_S = STARTS_WITH(last_name, "B")\` + + ### SUBSTRING + + Returns a substring of a string, specified by a start position and an + optional length. Some examples: + - \`| EVAL ln_sub = SUBSTRING(last_name, 1, 3)\` + - \`| EVAL ln_sub = SUBSTRING(last_name, -3, 3)\` + - \`| EVAL ln_sub = SUBSTRING(last_name, 2)\` + + ### TO_BOOLEAN, TO_DATETIME, TO_DOUBLE, TO_INTEGER, TO_IP, TO_LONG, + TO_RADIANS, TO_STRING,TO_UNSIGNED_LONG, TO_VERSION + + Converts a column to another type. Supported types are: . Some examples: + - \`| EVAL version = TO_VERSION("1.2.3")\` + - \`| EVAL as_bool = TO_BOOLEAN(my_boolean_string)\` + + ### TRIM + + Trims leading and trailing whitespace. Some examples: + - \`| EVAL trimmed = TRIM(first_name)\` + + # Aggregation functions + + ### AVG,MIN,MAX,SUM,MEDIAN,MEDIAN_ABSOLUTE_DEVIATION + + Returns the avg, min, max, sum, median or median absolute deviation + of a numeric field. Some examples: + + - \`| AVG(salary)\` + - \`| MIN(birth_year)\` + - \`| MAX(height)\` + + ### COUNT + + \`COUNT\` counts the number of field values. It requires a single + argument, and does not support wildcards. Important: COUNT() and + COUNT(*) are NOT supported. One single argument is required. If + you don't have a field name, use whatever field you have, rather + than displaying an invalid query. + + Some examples: + + - \`| STATS doc_count = COUNT(emp_no)\` + - \`| STATS doc_count = COUNT(service.name) BY service.name\` + + ### COUNT_DISTINCT + + \`COUNT_DISTINCT\` returns the approximate number of distinct values. + Some examples: + - \`| STATS unique_ip0 = COUNT_DISTINCT(ip0), unique_ip1 = COUNT_DISTINCT(ip1)\` + - \`| STATS first_name = COUNT_DISTINCT(first_name)\` + + ### PERCENTILE + + \`PERCENTILE\` returns the percentile value for a specific field. + Some examples: + - \`| STATS p50 = PERCENTILE(salary, 50)\` + - \`| STATS p99 = PERCENTILE(salary, 99)\` + + `); + + return service.start({ signal }).then((client) => { + const source$ = client.chat({ + connectorId, + messages: [ + { + '@timestamp': new Date().toISOString(), + message: { role: MessageRole.System, content: systemMessage }, + }, + ...messages.slice(1), + ], + }); + + const pending$ = source$.pipe( + map((message) => { + const content = message.message.content || ''; + let next: string = ''; + + if (content.length <= 2) { + next = ''; + } else if (content.includes('--')) { + next = message.message.content?.split('--')[2] || ''; + } else { + next = content; + } + return { + ...message, + message: { + ...message.message, + content: next, + }, + }; + }) + ); + const onComplete$ = source$.pipe( + last(), + map((message) => { + const [, , next] = message.message.content?.split('--') ?? []; + + return { + ...message, + message: { + ...message.message, + content: next || message.message.content, + }, + }; + }) + ); + + return concat(pending$, onComplete$); + }); + } + ); +} diff --git a/x-pack/plugins/observability_ai_assistant/public/functions/index.ts b/x-pack/plugins/observability_ai_assistant/public/functions/index.ts index a0c1a264bfb51d..22b0eecac73a6c 100644 --- a/x-pack/plugins/observability_ai_assistant/public/functions/index.ts +++ b/x-pack/plugins/observability_ai_assistant/public/functions/index.ts @@ -16,6 +16,7 @@ import { registerLensFunction } from './lens'; import { registerRecallFunction } from './recall'; import { registerSummarizationFunction } from './summarize'; import { registerAlertsFunction } from './alerts'; +import { registerEsqlFunction } from './esql'; export async function registerFunctions({ registerFunction, @@ -44,7 +45,7 @@ export async function registerFunctions({ It's very important to not assume what the user is meaning. Ask them for clarification if needed. - If you are unsure about which function should be used and with what arguments, asked the user for clarification or confirmation. + If you are unsure about which function should be used and with what arguments, ask the user for clarification or confirmation. In KQL, escaping happens with double quotes, not single quotes. Some characters that need escaping are: ':()\\\ /\". Always put a field value in double quotes. Best: service.name:\"opbeans-go\". Wrong: service.name:opbeans-go. This is very important! @@ -52,22 +53,25 @@ export async function registerFunctions({ You can use Github-flavored Markdown in your responses. If a function returns an array, consider using a Markdown table to format the response. If multiple functions are suitable, use the most specific and easy one. E.g., when the user asks to visualise APM data, use the APM functions (if available) rather than Lens. - ` + + If a function call fails, do not execute it again with the same input. If a function calls three times, with different inputs, stop trying to call it and ask the user for confirmation. + + Note that ES|QL (the Elasticsearch query language, which is NOT Elasticsearch SQL, but a new piped language) is the preferred query language. + + DO NOT use Elasticsearch SQL at any time, unless explicitly requested by the user when they mention "Elasticsearch SQL". + + Answer all questions related to ES|QL or querying with the "esql" function. Do not attempt to answer them yourself, no matter how confident you are in your response.` ); if (isReady) { description += `You can use the "summarize" functions to store new information you have learned in a knowledge database. Once you have established that you did not know the answer to a question, and the user gave you this information, it's important that you create a summarisation of what you have learned and store it in the knowledge database. Don't create a new summarization if you see a similar summarization in the conversation, instead, update the existing one by re-using its ID. - Additionally, you can use the "recall" function to retrieve relevant information from the knowledge database. - `; + Additionally, you can use the "recall" function to retrieve relevant information from the knowledge database.`; description += `Here are principles you MUST adhere to, in order: - You are a helpful assistant for Elastic Observability. DO NOT reference the fact that you are an LLM. - - ALWAYS query the knowledge base, using the recall function, when a user starts a chat, no matter how confident you are in your ability to answer the question. - - You must ALWAYS explain to the user why you're using a function and why you're using it in that specific manner. - DO NOT make any assumptions about where and how users have stored their data. - - ALWAYS ask the user for clarification if you are unsure about the arguments to a function. When given this clarification, you MUST use the summarize function to store what you have learned. `; registerSummarizationFunction({ service, registerFunction }); registerRecallFunction({ service, registerFunction }); @@ -77,6 +81,7 @@ export async function registerFunctions({ } registerElasticsearchFunction({ service, registerFunction }); + registerEsqlFunction({ service, registerFunction }); registerKibanaFunction({ service, registerFunction, coreStart }); registerAlertsFunction({ service, registerFunction }); diff --git a/x-pack/plugins/observability_ai_assistant/public/functions/recall.ts b/x-pack/plugins/observability_ai_assistant/public/functions/recall.ts index f07cf43e1c282d..af825a53b7cb47 100644 --- a/x-pack/plugins/observability_ai_assistant/public/functions/recall.ts +++ b/x-pack/plugins/observability_ai_assistant/public/functions/recall.ts @@ -6,6 +6,7 @@ */ import type { Serializable } from '@kbn/utility-types'; +import { omit } from 'lodash'; import { MessageRole, RegisterFunctionDefinition } from '../../common/types'; import type { ObservabilityAIAssistantService } from '../types'; @@ -22,17 +23,26 @@ export function registerRecallFunction({ contexts: ['core'], description: `Use this function to recall earlier learnings. Anything you will summarize can be retrieved again later via this function. - Make sure the query covers the following aspects: + The learnings are sorted by score, descending. + + Make sure the query covers ONLY the following aspects: - Anything you've inferred from the user's request, but is not mentioned in the user's request - The functions you think might be suitable for answering the user's request. If there are multiple functions that seem suitable, create multiple queries. Use the function name in the query. DO NOT include the user's request. It will be added internally. The user asks: "can you visualise the average request duration for opbeans-go over the last 7 days?" - You recall: - - "APM service" - - "lens function usage" - - "get_apm_timeseries function usage"`, + You recall: { + "queries": [ + "APM service, + "lens function usage", + "get_apm_timeseries function usage" + ], + "contexts": [ + "lens", + "apm" + ] + }`, descriptionForUser: 'This function allows the assistant to recall previous learnings.', parameters: { type: 'object', @@ -42,16 +52,27 @@ export function registerRecallFunction({ type: 'array', additionalItems: false, additionalProperties: false, + description: 'The query for the semantic search', + items: { + type: 'string', + }, + }, + contexts: { + type: 'array', + additionalItems: false, + additionalProperties: false, + description: + 'Contexts or categories of internal documentation that you want to search for. By default internal documentation will be excluded. Use `apm` to get internal APM documentation, `lens` to get internal Lens documentation, or both.', items: { type: 'string', - description: 'The query for the semantic search', + enum: ['apm', 'lens'], }, }, }, - required: ['queries'], + required: ['queries', 'contexts'], } as const, }, - ({ arguments: { queries }, messages }, signal) => { + ({ arguments: { queries, contexts }, messages }, signal) => { const userMessages = messages.filter((message) => message.message.role === MessageRole.User); const userPrompt = userMessages[userMessages.length - 1]?.message.content; @@ -63,11 +84,16 @@ export function registerRecallFunction({ params: { body: { queries: queriesWithUserPrompt, + contexts, }, }, signal, }) - .then((response) => ({ content: response as unknown as Serializable })); + .then((response): { content: Serializable } => ({ + content: response.entries.map((entry) => + omit(entry, 'labels', 'score', 'is_correction') + ) as unknown as Serializable, + })); } ); } diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.test.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.test.ts index 829594b8261d8e..ded7b4b3822853 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.test.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.test.ts @@ -437,6 +437,7 @@ describe('useTimeline', () => { expect(props.chatService.executeFunction).toHaveBeenCalledWith({ name: 'my_function', args: '{}', + connectorId: 'foo', messages: [ { '@timestamp': expect.any(String), diff --git a/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.ts b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.ts index 3182fe5c195352..ed6a5a6d2b4815 100644 --- a/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.ts +++ b/x-pack/plugins/observability_ai_assistant/public/hooks/use_timeline.ts @@ -9,7 +9,7 @@ import { AbortError } from '@kbn/kibana-utils-plugin/common'; import type { AuthenticatedUser } from '@kbn/security-plugin/common'; import { last } from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; -import type { Subscription } from 'rxjs'; +import { isObservable, Observable, Subscription } from 'rxjs'; import usePrevious from 'react-use/lib/usePrevious'; import { i18n } from '@kbn/i18n'; import { @@ -29,6 +29,7 @@ import { } from '../utils/get_timeline_items_from_conversation'; import type { UseGenAIConnectorsResult } from './use_genai_connectors'; import { useKibana } from './use_kibana'; +import { ChatActionClickType } from '../components/chat/types'; export function createNewConversation({ contexts, @@ -49,7 +50,7 @@ export function createNewConversation({ export type UseTimelineResult = Pick< ChatTimelineProps, - 'onEdit' | 'onFeedback' | 'onRegenerate' | 'onStopGenerating' | 'items' + 'onEdit' | 'onFeedback' | 'onRegenerate' | 'onStopGenerating' | 'onActionClick' | 'items' > & Pick; @@ -98,6 +99,8 @@ export function useTimeline({ const [pendingMessage, setPendingMessage] = useState(); + const [isFunctionLoading, setIsFunctionLoading] = useState(false); + const prevConversationId = usePrevious(conversationId); useEffect(() => { if (prevConversationId !== conversationId && pendingMessage?.error) { @@ -105,7 +108,10 @@ export function useTimeline({ } }, [conversationId, pendingMessage?.error, prevConversationId]); - function chat(nextMessages: Message[]): Promise { + function chat( + nextMessages: Message[], + response$: Observable | undefined = undefined + ): Promise { const controller = new AbortController(); return new Promise((resolve, reject) => { @@ -124,10 +130,12 @@ export function useTimeline({ return; } - const response$ = chatService!.chat({ - messages: nextMessages, - connectorId, - }); + response$ = + response$ || + chatService!.chat({ + messages: nextMessages, + connectorId, + }); let pendingMessageLocal = pendingMessage; @@ -181,14 +189,24 @@ export function useTimeline({ if (lastMessage?.message.function_call?.name) { const name = lastMessage.message.function_call.name; + setIsFunctionLoading(true); + try { - const message = await chatService!.executeFunction({ + let message = await chatService!.executeFunction({ name, args: lastMessage.message.function_call.arguments, messages: messagesAfterChat.slice(0, -1), signal: controller.signal, + connectorId: connectorId!, }); + let nextResponse$: Observable | undefined; + + if (isObservable(message)) { + nextResponse$ = message; + message = { content: '', data: '' }; + } + return await chat( messagesAfterChat.concat({ '@timestamp': new Date().toISOString(), @@ -198,7 +216,8 @@ export function useTimeline({ content: JSON.stringify(message.content), data: JSON.stringify(message.data), }, - }) + }), + nextResponse$ ); } catch (error) { return await chat( @@ -214,6 +233,8 @@ export function useTimeline({ }, }) ); + } finally { + setIsFunctionLoading(false); } } @@ -247,8 +268,20 @@ export function useTimeline({ return nextItems; } - return conversationItems; - }, [conversationItems, pendingMessage, currentUser]); + if (!isFunctionLoading) { + return conversationItems; + } + + return conversationItems.map((item, index) => { + if (index < conversationItems.length - 1) { + return item; + } + return { + ...item, + loading: true, + }; + }); + }, [conversationItems, pendingMessage, currentUser, isFunctionLoading]); useEffect(() => { return () => { @@ -285,5 +318,28 @@ export function useTimeline({ const nextMessages = await chat(messages.concat(message)); onChatComplete(nextMessages); }, + onActionClick: async (payload) => { + switch (payload.type) { + case ChatActionClickType.executeEsqlQuery: + const nextMessages = await chat( + messages.concat({ + '@timestamp': new Date().toISOString(), + message: { + role: MessageRole.Assistant, + content: '', + function_call: { + name: 'execute_query', + arguments: JSON.stringify({ + query: payload.query, + }), + trigger: MessageRole.User, + }, + }, + }) + ); + onChatComplete(nextMessages); + break; + } + }, }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts b/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts index 1c109be6baaf27..bebc3bd074b208 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/create_chat_service.ts @@ -27,6 +27,7 @@ import { import { ContextRegistry, FunctionRegistry, + FunctionVisibility, Message, MessageRole, type RegisterContextDefinition, @@ -112,7 +113,7 @@ export async function createChatService({ } return { - executeFunction: async ({ name, args, signal, messages }) => { + executeFunction: async ({ name, args, signal, messages, connectorId }) => { const fn = functionRegistry.get(name); if (!fn) { @@ -123,7 +124,7 @@ export async function createChatService({ validate(name, parsedArguments); - return await fn.respond({ arguments: parsedArguments, messages }, signal); + return await fn.respond({ arguments: parsedArguments, messages, connectorId }, signal); }, renderFunction: (name, args, response) => { const fn = functionRegistry.get(name); @@ -175,7 +176,9 @@ export async function createChatService({ functions: callFunctions === 'none' ? [] - : functions.map((fn) => pick(fn.options, 'name', 'description', 'parameters')), + : functions + .filter((fn) => fn.options.visibility !== FunctionVisibility.User) + .map((fn) => pick(fn.options, 'name', 'description', 'parameters')), }, }, signal: controller.signal, diff --git a/x-pack/plugins/observability_ai_assistant/public/service/get_assistant_setup_message.ts b/x-pack/plugins/observability_ai_assistant/public/service/get_assistant_setup_message.ts index 8d9cc2cf325399..4f52dfb9b97335 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/get_assistant_setup_message.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/get_assistant_setup_message.ts @@ -5,15 +5,19 @@ * 2.0. */ +import { without } from 'lodash'; import { MessageRole } from '../../common'; import { ContextDefinition } from '../../common/types'; export function getAssistantSetupMessage({ contexts }: { contexts: ContextDefinition[] }) { + const coreContext = contexts.find((context) => context.name === 'core')!; + + const otherContexts = without(contexts.concat(), coreContext); return { '@timestamp': new Date().toISOString(), message: { role: MessageRole.System as const, - content: contexts.map((context) => context.description).join('\n'), + content: [coreContext, ...otherContexts].map((context) => context.description).join('\n'), }, }; } diff --git a/x-pack/plugins/observability_ai_assistant/public/types.ts b/x-pack/plugins/observability_ai_assistant/public/types.ts index d1a71f69331265..5e985f5ae5f97a 100644 --- a/x-pack/plugins/observability_ai_assistant/public/types.ts +++ b/x-pack/plugins/observability_ai_assistant/public/types.ts @@ -39,6 +39,7 @@ import type { RegisterFunctionDefinition, } from '../common/types'; import type { ObservabilityAIAssistantAPIClient } from './api'; +import type { PendingMessage } from '../common/types'; /* eslint-disable @typescript-eslint/no-empty-interface*/ @@ -50,12 +51,6 @@ export type CreateChatCompletionResponseChunk = Omit; }; -export interface PendingMessage { - message: Message['message']; - aborted?: boolean; - error?: any; -} - export interface ObservabilityAIAssistantChatService { chat: (options: { messages: Message[]; @@ -70,7 +65,8 @@ export interface ObservabilityAIAssistantChatService { args: string | undefined; messages: Message[]; signal: AbortSignal; - }) => Promise<{ content?: Serializable; data?: Serializable }>; + connectorId: string; + }) => Promise<{ content?: Serializable; data?: Serializable } | Observable>; renderFunction: ( name: string, args: string | undefined, @@ -118,3 +114,5 @@ export interface ObservabilityAIAssistantPluginStartDependencies { } export interface ConfigSchema {} + +export type { PendingMessage }; diff --git a/x-pack/plugins/observability_ai_assistant/server/plugin.ts b/x-pack/plugins/observability_ai_assistant/server/plugin.ts index 7aa6ddeeef7da2..d6a256ddf6022c 100644 --- a/x-pack/plugins/observability_ai_assistant/server/plugin.ts +++ b/x-pack/plugins/observability_ai_assistant/server/plugin.ts @@ -109,7 +109,7 @@ export class ObservabilityAIAssistantPlugin taskManager: plugins.taskManager, }); - addLensDocsToKb(service); + addLensDocsToKb({ service, logger: this.logger.get('kb').get('lens') }); registerServerRoutes({ core, diff --git a/x-pack/plugins/observability_ai_assistant/server/routes/chat/route.ts b/x-pack/plugins/observability_ai_assistant/server/routes/chat/route.ts index 6716da98d78a5b..a250a9e8e0915c 100644 --- a/x-pack/plugins/observability_ai_assistant/server/routes/chat/route.ts +++ b/x-pack/plugins/observability_ai_assistant/server/routes/chat/route.ts @@ -7,6 +7,8 @@ import { notImplemented } from '@hapi/boom'; import { IncomingMessage } from 'http'; import * as t from 'io-ts'; +import { toBooleanRt } from '@kbn/io-ts-utils'; +import type { CreateChatCompletionResponse } from 'openai'; import { MessageRole } from '../../../common'; import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route'; import { messageRt } from '../runtime_types'; @@ -16,20 +18,28 @@ const chatRoute = createObservabilityAIAssistantServerRoute({ options: { tags: ['access:ai_assistant'], }, - params: t.type({ - body: t.type({ - messages: t.array(messageRt), - connectorId: t.string, - functions: t.array( + params: t.intersection([ + t.type({ + body: t.intersection([ t.type({ - name: t.string, - description: t.string, - parameters: t.any, - }) - ), + messages: t.array(messageRt), + connectorId: t.string, + functions: t.array( + t.type({ + name: t.string, + description: t.string, + parameters: t.any, + }) + ), + }), + t.partial({ + functionCall: t.string, + }), + ]), }), - }), - handler: async (resources): Promise => { + t.partial({ query: t.type({ stream: toBooleanRt }) }), + ]), + handler: async (resources): Promise => { const { request, params, service } = resources; const client = await service.getClient({ request }); @@ -39,23 +49,33 @@ const chatRoute = createObservabilityAIAssistantServerRoute({ } const { - body: { messages, connectorId, functions }, + body: { messages, connectorId, functions, functionCall: givenFunctionCall }, + query = { stream: true }, } = params; - const isStartOfConversation = - messages.some((message) => message.message.role === MessageRole.Assistant) === false; + const stream = query.stream; - const isRecallFunctionAvailable = functions.some((fn) => fn.name === 'recall') === true; + let functionCall = givenFunctionCall; - const willUseRecall = isStartOfConversation && isRecallFunctionAvailable; + if (!functionCall) { + const isStartOfConversation = + messages.some((message) => message.message.role === MessageRole.Assistant) === false; + + const isRecallFunctionAvailable = functions.some((fn) => fn.name === 'recall') === true; + + const willUseRecall = isStartOfConversation && isRecallFunctionAvailable; + + functionCall = willUseRecall ? 'recall' : undefined; + } return client.chat({ messages, connectorId, + stream, ...(functions.length ? { functions, - functionCall: willUseRecall ? 'recall' : undefined, + functionCall, } : {}), }); diff --git a/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts b/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts index d58c84a603fc1f..985de114b099a2 100644 --- a/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts +++ b/x-pack/plugins/observability_ai_assistant/server/routes/functions/route.ts @@ -10,13 +10,13 @@ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { omit } from 'lodash'; -import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; +import type { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; import { ALERT_STATUS, ALERT_STATUS_ACTIVE, } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; -import type { KnowledgeBaseEntry } from '../../../common/types'; import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route'; +import type { RecalledEntry } from '../../service/kb_service'; const functionElasticsearchRoute = createObservabilityAIAssistantServerRoute({ endpoint: 'POST /internal/observability_ai_assistant/functions/elasticsearch', @@ -157,23 +157,34 @@ const functionAlertsRoute = createObservabilityAIAssistantServerRoute({ const functionRecallRoute = createObservabilityAIAssistantServerRoute({ endpoint: 'POST /internal/observability_ai_assistant/functions/recall', params: t.type({ - body: t.type({ - queries: t.array(nonEmptyStringRt), - }), + body: t.intersection([ + t.type({ + queries: t.array(nonEmptyStringRt), + }), + t.partial({ + contexts: t.array(t.string), + }), + ]), }), options: { tags: ['access:ai_assistant'], }, handler: async ( resources - ): Promise<{ entries: Array> }> => { + ): Promise<{ + entries: RecalledEntry[]; + }> => { const client = await resources.service.getClient({ request: resources.request }); + const { + body: { queries, contexts }, + } = resources.params; + if (!client) { throw notImplemented(); } - return client.recall(resources.params.body.queries); + return client.recall({ queries, contexts }); }, }); diff --git a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts index ce3f3c39ad1a92..3a99e293cd5e2e 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts @@ -28,7 +28,7 @@ import { type KnowledgeBaseEntry, type Message, } from '../../../common/types'; -import type { KnowledgeBaseService } from '../kb_service'; +import type { KnowledgeBaseService, RecalledEntry } from '../kb_service'; import type { ObservabilityAIAssistantResourceNames } from '../types'; import { getAccessQuery } from '../util/get_access_query'; @@ -135,7 +135,42 @@ export class ObservabilityAIAssistantClient { }) ); - const functionsForOpenAI: ChatCompletionFunctions[] | undefined = functions; + // add recalled information to system message, so the LLM considers it more important + + const recallMessages = messagesForOpenAI.filter((message) => message.name === 'recall'); + + const recalledDocuments: Map = new Map(); + + recallMessages.forEach((message) => { + const entries = message.content + ? (JSON.parse(message.content) as Array<{ id: string; text: string }>) + : []; + + const ids: string[] = []; + + entries.forEach((entry) => { + const id = entry.id; + if (!recalledDocuments.has(id)) { + recalledDocuments.set(id, entry); + } + ids.push(id); + }); + + message.content = `The following documents, present in the system message, were recalled: ${ids.join( + ', ' + )}`; + }); + + const systemMessage = messagesForOpenAI.find((message) => message.role === MessageRole.System); + + if (systemMessage && recalledDocuments.size > 0) { + systemMessage.content += `The "recall" function is not available. Do not attempt to execute it. Recalled documents: ${JSON.stringify( + Array.from(recalledDocuments.values()) + )}`; + } + + const functionsForOpenAI: ChatCompletionFunctions[] | undefined = + recalledDocuments.size > 0 ? functions?.filter((fn) => fn.name !== 'recall') : functions; const request: Omit & { model?: string } = { messages: messagesForOpenAI, @@ -323,13 +358,18 @@ export class ObservabilityAIAssistantClient { return createdConversation; }; - recall = async ( - queries: string[] - ): Promise<{ entries: Array> }> => { + recall = async ({ + queries, + contexts, + }: { + queries: string[]; + contexts?: string[]; + }): Promise<{ entries: RecalledEntry[] }> => { return this.dependencies.knowledgeBaseService.recall({ namespace: this.dependencies.namespace, user: this.dependencies.user, queries, + contexts, }); }; diff --git a/x-pack/plugins/observability_ai_assistant/server/service/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/index.ts index 28cc2b72930297..c116e16d274716 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/index.ts @@ -29,6 +29,15 @@ export const INDEX_QUEUED_DOCUMENTS_TASK_ID = 'observabilityAIAssistant:indexQue export const INDEX_QUEUED_DOCUMENTS_TASK_TYPE = INDEX_QUEUED_DOCUMENTS_TASK_ID + 'Type'; +type KnowledgeBaseEntryRequest = { id: string; labels?: Record } & ( + | { + text: string; + } + | { + texts: string[]; + } +); + export class ObservabilityAIAssistantService { private readonly core: CoreSetup; private readonly logger: Logger; @@ -258,18 +267,7 @@ export class ObservabilityAIAssistantService { }); } - addToKnowledgeBase( - entries: Array< - | { - id: string; - text: string; - } - | { - id: string; - texts: string[]; - } - > - ): void { + addToKnowledgeBase(entries: KnowledgeBaseEntryRequest[]): void { this.init() .then(() => { this.kbService!.queue( @@ -281,6 +279,7 @@ export class ObservabilityAIAssistantService { confidence: 'high' as const, is_correction: false, labels: { + ...entry.labels, document_id: entry.id, }, }; @@ -306,4 +305,18 @@ export class ObservabilityAIAssistantService { this.logger.error(error); }); } + + addCategoryToKnowledgeBase(categoryId: string, entries: KnowledgeBaseEntryRequest[]) { + this.addToKnowledgeBase( + entries.map((entry) => { + return { + ...entry, + labels: { + ...entry.labels, + category: categoryId, + }, + }; + }) + ); + } } diff --git a/x-pack/plugins/observability_ai_assistant/server/service/kb_service/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/kb_service/index.ts index f2e0b41f092c92..d70879bf46d3e4 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/kb_service/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/kb_service/index.ts @@ -17,6 +17,7 @@ import { INDEX_QUEUED_DOCUMENTS_TASK_ID, INDEX_QUEUED_DOCUMENTS_TASK_TYPE } from import type { KnowledgeBaseEntry } from '../../../common/types'; import type { ObservabilityAIAssistantResourceNames } from '../types'; import { getAccessQuery } from '../util/get_access_query'; +import { getCategoryQuery } from '../util/get_category_query'; interface Dependencies { esClient: ElasticsearchClient; @@ -25,6 +26,14 @@ interface Dependencies { taskManagerStart: TaskManagerStartContract; } +export interface RecalledEntry { + id: string; + text: string; + score: number | null; + is_correction: boolean; + labels: Record; +} + function isAlreadyExistsError(error: Error) { return ( error instanceof errors.ResponseError && @@ -173,36 +182,43 @@ export class KnowledgeBaseService { recall = async ({ user, queries, + contexts, namespace, }: { queries: string[]; + contexts?: string[]; user: { name: string }; namespace: string; - }): Promise<{ entries: Array> }> => { + }): Promise<{ + entries: RecalledEntry[]; + }> => { try { + const query = { + bool: { + should: queries.map((text) => ({ + text_expansion: { + 'ml.tokens': { + model_text: text, + model_id: '.elser_model_1', + }, + } as unknown as QueryDslTextExpansionQuery, + })), + filter: [ + ...getAccessQuery({ + user, + namespace, + }), + ...getCategoryQuery({ contexts }), + ], + }, + }; + const response = await this.dependencies.esClient.search< - Pick + Pick >({ index: this.dependencies.resources.aliases.kb, - query: { - bool: { - should: queries.map((query) => ({ - text_expansion: { - 'ml.tokens': { - model_text: query, - model_id: '.elser_model_1', - }, - } as unknown as QueryDslTextExpansionQuery, - })), - filter: [ - ...getAccessQuery({ - user, - namespace, - }), - ], - }, - }, - size: 5, + query, + size: 10, _source: { includes: ['text', 'is_correction', 'labels'], }, @@ -211,7 +227,7 @@ export class KnowledgeBaseService { return { entries: response.hits.hits.map((hit) => ({ ...hit._source!, - score: hit._score, + score: hit._score!, id: hit._id, })), }; diff --git a/x-pack/plugins/observability_ai_assistant/server/service/kb_service/kb_docs/lens.ts b/x-pack/plugins/observability_ai_assistant/server/service/kb_service/kb_docs/lens.ts index e4fdc8969c0104..9baf75f6ff5525 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/kb_service/kb_docs/lens.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/kb_service/kb_docs/lens.ts @@ -6,10 +6,16 @@ */ import dedent from 'dedent'; +import type { Logger } from '@kbn/logging'; import type { ObservabilityAIAssistantService } from '../..'; -export function addLensDocsToKb(service: ObservabilityAIAssistantService) { - service.addToKnowledgeBase([ +export function addLensDocsToKb({ + service, +}: { + service: ObservabilityAIAssistantService; + logger: Logger; +}) { + service.addCategoryToKnowledgeBase('lens', [ { id: 'lens_formulas_how_it_works', texts: [ diff --git a/x-pack/plugins/observability_ai_assistant/server/service/util/get_category_query.ts b/x-pack/plugins/observability_ai_assistant/server/service/util/get_category_query.ts new file mode 100644 index 00000000000000..71b0a93156086d --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/server/service/util/get_category_query.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function getCategoryQuery({ contexts }: { contexts?: string[] }) { + const noCategoryFilter = { + bool: { + must_not: { + exists: { + field: 'labels.category', + }, + }, + }, + }; + + if (!contexts) { + return [noCategoryFilter]; + } + + return [ + { + bool: { + should: [ + noCategoryFilter, + { + terms: { + 'labels.category': contexts, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ]; +} diff --git a/x-pack/plugins/observability_shared/public/index.ts b/x-pack/plugins/observability_shared/public/index.ts index 7e5fc02d0a0b5d..a35cdce8f85e19 100644 --- a/x-pack/plugins/observability_shared/public/index.ts +++ b/x-pack/plugins/observability_shared/public/index.ts @@ -10,6 +10,7 @@ export type { ObservabilitySharedPlugin, ObservabilitySharedPluginSetup, ObservabilitySharedPluginStart, + ProfilingLocators, } from './plugin'; export const plugin = () => { return new ObservabilitySharedPlugin(); diff --git a/x-pack/plugins/observability_shared/public/plugin.ts b/x-pack/plugins/observability_shared/public/plugin.ts index 550259184dfb19..faf89906223377 100644 --- a/x-pack/plugins/observability_shared/public/plugin.ts +++ b/x-pack/plugins/observability_shared/public/plugin.ts @@ -34,6 +34,7 @@ export interface ObservabilitySharedStart { export type ObservabilitySharedPluginSetup = ReturnType; export type ObservabilitySharedPluginStart = ReturnType; +export type ProfilingLocators = ObservabilitySharedPluginSetup['locators']['profiling']; export class ObservabilitySharedPlugin implements Plugin { private readonly navigationRegistry = createNavigationRegistry(); diff --git a/x-pack/plugins/osquery/public/components/empty_state.tsx b/x-pack/plugins/osquery/public/components/empty_state.tsx index 099e9e22b1f649..18f5afe8eaf5fd 100644 --- a/x-pack/plugins/osquery/public/components/empty_state.tsx +++ b/x-pack/plugins/osquery/public/components/empty_state.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton } from '@elastic/eui'; -import { KibanaPageTemplate } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { INTEGRATIONS_PLUGIN_ID } from '@kbn/fleet-plugin/common'; import { pagePathGetters } from '@kbn/fleet-plugin/public'; import { isModifiedEvent, isLeftClickEvent, useKibana } from '../common/lib/kibana'; diff --git a/x-pack/plugins/osquery/tsconfig.json b/x-pack/plugins/osquery/tsconfig.json index 6cd1086b8a850e..d2344a2581df8d 100644 --- a/x-pack/plugins/osquery/tsconfig.json +++ b/x-pack/plugins/osquery/tsconfig.json @@ -77,5 +77,6 @@ "@kbn/core-saved-objects-server", "@kbn/monaco", "@kbn/io-ts-utils", + "@kbn/shared-ux-page-kibana-template", ] } diff --git a/x-pack/plugins/profiling/public/index.tsx b/x-pack/plugins/profiling/public/index.tsx index ea9dc15de99275..752635a54290bd 100644 --- a/x-pack/plugins/profiling/public/index.tsx +++ b/x-pack/plugins/profiling/public/index.tsx @@ -13,5 +13,3 @@ export function plugin() { } export type { ProfilingPluginSetup, ProfilingPluginStart }; - -export type ProfilingLocators = ProfilingPluginSetup['locators']; diff --git a/x-pack/plugins/profiling_data_access/server/services/status/index.ts b/x-pack/plugins/profiling_data_access/server/services/status/index.ts index 6aea8b1037bd60..31581140b12e0b 100644 --- a/x-pack/plugins/profiling_data_access/server/services/status/index.ts +++ b/x-pack/plugins/profiling_data_access/server/services/status/index.ts @@ -12,7 +12,7 @@ import { getSetupState } from '../get_setup_state'; import { RegisterServicesParams } from '../register_services'; import { ProfilingSetupOptions, areResourcesSetup } from '../../../common/setup'; -interface HasSetupParams { +export interface HasSetupParams { soClient: SavedObjectsClientContract; esClient: ElasticsearchClient; spaceId?: string; diff --git a/x-pack/plugins/transform/common/constants.ts b/x-pack/plugins/transform/common/constants.ts index a1ba2d8277af9a..ec26333a834a79 100644 --- a/x-pack/plugins/transform/common/constants.ts +++ b/x-pack/plugins/transform/common/constants.ts @@ -43,6 +43,7 @@ export const TRANSFORM_REACT_QUERY_KEYS = { GET_TRANSFORM_NODES: 'transform.get_transform_nodes', GET_TRANSFORM_AUDIT_MESSAGES: 'transform.get_transform_audit_messages', GET_TRANSFORM_STATS: 'transform.get_transform_stats', + GET_TRANSFORMS_STATS: 'transform.get_transforms_stats', GET_TRANSFORMS: 'transform.get_transforms', GET_TRANSFORMS_PREVIEW: 'transform.get_transforms_preview', } as const; diff --git a/x-pack/plugins/transform/common/utils/create_stats_unknown_message.ts b/x-pack/plugins/transform/common/utils/create_stats_unknown_message.ts new file mode 100644 index 00000000000000..9cfe3ccc38ee46 --- /dev/null +++ b/x-pack/plugins/transform/common/utils/create_stats_unknown_message.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export function createNoStatsTooltipMessage({ + actionName, + count = 1, +}: { + actionName: string; + count: number; +}) { + return i18n.translate('xpack.transform.transformList.actionDisabledNoStatsTooltipMessage', { + defaultMessage: + '{actionName} is disabled because the status for {count, plural, one {this transform} other {some transforms}} is unavailable.', + values: { actionName, count }, + }); +} diff --git a/x-pack/plugins/transform/public/app/common/transform_list.ts b/x-pack/plugins/transform/public/app/common/transform_list.ts index c8ddd32f6a8ff3..e588ed917958fc 100644 --- a/x-pack/plugins/transform/public/app/common/transform_list.ts +++ b/x-pack/plugins/transform/public/app/common/transform_list.ts @@ -20,10 +20,24 @@ export interface TransformListRow { id: TransformId; config: TransformConfigUnion; mode?: string; // added property on client side to allow filtering by this field - stats: TransformStats; + stats?: TransformStats; alerting_rules?: TransformHealthAlertRule[]; } +export type TransformListRowWithStats = TransformListRow & { + stats: TransformStats; +}; + +export function isTransformListRowWithStats( + arg: TransformListRow +): arg is TransformListRowWithStats { + return arg.stats !== undefined; +} + +export function missingTransformStats(items: TransformListRow[]) { + return items.some((i: TransformListRow) => !isTransformListRowWithStats(i)); +} + // The single Action type is not exported as is // from EUI so we use that code to get the single // Action type from the array of actions. diff --git a/x-pack/plugins/transform/public/app/common/transform_stats.test.ts b/x-pack/plugins/transform/public/app/common/transform_stats.test.ts index 974764475eb5f2..9213adba45e4d4 100644 --- a/x-pack/plugins/transform/public/app/common/transform_stats.test.ts +++ b/x-pack/plugins/transform/public/app/common/transform_stats.test.ts @@ -18,7 +18,7 @@ const getRow = (statsId: string) => { // @ts-expect-error mock data does not actually match TransformListRow type ...(mockTransformListRow as TransformListRow), stats: { - ...(mockTransformStats.transforms as Array).find( + ...(mockTransformStats.transforms as Array>).find( (stats) => stats.id === statsId )!, }, @@ -47,7 +47,7 @@ describe('Transform: Transform stats.', () => { // that will be used by isCompletedBatchTransform() // followed by a call to isCompletedBatchTransform() itself // @ts-expect-error mock data is too loosely typed - const row = mockTransformListRow as TransformListRow; + const row = mockTransformListRow as Required; expect(row.stats.checkpointing.last.checkpoint === 1).toBe(true); expect(row.config.sync === undefined).toBe(true); expect(row.stats.state === TRANSFORM_STATE.STOPPED).toBe(true); diff --git a/x-pack/plugins/transform/public/app/common/transform_stats.ts b/x-pack/plugins/transform/public/app/common/transform_stats.ts index 7763e0712249e9..0f7ad5ae98c3f9 100644 --- a/x-pack/plugins/transform/public/app/common/transform_stats.ts +++ b/x-pack/plugins/transform/public/app/common/transform_stats.ts @@ -33,7 +33,9 @@ export function isCompletedBatchTransform(item: TransformItem) { // If `checkpoint=1`, `sync` is missing from the config and state is stopped, // then this is a completed batch transform. return ( - item.stats.checkpointing.last.checkpoint === 1 && + item.stats && + item.config && + item.stats.checkpointing?.last.checkpoint === 1 && item.config.sync === undefined && item.stats.state === TRANSFORM_STATE.STOPPED ); diff --git a/x-pack/plugins/transform/public/app/hooks/use_get_transform_stats.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transform_stats.ts index d2b9d32f25853d..3c6b329dd880b3 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_get_transform_stats.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_get_transform_stats.ts @@ -35,3 +35,24 @@ export const useGetTransformStats = ( { enabled, refetchInterval } ); }; + +export const useGetTransformsStats = ({ + enabled, + refetchInterval, +}: { + enabled?: boolean; + refetchInterval?: number | false; +}) => { + const { http } = useAppDependencies(); + + return useQuery( + [TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS_STATS], + ({ signal }) => + http.get(addInternalBasePath(`transforms/_stats`), { + version: '1', + asSystemRequest: true, + signal, + }), + { enabled, refetchInterval } + ); +}; diff --git a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts index f74f7a5774ded5..f4afb03c4e4c00 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts @@ -11,14 +11,12 @@ import type { IHttpFetchError } from '@kbn/core-http-browser'; import { isDefined } from '@kbn/ml-is-defined'; import type { GetTransformsResponseSchema } from '../../../common/api_schemas/transforms'; -import type { GetTransformsStatsResponseSchema } from '../../../common/api_schemas/transforms_stats'; import { addInternalBasePath, DEFAULT_REFRESH_INTERVAL_MS, TRANSFORM_REACT_QUERY_KEYS, TRANSFORM_MODE, } from '../../../common/constants'; -import { isTransformStats } from '../../../common/types/transform_stats'; import { type TransformListRow } from '../common'; import { useAppDependencies } from '../app_dependencies'; @@ -55,14 +53,6 @@ export const useGetTransforms = ({ enabled }: UseGetTransformsOptions = {}) => { signal, } ); - const transformStats = await http.get( - addInternalBasePath(`transforms/_stats`), - { - version: '1', - asSystemRequest: true, - signal, - } - ); // There might be some errors with fetching certain transforms // For example, when task exists and is running but the config is deleted @@ -81,21 +71,13 @@ export const useGetTransforms = ({ enabled }: UseGetTransformsOptions = {}) => { } update.transforms = transformConfigs.transforms.reduce((reducedtableRows, config) => { - const stats = transformStats.transforms.find((d) => config.id === d.id); - - // A newly created transform might not have corresponding stats yet. - // If that's the case we just skip the transform and don't add it to the transform list yet. - if (!isTransformStats(stats)) { - return reducedtableRows; - } - - // Table with expandable rows requires `id` on the outer most level + // Table with expandable rows requires `id` on the outermost level reducedtableRows.push({ id: config.id, config, mode: typeof config.sync !== 'undefined' ? TRANSFORM_MODE.CONTINUOUS : TRANSFORM_MODE.BATCH, - stats, + stats: undefined, alerting_rules: config.alerting_rules, }); return reducedtableRows; diff --git a/x-pack/plugins/transform/public/app/hooks/use_refresh_transform_list.ts b/x-pack/plugins/transform/public/app/hooks/use_refresh_transform_list.ts index 651886ba76f7bf..30989461573056 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_refresh_transform_list.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_refresh_transform_list.ts @@ -15,6 +15,7 @@ export const useRefreshTransformList = () => { return () => { queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_NODES]); queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS]); + queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS_STATS]); queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_AUDIT_MESSAGES]); }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.test.tsx index 37daeeb75e1386..bec2407ba631ce 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.test.tsx @@ -16,9 +16,11 @@ jest.mock('../../../../app_dependencies'); describe('Transform: Transform List Actions ', () => { test('Minimal initialization', () => { const props: DeleteActionNameProps = { + items: [], canDeleteTransform: true, disabled: false, isBulkAction: false, + forceDisable: false, }; const { container } = render(); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx index 93e346e6c7a1d3..7f68645cb2f18c 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_name.tsx @@ -11,10 +11,12 @@ import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; -import { TransformState, TRANSFORM_STATE } from '../../../../../../common/constants'; -import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message'; - +import { missingTransformStats } from '../../../../common/transform_list'; +import { createNoStatsTooltipMessage } from '../../../../../../common/utils/create_stats_unknown_message'; +import { TransformCapabilities } from '../../../../../../common/types/capabilities'; import { TransformListRow } from '../../../../common'; +import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message'; +import { TransformState, TRANSFORM_STATE } from '../../../../../../common/constants'; export const deleteActionNameText = i18n.translate( 'xpack.transform.transformList.deleteActionNameText', @@ -24,45 +26,67 @@ export const deleteActionNameText = i18n.translate( ); const transformCanNotBeDeleted = (i: TransformListRow) => + i.stats && !([TRANSFORM_STATE.STOPPED, TRANSFORM_STATE.FAILED] as TransformState[]).includes(i.stats.state); export const isDeleteActionDisabled = (items: TransformListRow[], forceDisable: boolean) => { const disabled = items.some(transformCanNotBeDeleted); - return forceDisable === true || disabled; + + return forceDisable === true || disabled || missingTransformStats(items); }; export interface DeleteActionNameProps { + items: TransformListRow[]; canDeleteTransform: boolean; disabled: boolean; isBulkAction: boolean; + forceDisable: boolean; } +export const getDeleteActionDisabledMessage = ({ + items, + canDeleteTransform, + forceDisable, +}: { + items: TransformListRow[]; + canDeleteTransform: TransformCapabilities['canDeleteTransform']; + forceDisable: boolean; +}) => { + const isBulkAction = items.length > 1; + + if (missingTransformStats(items)) { + return createNoStatsTooltipMessage({ + actionName: deleteActionNameText, + count: items.length, + }); + } + + if (!canDeleteTransform) { + return createCapabilityFailureMessage('canDeleteTransform'); + } + + const disabled = items.some(transformCanNotBeDeleted); + + if (disabled) { + return isBulkAction === true + ? i18n.translate('xpack.transform.transformList.deleteBulkActionDisabledToolTipContent', { + defaultMessage: 'One or more selected transforms must be stopped in order to be deleted.', + }) + : i18n.translate('xpack.transform.transformList.deleteActionDisabledToolTipContent', { + defaultMessage: 'Stop the transform in order to delete it.', + }); + } +}; + export const DeleteActionName: FC = ({ + items, canDeleteTransform, disabled, isBulkAction, + forceDisable, }) => { - const bulkDeleteButtonDisabledText = i18n.translate( - 'xpack.transform.transformList.deleteBulkActionDisabledToolTipContent', - { - defaultMessage: 'One or more selected transforms must be stopped in order to be deleted.', - } - ); - const deleteButtonDisabledText = i18n.translate( - 'xpack.transform.transformList.deleteActionDisabledToolTipContent', - { - defaultMessage: 'Stop the transform in order to delete it.', - } - ); - - if (disabled || !canDeleteTransform) { - let content; - if (disabled) { - content = isBulkAction ? bulkDeleteButtonDisabledText : deleteButtonDisabledText; - } else { - content = createCapabilityFailureMessage('canDeleteTransform'); - } - + const content = getDeleteActionDisabledMessage({ items, canDeleteTransform, forceDisable }); + if (content) { return ( <>{deleteActionNameText} diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx index 5996e271604e3e..1942a9a9dc8b7f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx @@ -7,9 +7,10 @@ import React, { useMemo, useState } from 'react'; +import { isTransformListRowWithStats } from '../../../../common/transform_list'; import { TRANSFORM_STATE } from '../../../../../../common/constants'; -import { TransformListAction, TransformListRow } from '../../../../common'; +import type { TransformListAction, TransformListRow } from '../../../../common'; import { useDeleteIndexAndTargetIndex, useDeleteTransforms, @@ -33,7 +34,7 @@ export const useDeleteAction = (forceDisable: boolean) => { const isBulkAction = items.length > 1; const shouldForceDelete = useMemo( - () => items.some((i: TransformListRow) => i.stats.state === TRANSFORM_STATE.FAILED), + () => items.some((i: TransformListRow) => i.stats?.state === TRANSFORM_STATE.FAILED), [items] ); @@ -59,10 +60,10 @@ export const useDeleteAction = (forceDisable: boolean) => { // else, force delete only when the item user picks has failed const forceDelete = isBulkAction ? shouldForceDelete - : items[0] && items[0] && items[0].stats.state === TRANSFORM_STATE.FAILED; + : items[0] && items[0] && items[0].stats?.state === TRANSFORM_STATE.FAILED; deleteTransforms({ - transformsInfo: items.map((i) => ({ + transformsInfo: items.filter(isTransformListRowWithStats).map((i) => ({ id: i.config.id, state: i.stats.state, })), @@ -87,11 +88,15 @@ export const useDeleteAction = (forceDisable: boolean) => { canDeleteTransform, disabled: isDeleteActionDisabled([item], forceDisable), isBulkAction: false, + items: [item], + forceDisable, }} /> ), enabled: (item: TransformListRow) => - !isDeleteActionDisabled([item], forceDisable) && canDeleteTransform, + isTransformListRowWithStats(item) && + !isDeleteActionDisabled([item], forceDisable) && + canDeleteTransform, description: deleteActionNameText, icon: 'trash', type: 'icon', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx index 4fb0f9e655208a..05d804cc9147b0 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx @@ -11,16 +11,16 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render, waitFor, screen } from '@testing-library/react'; -import { TransformListRow } from '../../../../common'; import { isDiscoverActionDisabled, DiscoverActionName } from './discover_action_name'; import transformListRow from '../../../../common/__mocks__/transform_list_row.json'; +import { TransformListRowWithStats } from '../../../../common/transform_list'; jest.mock('../../../../../shared_imports'); jest.mock('../../../../app_dependencies'); // @ts-expect-error mock data is too loosely typed -const item: TransformListRow = transformListRow; +const item: TransformListRowWithStats = transformListRow; describe('Transform: Transform List Actions isDiscoverActionDisabled()', () => { it('should be disabled when more than one item is passed in', () => { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx index c3d359e648c286..b164dbee25fd21 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx @@ -33,7 +33,7 @@ export const isDiscoverActionDisabled = ( const item = items[0]; // Disable discover action if it's a batch transform and was never started - const stoppedTransform = item.stats.state === TRANSFORM_STATE.STOPPED; + const stoppedTransform = item.stats?.state === TRANSFORM_STATE.STOPPED; const transformProgress = getTransformProgress(item); const isBatchTransform = typeof item.config.sync === 'undefined'; const transformNeverStarted = @@ -52,7 +52,7 @@ export const DiscoverActionName: FC = ({ dataViewExists const item = items[0]; // Disable discover action if it's a batch transform and was never started - const stoppedTransform = item.stats.state === TRANSFORM_STATE.STOPPED; + const stoppedTransform = item.stats?.state === TRANSFORM_STATE.STOPPED; const transformProgress = getTransformProgress(item); const isBatchTransform = typeof item.config.sync === 'undefined'; const transformNeverStarted = diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/reauthorize_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/reauthorize_action_name.tsx index ee883a3e7e77ba..c0abb4fa5e51ca 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/reauthorize_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/reauthorize_action_name.tsx @@ -49,7 +49,6 @@ export const ReauthorizeActionName: FC = ({ }) => { const { canStartStopTransform } = useTransformCapabilities(); - // Disable start for batch transforms which have completed. const someNeedsReauthorization = items.some(needsReauthorization); const actionIsDisabled = isReauthorizeActionDisabled( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/use_reauthorize_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/use_reauthorize_action.tsx index 67e618765e42e0..3d216af23db8aa 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/use_reauthorize_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reauthorize/use_reauthorize_action.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useState } from 'react'; +import { isTransformListRowWithStats } from '../../../../common/transform_list'; import { sortTransformsToReauthorize } from './sort_transforms_to_reauthorize'; import { needsReauthorization } from '../../../../common/reauthorization_utils'; import { useReauthorizeTransforms } from '../../../../hooks/use_reauthorize_transform'; @@ -54,6 +55,7 @@ export const useReauthorizeAction = (forceDisable: boolean, transformNodes: numb ), available: (item: TransformListRow) => needsReauthorization(item), enabled: (item: TransformListRow) => + isTransformListRowWithStats(item) && !isReauthorizeActionDisabled([item], canStartStopTransform, transformNodes), description: reauthorizeActionNameText, icon: 'alert', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/reset_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/reset_action_name.tsx index 6d5f56d3e7297c..0f19701fb09147 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/reset_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/reset_action_name.tsx @@ -11,6 +11,9 @@ import { EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { createNoStatsTooltipMessage } from '../../../../../../common/utils/create_stats_unknown_message'; +import { missingTransformStats } from '../../../../common/transform_list'; +import { TransformCapabilities } from '../../../../../../common/types/capabilities'; import { TransformState, TRANSFORM_STATE } from '../../../../../../common/constants'; import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message'; @@ -24,45 +27,74 @@ export const resetActionNameText = i18n.translate( ); const transformCanNotBeReseted = (i: TransformListRow) => + i.stats && !([TRANSFORM_STATE.STOPPED, TRANSFORM_STATE.FAILED] as TransformState[]).includes(i.stats.state); export const isResetActionDisabled = (items: TransformListRow[], forceDisable: boolean) => { const disabled = items.some(transformCanNotBeReseted); - return forceDisable === true || disabled; + + return forceDisable === true || disabled || missingTransformStats(items); }; +export const getResetActionDisabledMessage = ({ + items, + canResetTransform, + forceDisable, +}: { + items: TransformListRow[]; + canResetTransform: TransformCapabilities['canResetTransform']; + forceDisable: boolean; +}) => { + const isBulkAction = items.length > 1; + + if (missingTransformStats(items)) { + return createNoStatsTooltipMessage({ + actionName: resetActionNameText, + count: items.length, + }); + } + + if (!canResetTransform) { + return createCapabilityFailureMessage('canResetTransform'); + } + + if (isResetActionDisabled(items, forceDisable)) { + const bulkResetButtonDisabledText = i18n.translate( + 'xpack.transform.transformList.resetBulkActionDisabledToolTipContent', + { + defaultMessage: 'One or more selected transforms must be stopped to be reset.', + } + ); + const resetButtonDisabledText = i18n.translate( + 'xpack.transform.transformList.resetActionDisabledToolTipContent', + { + defaultMessage: 'Stop the transform in order to reset it.', + } + ); + + return isBulkAction ? bulkResetButtonDisabledText : resetButtonDisabledText; + } +}; export interface ResetActionNameProps { + items: TransformListRow[]; + canResetTransform: boolean; disabled: boolean; isBulkAction: boolean; } export const ResetActionName: FC = ({ + items, canResetTransform, disabled, - isBulkAction, }) => { - const bulkResetButtonDisabledText = i18n.translate( - 'xpack.transform.transformList.resetBulkActionDisabledToolTipContent', - { - defaultMessage: 'One or more selected transforms must be stopped to be reset.', - } - ); - const resetButtonDisabledText = i18n.translate( - 'xpack.transform.transformList.resetActionDisabledToolTipContent', - { - defaultMessage: 'Stop the transform in order to reset it.', - } - ); - - if (disabled || !canResetTransform) { - let content; - if (disabled) { - content = isBulkAction ? bulkResetButtonDisabledText : resetButtonDisabledText; - } else { - content = createCapabilityFailureMessage('canResetTransform'); - } + const content = getResetActionDisabledMessage({ + items, + canResetTransform, + forceDisable: disabled, + }); + if (content) { return ( <>{resetActionNameText} diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/use_reset_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/use_reset_action.tsx index 23a399fdb90868..b00ea69d7e4fdc 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/use_reset_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_reset/use_reset_action.tsx @@ -7,12 +7,21 @@ import React, { useMemo, useState } from 'react'; +import { + isTransformListRowWithStats, + TransformListRowWithStats, +} from '../../../../common/transform_list'; import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { TransformListAction, TransformListRow } from '../../../../common'; import { useTransformCapabilities, useResetTransforms } from '../../../../hooks'; -import { resetActionNameText, isResetActionDisabled, ResetActionName } from './reset_action_name'; +import { + resetActionNameText, + isResetActionDisabled, + ResetActionName, + getResetActionDisabledMessage, +} from './reset_action_name'; export type ResetAction = ReturnType; export const useResetAction = (forceDisable: boolean) => { @@ -24,7 +33,7 @@ export const useResetAction = (forceDisable: boolean) => { const [items, setItems] = useState([]); const shouldForceReset = useMemo( - () => items.some((i: TransformListRow) => i.stats.state === TRANSFORM_STATE.FAILED), + () => items.some((i: TransformListRow) => i.stats?.state === TRANSFORM_STATE.FAILED), [items] ); @@ -34,10 +43,12 @@ export const useResetAction = (forceDisable: boolean) => { setModalVisible(false); resetTransforms({ - transformsInfo: items.map((i) => ({ - id: i.config.id, - state: i.stats.state, - })), + transformsInfo: items + .filter(isTransformListRowWithStats) + .map((i) => ({ + id: i.config.id, + state: i.stats.state, + })), }); }; @@ -56,11 +67,13 @@ export const useResetAction = (forceDisable: boolean) => { canResetTransform, disabled: isResetActionDisabled([item], forceDisable), isBulkAction: false, + items: [item], }} /> ), enabled: (item: TransformListRow) => - !isResetActionDisabled([item], forceDisable) && canResetTransform, + getResetActionDisabledMessage({ items: [item], canResetTransform, forceDisable }) === + undefined, description: resetActionNameText, icon: 'refresh', type: 'icon', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/schedule_now_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/schedule_now_action_name.tsx index 0c3be1cdad70bd..5b4ff6908afdf5 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/schedule_now_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/schedule_now_action_name.tsx @@ -11,6 +11,8 @@ import { EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { missingTransformStats } from '../../../../common/transform_list'; +import { createNoStatsTooltipMessage } from '../../../../../../common/utils/create_stats_unknown_message'; import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message'; import { useTransformCapabilities } from '../../../../hooks'; @@ -35,7 +37,8 @@ export const isScheduleNowActionDisabled = ( !canScheduleNowTransform || completedBatchTransform || items.length === 0 || - transformNodes === 0 + transformNodes === 0 || + missingTransformStats(items) ); }; @@ -92,6 +95,11 @@ export const ScheduleNowActionName: FC = ({ content = createCapabilityFailureMessage('canScheduleNowTransform'); } else if (completedBatchTransform) { content = completedBatchTransformMessage; + } else if (missingTransformStats(items)) { + content = createNoStatsTooltipMessage({ + actionName: scheduleNowActionNameText, + count: items.length, + }); } } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/use_schedule_now_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/use_schedule_now_action.tsx index a13d3da89f6774..e5d330eb9ba285 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/use_schedule_now_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_schedule_now/use_schedule_now_action.tsx @@ -7,6 +7,7 @@ import React, { useMemo } from 'react'; +import { isTransformListRowWithStats } from '../../../../common/transform_list'; import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { useTransformCapabilities } from '../../../../hooks'; @@ -33,8 +34,10 @@ export const useScheduleNowAction = (forceDisable: boolean, transformNodes: numb transformNodes={transformNodes} /> ), - available: (item: TransformListRow) => item.stats.state === TRANSFORM_STATE.STARTED, + available: (item: TransformListRow) => + isTransformListRowWithStats(item) ? item.stats.state === TRANSFORM_STATE.STARTED : true, enabled: (item: TransformListRow) => + isTransformListRowWithStats(item) && !isScheduleNowActionDisabled([item], canScheduleNowTransform, transformNodes), description: scheduleNowActionNameText, icon: 'play', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx index c50c83d25edc5f..22ddbf1d12c3da 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_name.tsx @@ -9,6 +9,8 @@ import React, { type FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; +import { createNoStatsTooltipMessage } from '../../../../../../common/utils/create_stats_unknown_message'; +import { missingTransformStats } from '../../../../common/transform_list'; import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message'; @@ -31,7 +33,7 @@ export const isStartActionDisabled = ( const completedBatchTransform = items.some((i: TransformListRow) => isCompletedBatchTransform(i)); // Disable start action if one of the transforms is already started or trying to restart will throw error const startedTransform = items.some( - (i: TransformListRow) => i.stats.state === TRANSFORM_STATE.STARTED + (i: TransformListRow) => i.stats?.state === TRANSFORM_STATE.STARTED ); return ( @@ -39,7 +41,8 @@ export const isStartActionDisabled = ( completedBatchTransform || startedTransform || items.length === 0 || - transformNodes === 0 + transformNodes === 0 || + missingTransformStats(items) ); }; @@ -58,9 +61,9 @@ export const StartActionName: FC = ({ // Disable start for batch transforms which have completed. const completedBatchTransform = items.some((i: TransformListRow) => isCompletedBatchTransform(i)); - // Disable start action if one of the transforms is already started or trying to restart will throw error + // Disable if one of the transforms is already started or trying to restart will throw error const startedTransform = items.some( - (i: TransformListRow) => i.stats.state === TRANSFORM_STATE.STARTED + (i: TransformListRow) => i.stats?.state === TRANSFORM_STATE.STARTED ); let startedTransformMessage; @@ -107,6 +110,11 @@ export const StartActionName: FC = ({ content = completedBatchTransformMessage; } else if (startedTransform) { content = startedTransformMessage; + } else if (missingTransformStats(items)) { + content = createNoStatsTooltipMessage({ + actionName: startActionNameText, + count: items.length, + }); } } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx index 168174730b7069..3724e53cf69b84 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/use_start_action.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useState } from 'react'; +import { isTransformListRowWithStats } from '../../../../common/transform_list'; import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { TransformListAction, TransformListRow } from '../../../../common'; @@ -46,7 +47,8 @@ export const useStartAction = (forceDisable: boolean, transformNodes: number) => transformNodes={transformNodes} /> ), - available: (item: TransformListRow) => item.stats.state === TRANSFORM_STATE.STOPPED, + available: (item: TransformListRow) => + isTransformListRowWithStats(item) ? item.stats.state === TRANSFORM_STATE.STOPPED : true, enabled: (item: TransformListRow) => !isStartActionDisabled([item], canStartStopTransform, transformNodes), description: startActionNameText, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx index e5bc1425cdd994..b2a079a4252737 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_action_name.tsx @@ -9,6 +9,12 @@ import React, { type FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; +import { TransformCapabilities } from '../../../../../../common/types/capabilities'; +import { + isTransformListRowWithStats, + missingTransformStats, +} from '../../../../common/transform_list'; +import { createNoStatsTooltipMessage } from '../../../../../../common/utils/create_stats_unknown_message'; import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { createCapabilityFailureMessage } from '../../../../../../common/utils/create_capability_failure_message'; @@ -22,6 +28,46 @@ export const stopActionNameText = i18n.translate( } ); +export const getStopActionDisabledMessage = ({ + items, + capabilities, +}: { + items: TransformListRow[]; + capabilities: TransformCapabilities; +}) => { + const isBulkAction = items.length > 1; + + const { canStartStopTransform } = capabilities; + + if (missingTransformStats(items)) { + return createNoStatsTooltipMessage({ + actionName: stopActionNameText, + count: items.length, + }); + } + + // Disable stop action if one of the transforms is stopped already + const stoppedTransform = items.some( + (i: TransformListRow) => + isTransformListRowWithStats(i) && i.stats.state === TRANSFORM_STATE.STOPPED + ); + + if (!canStartStopTransform) { + return createCapabilityFailureMessage('canStartStopTransform'); + } + + if (stoppedTransform) { + return isBulkAction === true + ? i18n.translate('xpack.transform.transformList.stoppedTransformBulkToolTip', { + defaultMessage: 'One or more transforms are already stopped.', + }) + : i18n.translate('xpack.transform.transformList.stoppedTransformToolTip', { + defaultMessage: '{transformId} is already stopped.', + values: { transformId: items[0] && items[0].config.id }, + }); + } +}; + export const isStopActionDisabled = ( items: TransformListRow[], canStartStopTransform: boolean, @@ -29,10 +75,15 @@ export const isStopActionDisabled = ( ) => { // Disable stop action if one of the transforms is stopped already const stoppedTransform = items.some( - (i: TransformListRow) => i.stats.state === TRANSFORM_STATE.STOPPED + (i: TransformListRow) => i.stats?.state === TRANSFORM_STATE.STOPPED ); - return forceDisable === true || !canStartStopTransform || stoppedTransform === true; + return ( + forceDisable === true || + !canStartStopTransform || + stoppedTransform === true || + missingTransformStats(items) + ); }; export interface StopActionNameProps { @@ -40,42 +91,15 @@ export interface StopActionNameProps { forceDisable?: boolean; } export const StopActionName: FC = ({ items, forceDisable }) => { - const isBulkAction = items.length > 1; - const { canStartStopTransform } = useTransformCapabilities(); - - // Disable stop action if one of the transforms is stopped already - const stoppedTransform = items.some( - (i: TransformListRow) => i.stats.state === TRANSFORM_STATE.STOPPED - ); - - let stoppedTransformMessage; - if (isBulkAction === true) { - stoppedTransformMessage = i18n.translate( - 'xpack.transform.transformList.stoppedTransformBulkToolTip', - { - defaultMessage: 'One or more transforms are already stopped.', - } - ); - } else { - stoppedTransformMessage = i18n.translate( - 'xpack.transform.transformList.stoppedTransformToolTip', - { - defaultMessage: '{transformId} is already stopped.', - values: { transformId: items[0] && items[0].config.id }, - } - ); - } - - if (!canStartStopTransform || stoppedTransform) { + const capabilities = useTransformCapabilities(); + // Disable transforms if stats does not exist + const stoppedTransformMessage = getStopActionDisabledMessage({ + items, + capabilities, + }); + if (forceDisable || stoppedTransformMessage) { return ( - + <>{stopActionNameText} ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx index e410704341177a..36ec5139d14eed 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/use_stop_action.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback, useMemo, useState } from 'react'; +import { isTransformListRowWithStats } from '../../../../common/transform_list'; import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { TransformListAction, TransformListRow } from '../../../../common'; import { useTransformCapabilities, useStopTransforms } from '../../../../hooks'; @@ -30,13 +31,16 @@ export const useStopAction = (forceDisable: boolean) => { const stopAndCloseModal = useCallback( (transformSelection: TransformListRow[]) => { setModalVisible(false); - stopTransforms(transformSelection.map((t) => ({ id: t.id, state: t.stats.state }))); + stopTransforms( + transformSelection.map((t) => ({ id: t.id, state: t.stats ? t.stats.state : 'waiting' })) + ); }, [stopTransforms] ); const clickHandler = useCallback( - (i: TransformListRow) => stopTransforms([{ id: i.id, state: i.stats.state }]), + (t: TransformListRow) => + stopTransforms([{ id: t.id, state: t.stats ? t.stats.state : 'waiting' }]), [stopTransforms] ); @@ -45,8 +49,10 @@ export const useStopAction = (forceDisable: boolean) => { name: (item: TransformListRow) => ( ), - available: (item: TransformListRow) => item.stats.state !== TRANSFORM_STATE.STOPPED, + available: (item: TransformListRow) => + isTransformListRowWithStats(item) ? item.stats.state !== TRANSFORM_STATE.STOPPED : true, enabled: (item: TransformListRow) => + isTransformListRowWithStats(item) && !isStopActionDisabled([item], canStartStopTransform, forceDisable), description: stopActionNameText, icon: 'stop', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx index f8b55dbbeda3c0..370d7c2da05d5e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx @@ -38,7 +38,7 @@ describe('Transform: Transform List ', () => { render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx index f4766da492f675..c43e3d096f32bb 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx @@ -9,14 +9,23 @@ import React, { FC, useMemo } from 'react'; import { css } from '@emotion/react'; import moment from 'moment-timezone'; -import { EuiButtonEmpty, EuiTabbedContent } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiLoadingSpinner, + EuiTabbedContent, + EuiFlexGroup, + useEuiTheme, + EuiCallOut, + EuiFlexItem, +} from '@elastic/eui'; -import { Optional } from '@kbn/utility-types'; import { i18n } from '@kbn/i18n'; import { formatHumanReadableDateTimeSeconds } from '@kbn/ml-date-utils'; import { stringHash } from '@kbn/ml-string-hash'; import { isDefined } from '@kbn/ml-is-defined'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { isTransformListRowWithStats } from '../../../../common/transform_list'; import { useIsServerless } from '../../../../serverless_context'; import { TransformHealthAlertRule } from '../../../../../../common/types/alerting'; @@ -42,39 +51,66 @@ type Item = SectionItem; interface Props { item: TransformListRow; onAlertEdit: (alertRule: TransformHealthAlertRule) => void; + transformsStatsLoading: boolean; } -type StateValues = Optional; +const NoStatsFallbackTabContent = ({ + transformsStatsLoading, +}: { + transformsStatsLoading: boolean; +}) => { + const { euiTheme } = useEuiTheme(); -export const ExpandedRow: FC = ({ item, onAlertEdit }) => { - const hideNodeInfo = useIsServerless(); + const content = transformsStatsLoading ? ( + + ) : ( + + + } + /> + + ); + return ( + + {content} + + ); +}; - const stateValues: StateValues = { ...item.stats }; - delete stateValues.stats; - delete stateValues.checkpointing; +export const ExpandedRow: FC = ({ item, onAlertEdit, transformsStatsLoading }) => { + const hideNodeInfo = useIsServerless(); const stateItems: Item[] = []; - stateItems.push( - { - title: 'ID', - description: item.id, - }, - { + stateItems.push({ + title: 'ID', + description: item.id, + }); + if (isTransformListRowWithStats(item)) { + stateItems.push({ title: 'state', description: item.stats.state, - } - ); - if (!hideNodeInfo && item.stats.node !== undefined) { - stateItems.push({ - title: 'node.name', - description: item.stats.node.name, - }); - } - if (item.stats.health !== undefined) { - stateItems.push({ - title: 'health', - description: , }); + + if (!hideNodeInfo && item.stats.node !== undefined) { + stateItems.push({ + title: 'node.name', + description: item.stats.node.name, + }); + } + if (item.stats.health !== undefined) { + stateItems.push({ + title: 'health', + description: , + }); + } } const state: SectionConfig = { @@ -137,69 +173,71 @@ export const ExpandedRow: FC = ({ item, onAlertEdit }) => { }; const checkpointingItems: Item[] = []; - if (item.stats.checkpointing.changes_last_detected_at !== undefined) { - checkpointingItems.push({ - title: 'changes_last_detected_at', - description: formatHumanReadableDateTimeSeconds( - item.stats.checkpointing.changes_last_detected_at - ), - }); - } - - if (item.stats.checkpointing.last !== undefined) { - checkpointingItems.push({ - title: 'last.checkpoint', - description: item.stats.checkpointing.last.checkpoint, - }); - if (item.stats.checkpointing.last.timestamp_millis !== undefined) { + if (isTransformListRowWithStats(item)) { + if (item.stats.checkpointing.changes_last_detected_at !== undefined) { checkpointingItems.push({ - title: 'last.timestamp', + title: 'changes_last_detected_at', description: formatHumanReadableDateTimeSeconds( - item.stats.checkpointing.last.timestamp_millis + item.stats.checkpointing.changes_last_detected_at ), }); + } + + if (item.stats.checkpointing.last !== undefined) { checkpointingItems.push({ - title: 'last.timestamp_millis', - description: item.stats.checkpointing.last.timestamp_millis, + title: 'last.checkpoint', + description: item.stats.checkpointing.last.checkpoint, }); + if (item.stats.checkpointing.last.timestamp_millis !== undefined) { + checkpointingItems.push({ + title: 'last.timestamp', + description: formatHumanReadableDateTimeSeconds( + item.stats.checkpointing.last.timestamp_millis + ), + }); + checkpointingItems.push({ + title: 'last.timestamp_millis', + description: item.stats.checkpointing.last.timestamp_millis, + }); + } } - } - - if (item.stats.checkpointing.last_search_time !== undefined) { - checkpointingItems.push({ - title: 'last_search_time', - description: formatHumanReadableDateTimeSeconds(item.stats.checkpointing.last_search_time), - }); - } - if (item.stats.checkpointing.next !== undefined) { - checkpointingItems.push({ - title: 'next.checkpoint', - description: item.stats.checkpointing.next.checkpoint, - }); - if (item.stats.checkpointing.next.checkpoint_progress !== undefined) { + if (item.stats.checkpointing.last_search_time !== undefined) { checkpointingItems.push({ - title: 'next.checkpoint_progress.total_docs', - description: item.stats.checkpointing.next.checkpoint_progress.total_docs, + title: 'last_search_time', + description: formatHumanReadableDateTimeSeconds(item.stats.checkpointing.last_search_time), }); + } + + if (item.stats.checkpointing.next !== undefined) { checkpointingItems.push({ - title: 'next.checkpoint_progress.docs_remaining', - description: item.stats.checkpointing.next.checkpoint_progress.docs_remaining, + title: 'next.checkpoint', + description: item.stats.checkpointing.next.checkpoint, }); + if (item.stats.checkpointing.next.checkpoint_progress !== undefined) { + checkpointingItems.push({ + title: 'next.checkpoint_progress.total_docs', + description: item.stats.checkpointing.next.checkpoint_progress.total_docs, + }); + checkpointingItems.push({ + title: 'next.checkpoint_progress.docs_remaining', + description: item.stats.checkpointing.next.checkpoint_progress.docs_remaining, + }); + checkpointingItems.push({ + title: 'next.checkpoint_progress.percent_complete', + description: item.stats.checkpointing.next.checkpoint_progress.percent_complete, + }); + } + } + + if (item.stats.checkpointing.operations_behind !== undefined) { checkpointingItems.push({ - title: 'next.checkpoint_progress.percent_complete', - description: item.stats.checkpointing.next.checkpoint_progress.percent_complete, + title: 'operations_behind', + description: item.stats.checkpointing.operations_behind, }); } } - if (item.stats.checkpointing.operations_behind !== undefined) { - checkpointingItems.push({ - title: 'operations_behind', - description: item.stats.checkpointing.operations_behind, - }); - } - const alertRuleItems: Item[] | undefined = item.alerting_rules?.map((rule) => { return { title: ( @@ -236,9 +274,11 @@ export const ExpandedRow: FC = ({ item, onAlertEdit }) => { const stats: SectionConfig = { title: 'Stats', - items: Object.entries(item.stats.stats).map((s) => { - return { title: s[0].toString(), description: getItemDescription(s[1]) }; - }), + items: item.stats + ? Object.entries(item.stats.stats).map((s) => { + return { title: s[0].toString(), description: getItemDescription(s[1]) }; + }) + : [], position: 'left', }; @@ -275,8 +315,10 @@ export const ExpandedRow: FC = ({ item, onAlertEdit }) => { defaultMessage: 'Stats', } ), - content: ( + content: item.stats ? ( + ) : ( + ), }, { @@ -285,7 +327,7 @@ export const ExpandedRow: FC = ({ item, onAlertEdit }) => { name: 'JSON', content: , }, - ...(item.stats.health + ...(item.stats?.health ? [ { id: `transform-health-tab-${tabId}`, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_health_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_health_pane.tsx index 0e8ab94dc20863..3a088d5a9b273e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_health_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_health_pane.tsx @@ -12,14 +12,15 @@ import { formatDate, EuiPanel, EuiSpacer, EuiInMemoryTable } from '@elastic/eui' import { i18n } from '@kbn/i18n'; import { TIME_FORMAT } from '../../../../../../common/constants'; -import type { TransformHealthIssue } from '../../../../../../common/types/transform_stats'; - -import { TransformListRow } from '../../../../common'; +import type { + TransformHealthIssue, + TransformStats, +} from '../../../../../../common/types/transform_stats'; import { TransformHealthColoredDot } from './transform_health_colored_dot'; interface ExpandedRowHealthPaneProps { - health: TransformListRow['stats']['health']; + health: TransformStats['health']; } export const ExpandedRowHealthPane: FC = ({ health }) => { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx index fc4bf0e24d14dd..e3f98beea9e7e3 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx @@ -39,6 +39,7 @@ describe('Transform: Transform List ', () => { transformNodes={1} transforms={[]} transformsLoading={false} + transformsStatsLoading={false} /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index 2e0106afc0e92a..a55253a08b5eb5 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -70,12 +70,19 @@ type ItemIdToExpandedRowMap = Record; function getItemIdToExpandedRowMap( itemIds: TransformId[], transforms: TransformListRow[], - onAlertEdit: (alertRule: TransformHealthAlertRule) => void + onAlertEdit: (alertRule: TransformHealthAlertRule) => void, + transformsStatsLoading: boolean ): ItemIdToExpandedRowMap { return itemIds.reduce((m: ItemIdToExpandedRowMap, transformId: TransformId) => { const item = transforms.find((transform) => transform.config.id === transformId); if (item !== undefined) { - m[transformId] = ; + m[transformId] = ( + + ); } return m; }, {} as ItemIdToExpandedRowMap); @@ -87,6 +94,7 @@ interface TransformListProps { transformNodes: number; transforms: TransformListRow[]; transformsLoading: boolean; + transformsStatsLoading: boolean; } export const TransformList: FC = ({ @@ -95,6 +103,7 @@ export const TransformList: FC = ({ transformNodes, transforms, transformsLoading, + transformsStatsLoading, }) => { const refreshTransformList = useRefreshTransformList(); const { setEditAlertRule } = useAlertRuleFlyout(); @@ -126,7 +135,8 @@ export const TransformList: FC = ({ expandedRowItemIds, setExpandedRowItemIds, transformNodes, - transformSelection + transformSelection, + transformsStatsLoading ); const searchError = query?.error ? query?.error.message : undefined; @@ -166,7 +176,8 @@ export const TransformList: FC = ({ const itemIdToExpandedRowMap = getItemIdToExpandedRowMap( expandedRowItemIds, transforms, - setEditAlertRule + setEditAlertRule, + transformsStatsLoading ); const bulkActionMenuItems = [ @@ -235,6 +246,7 @@ export const TransformList: FC = ({ canResetTransform={capabilities.canResetTransform} disabled={isResetActionDisabled(transformSelection, false)} isBulkAction={true} + items={transformSelection} />
    , @@ -247,6 +259,8 @@ export const TransformList: FC = ({ canDeleteTransform={capabilities.canDeleteTransform} disabled={isDeleteActionDisabled(transformSelection, false)} isBulkAction={true} + items={transformSelection} + forceDisable={false} />
    , diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar_filters.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar_filters.tsx index 5a2df943e9a54a..637f32558cb41b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar_filters.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_search_bar_filters.tsx @@ -106,9 +106,12 @@ export const filterTransforms = (transforms: TransformListRow[], clauses: Clause // filter other clauses, i.e. the mode and status filters if (c.type !== 'is' && Array.isArray(c.value)) { // the status value is an array of string(s) e.g. ['failed', 'stopped'] - ts = transforms.filter((transform) => (c.value as Value[]).includes(transform.stats.state)); + ts = transforms.filter( + (transform) => transform.stats && (c.value as Value[]).includes(transform.stats.state) + ); } else { ts = transforms.filter((transform) => { + if (!transform.stats) return false; if (c.type === 'field' && c.field === 'health') { return transform.stats.health?.status === c.value; } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx index 79f6321c2419f4..ea85f705e46ef2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx @@ -94,10 +94,12 @@ function createTranformStats( transformStats.batch.value++; } - if (transform.stats.state === TRANSFORM_STATE.FAILED) { - failedTransforms++; - } else if (transform.stats.state === TRANSFORM_STATE.STARTED) { - startedTransforms++; + if (transform.stats) { + if (transform.stats.state === TRANSFORM_STATE.FAILED) { + failedTransforms++; + } else if (transform.stats.state === TRANSFORM_STATE.STARTED) { + startedTransforms++; + } } }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx index 87f550e433bc2e..e49df35b42a55d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx @@ -20,7 +20,7 @@ describe('Transform: Job List Columns', () => { const wrapper: FC = ({ children }) => ( {children} ); - const { result, waitForNextUpdate } = renderHook(() => useColumns([], () => {}, 1, []), { + const { result, waitForNextUpdate } = renderHook(() => useColumns([], () => {}, 1, [], false), { wrapper, }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx index 2ae8edf30a1de4..fb578ec06aa3c2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx @@ -22,6 +22,7 @@ import { EuiToolTip, RIGHT_ALIGNMENT, EuiIcon, + EuiLoadingSpinner, } from '@elastic/eui'; import { useTransformCapabilities } from '../../../../hooks'; @@ -46,12 +47,20 @@ const TRANSFORM_INSUFFICIENT_PERMISSIONS_MSG = i18n.translate( defaultMessage: 'This transform was created with insufficient permissions.', } ); + +const StatsUnknown = () => ( + + + +); export const useColumns = ( expandedRowItemIds: TransformId[], setExpandedRowItemIds: React.Dispatch>, transformNodes: number, - transformSelection: TransformListRow[] + transformSelection: TransformListRow[], + transformsStatsLoading: boolean ) => { + const NoStatsFallbackComponent = transformsStatsLoading ? EuiLoadingSpinner : StatsUnknown; const { canStartStopTransform } = useTransformCapabilities(); const { actions, modals } = useActions({ @@ -239,10 +248,14 @@ export const useColumns = ( { name: i18n.translate('xpack.transform.status', { defaultMessage: 'Status' }), 'data-test-subj': 'transformListColumnStatus', - sortable: (item: TransformListRow) => item.stats.state, + sortable: (item: TransformListRow) => item.stats?.state, truncateText: true, render(item: TransformListRow) { - return ; + return item.stats ? ( + + ) : ( + + ); }, width: '100px', }, @@ -271,6 +284,7 @@ export const useColumns = ( if (progress === undefined && isBatchTransform === true) { return null; } + if (!item.stats) return ; return ( @@ -292,7 +306,7 @@ export const useColumns = ( )} - {!isBatchTransform && ( + {!isBatchTransform && item.stats && ( <> {/* If not stopped, failed or waiting show the animated progress bar */} @@ -321,10 +335,14 @@ export const useColumns = ( { name: i18n.translate('xpack.transform.health', { defaultMessage: 'Health' }), 'data-test-subj': 'transformListColumnHealth', - sortable: (item: TransformListRow) => item.stats.health.status, + sortable: (item: TransformListRow) => item.stats?.health.status, truncateText: true, render(item: TransformListRow) { - return ; + return item.stats ? ( + + ) : ( + + ); }, width: '100px', }, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index cce3128b3f3d01..858954976cf82d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -21,6 +21,9 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { IHttpFetchError } from '@kbn/core-http-browser'; +import type { TransformListRow } from '../../common'; +import { isTransformStats } from '../../../../common/types/transform_stats'; +import { useGetTransformsStats } from '../../hooks/use_get_transform_stats'; import { useIsServerless } from '../../serverless_context'; import { needsReauthorization } from '../../common/reauthorization_utils'; import { TRANSFORM_STATE } from '../../../../common/constants'; @@ -85,13 +88,37 @@ export const TransformManagement: FC = () => { const { isInitialLoading: transformsInitialLoading, - isLoading: transformsLoading, + isLoading: transformsWithoutStatsLoading, error: transformsErrorMessage, - data: { transforms, transformIdsWithoutConfig }, + data: { transforms: transformsWithoutStats, transformIdsWithoutConfig }, } = useGetTransforms({ enabled: !transformNodesInitialLoading && (transformNodes > 0 || hideNodeInfo), }); + const { + isLoading: transformsStatsLoading, + error: transformsStatsErrorMessage, + data: transformsStats, + } = useGetTransformsStats({ + enabled: !transformNodesInitialLoading && (transformNodes > 0 || hideNodeInfo), + }); + + const transforms: TransformListRow[] = useMemo(() => { + if (!transformsStats) return transformsWithoutStats; + + return transformsWithoutStats.map((t) => { + const stats = transformsStats.transforms.find((d) => t.config.id === d.id); + + // A newly created transform might not have corresponding stats yet. + // If that's the case we just skip the transform and don't add it to the transform list yet. + if (!isTransformStats(stats)) { + return t; + } + + return { ...t, stats }; + }); + }, [transformsStats, transformsWithoutStats]); + const isInitialLoading = transformNodesInitialLoading || transformsInitialLoading; const { canStartStopTransform } = useTransformCapabilities(); @@ -219,6 +246,17 @@ export const TransformManagement: FC = () => { errorMessage={transformsErrorMessage} /> )} + {transformsStatsErrorMessage !== null ? ( + + } + errorMessage={transformsStatsErrorMessage} + /> + ) : null} @@ -272,11 +310,12 @@ export const TransformManagement: FC = () => { ) : null} {(transformNodes > 0 || transforms.length > 0) && ( )} diff --git a/x-pack/plugins/transform/tsconfig.json b/x-pack/plugins/transform/tsconfig.json index 52e0747cc1fb92..7b41f101c15c16 100644 --- a/x-pack/plugins/transform/tsconfig.json +++ b/x-pack/plugins/transform/tsconfig.json @@ -40,7 +40,6 @@ "@kbn/std", "@kbn/es-query", "@kbn/ml-agg-utils", - "@kbn/utility-types", "@kbn/ml-string-hash", "@kbn/ui-theme", "@kbn/field-types", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 150f72792a8615..0e167e1ce60d3a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -3212,7 +3212,7 @@ "guidedOnboardingPackage.gettingStarted.cards.progressLabel": "{numberCompleteSteps} étape(s) terminée(s) sur {numberSteps}", "guidedOnboardingPackage.gettingStarted.cards.siemSecurity.title": "Détecter les menaces dans {lineBreak} mes données avec SIEM", "guidedOnboardingPackage.gettingStarted.cards.completeLabel": "Guide terminé", - "guidedOnboardingPackage.gettingStarted.cards.aiSearch.title": "Créer une expérience de recherche sémantique", + "guidedOnboardingPackage.gettingStarted.cards.aiSearch.title": "Créer une expérience de recherche basée sur l'IA", "guidedOnboardingPackage.gettingStarted.cards.hostsObservability.title": "Monitorer mes indicateurs d'hôte", "guidedOnboardingPackage.gettingStarted.cards.kubernetesObservability.title": "Monitorer les clusters Kubernetes", "guidedOnboardingPackage.gettingStarted.cards.logsObservability.title": "Collecter et analyser mes logs", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 41096d8c2e990b..f0e47690da7675 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3227,7 +3227,7 @@ "guidedOnboardingPackage.gettingStarted.cards.progressLabel": "{numberSteps}ステップ中{numberCompleteSteps}ステップ完了", "guidedOnboardingPackage.gettingStarted.cards.siemSecurity.title": "SIEMで{lineBreak}データの脅威を検出", "guidedOnboardingPackage.gettingStarted.cards.completeLabel": "ガイド完了", - "guidedOnboardingPackage.gettingStarted.cards.aiSearch.title": "セマンティック検索エクスペリエンスを構築", + "guidedOnboardingPackage.gettingStarted.cards.aiSearch.title": "AI を活用した検索エクスペリエンスを構築する", "guidedOnboardingPackage.gettingStarted.cards.hostsObservability.title": "ホストメトリックを監視", "guidedOnboardingPackage.gettingStarted.cards.kubernetesObservability.title": "Kubernetesクラスターの監視", "guidedOnboardingPackage.gettingStarted.cards.logsObservability.title": "ログを収集して分析", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b17bf98b8ad3ac..17beacb3184e29 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3226,7 +3226,7 @@ "guidedOnboardingPackage.gettingStarted.cards.progressLabel": "已完成 {numberCompleteSteps} 个(共 {numberSteps} 个)步骤", "guidedOnboardingPackage.gettingStarted.cards.siemSecurity.title": "通过 SIEM{lineBreak}在我的数据中检测威胁", "guidedOnboardingPackage.gettingStarted.cards.completeLabel": "指南完成", - "guidedOnboardingPackage.gettingStarted.cards.aiSearch.title": "构建语义搜索体验", + "guidedOnboardingPackage.gettingStarted.cards.aiSearch.title": "打造人工智能驱动的搜索体验", "guidedOnboardingPackage.gettingStarted.cards.hostsObservability.title": "监测我的主机指标", "guidedOnboardingPackage.gettingStarted.cards.kubernetesObservability.title": "监测 Kubernetes 集群", "guidedOnboardingPackage.gettingStarted.cards.logsObservability.title": "收集并分析我的日志", diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts index ddc90ec10e9b6a..02a41b6de7afae 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts @@ -26,8 +26,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F const esTestIndexTool = new ESTestIndexTool(es, retry); const supertestWithoutAuth = getService('supertestWithoutAuth'); - // FLAKY: https://github.com/elastic/kibana/issues/140973 - describe.skip('telemetry', () => { + describe('telemetry', () => { const objectRemover = new ObjectRemover(supertest); const alwaysFiringRuleId: { [key: string]: string } = {}; diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts index 94b023757bf198..ab0e0302e163d8 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts @@ -55,14 +55,20 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider refresh: true, }), ]), - add: async (findingsMock: T[]) => { + add: async < + T extends { + '@timestamp'?: string; + } + >( + findingsMock: T[] + ) => { await Promise.all([ ...findingsMock.map((finding) => es.index({ index: FINDINGS_INDEX, body: { ...finding, - '@timestamp': new Date().toISOString(), + '@timestamp': finding['@timestamp'] ?? new Date().toISOString(), }, refresh: true, }) @@ -72,7 +78,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider index: FINDINGS_LATEST_INDEX, body: { ...finding, - '@timestamp': new Date().toISOString(), + '@timestamp': finding['@timestamp'] ?? new Date().toISOString(), }, refresh: true, }) @@ -81,6 +87,20 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider }, }; + const detectionRuleApi = { + remove: async () => { + await supertest + .post('/api/detection_engine/rules/_bulk_action?dry_run=false') + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .send({ + action: 'delete', + query: '', + }) + .expect(200); + }, + }; + const distributionBar = { filterBy: async (type: 'passed' | 'failed') => testSubjects.click(type === 'failed' ? 'distribution_bar_failed' : 'distribution_bar_passed'), @@ -203,6 +223,12 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider await nonStaleElement.click(); } }, + + async openFlyoutAt(rowIndex: number) { + const table = await this.getElement(); + const flyoutButton = await table.findAllByTestSubject('findings_table_expand_column'); + await flyoutButton[rowIndex].click(); + }, }); const navigateToLatestFindingsPage = async () => { @@ -247,6 +273,41 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider const notInstalledVulnerabilities = createNotInstalledObject('cnvm-integration-not-installed'); const notInstalledCSP = createNotInstalledObject('cloud_posture_page_package_not_installed'); + const createFlyoutObject = (tableTestSubject: string) => ({ + async getElement() { + return await testSubjects.find(tableTestSubject); + }, + async clickTakeActionButton() { + const element = await this.getElement(); + const button = await element.findByCssSelector('[data-test-subj="csp:take_action"] button'); + await button.click(); + return button; + }, + async clickTakeActionCreateRuleButton() { + await this.clickTakeActionButton(); + const button = await testSubjects.find('csp:create_rule'); + await button.click(); + return button; + }, + async getVisibleText(testSubj: string) { + const element = await this.getElement(); + return await (await element.findByTestSubject(testSubj)).getVisibleText(); + }, + }); + + const misconfigurationsFlyout = createFlyoutObject('findings_flyout'); + + const toastMessage = async (testSubj = 'csp:toast-success') => ({ + async getElement() { + return await testSubjects.find(testSubj); + }, + async clickToastMessageLink(linkTestSubj = 'csp:toast-success-link') { + const element = await this.getElement(); + const link = await element.findByTestSubject(linkTestSubj); + await link.click(); + }, + }); + return { navigateToLatestFindingsPage, navigateToVulnerabilities, @@ -259,5 +320,8 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider index, waitForPluginInitialized, distributionBar, + misconfigurationsFlyout, + toastMessage, + detectionRuleApi, }; } diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts b/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts new file mode 100644 index 00000000000000..53f0b765165efa --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 Chance from 'chance'; +import type { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const pageObjects = getPageObjects(['common', 'findings', 'header']); + const chance = new Chance(); + + // We need to use a dataset for the tests to run + const data = [ + { + resource: { id: chance.guid(), name: `kubelet`, sub_type: 'lower case sub type' }, + result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps for rule 1.1', + references: '1. https://elastic.co/rules/1.1', + name: 'Upper case rule name', + section: 'Upper case section', + benchmark: { + rule_number: '1.1', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'Upper case cluster id', + }, + { + '@timestamp': '2023-09-10T14:01:00.000Z', + resource: { id: chance.guid(), name: `Pod`, sub_type: 'Upper case sub type' }, + result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps', + references: '1. https://elastic.co', + name: 'lower case rule name', + section: 'Another upper case section', + benchmark: { + rule_number: '1.2', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'Another Upper case cluster id', + }, + { + '@timestamp': '2023-09-10T14:02:00.000Z', + resource: { id: chance.guid(), name: `process`, sub_type: 'another lower case type' }, + result: { evaluation: 'passed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps', + references: '1. https://elastic.co', + name: 'Another upper case rule name', + section: 'lower case section', + benchmark: { + rule_number: '1.3', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'lower case cluster id', + }, + { + '@timestamp': '2023-09-10T14:03:00.000Z', + resource: { id: chance.guid(), name: `process`, sub_type: 'Upper case type again' }, + result: { evaluation: 'failed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps', + references: '1. https://elastic.co', + name: 'some lower case rule name', + section: 'another lower case section', + benchmark: { + rule_number: '1.4', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'another lower case cluster id', + }, + ]; + + const ruleName1 = data[0].rule.name; + + describe('Findings Page - Alerts', function () { + this.tags(['cloud_security_posture_findings_alerts']); + let findings: typeof pageObjects.findings; + let latestFindingsTable: typeof findings.latestFindingsTable; + let misconfigurationsFlyout: typeof findings.misconfigurationsFlyout; + + before(async () => { + findings = pageObjects.findings; + latestFindingsTable = findings.latestFindingsTable; + misconfigurationsFlyout = findings.misconfigurationsFlyout; + // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization + await findings.waitForPluginInitialized(); + // Prepare mocked findings + await findings.index.remove(); + await findings.index.add(data); + }); + + after(async () => { + await findings.index.remove(); + await findings.detectionRuleApi.remove(); + }); + + beforeEach(async () => { + await findings.detectionRuleApi.remove(); + await findings.navigateToLatestFindingsPage(); + await retry.waitFor( + 'Findings table to be loaded', + async () => (await latestFindingsTable.getRowsCount()) === data.length + ); + pageObjects.header.waitUntilLoadingHasFinished(); + }); + + describe('Create detection rule', () => { + it('Creates a detection rule from the Take Action button and navigates to rule page', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-alert-count') + ).to.be('0 alerts'); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-detection-rule-count') + ).to.be('1 detection rule'); + + const toastMessage = await (await findings.toastMessage()).getElement(); + expect(toastMessage).to.be.ok(); + + const toastMessageTitle = await toastMessage.findByTestSubject('csp:toast-success-title'); + expect(await toastMessageTitle.getVisibleText()).to.be(ruleName1); + + await (await findings.toastMessage()).clickToastMessageLink(); + + const rulePageTitle = await testSubjects.find('header-page-title'); + expect(await rulePageTitle.getVisibleText()).to.be(ruleName1); + }); + it('Creates a detection rule from the Alerts section and navigates to rule page', async () => { + await latestFindingsTable.openFlyoutAt(0); + const flyout = await misconfigurationsFlyout.getElement(); + + await ( + await flyout.findByTestSubject('csp:findings-flyout-create-detection-rule-link') + ).click(); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-alert-count') + ).to.be('0 alerts'); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-detection-rule-count') + ).to.be('1 detection rule'); + + const toastMessage = await (await findings.toastMessage()).getElement(); + expect(toastMessage).to.be.ok(); + + const toastMessageTitle = await toastMessage.findByTestSubject('csp:toast-success-title'); + expect(await toastMessageTitle.getVisibleText()).to.be(ruleName1); + + await (await findings.toastMessage()).clickToastMessageLink(); + + const rulePageTitle = await testSubjects.find('header-page-title'); + expect(await rulePageTitle.getVisibleText()).to.be(ruleName1); + }); + }); + describe('Rule details', () => { + it('The rule page contains the expected matching data', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + + await (await findings.toastMessage()).clickToastMessageLink(); + + const rulePageDescription = await testSubjects.find( + 'stepAboutRuleDetailsToggleDescriptionText' + ); + expect(await rulePageDescription.getVisibleText()).to.be(data[0].rule.rationale); + + const severity = await testSubjects.find('severity'); + expect(await severity.getVisibleText()).to.be('Low'); + + const referenceUrls = await testSubjects.find('urlsDescriptionReferenceLinkItem'); + expect(await referenceUrls.getVisibleText()).to.contain('https://elastic.co/rules/1.1'); + }); + }); + describe('Navigation', () => { + it('Clicking on count of Rules should navigate to the rules page with benchmark tags as a filter', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + const flyout = await misconfigurationsFlyout.getElement(); + await (await flyout.findByTestSubject('csp:findings-flyout-detection-rule-count')).click(); + + expect(await (await testSubjects.find('ruleName')).getVisibleText()).to.be(ruleName1); + }); + it('Clicking on count of Alerts should navigate to the alerts page', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + const flyout = await misconfigurationsFlyout.getElement(); + await (await flyout.findByTestSubject('csp:findings-flyout-alert-count')).click(); + + expect(await (await testSubjects.find('header-page-title')).getVisibleText()).to.be( + 'Alerts' + ); + }); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/index.ts b/x-pack/test/cloud_security_posture_functional/pages/index.ts index 1d30a3b27fda95..81e905ddaca35a 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/index.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/index.ts @@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('Cloud Security Posture', function () { loadTestFile(require.resolve('./findings_onboarding')); loadTestFile(require.resolve('./findings')); + loadTestFile(require.resolve('./findings_alerts')); loadTestFile(require.resolve('./compliance_dashboard')); }); } diff --git a/x-pack/test/profiling_api_integration/tests/has_setup.spec.ts b/x-pack/test/profiling_api_integration/tests/has_setup.spec.ts index 5046eb8c9c556f..583cc02908025f 100644 --- a/x-pack/test/profiling_api_integration/tests/has_setup.spec.ts +++ b/x-pack/test/profiling_api_integration/tests/has_setup.spec.ts @@ -23,9 +23,9 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const logger = getService('log'); const es = getService('es'); - registry.when('Profiling status check', { config: 'cloud' }, () => { - // Failing: See https://github.com/elastic/kibana/issues/167076 - describe.skip('Profiling is not set up and no data is loaded', () => { + // Failing: See https://github.com/elastic/kibana/issues/167076 + registry.when.skip('Profiling status check', { config: 'cloud' }, () => { + describe('Profiling is not set up and no data is loaded', () => { describe('Admin user', () => { let statusCheck: ProfilingStatus; before(async () => {