diff --git a/.buildkite/scripts/steps/esql_grammar_sync.sh b/.buildkite/scripts/steps/esql_grammar_sync.sh index ee8a61530f3dea..2aa1144dce8cb9 100755 --- a/.buildkite/scripts/steps/esql_grammar_sync.sh +++ b/.buildkite/scripts/steps/esql_grammar_sync.sh @@ -121,7 +121,7 @@ main () { git push origin "$BRANCH_NAME" # Create a PR - gh pr create --draft --title "$PR_TITLE" --body "$PR_BODY" --base main --head "${BRANCH_NAME}" --label 'release_note:skip' --label 'Team:ESQL' + gh pr create --title "$PR_TITLE" --body "$PR_BODY" --base main --head "${BRANCH_NAME}" --label 'release_note:skip' --label 'Team:ESQL' } main diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 158c177c50761c..9e6bca0b544336 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -692,6 +692,7 @@ x-pack/plugins/search_connectors @elastic/enterprise-search-frontend packages/kbn-search-errors @elastic/kibana-data-discovery examples/search_examples @elastic/kibana-data-discovery packages/kbn-search-index-documents @elastic/enterprise-search-frontend +x-pack/plugins/search_notebooks @elastic/enterprise-search-frontend x-pack/plugins/search_playground @elastic/enterprise-search-frontend packages/kbn-search-response-warnings @elastic/kibana-data-discovery x-pack/plugins/searchprofiler @elastic/kibana-management diff --git a/.node-version b/.node-version index 2dbbe00e679a26..bc78e9f2695ea6 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.11.1 +20.12.1 diff --git a/.nvmrc b/.nvmrc index 2dbbe00e679a26..bc78e9f2695ea6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.11.1 +20.12.1 diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 35c8ec6bd01541..b004b398532923 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -22,13 +22,13 @@ load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install # Setup the Node.js toolchain for the architectures we want to support node_repositories( node_repositories = { - "20.11.1-darwin_amd64": ("node-v20.11.1-darwin-x64.tar.gz", "node-v20.11.1-darwin-x64", "c52e7fb0709dbe63a4cbe08ac8af3479188692937a7bd8e776e0eedfa33bb848"), - "20.11.1-darwin_arm64": ("node-v20.11.1-darwin-arm64.tar.gz", "node-v20.11.1-darwin-arm64", "e0065c61f340e85106a99c4b54746c5cee09d59b08c5712f67f99e92aa44995d"), - "20.11.1-linux_arm64": ("node-v20.11.1-linux-arm64.tar.xz", "node-v20.11.1-linux-arm64", "36bac185164aa11940715425da1db2ec46e3354325bda9ee0b98b4a607aa0d8b"), - "20.11.1-linux_amd64": ("node-v20.11.1-linux-x64.tar.xz", "node-v20.11.1-linux-x64", "e2c39cb70b9ff79575a02747dd1e89917817cce05da21bef6b94eb9e92442024"), - "20.11.1-windows_amd64": ("node-v20.11.1-win-x64.zip", "node-v20.11.1-win-x64", "bc032628d77d206ffa7f133518a6225a9c5d6d9210ead30d67e294ff37044bda"), + "20.12.1-darwin_amd64": ("node-v20.12.1-darwin-x64.tar.gz", "node-v20.12.1-darwin-x64", "f5dc3c71c87c58c9b019d9f85302db3a6a6c47167c5a0480b697f153d02ac316"), + "20.12.1-darwin_arm64": ("node-v20.12.1-darwin-arm64.tar.gz", "node-v20.12.1-darwin-arm64", "65df8cb0724e3a58c7757b75a70cc1057e1f67ffc5e852bfe6241de0b37c70a0"), + "20.12.1-linux_arm64": ("node-v20.12.1-linux-arm64.tar.xz", "node-v20.12.1-linux-arm64", "903a0f94312ba819f16a133e9dc378db128804ff45ea69e11af7152c303e8a85"), + "20.12.1-linux_amd64": ("node-v20.12.1-linux-x64.tar.xz", "node-v20.12.1-linux-x64", "e43b54ecea97b4419a5526af57bcf4f22a3d5583e1b2cacf461da71b9ba2befe"), + "20.12.1-windows_amd64": ("node-v20.12.1-win-x64.zip", "node-v20.12.1-win-x64", "629e2619ef88c5a8ce9944201f00ca3124f079c43ceef7ab0826c6fd19e09d75"), }, - node_version = "20.11.1", + node_version = "20.12.1", node_urls = [ "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/dist/v{version}/{filename}", ], diff --git a/docs/developer/advanced/upgrading-nodejs.asciidoc b/docs/developer/advanced/upgrading-nodejs.asciidoc index dea7c557042994..546d5ae46f164c 100644 --- a/docs/developer/advanced/upgrading-nodejs.asciidoc +++ b/docs/developer/advanced/upgrading-nodejs.asciidoc @@ -19,7 +19,7 @@ These files must be updated when upgrading Node.js: - {kib-repo}blob/{branch}/WORKSPACE.bazel[`WORKSPACE.bazel`] - The version is specified in the `node_version` property. Besides this property, the list of files under `node_repositories` must be updated along with their respective SHA256 hashes. These can be found in the `SHASUMS256.txt` file inside the public `kibana-custom-node-artifacts` GCP bucket. - Example for Node.js v20.11.1: https://storage.googleapis.com/kibana-custom-node-artifacts/node-glibc-217/dist/v20.11.1/SHASUMS256.txt[kibana-custom-node-artifacts/node-glibc-217/dist/v20.11.1/SHASUMS256.txt] + Example for Node.js v20.12.1: https://storage.googleapis.com/kibana-custom-node-artifacts/node-glibc-217/dist/v20.12.1/SHASUMS256.txt[kibana-custom-node-artifacts/node-glibc-217/dist/v20.12.1/SHASUMS256.txt] See PR {kib-repo}pull/128123[#128123] for an example of how the Node.js version has been upgraded previously. @@ -43,7 +43,7 @@ The only difference between the offical Node.js build and our custom build, is t ==== How to start a new build To generate a new custom Node.js build, https://buildkite.com/elastic/kibana-custom-node-dot-js-builds#new[start a new build] on our dedicated Buildkite pipeline (requires Elastic employee permissions). -Give it a clear name (e.g. `Node 20.11.1`) and remember so set the custom `OVERRIDE_TARGET_VERSION` environment variable to the desired Node.js version - e.g. `OVERRIDE_TARGET_VERSION=20.11.1`. +Give it a clear name (e.g. `Node 20.12.1`) and remember so set the custom `OVERRIDE_TARGET_VERSION` environment variable to the desired Node.js version - e.g. `OVERRIDE_TARGET_VERSION=20.12.1`. You find the "Environment Variables" field by expanding "Options >" in the "New Build" dialog. === Backporting diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 472ad01dc9ce14..3bbc594a21c5ae 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -757,6 +757,10 @@ It uses Chromium and Puppeteer underneath to run the browser in headless mode. |This plugin contains common assets and endpoints for the use of connectors in Kibana. Primarily used by the enterprise_search and serverless_search plugins. +|{kib-repo}blob/{branch}/x-pack/plugins/search_notebooks/README.mdx[searchNotebooks] +|This plugin contains endpoints and components for rendering search python notebooks in the persistent dev console. + + |{kib-repo}blob/{branch}/x-pack/plugins/search_playground/README.md[searchPlayground] |The Search Playground is a tool for developers to experiment with their own data using LLMs. diff --git a/package.json b/package.json index 4de6107e135e68..cc68dcb4c6601f 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "url": "https://github.com/elastic/kibana.git" }, "engines": { - "node": "20.11.1", + "node": "20.12.1", "yarn": "^1.22.19" }, "resolutions": { @@ -696,6 +696,7 @@ "@kbn/search-errors": "link:packages/kbn-search-errors", "@kbn/search-examples-plugin": "link:examples/search_examples", "@kbn/search-index-documents": "link:packages/kbn-search-index-documents", + "@kbn/search-notebooks": "link:x-pack/plugins/search_notebooks", "@kbn/search-playground": "link:x-pack/plugins/search_playground", "@kbn/search-response-warnings": "link:packages/kbn-search-response-warnings", "@kbn/searchprofiler-plugin": "link:x-pack/plugins/searchprofiler", diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index b001229dd232da..cdc47c69caef3e 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -176,7 +176,7 @@ export const HASH_TO_VERSION_MAP = { 'guided-onboarding-guide-state|a3db59c45a3fd2730816d4f53c35c7d9': '10.0.0', 'guided-onboarding-plugin-state|3d1b76c39bfb2cc8296b024d73854724': '10.0.0', 'index-pattern|83c02d842fe2a94d14dfa13f7dcd6e87': '10.0.0', - 'infra-custom-dashboards|6eed22cbe14594bad8c076fa864930de': '10.0.0', + 'infra-custom-dashboards|1eb3c9e1888b8daea8495769e8d3ba2d': '10.2.0', 'infrastructure-monitoring-log-view|c50526fc6040c5355ed027d34d05b35c': '10.0.0', 'infrastructure-ui-source|3d1b76c39bfb2cc8296b024d73854724': '10.0.0', 'ingest_manager_settings|b91ffb075799c78ffd7dbd51a279c8c9': '10.1.0', diff --git a/packages/kbn-apm-config-loader/src/config.test.ts b/packages/kbn-apm-config-loader/src/config.test.ts index 3d0bd98bec4679..e955b175de1295 100644 --- a/packages/kbn-apm-config-loader/src/config.test.ts +++ b/packages/kbn-apm-config-loader/src/config.test.ts @@ -7,9 +7,9 @@ */ import type { AgentConfigOptions, Labels } from 'elastic-apm-node'; import { - gitRevExecMock, - mockedRootDir, packageMock, + mockedRootDir, + gitRevExecMock, readUuidFileMock, resetAllMocks, } from './config.test.mocks'; @@ -153,7 +153,6 @@ describe('ApmConfiguration', () => { delete process.env.ELASTIC_APM_API_KEY; delete process.env.ELASTIC_APM_KIBANA_FRONTEND_ACTIVE; delete process.env.ELASTIC_APM_SERVER_URL; - delete process.env.ELASTIC_APM_GLOBAL_LABELS; delete process.env.NODE_ENV; }); @@ -186,21 +185,6 @@ describe('ApmConfiguration', () => { }) ); }); - - it('ELASTIC_APM_GLOBAL_LABELS', () => { - process.env.ELASTIC_APM_GLOBAL_LABELS = 'test1=1,test2=2'; - const config = new ApmConfiguration(mockedRootDir, {}, true); - - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - globalLabels: { - git_rev: 'sha', - test1: '1', - test2: '2', - }, - }) - ); - }); }); it('ELASTIC_APM_KIBANA_FRONTEND_ACTIVE', () => { diff --git a/packages/kbn-apm-config-loader/src/config.ts b/packages/kbn-apm-config-loader/src/config.ts index 583dd8fc58b567..8771eb042dc750 100644 --- a/packages/kbn-apm-config-loader/src/config.ts +++ b/packages/kbn-apm-config-loader/src/config.ts @@ -7,7 +7,6 @@ */ import { join } from 'path'; -import deepmerge from 'deepmerge'; import { merge, isEmpty } from 'lodash'; import { execSync } from 'child_process'; import { getDataPath } from '@kbn/utils'; @@ -310,10 +309,7 @@ export class ApmConfiguration { const { servicesOverrides, redactUsers, ...configFromKibanaConfig } = this.getConfigFromKibanaConfig(); const configFromEnv = this.getConfigFromEnv(configFromKibanaConfig); - const config = [configFromKibanaConfig, configFromEnv].reduce( - (acc, conf) => deepmerge(acc, conf), - {} - ); + const config = merge({}, configFromKibanaConfig, configFromEnv); if (config.active === false && config.contextPropagationOnly !== false) { throw new Error( diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 11a0173c973ce9..5a26aca6bacc19 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -462,8 +462,8 @@ ], "infra-custom-dashboards": [ "assetType", - "dashboardIdList", - "kuery" + "dashboardFilterAssetIdEnabled", + "dashboardSavedObjectId" ], "infrastructure-monitoring-log-view": [ "name" diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 2096be33bff9e6..246c02b7dfec30 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1559,15 +1559,16 @@ } }, "infra-custom-dashboards": { + "dynamic": false, "properties": { "assetType": { "type": "keyword" }, - "dashboardIdList": { + "dashboardSavedObjectId": { "type": "keyword" }, - "kuery": { - "type": "text" + "dashboardFilterAssetIdEnabled": { + "type": "boolean" } } }, diff --git a/packages/kbn-es-types/src/search.test.ts b/packages/kbn-es-types/src/search.test.ts new file mode 100644 index 00000000000000..6a5218b8f3a471 --- /dev/null +++ b/packages/kbn-es-types/src/search.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the 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 { AggregateOfMap } from './search'; + +xdescribe('AggregateOfMap', () => { + test('aggregations should assume buckets are there if type is explicit', () => { + type MyAggregation = {} & { + group_by: { + terms: unknown; + }; + }; + + const aggregation = {} as unknown as AggregateOfMap; + aggregation.group_by.buckets.length.toFixed(); // using a number-specific method + }); + + test('aggregations should not assume buckets are there if the aggregation may be undefined', () => { + type MyAggregation = + | undefined + | ({} & { + group_by: { + terms: unknown; + }; + }); + + const aggregation = {} as unknown as AggregateOfMap; + aggregation?.group_by.buckets.length.toFixed(); // using a number-specific method + // @ts-expect-error "aggregation" may be undefined + aggregation.group_by.buckets.length.toFixed(); // using a number-specific method + }); + + test('aggregations should not assume buckets are there if the bucket name may be undefined', () => { + type MyAggregation = + | {} & { + group_by?: { + terms: unknown; + }; + }; + + const aggregation = {} as unknown as AggregateOfMap; + aggregation.group_by?.buckets.length.toFixed(); // using a number-specific method + // @ts-expect-error "group_by" may be undefined + aggregation.group_by.buckets.length.toFixed(); // using a number-specific method + }); +}); diff --git a/packages/kbn-es-types/src/search.ts b/packages/kbn-es-types/src/search.ts index 2aa069363d3aa6..43fbe155860901 100644 --- a/packages/kbn-es-types/src/search.ts +++ b/packages/kbn-es-types/src/search.ts @@ -604,8 +604,7 @@ export type AggregateOf< export type AggregateOfMap = { [TAggregationName in keyof TAggregationMap]: Required[TAggregationName] extends AggregationsAggregationContainer - ? // @ts-expect-error not sure how to fix this, anything I've tried causes errors upstream - Dario - AggregateOf + ? AggregateOf[TAggregationName], TDocument> : never; // using never means we effectively ignore optional keys, using {} creates a union type of { ... } | {} }; diff --git a/packages/kbn-search-connectors/components/sync_jobs/__snapshots__/documents_panel.test.tsx.snap b/packages/kbn-search-connectors/components/sync_jobs/__snapshots__/documents_panel.test.tsx.snap index da48c7e846733b..e29361b08a9c12 100644 --- a/packages/kbn-search-connectors/components/sync_jobs/__snapshots__/documents_panel.test.tsx.snap +++ b/packages/kbn-search-connectors/components/sync_jobs/__snapshots__/documents_panel.test.tsx.snap @@ -9,15 +9,11 @@ exports[`DocumentsPanel renders 1`] = ` Array [ Object { "field": "added", - "name": "Added", + "name": "Upserted", }, Object { "field": "removed", - "name": "Removed", - }, - Object { - "field": "total", - "name": "Total", + "name": "Deleted", }, Object { "field": "volume", diff --git a/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx b/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx index 92189ece90da9e..0bfe917d3aa01c 100644 --- a/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx +++ b/packages/kbn-search-connectors/components/sync_jobs/documents_panel.tsx @@ -17,7 +17,6 @@ import { FlyoutPanel } from './flyout_panel'; interface SyncJobDocumentsPanelProps { added: number; removed: number; - total: number; volume: number; } @@ -26,19 +25,13 @@ export const SyncJobDocumentsPanel: React.FC = (sync { field: 'added', name: i18n.translate('searchConnectors.index.syncJobs.documents.added', { - defaultMessage: 'Added', + defaultMessage: 'Upserted', }), }, { field: 'removed', name: i18n.translate('searchConnectors.index.syncJobs.documents.removed', { - defaultMessage: 'Removed', - }), - }, - { - field: 'total', - name: i18n.translate('searchConnectors.index.syncJobs.documents.total', { - defaultMessage: 'Total', + defaultMessage: 'Deleted', }), }, { diff --git a/packages/kbn-search-connectors/components/sync_jobs/sync_job_flyout.tsx b/packages/kbn-search-connectors/components/sync_jobs/sync_job_flyout.tsx index 07d635adf5ec30..30101b55ec81f7 100644 --- a/packages/kbn-search-connectors/components/sync_jobs/sync_job_flyout.tsx +++ b/packages/kbn-search-connectors/components/sync_jobs/sync_job_flyout.tsx @@ -76,7 +76,6 @@ export const SyncJobFlyout: React.FC = ({ onClose, syncJob } diff --git a/packages/kbn-search-connectors/components/sync_jobs/sync_jobs_table.tsx b/packages/kbn-search-connectors/components/sync_jobs/sync_jobs_table.tsx index e55c286f7b58b9..7426d7dad3dec8 100644 --- a/packages/kbn-search-connectors/components/sync_jobs/sync_jobs_table.tsx +++ b/packages/kbn-search-connectors/components/sync_jobs/sync_jobs_table.tsx @@ -63,7 +63,7 @@ export const SyncJobsTable: React.FC = ({ { field: 'indexed_document_count', name: i18n.translate('searchConnectors.searchIndices.addedDocs.columnTitle', { - defaultMessage: 'Docs added', + defaultMessage: 'Docs upserted', }), sortable: true, truncateText: true, diff --git a/packages/shared-ux/modal/tabbed/src/context/index.tsx b/packages/shared-ux/modal/tabbed/src/context/index.tsx index 9021b6b8648263..edb80300874bb3 100644 --- a/packages/shared-ux/modal/tabbed/src/context/index.tsx +++ b/packages/shared-ux/modal/tabbed/src/context/index.tsx @@ -144,7 +144,7 @@ export function ModalContextProvider "guided-onboarding-guide-state": "d338972ed887ac480c09a1a7fbf582d6a3827c91", "guided-onboarding-plugin-state": "bc109e5ef46ca594fdc179eda15f3095ca0a37a4", "index-pattern": "997108a9ea1e8076e22231e1c95517cdb192b9c5", - "infra-custom-dashboards": "b92b6db1c1f8998af6e2951a17b76cf886c6bee5", + "infra-custom-dashboards": "1a5994f2e05bb8a1609825ddbf5012f77c5c67f3", "infrastructure-monitoring-log-view": "5f86709d3c27aed7a8379153b08ee5d3d90d77f5", "infrastructure-ui-source": "113182d6895764378dfe7fa9fa027244f3a457c4", "ingest-agent-policies": "7633e578f60c074f8267bc50ec4763845e431437", diff --git a/src/plugins/console/public/application/containers/editor/monaco/use_set_initial_value.ts b/src/plugins/console/public/application/containers/editor/monaco/use_set_initial_value.ts index 0107a95ccbfc02..b95a8ac46ac5dc 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/use_set_initial_value.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/use_set_initial_value.ts @@ -41,11 +41,32 @@ const readLoadFromParam = () => { * * @param params The {@link SetInitialValueParams} to use. */ -export const useSetInitialValue = (params: SetInitialValueParams) => { +export const useSetInitialValue = async (params: SetInitialValueParams) => { const { initialTextValue, setValue, toasts } = params; - const loadBufferFromRemote = (url: string) => { - // TODO: Add support for fetching from HTTP + const loadBufferFromRemote = async (url: string) => { + if (/^https?:\/\//.test(url)) { + // Check if this is a valid URL + try { + new URL(url); + } catch (e) { + return; + } + // Parse the URL to avoid issues with spaces and other special characters. + const parsedURL = new URL(url); + if (parsedURL.origin === 'https://www.elastic.co') { + const resp = await fetch(parsedURL); + const data = await resp.text(); + setValue(`${initialTextValue}\n\n${data}`); + } else { + toasts.addWarning( + i18n.translate('console.loadFromDataUnrecognizedUrlErrorMessage', { + defaultMessage: + 'Only URLs with the Elastic domain (www.elastic.co) can be loaded in Console.', + }) + ); + } + } // If we have a data URI instead of HTTP, LZ-decode it. This enables // opening requests in Console from anywhere in Kibana. @@ -67,12 +88,12 @@ export const useSetInitialValue = (params: SetInitialValueParams) => { }; // Support for loading a console snippet from a remote source, like support docs. - const onHashChange = debounce(() => { + const onHashChange = debounce(async () => { const url = readLoadFromParam(); if (!url) { return; } - loadBufferFromRemote(url); + await loadBufferFromRemote(url); }, 200); window.addEventListener('hashchange', onHashChange); @@ -80,7 +101,7 @@ export const useSetInitialValue = (params: SetInitialValueParams) => { const loadFromParam = readLoadFromParam(); if (loadFromParam) { - loadBufferFromRemote(loadFromParam); + await loadBufferFromRemote(loadFromParam); } else { setValue(initialTextValue || DEFAULT_INPUT_VALUE); } diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 510d57fc6c09a9..93480cd3cf9457 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -56,6 +56,9 @@ export { ValidatedDualRange } from './validated_range'; export type { ToastInput, KibanaReactNotifications } from './notifications'; export { createNotifications } from './notifications'; +/** @deprecated use `Markdown` from `@kbn/shared-ux-markdown` */ +export { Markdown, MarkdownSimple } from './markdown'; + export { toMountPoint } from './util'; export type { ToMountPointOptions } from './util'; diff --git a/src/plugins/kibana_react/public/markdown/__snapshots__/markdown.test.tsx.snap b/src/plugins/kibana_react/public/markdown/__snapshots__/markdown.test.tsx.snap new file mode 100644 index 00000000000000..4299acdd32c9e8 --- /dev/null +++ b/src/plugins/kibana_react/public/markdown/__snapshots__/markdown.test.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`props markdown 1`] = ` +
I am some content with markdown

+", + } + } +/> +`; + +exports[`props openLinksInNewTab 1`] = ` +
I am some content with markdown

+", + } + } +/> +`; + +exports[`props whiteListedRules 1`] = ` +
I am some [content](https://en.wikipedia.org/wiki/Content) with markdown

+", + } + } +/> +`; + +exports[`render 1`] = ` +
+`; + +exports[`should never render html tags 1`] = ` +
<div>I may be dangerous if rendered as html</div>

+", + } + } +/> +`; diff --git a/src/plugins/kibana_react/public/markdown/_markdown.scss b/src/plugins/kibana_react/public/markdown/_markdown.scss new file mode 100644 index 00000000000000..a3bba38509bcdb --- /dev/null +++ b/src/plugins/kibana_react/public/markdown/_markdown.scss @@ -0,0 +1,328 @@ +// Default styles for Markdown element +// +// 1. Links +// 2. Headings +// 3. Images +// 4. Blockquotes +// 5. Horizontal rules +// 6. Lists +// 7. Tables +// 8. Code blocks + +// Functions +// Note: The inlined base font size is set in common/functions/font.js. It should match $kbnMdFontSize. +$kbnDefaultFontSize: 14px; + +@function canvasToEm($size) { + @return #{calc($size / $kbnDefaultFontSize)}em; +} + +.kbnMarkdown__body { + // Font size variables + $kbnMarkdownFontSizeS: canvasToEm(12px); + $kbnMarkdownFontSize: canvasToEm(14px); + $kbnMarkdownFontSizeL: canvasToEm(20px); + $kbnMarkdownFontSizeXL: canvasToEm(28px); + $kbnMarkdownFontSizeXXL: canvasToEm(36px); + + // Spacing variables + $kbnMarkdownSizeL: canvasToEm(24px); + $kbnMarkdownSize: canvasToEm(16px); + $kbnMarkdownSizeS: canvasToEm(12px); + $kbnMarkdownSizeXS: canvasToEm(8px); + $kbnMarkdownSizeXXS: canvasToEm(4px); + + // Grayscale variables + $kbnMarkdownAlphaLightestShade: rgba($euiColorFullShade, .05); + $kbnMarkdownAlphaLightShade: rgba($euiColorFullShade, .15); + $kbnMarkdownAlphaDarkShade: rgba($euiColorFullShade, .65); + + // Reverse grayscale for opposite of theme + $kbnMarkdownAlphaLightestShadeReversed: rgba($euiColorEmptyShade, .05); + $kbnMarkdownAlphaLightShadeReversed: rgba($euiColorEmptyShade, .15); + $kbnMarkdownAlphaDarkShadeReversed: rgba($euiColorEmptyShade, .65); + + &--reversed { + color: $euiColorLightestShade; + } + + > *:first-child { + margin-top: 0 !important; + } + + > *:last-child { + margin-bottom: 0 !important; + } + + p, + blockquote, + ul, + ol, + dl, + table, + pre { + margin-top: 0; + margin-bottom: $kbnMarkdownSize; + line-height: 1.5em; + } + + strong { + font-weight: 600; + } + + // 1. Links + a { + color: inherit; + text-decoration: underline; + } + + a:hover { + text-decoration: underline dotted; + } + + a:active, + a:hover { + outline-width: 0; + } + + a:not([href]) { + color: inherit; + text-decoration: none; + } + + // 2. Headings + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 0; + margin-bottom: $kbnMarkdownSizeXS; + } + + h1 { + font-size: $kbnMarkdownFontSizeXXL; + line-height: 1.333333em; + font-weight: 300; + } + + h2 { + font-size: $kbnMarkdownFontSizeXL; + line-height: 1.428571em; + font-weight: 300; + } + + h3 { + font-size: $kbnMarkdownFontSizeL; + line-height: 1.6em; + font-weight: 600; + } + + h4 { + font-size: $kbnMarkdownSize; + line-height: 1.5em; + font-weight: 600; + } + + h5 { + font-size: $kbnMarkdownFontSize; + line-height: 1.142857em; + font-weight: 700; + } + + h6 { + font-size: $kbnMarkdownFontSizeS; + line-height: 1.333333em; + font-weight: 700; + text-transform: uppercase; + } + + // 3. Images + img { + max-width: 100%; + box-sizing: content-box; + border-style: none; + pointer-events: auto; + } + + // 4. Blockquotes + blockquote { + padding: 0 1em; + border-left: $kbnMarkdownSizeXXS solid $kbnMarkdownAlphaLightShade; + } + &--reversed blockquote { + border-left-color: $kbnMarkdownAlphaLightShadeReversed; + } + + // 5. Horizontal rules + hr { + overflow: hidden; + background: transparent; + height: 2px; + padding: 0; + margin: $kbnMarkdownSizeL 0; + background-color: $kbnMarkdownAlphaLightShade; + border: 0; + } + &--reversed hr { + background-color: $kbnMarkdownAlphaLightShadeReversed; + } + + hr::before { + display: table; + content: ''; + } + + hr::after { + display: table; + clear: both; + content: ''; + } + + // 6. Lists + ul, + ol { + padding-left: $kbnMarkdownSizeL; + margin-top: 0; + margin-bottom: $kbnMarkdownSize; + } + + ul { + list-style-type: disc; + } + ol { + list-style-type: decimal; + } + + ul ul { + list-style-type: circle; + } + + ol ol, + ul ol { + list-style-type: lower-roman; + } + + ul ul ol, + ul ol ol, + ol ul ol, + ol ol ol { + list-style-type: lower-alpha; + } + + dd { + margin-left: 0; + } + + ul ul, + ul ol, + ol ol, + ol ul { + margin-top: 0; + margin-bottom: 0; + } + + li > p { + margin-bottom: $kbnMarkdownSizeXS; + } + + li + li { + margin-top: $kbnMarkdownSizeXXS; + } + + // 7. Tables + table { + display: block; + width: 100%; + overflow: auto; + border-left: 1px solid $kbnMarkdownAlphaLightShade; + border-spacing: 0; + border-collapse: collapse; + } + &--reversed table { + border-left-color: $kbnMarkdownAlphaLightShadeReversed; + } + + td, + th { + padding: 0; + } + + table th, + table td { + padding: $kbnMarkdownSizeXXS $kbnMarkdownSizeS; + border-top: 1px solid $kbnMarkdownAlphaLightShade; + border-bottom: 1px solid $kbnMarkdownAlphaLightShade; + + &:last-child { + border-right: 1px solid $kbnMarkdownAlphaLightShade; + } + } + &--reversed table th, + &--reversed table td { + border-color: $kbnMarkdownAlphaLightShadeReversed; + } + + table tr { + background-color: transparent; + border-top: 1px solid $kbnMarkdownAlphaLightShade; + } + &--reversed table tr { + border-top-color: $kbnMarkdownAlphaLightShadeReversed; + } + + // 8. Code blocks + code, + pre { + margin-bottom: $kbnMarkdownSizeXS; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: $kbnMarkdownFontSizeS; + } + + code { + padding: $kbnMarkdownSizeXXS 0; + margin: 0; + background-color: $kbnMarkdownAlphaLightestShade; + border-radius: $kbnMarkdownSizeXXS; + } + &--reversed code { + background-color: $kbnMarkdownAlphaLightestShadeReversed; + } + + code::before, + code::after { + letter-spacing: -.2em; + content: '\00a0'; + } + + pre { + padding: $kbnMarkdownSize; + overflow: auto; + font-size: $kbnMarkdownFontSizeS; + line-height: 1.333333em; + background-color: $kbnMarkdownAlphaLightestShade; + border-radius: $kbnMarkdownSizeXXS; + word-wrap: normal; + } + &--reversed pre { + background-color: $kbnMarkdownAlphaLightestShadeReversed; + } + + pre code { + display: inline; + max-width: auto; + padding: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + white-space: pre; + background-color: transparent; + border: 0; + } + + pre code::before, + pre code::after { + content: normal; + } +} diff --git a/src/plugins/kibana_react/public/markdown/index.scss b/src/plugins/kibana_react/public/markdown/index.scss new file mode 100644 index 00000000000000..f997ea5384eacd --- /dev/null +++ b/src/plugins/kibana_react/public/markdown/index.scss @@ -0,0 +1 @@ +@import './markdown'; diff --git a/src/plugins/kibana_react/public/markdown/index.tsx b/src/plugins/kibana_react/public/markdown/index.tsx new file mode 100644 index 00000000000000..c15d4269cf3c07 --- /dev/null +++ b/src/plugins/kibana_react/public/markdown/index.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 and the 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 { EuiSkeletonText, EuiDelayRender } from '@elastic/eui'; +import type { MarkdownSimpleProps } from './markdown_simple'; +import type { MarkdownProps } from './markdown'; + +const Fallback = () => ( + + + +); + +/** @deprecated use `Markdown` from `@kbn/shared-ux-markdown` */ +const LazyMarkdownSimple = React.lazy(() => import('./markdown_simple')); +export const MarkdownSimple = (props: MarkdownSimpleProps) => ( + }> + + +); + +/** @deprecated use `Markdown` from `@kbn/shared-ux-markdown` */ +const LazyMarkdown = React.lazy(() => import('./markdown')); +export const Markdown = (props: MarkdownProps) => ( + }> + + +); diff --git a/src/plugins/kibana_react/public/markdown/markdown.test.tsx b/src/plugins/kibana_react/public/markdown/markdown.test.tsx new file mode 100644 index 00000000000000..1dda072be9e5fd --- /dev/null +++ b/src/plugins/kibana_react/public/markdown/markdown.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the 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 } from 'enzyme'; + +import { Markdown } from './markdown'; + +test('render', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('should never render html tags', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); +}); + +test('should render links with parentheses correctly', () => { + const component = shallow( + + ); + expect(component.render().find('a').prop('href')).toBe( + 'https://example.com/foo/bar?group=(()filters:!t)' + ); +}); + +test('should add `noreferrer` and `nooopener` to all links in new tabs', () => { + const component = shallow( + + ); + expect(component.render().find('a').prop('rel')).toBe('noopener noreferrer'); +}); + +describe('props', () => { + const markdown = 'I am *some* [content](https://en.wikipedia.org/wiki/Content) with `markdown`'; + + test('markdown', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + test('openLinksInNewTab', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + test('whiteListedRules', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + test('should update markdown when openLinksInNewTab prop change', () => { + const component = shallow(); + expect(component.render().find('a').prop('target')).not.toBe('_blank'); + component.setProps({ openLinksInNewTab: true }); + expect(component.render().find('a').prop('target')).toBe('_blank'); + }); + + test('should update markdown when whiteListedRules prop change', () => { + const md = '*emphasis* `backticks`'; + const component = shallow( + + ); + expect(component.render().find('em')).toHaveLength(1); + expect(component.render().find('code')).toHaveLength(1); + component.setProps({ whiteListedRules: ['backticks'] }); + expect(component.render().find('code')).toHaveLength(1); + expect(component.render().find('em')).toHaveLength(0); + }); +}); diff --git a/src/plugins/kibana_react/public/markdown/markdown.tsx b/src/plugins/kibana_react/public/markdown/markdown.tsx new file mode 100644 index 00000000000000..62f2564daf1f55 --- /dev/null +++ b/src/plugins/kibana_react/public/markdown/markdown.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the 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 classNames from 'classnames'; +import React, { useEffect } from 'react'; +import MarkdownIt from 'markdown-it'; +import { memoize } from 'lodash'; +import { getSecureRelForTarget } from '@elastic/eui'; + +import './index.scss'; +/** + * Return a memoized markdown rendering function that use the specified + * whiteListedRules and openLinksInNewTab configurations. + * @param {Array of Strings} whiteListedRules - white list of markdown rules + * list of rules can be found at https://github.com/markdown-it/markdown-it/issues/361 + * @param {Boolean} openLinksInNewTab + * @return {Function} Returns an Object to use with dangerouslySetInnerHTML + * with the rendered markdown HTML + */ +export const markdownFactory = memoize( + (whiteListedRules: string[] = [], openLinksInNewTab: boolean = false) => { + let markdownIt: MarkdownIt; + + // It is imperative that the html config property be set to false, to mitigate XSS: the output of markdown-it is + // fed directly to the DOM via React's dangerouslySetInnerHTML below. + + if (whiteListedRules && whiteListedRules.length > 0) { + markdownIt = new MarkdownIt('zero', { html: false, linkify: true }); + markdownIt.enable(whiteListedRules); + } else { + markdownIt = new MarkdownIt({ html: false, linkify: true }); + } + + if (openLinksInNewTab) { + // All links should open in new browser tab. + // Define custom renderer to add 'target' attribute + // https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer + const originalLinkRender = + markdownIt.renderer.rules.link_open || + function (tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options); + }; + markdownIt.renderer.rules.link_open = function (tokens, idx, options, env, self) { + const href = tokens[idx].attrGet('href'); + const target = '_blank'; + const rel = getSecureRelForTarget({ href: href === null ? undefined : href, target }); + + // https://www.jitbit.com/alexblog/256-targetblank---the-most-underestimated-vulnerability-ever/ + tokens[idx].attrPush(['target', target]); + if (rel) { + tokens[idx].attrPush(['rel', rel]); + } + return originalLinkRender(tokens, idx, options, env, self); + }; + } + /** + * This method is used to render markdown from the passed parameter + * into HTML. It will just return an empty string when the markdown is empty. + * @param {String} markdown - The markdown String + * @return {String} - Returns the rendered HTML as string. + */ + return (markdown: string) => { + return markdown ? markdownIt.render(markdown) : ''; + }; + }, + (whiteListedRules: string[] = [], openLinksInNewTab: boolean = false) => { + return `${whiteListedRules.join('_')}${openLinksInNewTab}`; + } +); + +export interface MarkdownProps extends React.HTMLAttributes { + className?: string; + markdown?: string; + openLinksInNewTab?: boolean; + whiteListedRules?: string[]; + onRender?: () => void; +} + +export const Markdown = (props: MarkdownProps) => { + useEffect(() => { + props.onRender?.(); + }, [props]); + + const { className, markdown = '', openLinksInNewTab, whiteListedRules, ...rest } = props; + const classes = classNames('kbnMarkdown__body', className); + const markdownRenderer = markdownFactory(whiteListedRules, openLinksInNewTab); + const renderedMarkdown = markdownRenderer(markdown); + return ( +
+ ); +}; + +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default Markdown; diff --git a/src/plugins/kibana_react/public/markdown/markdown_simple.tsx b/src/plugins/kibana_react/public/markdown/markdown_simple.tsx new file mode 100644 index 00000000000000..24b9eee7b1ca5d --- /dev/null +++ b/src/plugins/kibana_react/public/markdown/markdown_simple.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the 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 ReactMarkdown from 'react-markdown'; + +export interface MarkdownSimpleProps { + children: string; +} + +// Render markdown string into JSX inside of a Fragment. +export const MarkdownSimple = ({ children }: MarkdownSimpleProps) => ( + {children} +); + +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default MarkdownSimple; diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/markdown/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/markdown/vis.js index 2ad505c2590822..0701f9498c793e 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/markdown/vis.js +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/markdown/vis.js @@ -11,7 +11,7 @@ import React from 'react'; import classNames from 'classnames'; import { get } from 'lodash'; import { ClassNames } from '@emotion/react'; -import { Markdown } from '@kbn/shared-ux-markdown'; +import { Markdown } from '@kbn/kibana-react-plugin/public'; import { ErrorComponent } from '../../error'; import { replaceVars } from '../../lib/replace_vars'; @@ -79,11 +79,9 @@ function MarkdownVisualization(props) { {!markdownError && ( - {markdownSource} - + /> )}
diff --git a/test/functional/apps/visualize/group6/_tsvb_markdown.ts b/test/functional/apps/visualize/group6/_tsvb_markdown.ts index 7652bf875aadcd..f015a0a0107483 100644 --- a/test/functional/apps/visualize/group6/_tsvb_markdown.ts +++ b/test/functional/apps/visualize/group6/_tsvb_markdown.ts @@ -80,7 +80,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const html = '

hello world

'; await visualBuilder.enterMarkdown(html); const markdownText = await visualBuilder.getMarkdownText(); - expect(markdownText).to.be(''); + expect(markdownText).to.be(html); }); it('markdown variables should be clickable', async () => { diff --git a/tsconfig.base.json b/tsconfig.base.json index 7ce776eaa9ac0c..fef61739470312 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1378,6 +1378,8 @@ "@kbn/search-examples-plugin/*": ["examples/search_examples/*"], "@kbn/search-index-documents": ["packages/kbn-search-index-documents"], "@kbn/search-index-documents/*": ["packages/kbn-search-index-documents/*"], + "@kbn/search-notebooks": ["x-pack/plugins/search_notebooks"], + "@kbn/search-notebooks/*": ["x-pack/plugins/search_notebooks/*"], "@kbn/search-playground": ["x-pack/plugins/search_playground"], "@kbn/search-playground/*": ["x-pack/plugins/search_playground/*"], "@kbn/search-response-warnings": ["packages/kbn-search-response-warnings"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 04e968a7f9b5cf..98a4912804d1e0 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -107,6 +107,7 @@ ], "xpack.runtimeFields": "plugins/runtime_fields", "xpack.screenshotting": "plugins/screenshotting", + "xpack.searchNotebooks": "plugins/search_notebooks", "xpack.searchPlayground": "plugins/search_playground", "xpack.searchProfiler": "plugins/searchprofiler", "xpack.security": "plugins/security", diff --git a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx index 479b8e39d232da..d84675d8e78836 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx @@ -18,7 +18,9 @@ import type { AssigneeWithProfile } from '../../user_profiles/types'; jest.mock('../../../containers/user_profiles/api'); -describe('SuggestUsersPopover', () => { +// FLAKY: https://github.com/elastic/kibana/issues/171600 +// FLAKY: https://github.com/elastic/kibana/issues/171601 +describe.skip('SuggestUsersPopover', () => { let appMockRender: AppMockRenderer; let defaultProps: SuggestUsersPopoverProps; diff --git a/x-pack/plugins/cloud_security_posture/common/runtime_mappings/get_belongs_to_runtime_mapping.ts b/x-pack/plugins/cloud_security_posture/common/runtime_mappings/get_belongs_to_runtime_mapping.ts deleted file mode 100644 index a3b81fa0a5e162..00000000000000 --- a/x-pack/plugins/cloud_security_posture/common/runtime_mappings/get_belongs_to_runtime_mapping.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; - -/** - * Creates the `belongs_to` runtime field with the value of either - * `account.cloud.name` or `cluster.id` based on the value of `rule.benchmark.posture_type` - */ -export const getBelongsToRuntimeMapping = (): MappingRuntimeFields => ({ - belongs_to: { - type: 'keyword', - script: { - source: ` - def postureTypeAvailable = doc.containsKey("rule.benchmark.posture_type") && - !doc["rule.benchmark.posture_type"].empty; - def orchestratorIdAvailable = doc.containsKey("orchestrator.cluster.id") && - !doc["orchestrator.cluster.id"].empty; - - if (!postureTypeAvailable) { - def belongs_to = orchestratorIdAvailable ? - doc["orchestrator.cluster.id"].value : doc["cluster_id"].value; - emit(belongs_to); - } else { - def policy_template_type = doc["rule.benchmark.posture_type"].value; - - if (policy_template_type == "cspm") { - def belongs_to = doc["cloud.account.name"].value; - emit(belongs_to); - } else if (policy_template_type == "kspm") { - def belongs_to = orchestratorIdAvailable ? - doc["orchestrator.cluster.id"].value : doc["cluster_id"].value; - emit(belongs_to); - } else { - // Default behaviour when policy_template_type is unknown - def belongs_to = orchestratorIdAvailable ? - doc["orchestrator.cluster.id"].value : doc["cluster_id"].value; - emit(belongs_to); - } - } - `, - }, - }, -}); diff --git a/x-pack/plugins/cloud_security_posture/common/runtime_mappings/get_identifier_runtime_mapping.ts b/x-pack/plugins/cloud_security_posture/common/runtime_mappings/get_identifier_runtime_mapping.ts index e077574d449ecf..82137a3dafffb3 100644 --- a/x-pack/plugins/cloud_security_posture/common/runtime_mappings/get_identifier_runtime_mapping.ts +++ b/x-pack/plugins/cloud_security_posture/common/runtime_mappings/get_identifier_runtime_mapping.ts @@ -20,6 +20,8 @@ export const getIdentifierRuntimeMapping = (): MappingRuntimeFields => ({ !doc["rule.benchmark.posture_type"].empty; def orchestratorIdAvailable = doc.containsKey("orchestrator.cluster.id") && !doc["orchestrator.cluster.id"].empty; + def cloudAccountIdAvailable = doc.containsKey("cloud.account.id") && !doc["cloud.account.id"].empty && + doc["cloud.account.id"].value != ""; if (!postureTypeAvailable) { def identifier = orchestratorIdAvailable ? doc["orchestrator.cluster.id"].value : doc["cluster_id"].value; @@ -28,7 +30,13 @@ export const getIdentifierRuntimeMapping = (): MappingRuntimeFields => ({ def policy_template_type = doc["rule.benchmark.posture_type"].value; if (policy_template_type == "cspm") { - emit(doc["cloud.account.id"].value); + // Checking for emptiness due to backwards compatibility with 8.13 + // where cloud.account.id was not available and no field was eligible for asset identifier + if (cloudAccountIdAvailable) { + emit(doc["cloud.account.id"].value); + } else { + return; + } } else if (policy_template_type == "kspm") { def identifier = orchestratorIdAvailable ? doc["orchestrator.cluster.id"].value : doc["cluster_id"].value; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_create_inline.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_create_inline.tsx index b3f06abf72e122..74a71347b41f01 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_create_inline.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_create_inline.tsx @@ -114,7 +114,7 @@ export const AgentPolicyCreateInlineForm: React.FunctionComponent = ({ ) : ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.test.tsx index 2abfb3168cdb10..a5bbb12f0bb524 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.test.tsx @@ -109,4 +109,22 @@ describe('package card', () => { }); expect(window.open).toHaveBeenCalledWith('https://google.com', '_blank'); }); + + it.each([true, false])( + 'renders card with a badge when quickstart flag is enabled', + async (isQuickstart) => { + const { + utils: { queryByTitle }, + } = renderPackageCard({ + id: 'card-1', + url: 'https://google.com', + fromIntegrations: 'installed', + title: 'System', + description: 'System', + isQuickstart, + } as PackageCardProps); + const badgeElement = await queryByTitle('Quickstart'); + expect(!!badgeElement).toEqual(isQuickstart); + } + ); }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 2177f8e21436ca..5618cc918bb717 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -11,6 +11,7 @@ import { EuiBadge, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiToolTip } f import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { @@ -29,8 +30,9 @@ export type PackageCardProps = IntegrationCardItem; // Min-height is roughly 3 lines of content. // This keeps the cards from looking overly unbalanced because of content differences. -const Card = styled(EuiCard)` +const Card = styled(EuiCard)<{ isquickstart?: boolean }>` min-height: 127px; + border-color: ${({ isquickstart }) => (isquickstart ? '#ba3d76' : null)}; `; export function PackageCard({ @@ -49,6 +51,8 @@ export function PackageCard({ isUpdateAvailable, showLabels = true, extraLabelsBadges, + isQuickstart = false, + onCardClick: onClickProp = undefined, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; @@ -144,6 +148,8 @@ export function PackageCard({ } - onClick={onCardClick} + onClick={onClickProp ?? onCardClick} > {showLabels && extraLabelsBadges ? extraLabelsBadges : null} @@ -172,3 +178,14 @@ export function PackageCard({ ); } + +function quickstartBadge(isQuickstart: boolean): { label: string; color: 'accent' } | undefined { + return isQuickstart + ? { + label: i18n.translate('xpack.fleet.packageCard.quickstartBadge.label', { + defaultMessage: 'Quickstart', + }), + color: 'accent', + } + : undefined; +} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx index 214ce24463e547..21cdf57c0d50f9 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useRef, useEffect } from 'react'; +import React, { useCallback, useRef, useEffect, forwardRef } from 'react'; import { css } from '@emotion/react'; import { EuiFlexGrid, EuiFlexItem, EuiSpacer, EuiText, EuiAutoSizer } from '@elastic/eui'; import { VariableSizeList as List } from 'react-window'; @@ -47,6 +47,8 @@ const VirtualizedRow: React.FC<{ ); }; +const CARD_OFFSET = 16; + export const GridColumn = ({ list, showMissingIntegrationMessage = false, @@ -105,6 +107,17 @@ export const GridColumn = ({ ref={listRef} layout="vertical" itemCount={Math.ceil(list.length / 3)} + innerElementType={forwardRef(({ style, children, ...rest }, ref) => ( +
+ {children} +
+ ))} itemSize={(index) => { const test = itemsSizeRefs.current.get(index) ?? 200; @@ -116,7 +129,13 @@ export const GridColumn = ({ > {({ index, style }) => { return ( - + {list.slice(index * 3, index * 3 + 3).map((item) => { return ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx index 8aabb1771680ab..0cfb8049800080 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx @@ -17,12 +17,17 @@ export default { title: 'Sections/EPM/Package List Grid', }; -type Args = Pick; +type Args = Pick< + Props, + 'title' | 'isLoading' | 'showMissingIntegrationMessage' | 'showControls' | 'showSearchTools' +>; const args: Args = { title: 'Installed integrations', isLoading: false, showMissingIntegrationMessage: false, + showControls: true, + showSearchTools: true, }; const categories = [ { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index fee2a12974c695..0ff1a708dabc2b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx @@ -67,6 +67,8 @@ export interface Props { selectedSubCategory?: string; setSelectedSubCategory?: (c: string | undefined) => void; showMissingIntegrationMessage?: boolean; + showControls?: boolean; + showSearchTools?: boolean; } export const PackageListGrid: FunctionComponent = ({ @@ -87,6 +89,8 @@ export const PackageListGrid: FunctionComponent = ({ showMissingIntegrationMessage = false, callout, showCardLabels = true, + showControls = true, + showSearchTools = true, }) => { const localSearchRef = useLocalSearch(list, !!isLoading); @@ -174,23 +178,29 @@ export const PackageListGrid: FunctionComponent = ({ gutterSize="xl" data-test-subj="epmList.integrationCards" > - - - + {showControls && ( + + + + )} + - - - + {showSearchTools && ( + + + + )} + {showIntegrationsSubcategories && availableSubCategories?.length ? : null} {showIntegrationsSubcategories ? ( @@ -257,7 +267,7 @@ export const PackageListGrid: FunctionComponent = ({ {callout} ) : null} - + void; } export const mapToCard = ({ diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx index 310f441b042456..a3216d9c684042 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -80,7 +80,7 @@ export const AgentPolicySelection: React.FC = (props) => { ) : ( diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/agent_policy_selection_step.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/agent_policy_selection_step.tsx index a0828bb72f4890..972fb4f2b5df3a 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/agent_policy_selection_step.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/agent_policy_selection_step.tsx @@ -34,7 +34,7 @@ export const AgentPolicySelectionStep = ({ }): EuiContainedStepProps => { return { title: i18n.translate('xpack.fleet.agentEnrollment.stepChooseAgentPolicyTitle', { - defaultMessage: 'What type of host are you adding?', + defaultMessage: 'What type of host do you want to monitor?', }), children: ( <> diff --git a/x-pack/plugins/fleet/public/index.ts b/x-pack/plugins/fleet/public/index.ts index 1efc18a53e1e8a..003e3de3f35dd3 100644 --- a/x-pack/plugins/fleet/public/index.ts +++ b/x-pack/plugins/fleet/public/index.ts @@ -64,3 +64,12 @@ export { PackagePolicyEditorDatastreamMappings } from './applications/fleet/sect export type { PackagePolicyEditorDatastreamMappingsProps } from './applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_mappings'; export type { DynamicPagePathValues } from './constants'; + +export const PackageList = () => { + return import('./applications/integrations/sections/epm/components/package_list_grid'); +}; +export const AvailablePackagesHook = () => { + return import( + './applications/integrations/sections/epm/screens/home/hooks/use_available_packages' + ); +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index e67a6feda2e09d..5f66bd2e838c03 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -238,10 +238,9 @@ export function LayerPanels( [dispatchLens, props.framePublicAPI.dataViews.indexPatterns, props.indexPatternService] ); - const addLayer: AddLayerFunction = (layerType, extraArg, ignoreInitialValues) => { + const addLayer: AddLayerFunction = (layerType, extraArg, ignoreInitialValues, seriesType) => { const layerId = generateId(); - - dispatchLens(addLayerAction({ layerId, layerType, extraArg, ignoreInitialValues })); + dispatchLens(addLayerAction({ layerId, layerType, extraArg, ignoreInitialValues, seriesType })); setNextFocusedLayerId(layerId); }; @@ -335,6 +334,7 @@ export function LayerPanels( })} {!hideAddLayerButton && activeVisualization?.getAddLayerButtonComponent?.({ + state: visualization.state, supportedLayers: activeVisualization.getSupportedLayers( visualization.state, props.framePublicAPI diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index dc59192c2d1956..3146f61d3c5d08 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -13,6 +13,7 @@ import { History } from 'history'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { EventAnnotationGroupConfig } from '@kbn/event-annotation-common'; import { DragDropIdentifier, DropType } from '@kbn/dom-drag-drop'; +import { SeriesType } from '@kbn/visualizations-plugin/common'; import { LensEmbeddableInput } from '..'; import { TableInspectorAdapter } from '../editor_frame_service/types'; import type { @@ -244,6 +245,7 @@ export const addLayer = createAction<{ layerType: LayerType; extraArg: unknown; ignoreInitialValues?: boolean; + seriesType?: SeriesType; }>('lens/addLayer'); export const onDropToDimension = createAction<{ source: DragDropIdentifier; @@ -908,7 +910,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { .addCase( addLayer, - (state, { payload: { layerId, layerType, extraArg, ignoreInitialValues } }) => { + (state, { payload: { layerId, layerType, extraArg, seriesType, ignoreInitialValues } }) => { if (!state.activeDatasourceId || !state.visualization.activeId) { return state; } @@ -924,7 +926,8 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { layerId, layerType, currentDataViewsId, - extraArg + extraArg, + seriesType ); const framePublicAPI = selectFramePublicAPI({ lens: current(state) }, datasourceMap); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 1f189f80c14b29..c1c7be77b9608b 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -17,7 +17,11 @@ import type { Datatable, ExpressionRendererEvent, } from '@kbn/expressions-plugin/public'; -import type { Configuration, NavigateToLensContext } from '@kbn/visualizations-plugin/common'; +import type { + Configuration, + NavigateToLensContext, + SeriesType, +} from '@kbn/visualizations-plugin/common'; import type { Query } from '@kbn/es-query'; import type { UiActionsStart, @@ -1000,7 +1004,8 @@ interface VisualizationStateFromContextChangeProps { export type AddLayerFunction = ( layerType: LayerType, extraArg?: T, - ignoreInitialValues?: boolean + ignoreInitialValues?: boolean, + seriesType?: SeriesType ) => void; export type AnnotationGroups = Record; @@ -1024,7 +1029,8 @@ export type RegisterLibraryAnnotationGroupFunction = (groupInfo: { id: string; group: EventAnnotationGroupConfig; }) => void; -interface AddLayerButtonProps { +interface AddLayerButtonProps { + state: T; supportedLayers: VisualizationLayerDescription[]; addLayer: AddLayerFunction; ensureIndexPattern: (specOrId: DataViewSpec | string) => Promise; @@ -1110,7 +1116,8 @@ export interface Visualization T; /** Retrieve a list of supported layer types with initialization data */ @@ -1254,8 +1261,8 @@ export interface Visualization null | ReactElement<{ columnId: string; label: string }>; getAddLayerButtonComponent?: ( - props: AddLayerButtonProps - ) => null | ReactElement; + props: AddLayerButtonProps + ) => null | ReactElement>; /** * Creates map of columns ids and unique lables. Used only for noDatasource layers */ diff --git a/x-pack/plugins/lens/public/visualizations/xy/add_layer.test.tsx b/x-pack/plugins/lens/public/visualizations/xy/add_layer.test.tsx new file mode 100644 index 00000000000000..5ecef7e45eebea --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/xy/add_layer.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { AddLayerButton } from './add_layer'; +import { XYState } from './types'; +import { Position } from '@elastic/charts'; +import { LayerTypes } from '@kbn/visualizations-plugin/common'; +import { eventAnnotationServiceMock } from '@kbn/event-annotation-plugin/public/mocks'; +import { IconChartBarAnnotations } from '@kbn/chart-icons'; + +describe('AddLayerButton', () => { + const addLayer = jest.fn(); + + const renderAddLayerButton = () => { + const state: XYState = { + legend: { position: Position.Bottom, isVisible: true }, + valueLabels: 'show', + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + layerType: LayerTypes.DATA, + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b', 'c'], + }, + ], + }; + const supportedLayers = [ + { + type: LayerTypes.DATA, + label: 'Visualization', + }, + { + type: LayerTypes.REFERENCELINE, + label: LayerTypes.REFERENCELINE, + }, + { + type: LayerTypes.ANNOTATIONS, + label: 'Annotations', + icon: IconChartBarAnnotations, + disabled: true, + }, + ]; + + const rtlRender = render( + + ); + return { + ...rtlRender, + clickAddLayer: () => { + fireEvent.click(screen.getByLabelText('Add layer')); + }, + clickVisualizationButton: () => { + fireEvent.click(screen.getByRole('button', { name: 'Visualization' })); + }, + clickSeriesOptionsButton: (seriesType = 'line') => { + const lineOption = screen.getByTestId(`lnsXY_seriesType-${seriesType}`); + fireEvent.click(lineOption); + }, + waitForSeriesOptions: async () => { + await waitFor(() => { + expect(screen.queryByTestId('lnsXY_seriesType-area')).toBeInTheDocument(); + }); + }, + getSeriesTypeOptions: () => { + return within( + screen.getByTestId('contextMenuPanelTitleButton').parentElement as HTMLElement + ) + .getAllByRole('button') + .map((el) => el.textContent); + }, + }; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders all compatible series types', async () => { + const { clickAddLayer, clickVisualizationButton, waitForSeriesOptions, getSeriesTypeOptions } = + renderAddLayerButton(); + clickAddLayer(); + clickVisualizationButton(); + await waitForSeriesOptions(); + + expect(getSeriesTypeOptions()).toEqual([ + 'Select visualization type', + 'Bar vertical', + 'Bar vertical stacked', + 'Bar vertical percentage', + 'Area', + 'Area stacked', + 'Area percentage', + 'Line', + ]); + }); + it('calls addLayer with a proper series type when button is clicked', async () => { + const { + clickAddLayer, + clickVisualizationButton, + waitForSeriesOptions, + clickSeriesOptionsButton, + } = renderAddLayerButton(); + clickAddLayer(); + clickVisualizationButton(); + await waitForSeriesOptions(); + clickSeriesOptionsButton('line'); + expect(addLayer).toHaveBeenCalledWith(LayerTypes.DATA, undefined, undefined, 'line'); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/xy/add_layer.tsx b/x-pack/plugins/lens/public/visualizations/xy/add_layer.tsx index 93cfd1a48e6281..933aaca9f3915d 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/add_layer.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/add_layer.tsx @@ -20,16 +20,27 @@ import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public' import { AddLayerFunction, VisualizationLayerDescription } from '../../types'; import { LoadAnnotationLibraryFlyout } from './load_annotation_library_flyout'; import type { ExtraAppendLayerArg } from './visualization'; +import { SeriesType, XYState, visualizationTypes } from './types'; +import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; +import { getDataLayers } from './visualization_helpers'; import { ExperimentalBadge } from '../../shared_components'; interface AddLayerButtonProps { + state: XYState; supportedLayers: VisualizationLayerDescription[]; addLayer: AddLayerFunction; eventAnnotationService: EventAnnotationServiceType; isInlineEditing?: boolean; } +export enum AddLayerPanelType { + main = 'main', + selectAnnotationMethod = 'selectAnnotationMethod', + selectVisualizationType = 'selectVisualizationType', +} + export function AddLayerButton({ + state, supportedLayers, addLayer, eventAnnotationService, @@ -47,7 +58,7 @@ export function AddLayerButton({ toolTipContent, }: typeof supportedLayers[0]) => { return { - panel: 1, + panel: AddLayerPanelType.selectAnnotationMethod, toolTipContent, disabled, name: ( @@ -66,6 +77,33 @@ export function AddLayerButton({ }; }; + const dataPanel = ({ + type, + label, + icon, + disabled, + toolTipContent, + }: typeof supportedLayers[0]) => { + return { + panel: AddLayerPanelType.selectVisualizationType, + toolTipContent, + disabled, + name: {label}, + className: 'lnsLayerAddButton', + icon: icon && , + ['data-test-subj']: `lnsLayerAddButton-${type}`, + }; + }; + + const horizontalOnly = isHorizontalChart(state.layers); + + const availableVisTypes = visualizationTypes.filter( + (t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly + ); + + const currentLayerVisType = + availableVisTypes.findIndex((t) => t.id === getDataLayers(state.layers)?.[0]?.seriesType) || 0; + return ( <> ({ + name: t.fullLabel || t.label, + icon: t.icon && , + onClick: () => { + addLayer(LayerTypes.DATA, undefined, undefined, t.id as SeriesType); + toggleLayersChoice(false); + }, + 'data-test-subj': `lnsXY_seriesType-${t.id}`, + })), + }, ]} /> diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx index 1fc617ce91fe55..1d678ef9029915 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx @@ -208,20 +208,20 @@ export const getXyVisualization = ({ return state; }, - appendLayer(state, layerId, layerType, indexPatternId, extraArg) { + appendLayer(state, layerId, layerType, indexPatternId, extraArg, seriesType) { if (layerType === 'metricTrendline') { return state; } - const firstUsedSeriesType = getDataLayers(state.layers)?.[0]?.seriesType; return { ...state, layers: [ ...state.layers, newLayerState({ - seriesType: firstUsedSeriesType || state.preferredSeriesType, layerId, layerType, + seriesType: + seriesType || getDataLayers(state.layers)?.[0]?.seriesType || state.preferredSeriesType, indexPatternId, extraArg, }), @@ -734,7 +734,7 @@ export const getXyVisualization = ({ { + addLayer={async (type, loadedGroupInfo, _, seriesType) => { if (type === LayerTypes.ANNOTATIONS && loadedGroupInfo) { await props.ensureIndexPattern( loadedGroupInfo.dataViewSpec ?? loadedGroupInfo.indexPatternId @@ -745,8 +745,7 @@ export const getXyVisualization = ({ group: loadedGroupInfo, }); } - - props.addLayer(type, loadedGroupInfo, !!loadedGroupInfo); + props.addLayer(type, loadedGroupInfo, !!loadedGroupInfo, seriesType); }} /> ); diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index f9aa282b4b1182..7703ca87317e15 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -278,7 +278,6 @@ class AnnotationsTableUI extends Component { entityCondition[annotation.by_field_name] = annotation.by_field_value; } mlTimeSeriesExplorer.entities = entityCondition; - // appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; const mlLocator = share.url.locators.get(ML_APP_LOCATOR); const singleMetricViewerLink = await mlLocator.getUrl( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index 9ee4f5dcdc9911..ddea5031d4bb06 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -6,7 +6,7 @@ */ import type { FC } from 'react'; -import React, { useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTitle, @@ -24,7 +24,6 @@ import { useMlKibana, useNavigateToPath } from '../../../../contexts/kibana'; import { useDataSource } from '../../../../contexts/ml'; import { DataRecognizer } from '../../../../components/data_recognizer'; import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed'; -import { timeBasedIndexCheck } from '../../../../util/index_utils'; import { LinkCard } from '../../../../components/link_card'; import { CategorizationIcon } from './categorization_job_icon'; import { ML_APP_LOCATOR, ML_PAGES } from '../../../../../../common/constants/locator'; @@ -35,7 +34,10 @@ import { MlPageHeader } from '../../../../components/page_header'; export const Page: FC = () => { const { - services: { share }, + services: { + share, + notifications: { toasts }, + }, } = useMlKibana(); const dataSourceContext = useDataSource(); @@ -48,7 +50,22 @@ export const Page: FC = () => { const { selectedDataView, selectedSavedSearch } = dataSourceContext; - const isTimeBasedIndex = timeBasedIndexCheck(selectedDataView); + const isTimeBasedIndex: boolean = selectedDataView.isTimeBased(); + + useEffect(() => { + if (!isTimeBasedIndex) { + toasts.addWarning({ + title: i18n.translate('xpack.ml.dataViewNotBasedOnTimeSeriesNotificationTitle', { + defaultMessage: 'The data view {dataViewIndexPattern} is not based on a time series', + values: { dataViewIndexPattern: selectedDataView.getIndexPattern() }, + }), + text: i18n.translate('xpack.ml.dataViewNotBasedOnTimeSeriesNotificationDescription', { + defaultMessage: 'Anomaly detection only runs over time-based indices', + }), + }); + } + }, [isTimeBasedIndex, selectedDataView, toasts]); + const hasGeoFields = useMemo( () => [ diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx index b26dc876a33e13..6ad84f892eb318 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx @@ -15,6 +15,7 @@ import { getFunctionDescription, isMetricDetector } from '../../get_function_des import { useToastNotificationService } from '../../../services/toast_notification_service'; import { useMlResultsService } from '../../../services/results_service'; import type { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs'; +import type { MlEntity } from '../../../../embeddables'; const plotByFunctionOptions = [ { @@ -50,7 +51,7 @@ export const PlotByFunctionControls = ({ setFunctionDescription: (func: string) => void; selectedDetectorIndex: number; selectedJobId: string; - selectedEntities: Record; + selectedEntities?: MlEntity; entityControlsCount: number; }) => { const toastNotificationService = useToastNotificationService(); @@ -59,7 +60,7 @@ export const PlotByFunctionControls = ({ const getFunctionDescriptionToPlot = useCallback( async ( _selectedDetectorIndex: number, - _selectedEntities: Record, + _selectedEntities: MlEntity | undefined, _selectedJobId: string, _selectedJob: CombinedJob ) => { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx index 26556f9d1f4c62..ee96494a77e09c 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx @@ -37,6 +37,7 @@ import { import type { FieldDefinition } from '../../../services/results_service/result_service_rx'; import { getViewableDetectors } from '../../timeseriesexplorer_utils/get_viewable_detectors'; import { PlotByFunctionControls } from '../plot_function_controls'; +import type { MlEntity } from '../../../../embeddables'; function getEntityControlOptions(fieldValues: FieldDefinition['values']): ComboBoxOption[] { if (!Array.isArray(fieldValues)) { @@ -76,7 +77,7 @@ interface SeriesControlsProps { functionDescription?: string; job?: CombinedJob | MlJob; selectedDetectorIndex: number; - selectedEntities: Record; + selectedEntities?: MlEntity; selectedJobId: JobId; setFunctionDescription: (func: string) => void; } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts index 69b85acad86658..214fe8bf45f472 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts @@ -8,13 +8,14 @@ import { mlJobService } from '../services/job_service'; import type { Entity } from './components/entity_control/entity_control'; import type { JobId, CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import type { MlEntity } from '../../embeddables'; /** * Extracts entities from the detector configuration */ export function getControlsForDetector( selectedDetectorIndex: number, - selectedEntities: Record, + selectedEntities: MlEntity | undefined, selectedJobId: JobId, job?: CombinedJob ): Entity[] { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts index 30e6afb9cf66f6..274bfd3c6f27cc 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts @@ -14,6 +14,7 @@ import { getControlsForDetector } from './get_controls_for_detector'; import { getCriteriaFields } from './get_criteria_fields'; import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { getViewableDetectors } from './timeseriesexplorer_utils/get_viewable_detectors'; +import type { MlEntity } from '../../embeddables'; export function isMetricDetector(selectedJob: CombinedJob, selectedDetectorIndex: number) { const detectors = getViewableDetectors(selectedJob); @@ -37,7 +38,7 @@ export const getFunctionDescription = async ( selectedJob, }: { selectedDetectorIndex: number; - selectedEntities: Record; + selectedEntities: MlEntity | undefined; selectedJobId: string; selectedJob: CombinedJob; }, diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js index 945a765616f8fb..42e942ae907fc7 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js @@ -717,6 +717,7 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component { !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || previousProps.selectedForecastId !== this.props.selectedForecastId || + previousProps.selectedJob?.job_id !== this.props.selectedJob?.job_id || previousProps.selectedJobId !== this.props.selectedJobId || previousProps.functionDescription !== this.props.functionDescription ) { @@ -727,6 +728,7 @@ export class TimeSeriesExplorerEmbeddableChart extends React.Component { !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || previousProps.selectedForecastId !== this.props.selectedForecastId || previousProps.selectedJobId !== this.props.selectedJobId || + previousProps.selectedJob?.job_id !== this.props.selectedJob?.job_id || previousProps.functionDescription !== this.props.functionDescription; this.loadSingleMetricData(fullRefresh); } diff --git a/x-pack/plugins/ml/public/application/util/index_service.ts b/x-pack/plugins/ml/public/application/util/index_service.ts index 84da4ee567ab46..bbdddbd6372685 100644 --- a/x-pack/plugins/ml/public/application/util/index_service.ts +++ b/x-pack/plugins/ml/public/application/util/index_service.ts @@ -11,7 +11,6 @@ import type { Job } from '../../../common/types/anomaly_detection_jobs'; import { useMlKibana } from '../contexts/kibana'; -// TODO Consolidate with legacy code in `ml/public/application/util/index_utils.ts`. export function indexServiceFactory(dataViewsService: DataViewsContract) { /** * Retrieves the data view ID from the given name. diff --git a/x-pack/plugins/ml/public/application/util/index_utils.ts b/x-pack/plugins/ml/public/application/util/index_utils.ts index a9074e573e55a7..4d8eddde1a54b3 100644 --- a/x-pack/plugins/ml/public/application/util/index_utils.ts +++ b/x-pack/plugins/ml/public/application/util/index_utils.ts @@ -5,11 +5,9 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; import type { SavedSearch, SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; import type { Query, Filter } from '@kbn/es-query'; import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; -import { getToastNotifications } from './dependency_cache'; export interface DataViewAndSavedSearch { savedSearch: SavedSearch | null; @@ -48,31 +46,6 @@ export function getQueryFromSavedSearchObject(savedSearch: SavedSearch) { }; } -/** - * Returns true if the index passed in is time based - * an optional flag will trigger the display a notification at the top of the page - * warning that the index is not time based - */ -export function timeBasedIndexCheck(dataView: DataView, showNotification = false) { - if (!dataView.isTimeBased()) { - if (showNotification) { - const toastNotifications = getToastNotifications(); - toastNotifications.addWarning({ - title: i18n.translate('xpack.ml.dataViewNotBasedOnTimeSeriesNotificationTitle', { - defaultMessage: 'The data view {dataViewIndexPattern} is not based on a time series', - values: { dataViewIndexPattern: dataView.getIndexPattern() }, - }), - text: i18n.translate('xpack.ml.dataViewNotBasedOnTimeSeriesNotificationDescription', { - defaultMessage: 'Anomaly detection only runs over time-based indices', - }), - }); - } - return false; - } else { - return true; - } -} - /** * Returns true if the index pattern contains a : * which means it is cross-cluster diff --git a/x-pack/plugins/ml/public/embeddables/constants.ts b/x-pack/plugins/ml/public/embeddables/constants.ts index 1001cd89c74989..1e42301676dfcd 100644 --- a/x-pack/plugins/ml/public/embeddables/constants.ts +++ b/x-pack/plugins/ml/public/embeddables/constants.ts @@ -11,5 +11,7 @@ export const ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE = 'ml_single_metric_vi export type AnomalySwimLaneEmbeddableType = typeof ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; export type AnomalyExplorerChartsEmbeddableType = typeof ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE; +export type AnomalySingleMetricViewerEmbeddableType = + typeof ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE; export type MlEmbeddableTypes = AnomalySwimLaneEmbeddableType | AnomalyExplorerChartsEmbeddableType; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container.tsx index 3aa0e332dcaf5c..d7dae512e19354 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container.tsx @@ -38,7 +38,7 @@ interface AppStateZoom { export interface EmbeddableSingleMetricViewerContainerProps { id: string; embeddableContext: InstanceType; - embeddableInput: Observable; + embeddableInput$: Observable; services: SingleMetricViewerEmbeddableServices; refresh: Observable; onInputChange: (input: Partial) => void; @@ -50,10 +50,10 @@ export interface EmbeddableSingleMetricViewerContainerProps { export const EmbeddableSingleMetricViewerContainer: FC< EmbeddableSingleMetricViewerContainerProps -> = ({ id, embeddableContext, embeddableInput, services, refresh, onRenderComplete }) => { +> = ({ id, embeddableContext, embeddableInput$, services, refresh, onRenderComplete }) => { useEmbeddableExecutionContext( services[0].executionContext, - embeddableInput, + embeddableInput$, ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE, id ); @@ -66,7 +66,7 @@ export const EmbeddableSingleMetricViewerContainer: FC< const { mlApiServices, mlJobService } = services[2]; const { data, bounds, lastRefresh } = useSingleMetricViewerInputResolver( - embeddableInput, + embeddableInput$, refresh, services[1].data.query.timefilter.timefilter, onRenderComplete diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable.tsx index 719d300162d094..8cbd2ad30ae8e1 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable.tsx @@ -9,11 +9,11 @@ import React, { Suspense } from 'react'; import ReactDOM from 'react-dom'; import { pick } from 'lodash'; -import { Embeddable } from '@kbn/embeddable-plugin/public'; +import { Embeddable, embeddableInputToSubject } from '@kbn/embeddable-plugin/public'; +import { Subject, Subscription, type BehaviorSubject } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; -import { Subject } from 'rxjs'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import type { IContainer } from '@kbn/embeddable-plugin/public'; import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker'; @@ -28,6 +28,7 @@ import type { } from '..'; import { ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE } from '..'; import { EmbeddableLoading } from '../common/components/embeddable_loading_fallback'; +import type { MlEntity } from '..'; export const getDefaultSingleMetricViewerPanelTitle = (jobIds: JobId[]) => i18n.translate('xpack.ml.singleMetricViewerEmbeddable.title', { @@ -45,12 +46,49 @@ export class SingleMetricViewerEmbeddable extends Embeddable< private reload$ = new Subject(); public readonly type: string = ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE; + // API + public readonly functionDescription: BehaviorSubject; + public readonly jobIds: BehaviorSubject; + public readonly selectedDetectorIndex: BehaviorSubject; + public readonly selectedEntities: BehaviorSubject; + + private apiSubscriptions = new Subscription(); + constructor( initialInput: SingleMetricViewerEmbeddableInput, public services: [CoreStart, MlDependencies, SingleMetricViewerServices], parent?: IContainer ) { super(initialInput, {} as AnomalyChartsEmbeddableOutput, parent); + + this.jobIds = embeddableInputToSubject( + this.apiSubscriptions, + this, + 'jobIds' + ); + + this.functionDescription = embeddableInputToSubject< + string | undefined, + SingleMetricViewerEmbeddableInput + >(this.apiSubscriptions, this, 'functionDescription'); + + this.selectedDetectorIndex = embeddableInputToSubject< + number | undefined, + SingleMetricViewerEmbeddableInput + >(this.apiSubscriptions, this, 'selectedDetectorIndex'); + + this.selectedEntities = embeddableInputToSubject< + MlEntity | undefined, + SingleMetricViewerEmbeddableInput + >(this.apiSubscriptions, this, 'selectedEntities'); + } + + public updateUserInput(update: SingleMetricViewerEmbeddableInput) { + this.updateInput(update); + } + + public reportsEmbeddableLoad() { + return true; } public onLoading() { @@ -65,7 +103,7 @@ export class SingleMetricViewerEmbeddable extends Embeddable< public onRenderComplete() { this.renderComplete.dispatchComplete(); - this.updateOutput({ loading: false, error: undefined }); + this.updateOutput({ loading: false, rendered: true, error: undefined }); } public render(node: HTMLElement) { @@ -102,7 +140,7 @@ export class SingleMetricViewerEmbeddable extends Embeddable< @@ -58,11 +59,16 @@ export class SingleMetricViewerEmbeddableFactory const { resolveEmbeddableSingleMetricViewerUserInput } = await import( './single_metric_viewer_setup_flyout' ); - return await resolveEmbeddableSingleMetricViewerUserInput( + const userInput = await resolveEmbeddableSingleMetricViewerUserInput( coreStart, pluginStart, - singleMetricServices + singleMetricServices.mlApiServices ); + + return { + ...userInput, + title: userInput.panelTitle, + }; } catch (e) { return Promise.reject(); } @@ -142,7 +148,10 @@ export class SingleMetricViewerEmbeddableFactory ]; } - public async create(initialInput: SingleMetricViewerEmbeddableInput, parent?: IContainer) { + public async create( + initialInput: SingleMetricViewerEmbeddableInput, + parent?: IContainer + ): Promise> { const services = await this.getServices(); const { SingleMetricViewerEmbeddable } = await import('./single_metric_viewer_embeddable'); return new SingleMetricViewerEmbeddable(initialInput, services, parent); diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_initializer.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_initializer.tsx index b14539480a9557..e3d35fa472b982 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_initializer.tsx @@ -23,52 +23,60 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import type { MlJob } from '@elastic/elasticsearch/lib/api/types'; import type { TimeRangeBounds } from '@kbn/ml-time-buckets'; -import type { SingleMetricViewerServices } from '..'; +import type { SingleMetricViewerEmbeddableInput } from '..'; import { SeriesControls } from '../../application/timeseriesexplorer/components/series_controls'; import { APP_STATE_ACTION, type TimeseriesexplorerActionType, } from '../../application/timeseriesexplorer/timeseriesexplorer_constants'; +import type { SingleMetricViewerEmbeddableCustomInput, MlEntity } from '..'; export interface SingleMetricViewerInitializerProps { bounds: TimeRangeBounds; defaultTitle: string; - initialInput?: SingleMetricViewerServices; + initialInput?: Partial; job: MlJob; - onCreate: (props: { - panelTitle: string; - functionDescription?: string; - selectedDetectorIndex: number; - selectedEntities: any; - }) => void; + onCreate: (props: Partial) => void; onCancel: () => void; } export const SingleMetricViewerInitializer: FC = ({ - bounds, defaultTitle, + bounds, initialInput, job, onCreate, onCancel, }) => { + const isNewJob = initialInput?.jobIds !== undefined && initialInput?.jobIds[0] !== job.job_id; + const [panelTitle, setPanelTitle] = useState(defaultTitle); - const [functionDescription, setFunctionDescription] = useState(); - const [selectedDetectorIndex, setSelectedDetectorIndex] = useState(0); - const [selectedEntities, setSelectedEntities] = useState(); + const [functionDescription, setFunctionDescription] = useState( + initialInput?.functionDescription + ); + // Reset detector index and entities if the job has changed + const [selectedDetectorIndex, setSelectedDetectorIndex] = useState( + !isNewJob && initialInput?.selectedDetectorIndex ? initialInput.selectedDetectorIndex : 0 + ); + const [selectedEntities, setSelectedEntities] = useState( + !isNewJob && initialInput?.selectedEntities ? initialInput.selectedEntities : undefined + ); const isPanelTitleValid = panelTitle.length > 0; - const handleStateUpdate = (action: TimeseriesexplorerActionType, payload: any) => { + const handleStateUpdate = ( + action: TimeseriesexplorerActionType, + payload: string | number | MlEntity + ) => { switch (action) { case APP_STATE_ACTION.SET_ENTITIES: - setSelectedEntities(payload); + setSelectedEntities(payload as MlEntity); break; case APP_STATE_ACTION.SET_FUNCTION_DESCRIPTION: - setFunctionDescription(payload); + setFunctionDescription(payload as string); break; case APP_STATE_ACTION.SET_DETECTOR_INDEX: - setSelectedDetectorIndex(payload); + setSelectedDetectorIndex(payload as number); break; default: break; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx index b20d032f5b907d..11927e9bca377f 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx @@ -10,18 +10,19 @@ import type { CoreStart } from '@kbn/core/public'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { getDefaultSingleMetricViewerPanelTitle } from './single_metric_viewer_embeddable'; -import type { SingleMetricViewerEmbeddableInput, SingleMetricViewerServices } from '..'; +import type { SingleMetricViewerEmbeddableInput } from '..'; import { resolveJobSelection } from '../common/resolve_job_selection'; import { SingleMetricViewerInitializer } from './single_metric_viewer_initializer'; import type { MlStartDependencies } from '../../plugin'; +import type { MlApiServices } from '../../application/services/ml_api_service'; export async function resolveEmbeddableSingleMetricViewerUserInput( coreStart: CoreStart, pluginStart: MlStartDependencies, - input: SingleMetricViewerServices + mlApiServices: MlApiServices, + input?: SingleMetricViewerEmbeddableInput ): Promise> { const { overlays, theme, i18n } = coreStart; - const { mlApiServices } = input; const timefilter = pluginStart.data.query.timefilter.timefilter; return new Promise(async (resolve, reject) => { @@ -29,39 +30,30 @@ export async function resolveEmbeddableSingleMetricViewerUserInput( const { jobIds } = await resolveJobSelection( coreStart, pluginStart.data.dataViews, - undefined, + input?.jobIds ? input.jobIds : undefined, true ); - const title = getDefaultSingleMetricViewerPanelTitle(jobIds); + const title = input?.title ?? getDefaultSingleMetricViewerPanelTitle(jobIds); const { jobs } = await mlApiServices.getJobs({ jobId: jobIds.join(',') }); const modalSession = overlays.openModal( toMountPoint( { + onCreate={(explicitInput) => { modalSession.close(); resolve({ jobIds, - title: panelTitle, - functionDescription, - panelTitle, - selectedDetectorIndex, - selectedEntities, + ...explicitInput, }); }} onCancel={() => { diff --git a/x-pack/plugins/ml/public/embeddables/types.ts b/x-pack/plugins/ml/public/embeddables/types.ts index 14ee0e7c6d444a..38032b7d80b91a 100644 --- a/x-pack/plugins/ml/public/embeddables/types.ts +++ b/x-pack/plugins/ml/public/embeddables/types.ts @@ -17,6 +17,8 @@ import type { HasType, PublishesUnifiedSearch, PublishesViewMode, + PublishesWritablePanelTitle, + PublishingSubject, } from '@kbn/presentation-publishing'; import type { JobId } from '../../common/types/anomaly_detection_jobs'; import type { MlDependencies } from '../application/app'; @@ -33,6 +35,7 @@ import type { MlResultsService } from '../application/services/results_service'; import type { MlTimeSeriesSearchService } from '../application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service'; import type { AnomalyExplorerChartsEmbeddableType, + AnomalySingleMetricViewerEmbeddableType, AnomalySwimLaneEmbeddableType, MlEmbeddableTypes, } from './constants'; @@ -41,6 +44,8 @@ export type MlEmbeddableBaseApi = Partial< HasParentApi & PublishesViewMode & PublishesUnifiedSearch >; +export type MlEntity = Record; + /** Manual input by the user */ export interface AnomalySwimlaneEmbeddableUserInput { jobIds: JobId[]; @@ -122,7 +127,7 @@ export interface SingleMetricViewerEmbeddableCustomInput { functionDescription?: string; panelTitle: string; selectedDetectorIndex: number; - selectedEntities: MlEntityField[]; + selectedEntities?: MlEntity; // Embeddable inputs which are not included in the default interface filters: Filter[]; query: Query; @@ -133,6 +138,21 @@ export interface SingleMetricViewerEmbeddableCustomInput { export type SingleMetricViewerEmbeddableInput = EmbeddableInput & SingleMetricViewerEmbeddableCustomInput; +export interface SingleMetricViewerComponentApi { + functionDescription?: PublishingSubject; + jobIds: PublishingSubject; + selectedDetectorIndex: PublishingSubject; + selectedEntities?: PublishingSubject; + + updateUserInput: (input: Partial) => void; +} + +export interface SingleMetricViewerEmbeddableApi + extends HasType, + PublishesWritablePanelTitle, + MlEmbeddableBaseApi, + SingleMetricViewerComponentApi {} + export interface AnomalyChartsServices { anomalyDetectorService: AnomalyDetectorService; anomalyExplorerService: AnomalyExplorerChartsService; diff --git a/x-pack/plugins/ml/public/ui_actions/edit_single_metric_viewer_panel_action.tsx b/x-pack/plugins/ml/public/ui_actions/edit_single_metric_viewer_panel_action.tsx new file mode 100644 index 00000000000000..bb953b739d7127 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/edit_single_metric_viewer_panel_action.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import type { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public'; +import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import { isSingleMetricViewerEmbeddableContext } from './open_in_single_metric_viewer_action'; +import type { MlCoreSetup } from '../plugin'; +import { HttpService } from '../application/services/http_service'; +import type { + SingleMetricViewerEmbeddableInput, + SingleMetricViewerEmbeddableApi, +} from '../embeddables/types'; + +export const EDIT_SINGLE_METRIC_VIEWER_PANEL_ACTION = 'editSingleMetricViewerPanelAction'; + +export type EditSingleMetricViewerPanelActionContext = EmbeddableApiContext & { + embeddable: SingleMetricViewerEmbeddableApi; +}; + +export function createEditSingleMetricViewerPanelAction( + getStartServices: MlCoreSetup['getStartServices'] +): UiActionsActionDefinition { + return { + id: 'edit-single-metric-viewer', + type: EDIT_SINGLE_METRIC_VIEWER_PANEL_ACTION, + order: 50, + getIconType(): string { + return 'pencil'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.editSingleMetricViewerTitle', { + defaultMessage: 'Edit single metric viewer', + }), + async execute(context) { + if (!isSingleMetricViewerEmbeddableContext(context)) { + throw new IncompatibleActionError(); + } + + const [coreStart, pluginStart] = await getStartServices(); + + try { + const { resolveEmbeddableSingleMetricViewerUserInput } = await import( + '../embeddables/single_metric_viewer/single_metric_viewer_setup_flyout' + ); + + const { mlApiServicesProvider } = await import('../application/services/ml_api_service'); + const httpService = new HttpService(coreStart.http); + const mlApiServices = mlApiServicesProvider(httpService); + + const { jobIds, selectedEntities, selectedDetectorIndex, panelTitle } = context.embeddable; + + const result = await resolveEmbeddableSingleMetricViewerUserInput( + coreStart, + pluginStart, + mlApiServices, + { + jobIds: jobIds.getValue(), + selectedDetectorIndex: selectedDetectorIndex.getValue(), + selectedEntities: selectedEntities?.getValue(), + title: panelTitle?.getValue(), + } as SingleMetricViewerEmbeddableInput + ); + + context.embeddable.updateUserInput(result); + context.embeddable.setPanelTitle(result.panelTitle); + } catch (e) { + return Promise.reject(); + } + }, + async isCompatible(context: EmbeddableApiContext) { + return ( + isSingleMetricViewerEmbeddableContext(context) && + context.embeddable.viewMode?.getValue() === 'edit' + ); + }, + }; +} diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index e55b1fc1c7b003..1d0a7d39c28713 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -16,11 +16,13 @@ import { createApplyTimeRangeSelectionAction } from './apply_time_range_action'; import { createClearSelectionAction } from './clear_selection_action'; import { createEditAnomalyChartsPanelAction } from './edit_anomaly_charts_panel_action'; import { createEditSwimlanePanelAction } from './edit_swimlane_panel_action'; +import { createEditSingleMetricViewerPanelAction } from './edit_single_metric_viewer_panel_action'; import { createCategorizationADJobAction, createCategorizationADJobTrigger, } from './open_create_categorization_job_action'; import { createOpenInExplorerAction } from './open_in_anomaly_explorer_action'; +import { createOpenInSingleMetricViewerAction } from './open_in_single_metric_viewer_action'; import { createVisToADJobAction } from './open_vis_in_ml_action'; import { entityFieldSelectionTrigger, @@ -43,7 +45,13 @@ export function registerMlUiActions( ) { // Initialize actions const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); + const editSingleMetricViewerPanelAction = createEditSingleMetricViewerPanelAction( + core.getStartServices + ); const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); + const openInSingleMetricViewerAction = createOpenInSingleMetricViewerAction( + core.getStartServices + ); const applyInfluencerFiltersAction = createApplyInfluencerFiltersAction(core.getStartServices); const applyEntityFieldFilterAction = createApplyEntityFieldFiltersAction(core.getStartServices); const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); @@ -59,8 +67,10 @@ export function registerMlUiActions( // Assign triggers uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, editSingleMetricViewerPanelAction); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, editExplorerPanelAction); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, openInExplorerAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, openInSingleMetricViewerAction.id); uiActions.registerTrigger(swimLaneSelectionTrigger); uiActions.registerTrigger(entityFieldSelectionTrigger); @@ -69,6 +79,7 @@ export function registerMlUiActions( uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyTimeRangeSelectionAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInSingleMetricViewerAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, visToAdJobAction); diff --git a/x-pack/plugins/ml/public/ui_actions/open_in_single_metric_viewer_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_in_single_metric_viewer_action.tsx new file mode 100644 index 00000000000000..7e13830ecdc5c8 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_in_single_metric_viewer_action.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimeRange } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import { type EmbeddableApiContext, apiIsOfType } from '@kbn/presentation-publishing'; +import { + type UiActionsActionDefinition, + IncompatibleActionError, +} from '@kbn/ui-actions-plugin/public'; +import { ML_APP_LOCATOR, ML_PAGES } from '../../common/constants/locator'; +import type { MlEmbeddableBaseApi, SingleMetricViewerEmbeddableApi } from '../embeddables'; +import { ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE } from '../embeddables'; + +import type { MlCoreSetup } from '../plugin'; + +export interface OpenInSingleMetricViewerActionContext extends EmbeddableApiContext { + embeddable: SingleMetricViewerEmbeddableApi; +} + +export const OPEN_IN_SINGLE_METRIC_VIEWER_ACTION = 'openInSingleMetricViewerAction'; + +export function isSingleMetricViewerEmbeddableContext( + arg: unknown +): arg is OpenInSingleMetricViewerActionContext { + return ( + isPopulatedObject(arg, ['embeddable']) && + apiIsOfType(arg.embeddable, ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE) + ); +} + +const getTimeRange = (embeddable: MlEmbeddableBaseApi): TimeRange | undefined => { + return embeddable.timeRange$?.getValue() ?? embeddable.parentApi?.timeRange$?.getValue(); +}; + +export function createOpenInSingleMetricViewerAction( + getStartServices: MlCoreSetup['getStartServices'] +): UiActionsActionDefinition { + return { + id: 'open-in-single-metric-viewer', + type: OPEN_IN_SINGLE_METRIC_VIEWER_ACTION, + order: 100, + getIconType(): string { + return 'visLine'; + }, + getDisplayName() { + return i18n.translate('xpack.ml.actions.openInSingleMetricViewerTitle', { + defaultMessage: 'Open in Single Metric Viewer', + }); + }, + async getHref(context): Promise { + const [, pluginsStart] = await getStartServices(); + const locator = pluginsStart.share.url.locators.get(ML_APP_LOCATOR)!; + + if (isSingleMetricViewerEmbeddableContext(context)) { + const { embeddable } = context; + const { jobIds, query$, selectedEntities } = embeddable; + + return locator.getUrl( + { + page: ML_PAGES.SINGLE_METRIC_VIEWER, + // @ts-ignore entities is not compatible with SerializableRecord + pageState: { + timeRange: getTimeRange(embeddable), + refreshInterval: { + display: 'Off', + pause: true, + value: 0, + }, + jobIds: jobIds.getValue(), + query: query$?.getValue(), + entities: selectedEntities?.getValue(), + }, + }, + { absolute: true } + ); + } + }, + async execute(context) { + if (!isSingleMetricViewerEmbeddableContext(context)) { + throw new IncompatibleActionError(); + } + const [{ application }] = await getStartServices(); + const singleMetricViewerUrl = await this.getHref!(context); + if (singleMetricViewerUrl) { + await application.navigateToUrl(singleMetricViewerUrl!); + } + }, + async isCompatible(context: EmbeddableApiContext) { + return isSingleMetricViewerEmbeddableContext(context); + }, + }; +} diff --git a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_apm_timeseries/fetch_timeseries.ts b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_apm_timeseries/fetch_timeseries.ts index 21b3f4c36a4723..b266348c1ea290 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_apm_timeseries/fetch_timeseries.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_apm_timeseries/fetch_timeseries.ts @@ -122,10 +122,11 @@ export async function fetchSeries({ } return response.aggregations.groups.buckets.map((bucket) => { + const bucketValue = bucket.value as ValueAggregationMap | undefined; let value = - bucket.value?.value === undefined || bucket.value?.value === null + bucketValue?.value === undefined || bucketValue?.value === null ? null - : Number(bucket.value.value); + : Number(bucketValue.value); if (value !== null) { value = diff --git a/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts b/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts index ef32f2fb9cf60d..41bab533cf7d45 100644 --- a/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts +++ b/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts @@ -10,7 +10,11 @@ import { InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; export type InfraCustomDashboardAssetType = InventoryItemType; export interface InfraCustomDashboard { - dashboardIdList: string[]; + dashboardSavedObjectId: string; assetType: InfraCustomDashboardAssetType; - kuery?: string; + dashboardFilterAssetIdEnabled: boolean; +} + +export interface InfraSavedCustomDashboard extends InfraCustomDashboard { + id: string; } diff --git a/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts b/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts index 9b1bafaa19da14..f89f66ca86e379 100644 --- a/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts +++ b/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts @@ -12,24 +12,22 @@ const AssetTypeRT = rt.type({ assetType: ItemTypeRT, }); -const CustomDashboardRT = rt.intersection([ - AssetTypeRT, - rt.type({ - dashboardIdList: rt.array(rt.string), - }), - rt.partial({ - kuery: rt.string, - }), -]); +const PayloadRT = rt.type({ + dashboardSavedObjectId: rt.string, + dashboardFilterAssetIdEnabled: rt.boolean, +}); + +const SavedObjectIdRT = rt.type({ + id: rt.string, +}); + +const InfraCustomDashboardRT = rt.intersection([AssetTypeRT, PayloadRT, SavedObjectIdRT]); /** GET endpoint */ -export const InfraGetCustomDashboardsRequestParamsRT = AssetTypeRT; -export const InfraGetCustomDashboardsResponseBodyRT = CustomDashboardRT; -export type InfraGetCustomDashboardsRequestParams = rt.TypeOf< - typeof InfraGetCustomDashboardsRequestParamsRT ->; +export const InfraGetCustomDashboardsRequestPathParamsRT = AssetTypeRT; +export const InfraGetCustomDashboardsResponseBodyRT = rt.array(InfraCustomDashboardRT); export type InfraGetCustomDashboardsResponseBody = rt.TypeOf< typeof InfraGetCustomDashboardsResponseBodyRT >; @@ -37,11 +35,27 @@ export type InfraGetCustomDashboardsResponseBody = rt.TypeOf< /** * POST endpoint */ -export const InfraSaveCustomDashboardsRequestPayloadRT = CustomDashboardRT; -export const InfraSaveCustomDashboardsResponseBodyRT = CustomDashboardRT; +export const InfraSaveCustomDashboardsRequestPayloadRT = PayloadRT; +export const InfraSaveCustomDashboardsResponseBodyRT = InfraCustomDashboardRT; export type InfraSaveCustomDashboardsRequestPayload = rt.TypeOf< typeof InfraSaveCustomDashboardsRequestPayloadRT >; export type InfraSaveCustomDashboardsResponseBody = rt.TypeOf< typeof InfraSaveCustomDashboardsResponseBodyRT >; + +/** + * PUT endpoint + */ +export const InfraUpdateCustomDashboardsRequestPathParamsRT = rt.intersection([ + AssetTypeRT, + SavedObjectIdRT, +]); + +/** + * DELETE endpoint + */ +export const InfraDeleteCustomDashboardsRequestParamsRT = rt.intersection([ + AssetTypeRT, + SavedObjectIdRT, +]); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/custom_dashboards.ts b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/custom_dashboards.ts index 6ed41cb83db714..7c4e11d26c1d4c 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/custom_dashboards.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/custom_dashboards.ts @@ -8,8 +8,12 @@ import type { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; import { initGetCustomDashboardRoute } from './get_custom_dashboard'; import { initSaveCustomDashboardRoute } from './save_custom_dashboard'; +import { initDeleteCustomDashboardRoute } from './delete_custom_dashboard'; +import { initUpdateCustomDashboardRoute } from './update_custom_dashboard'; export function initCustomDashboardsRoutes(framework: KibanaFramework) { initGetCustomDashboardRoute(framework); initSaveCustomDashboardRoute(framework); + initDeleteCustomDashboardRoute(framework); + initUpdateCustomDashboardRoute(framework); } diff --git a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/delete_custom_dashboard.ts b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/delete_custom_dashboard.ts new file mode 100644 index 00000000000000..0742fd043a0916 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/delete_custom_dashboard.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createRouteValidationFunction } from '@kbn/io-ts-utils'; +import { InfraDeleteCustomDashboardsRequestParamsRT } from '../../../common/http_api/custom_dashboards_api'; +import { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; +import { handleRouteErrors } from '../../utils/handle_route_errors'; +import { checkCustomDashboardsEnabled } from './lib/check_custom_dashboards_enabled'; +import { deleteCustomDashboard } from './lib/delete_custom_dashboard'; + +export function initDeleteCustomDashboardRoute(framework: KibanaFramework) { + const validateParams = createRouteValidationFunction(InfraDeleteCustomDashboardsRequestParamsRT); + + framework.registerRoute( + { + method: 'delete', + path: '/api/infra/{assetType}/custom-dashboards/{id}', + validate: { + params: validateParams, + }, + options: { + access: 'internal', + }, + }, + handleRouteErrors(async (context, request, response) => { + const { savedObjectsClient, uiSettingsClient } = await context.infra; + + await checkCustomDashboardsEnabled(uiSettingsClient); + + const { id } = request.params; + + await deleteCustomDashboard({ + savedObjectsClient, + savedObjectId: id, + }); + + return response.ok({ + body: id, + }); + }) + ); +} diff --git a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/get_custom_dashboard.ts b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/get_custom_dashboard.ts index 5b5136b104f9d3..561c1995dba13e 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/get_custom_dashboard.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/get_custom_dashboard.ts @@ -7,7 +7,7 @@ import { createRouteValidationFunction } from '@kbn/io-ts-utils'; import { - InfraGetCustomDashboardsRequestParamsRT, + InfraGetCustomDashboardsRequestPathParamsRT, InfraGetCustomDashboardsResponseBodyRT, } from '../../../common/http_api/custom_dashboards_api'; import { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; @@ -16,12 +16,12 @@ import { checkCustomDashboardsEnabled } from './lib/check_custom_dashboards_enab import { findCustomDashboard } from './lib/find_custom_dashboard'; export function initGetCustomDashboardRoute(framework: KibanaFramework) { - const validateParams = createRouteValidationFunction(InfraGetCustomDashboardsRequestParamsRT); + const validateParams = createRouteValidationFunction(InfraGetCustomDashboardsRequestPathParamsRT); framework.registerRoute( { method: 'get', - path: '/api/infra/custom-dashboards/{assetType}', + path: '/api/infra/{assetType}/custom-dashboards', validate: { params: validateParams, }, @@ -37,24 +37,8 @@ export function initGetCustomDashboardRoute(framework: KibanaFramework) { const params = request.params; const customDashboards = await findCustomDashboard(params.assetType, savedObjectsClient); - if (customDashboards.total === 0) { - return response.ok({ - body: InfraGetCustomDashboardsResponseBodyRT.encode({ - assetType: params.assetType, - dashboardIdList: [], - kuery: undefined, - }), - }); - } - - const attributes = customDashboards.saved_objects[0].attributes; - return response.ok({ - body: InfraGetCustomDashboardsResponseBodyRT.encode({ - assetType: attributes.assetType, - dashboardIdList: attributes.dashboardIdList, - kuery: attributes.kuery, - }), + body: InfraGetCustomDashboardsResponseBodyRT.encode(customDashboards), }); }) ); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/lib/delete_custom_dashboard.ts b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/lib/delete_custom_dashboard.ts new file mode 100644 index 00000000000000..6f2cbbd19cc504 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/lib/delete_custom_dashboard.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '../../../saved_objects'; + +interface Options { + savedObjectsClient: SavedObjectsClientContract; + savedObjectId: string; +} +export function deleteCustomDashboard({ savedObjectsClient, savedObjectId }: Options) { + return savedObjectsClient.delete(INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, savedObjectId); +} diff --git a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts index 5f5956d5c8fc63..dd575287c807af 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts @@ -12,13 +12,22 @@ import type { InfraCustomDashboardAssetType, } from '../../../../common/custom_dashboards'; -export function findCustomDashboard( +export async function findCustomDashboard( assetType: InfraCustomDashboardAssetType, savedObjectsClient: SavedObjectsClientContract ) { - return savedObjectsClient.find({ + const result = await savedObjectsClient.find({ type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, search: assetType, searchFields: ['assetType'] as [keyof InfraCustomDashboard], + page: 1, + perPage: 1000, + sortField: 'updated_at', + sortOrder: 'desc', }); + + return result.saved_objects.map(({ id, attributes }) => ({ + id, + ...attributes, + })); } diff --git a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/save_custom_dashboard.ts b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/save_custom_dashboard.ts index dc811b99b47de5..cab4d2de835d54 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/save_custom_dashboard.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/save_custom_dashboard.ts @@ -9,24 +9,26 @@ import { createRouteValidationFunction } from '@kbn/io-ts-utils'; import { InfraCustomDashboard } from '../../../common/custom_dashboards'; import { InfraSaveCustomDashboardsRequestPayloadRT, - InfraSaveCustomDashboardsRequestPayload, InfraSaveCustomDashboardsResponseBodyRT, + InfraGetCustomDashboardsRequestPathParamsRT, } from '../../../common/http_api/custom_dashboards_api'; import { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; import { INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '../../saved_objects'; import { checkCustomDashboardsEnabled } from './lib/check_custom_dashboards_enabled'; -import { findCustomDashboard } from './lib/find_custom_dashboard'; import { handleRouteErrors } from '../../utils/handle_route_errors'; +import { findCustomDashboard } from './lib/find_custom_dashboard'; export function initSaveCustomDashboardRoute(framework: KibanaFramework) { const validatePayload = createRouteValidationFunction(InfraSaveCustomDashboardsRequestPayloadRT); + const validateParams = createRouteValidationFunction(InfraGetCustomDashboardsRequestPathParamsRT); framework.registerRoute( { method: 'post', - path: '/api/infra/custom-dashboards', + path: '/api/infra/{assetType}/custom-dashboards', validate: { body: validatePayload, + params: validateParams, }, options: { access: 'internal', @@ -37,29 +39,33 @@ export function initSaveCustomDashboardRoute(framework: KibanaFramework) { await checkCustomDashboardsEnabled(uiSettingsClient); - const payload: InfraSaveCustomDashboardsRequestPayload = request.body; - const customDashboards = await findCustomDashboard(payload.assetType, savedObjectsClient); + const { dashboardSavedObjectId } = request.body; + + const { assetType } = request.params; - if (customDashboards.total === 0) { - const savedCustomDashboard = await savedObjectsClient.create( - INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, - payload - ); + const customDashboards = await findCustomDashboard(assetType, savedObjectsClient); + + const dashboardExist = customDashboards.find( + (customDashboard) => customDashboard.dashboardSavedObjectId === dashboardSavedObjectId + ); - return response.ok({ - body: InfraSaveCustomDashboardsResponseBodyRT.encode(savedCustomDashboard.attributes), + if (dashboardExist) { + return response.badRequest({ + body: `Dashboard with id ${dashboardSavedObjectId} has already been linked to ${assetType}`, }); } - const savedCustomDashboard = await savedObjectsClient.update( + const savedCustomDashboard = await savedObjectsClient.create( INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, - customDashboards.saved_objects[0].id, - payload + { + assetType, + ...request.body, + } ); return response.ok({ body: InfraSaveCustomDashboardsResponseBodyRT.encode({ - ...payload, + id: savedCustomDashboard.id, ...savedCustomDashboard.attributes, }), }); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/update_custom_dashboard.ts b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/update_custom_dashboard.ts new file mode 100644 index 00000000000000..2634604b9a76a5 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/server/routes/custom_dashboards/update_custom_dashboard.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createRouteValidationFunction } from '@kbn/io-ts-utils'; +import { InfraCustomDashboard } from '../../../common/custom_dashboards'; +import { + InfraSaveCustomDashboardsRequestPayloadRT, + InfraSaveCustomDashboardsResponseBodyRT, + InfraUpdateCustomDashboardsRequestPathParamsRT, +} from '../../../common/http_api/custom_dashboards_api'; +import { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; +import { INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { checkCustomDashboardsEnabled } from './lib/check_custom_dashboards_enabled'; +import { handleRouteErrors } from '../../utils/handle_route_errors'; + +export function initUpdateCustomDashboardRoute(framework: KibanaFramework) { + const validatePayload = createRouteValidationFunction(InfraSaveCustomDashboardsRequestPayloadRT); + const validateParams = createRouteValidationFunction( + InfraUpdateCustomDashboardsRequestPathParamsRT + ); + + framework.registerRoute( + { + method: 'put', + path: '/api/infra/{assetType}/custom-dashboards/{id}', + validate: { + body: validatePayload, + params: validateParams, + }, + options: { + access: 'internal', + }, + }, + handleRouteErrors(async (context, request, response) => { + const { savedObjectsClient, uiSettingsClient } = await context.infra; + + await checkCustomDashboardsEnabled(uiSettingsClient); + + const { id, assetType } = request.params; + + const savedCustomDashboard = await savedObjectsClient.update( + INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + id, + { + assetType, + ...request.body, + } + ); + + return response.ok({ + body: InfraSaveCustomDashboardsResponseBodyRT.encode({ + id: savedCustomDashboard.id, + assetType, + ...request.body, + ...savedCustomDashboard.attributes, + }), + }); + }) + ); +} diff --git a/x-pack/plugins/observability_solution/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.ts b/x-pack/plugins/observability_solution/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.ts index 8075a51336f36e..76220a94631210 100644 --- a/x-pack/plugins/observability_solution/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.ts +++ b/x-pack/plugins/observability_solution/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.ts @@ -7,20 +7,21 @@ import { SavedObjectsFieldMapping, SavedObjectsType } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; -import { schema, Type } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { InfraCustomDashboard } from '../../../common/custom_dashboards'; export const INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE = 'infra-custom-dashboards'; const properties: Record = { - dashboardIdList: { type: 'keyword' }, - assetType: { type: 'keyword' }, - kuery: { type: 'text' }, -}; -const createSchema: Record> = { - dashboardIdList: schema.arrayOf(schema.string()), - assetType: schema.string(), - kuery: schema.maybe(schema.string()), + assetType: { + type: 'keyword', + }, + dashboardSavedObjectId: { + type: 'keyword', + }, + dashboardFilterAssetIdEnabled: { + type: 'boolean', + }, }; export const infraCustomDashboardsSavedObjectType: SavedObjectsType = { @@ -28,6 +29,7 @@ export const infraCustomDashboardsSavedObjectType: SavedObjectsType = { hidden: false, namespaceType: 'multiple', mappings: { + dynamic: false, properties, }, management: { @@ -42,7 +44,37 @@ export const infraCustomDashboardsSavedObjectType: SavedObjectsType = { '1': { changes: [], schemas: { - create: schema.object(createSchema), + create: schema.object({ + dashboardIdList: schema.arrayOf(schema.string()), + assetType: schema.string(), + kuery: schema.maybe(schema.string()), + }), + }, + }, + '2': { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + dashboardSavedObjectId: { + type: 'keyword', + }, + dashboardFilterAssetIdEnabled: { + type: 'boolean', + }, + }, + }, + { + type: 'mappings_deprecation', + deprecatedMappings: ['dashboardIdList', 'kuery'], + }, + ], + schemas: { + create: schema.object({ + dashboardSavedObjectId: schema.string(), + assetType: schema.string(), + dashboardFilterAssetIdEnabled: schema.boolean(), + }), }, }, }, diff --git a/x-pack/plugins/search_notebooks/README.mdx b/x-pack/plugins/search_notebooks/README.mdx new file mode 100755 index 00000000000000..c1429a0c598ab1 --- /dev/null +++ b/x-pack/plugins/search_notebooks/README.mdx @@ -0,0 +1,3 @@ +# Search Notebooks plugin + +This plugin contains endpoints and components for rendering search python notebooks in the persistent dev console. diff --git a/x-pack/plugins/search_notebooks/common/index.ts b/x-pack/plugins/search_notebooks/common/index.ts new file mode 100644 index 00000000000000..8a2a062f51aa04 --- /dev/null +++ b/x-pack/plugins/search_notebooks/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PLUGIN_ID = 'searchNotebooks'; +export const PLUGIN_NAME = 'searchNotebooks'; diff --git a/x-pack/plugins/search_notebooks/jest.config.js b/x-pack/plugins/search_notebooks/jest.config.js new file mode 100644 index 00000000000000..ff4a9e41f1b1a4 --- /dev/null +++ b/x-pack/plugins/search_notebooks/jest.config.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/search_notebooks'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/search_notebooks', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/search_notebooks/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/x-pack/plugins/search_notebooks/kibana.jsonc b/x-pack/plugins/search_notebooks/kibana.jsonc new file mode 100644 index 00000000000000..d3f671c90e3223 --- /dev/null +++ b/x-pack/plugins/search_notebooks/kibana.jsonc @@ -0,0 +1,19 @@ +{ + "type": "plugin", + "id": "@kbn/search-notebooks", + "owner": "@elastic/enterprise-search-frontend", + "description": "Plugin to provide access to and rendering of python notebooks for use in the persistent developer console.", + "plugin": { + "id": "searchNotebooks", + "server": true, + "browser": false, + "configPath": [ + "xpack", + "search", + "notebooks" + ], + "requiredPlugins": [], + "optionalPlugins": [], + "requiredBundles": [] + } +} diff --git a/x-pack/plugins/search_notebooks/package.json b/x-pack/plugins/search_notebooks/package.json new file mode 100644 index 00000000000000..a81945be3c7ad4 --- /dev/null +++ b/x-pack/plugins/search_notebooks/package.json @@ -0,0 +1,11 @@ +{ + "name": "@kbn/search-notebooks", + "version": "1.0.0", + "license": "Elastic License 2.0", + "private": true, + "scripts": { + "build": "yarn plugin-helpers build", + "plugin-helpers": "node ../../../scripts/plugin_helpers", + "kbn": "node ../../../scripts/kbn" + } +} diff --git a/x-pack/plugins/search_notebooks/scripts/download_notebooks.sh b/x-pack/plugins/search_notebooks/scripts/download_notebooks.sh new file mode 100755 index 00000000000000..11bf594695fc39 --- /dev/null +++ b/x-pack/plugins/search_notebooks/scripts/download_notebooks.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +SCRIPT_DIR="$(dirname -- "$0")" +URL_FILE="$SCRIPT_DIR/notebooks.txt" +DATA_DIR="$SCRIPT_DIR/../server/data" + +echo "Saving notebooks to $DATA_DIR" + +# Check if the DATA_DIR exists, if not, create it +if [ ! -d "$DATA_DIR" ]; then + echo "Creating directory $DATA_DIR" + mkdir -p "$DATA_DIR" +fi + +# Check if the URL file exists +if [ ! -f "$URL_FILE" ]; then + echo "URL file does not exist: $URL_FILE" + exit 1 +fi + +# Read each line from the URL file +while IFS= read -r url; do + if [ -z "$url" ]; then + # Skip empty lines + continue + fi + + echo "Downloading: $url" + # Extract the filename from the URL + full_filename=$(basename "$url") + filename="${full_filename%.*}" + # Convert filename to snake case + # This replaces non-alphanumeric characters with underscores and lowercases the result + snake_case_filename=$(echo "$filename" | sed -r 's/[^a-zA-Z0-9]+/_/g' | tr '[:upper:]' '[:lower:]') + + # Use curl to download the file. -L follows redirects, and -o specifies the output file path. + curl -L "$url" -o "$DATA_DIR/$snake_case_filename.json" +done < "$URL_FILE" + +echo "Download completed." diff --git a/x-pack/plugins/search_notebooks/scripts/notebooks.txt b/x-pack/plugins/search_notebooks/scripts/notebooks.txt new file mode 100644 index 00000000000000..889e18263dd3e9 --- /dev/null +++ b/x-pack/plugins/search_notebooks/scripts/notebooks.txt @@ -0,0 +1,5 @@ +https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/00-quick-start.ipynb +https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/01-keyword-querying-filtering.ipynb +https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/02-hybrid-search.ipynb +https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/03-ELSER.ipynb +https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/04-multilingual.ipynb diff --git a/x-pack/plugins/search_notebooks/server/config.ts b/x-pack/plugins/search_notebooks/server/config.ts new file mode 100644 index 00000000000000..9e2651a1d76aef --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/config.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 { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '@kbn/core/server'; + +export * from './types'; + +const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +type SearchNotebooksSchema = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export type SearchNotebooksConfig = TypeOf; diff --git a/x-pack/plugins/search_notebooks/server/data/00_quick_start.json b/x-pack/plugins/search_notebooks/server/data/00_quick_start.json new file mode 100644 index 00000000000000..3462ab9ff5f108 --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/data/00_quick_start.json @@ -0,0 +1,599 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "87773ce7", + "metadata": { + "id": "87773ce7" + }, + "source": [ + "# Semantic search quick start\n", + "\n", + "\"Open\n", + "\n", + "This interactive notebook will introduce you to some basic operations with Elasticsearch, using the official [Elasticsearch Python client](https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/connecting.html).\n", + "You'll perform semantic search using [Sentence Transformers](https://www.sbert.net) for text embedding. Learn how to integrate traditional text-based search with semantic search, for a hybrid search system." + ] + }, + { + "cell_type": "markdown", + "id": "a32202e2", + "metadata": { + "id": "a32202e2" + }, + "source": [ + "## Create Elastic Cloud deployment\n", + "\n", + "If you don't have an Elastic Cloud deployment, sign up [here](https://cloud.elastic.co/registration?utm_source=github&utm_content=elasticsearch-labs-notebook) for a free trial.\n", + "\n", + "Once logged in to your Elastic Cloud account, go to the [Create deployment](https://cloud.elastic.co/deployments/create) page and select **Create deployment**. Leave all settings with their default values." + ] + }, + { + "cell_type": "markdown", + "id": "52a6a607", + "metadata": { + "id": "52a6a607" + }, + "source": [ + "## Install packages and import modules\n", + "\n", + "To get started, we'll need to connect to our Elastic deployment using the Python client.\n", + "Because we're using an Elastic Cloud deployment, we'll use the **Cloud ID** to identify our deployment.\n", + "\n", + "First we need to install the `elasticsearch` Python client." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffc5fa6f", + "metadata": { + "id": "ffc5fa6f" + }, + "outputs": [], + "source": [ + "!pip install -qU elasticsearch sentence-transformers" + ] + }, + { + "cell_type": "markdown", + "id": "28AH8LhI-0UD", + "metadata": { + "id": "28AH8LhI-0UD" + }, + "source": [ + "# Setup the Embedding Model\n", + "\n", + "For this example, we're using `all-MiniLM-L6-v2`, part of the `sentence_transformers` library. You can read more about this model on [Huggingface](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "WHC3hHGW-wbI", + "metadata": { + "id": "WHC3hHGW-wbI" + }, + "outputs": [], + "source": [ + "from sentence_transformers import SentenceTransformer\n", + "\n", + "model = SentenceTransformer(\"all-MiniLM-L6-v2\")" + ] + }, + { + "cell_type": "markdown", + "id": "0241694c", + "metadata": { + "id": "0241694c" + }, + "source": [ + "## Initialize the Elasticsearch client\n", + "\n", + "Now we can instantiate the [Elasticsearch python client](https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/index.html), providing the cloud id and password in your deployment." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f38e0397", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "f38e0397", + "outputId": "ad6df489-d242-4229-a42a-39c5ca19d124" + }, + "outputs": [], + "source": [ + "from elasticsearch import Elasticsearch\n", + "from getpass import getpass\n", + "\n", + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id\n", + "ELASTIC_CLOUD_ID = getpass(\"Elastic Cloud ID: \")\n", + "\n", + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#creating-an-api-key\n", + "ELASTIC_API_KEY = getpass(\"Elastic Api Key: \")\n", + "\n", + "# Create the client instance\n", + "client = Elasticsearch(\n", + " # For local development\n", + " # hosts=[\"http://localhost:9200\"]\n", + " cloud_id=ELASTIC_CLOUD_ID,\n", + " api_key=ELASTIC_API_KEY,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "fcd165fa", + "metadata": { + "id": "fcd165fa" + }, + "source": [ + "If you're running Elasticsearch locally or self-managed, you can pass in the Elasticsearch host instead. [Read more](https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/connecting.html#_verifying_https_with_certificate_fingerprints_python_3_10_or_later) on how to connect to Elasticsearch locally." + ] + }, + { + "cell_type": "markdown", + "id": "1462ebd8", + "metadata": { + "id": "1462ebd8" + }, + "source": [ + "Confirm that the client has connected with this test." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "25c618eb", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "25c618eb", + "outputId": "30a6ba5b-5109-4457-ddfe-5633a077ca9b" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'name': 'instance-0000000011', 'cluster_name': 'd1bd36862ce54c7b903e2aacd4cd7f0a', 'cluster_uuid': 'tIkh0X_UQKmMFQKSfUw-VQ', 'version': {'number': '8.9.0', 'build_flavor': 'default', 'build_type': 'docker', 'build_hash': '8aa461beb06aa0417a231c345a1b8c38fb498a0d', 'build_date': '2023-07-19T14:43:58.555259655Z', 'build_snapshot': False, 'lucene_version': '9.7.0', 'minimum_wire_compatibility_version': '7.17.0', 'minimum_index_compatibility_version': '7.0.0'}, 'tagline': 'You Know, for Search'}\n" + ] + } + ], + "source": [ + "print(client.info())" + ] + }, + { + "cell_type": "markdown", + "id": "61e1e6d8", + "metadata": { + "id": "61e1e6d8" + }, + "source": [ + "## Index some test data\n", + "\n", + "Our client is set up and connected to our Elastic deployment.\n", + "Now we need some data to test out the basics of Elasticsearch queries.\n", + "We'll use a small index of books with the following fields:\n", + "\n", + "- `title`\n", + "- `authors`\n", + "- `publish_date`\n", + "- `num_reviews`\n", + "- `publisher`\n", + "\n", + "### Create an index\n", + "\n", + "First ensure that you do not have a previously created index with the name `book_index`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "_OAahfg-tqrf", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "_OAahfg-tqrf", + "outputId": "d8f81ba4-cdc9-4e30-edf7-6d5bb16920eb" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "ObjectApiResponse({'acknowledged': True})" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "client.indices.delete(index=\"book_index\", ignore_unavailable=True)" + ] + }, + { + "cell_type": "markdown", + "id": "064b761a-565d-42f4-9b4a-4df4f190fd3b", + "metadata": {}, + "source": [ + "🔐 NOTE: at any time you can come back to this section and run the `delete` function above to remove your index and start from scratch.\n", + "\n", + "Let's create an Elasticsearch index with the correct mappings for our test data. " + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "6bc95238", + "metadata": { + "id": "6bc95238" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "ObjectApiResponse({'acknowledged': True, 'shards_acknowledged': True, 'index': 'book_index'})" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Define the mapping\n", + "mappings = {\n", + " \"properties\": {\n", + " \"title_vector\": {\n", + " \"type\": \"dense_vector\",\n", + " \"dims\": 384,\n", + " \"index\": \"true\",\n", + " \"similarity\": \"cosine\",\n", + " }\n", + " }\n", + "}\n", + "\n", + "# Create the index\n", + "client.indices.create(index=\"book_index\", mappings=mappings)" + ] + }, + { + "cell_type": "markdown", + "id": "075f5eb6", + "metadata": { + "id": "075f5eb6" + }, + "source": [ + "### Index test data\n", + "\n", + "Run the following command to upload some test data, containing information about 10 popular programming books from this [dataset](https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/data.json).\n", + "`model.encode` will encode the text into a vector on the fly, using the model we initialized earlier." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "008d723e", + "metadata": { + "id": "008d723e" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "ObjectApiResponse({'took': 49, 'errors': False, 'items': [{'index': {'_index': 'book_index', '_id': 'HwOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 0, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'IAOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 1, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'IQOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 2, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'IgOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 3, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'IwOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 4, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'JAOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 5, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'JQOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 6, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'JgOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 7, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'JwOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 8, '_primary_term': 1, 'status': 201}}, {'index': {'_index': 'book_index', '_id': 'KAOa7osBiUNHLMdf3q2r', '_version': 1, 'result': 'created', 'forced_refresh': True, '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 9, '_primary_term': 1, 'status': 201}}]})" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import json\n", + "from urllib.request import urlopen\n", + "\n", + "url = \"https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/data.json\"\n", + "response = urlopen(url)\n", + "books = json.loads(response.read())\n", + "\n", + "operations = []\n", + "for book in books:\n", + " operations.append({\"index\": {\"_index\": \"book_index\"}})\n", + " # Transforming the title into an embedding using the model\n", + " book[\"title_vector\"] = model.encode(book[\"title\"]).tolist()\n", + " operations.append(book)\n", + "client.bulk(index=\"book_index\", operations=operations, refresh=True)" + ] + }, + { + "cell_type": "markdown", + "id": "cd8b03e0", + "metadata": { + "id": "cd8b03e0" + }, + "source": [ + "## Aside: Pretty printing Elasticsearch responses\n", + "\n", + "Your API calls will return hard-to-read nested JSON.\n", + "We'll create a little function called `pretty_response` to return nice, human-readable outputs from our examples." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "f12ce2c9", + "metadata": { + "id": "f12ce2c9" + }, + "outputs": [], + "source": [ + "def pretty_response(response):\n", + " if len(response[\"hits\"][\"hits\"]) == 0:\n", + " print(\"Your search returned no results.\")\n", + " else:\n", + " for hit in response[\"hits\"][\"hits\"]:\n", + " id = hit[\"_id\"]\n", + " publication_date = hit[\"_source\"][\"publish_date\"]\n", + " score = hit[\"_score\"]\n", + " title = hit[\"_source\"][\"title\"]\n", + " summary = hit[\"_source\"][\"summary\"]\n", + " publisher = hit[\"_source\"][\"publisher\"]\n", + " num_reviews = hit[\"_source\"][\"num_reviews\"]\n", + " authors = hit[\"_source\"][\"authors\"]\n", + " pretty_output = f\"\\nID: {id}\\nPublication date: {publication_date}\\nTitle: {title}\\nSummary: {summary}\\nPublisher: {publisher}\\nReviews: {num_reviews}\\nAuthors: {authors}\\nScore: {score}\"\n", + " print(pretty_output)" + ] + }, + { + "cell_type": "markdown", + "id": "39bdefe0", + "metadata": { + "id": "39bdefe0" + }, + "source": [ + "## Making queries\n", + "\n", + "Now that we have indexed the books, we want to perform a semantic search for books that are similar to a given query.\n", + "We embed the query and perform a search." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "Df7hwcIjYwMT", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "Df7hwcIjYwMT", + "outputId": "e63884d7-d4a5-4f5d-ea43-fc2f0793f040" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 0.80428284\n", + "\n", + "ID: IwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2015-03-27\n", + "Title: You Don't Know JS: Up & Going\n", + "Summary: Introduction to JavaScript and programming as a whole\n", + "Publisher: oreilly\n", + "Reviews: 36\n", + "Authors: ['kyle simpson']\n", + "Score: 0.6989136\n", + "\n", + "ID: JAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2018-12-04\n", + "Title: Eloquent JavaScript\n", + "Summary: A modern introduction to programming\n", + "Publisher: no starch press\n", + "Reviews: 38\n", + "Authors: ['marijn haverbeke']\n", + "Score: 0.6796988\n", + "\n", + "ID: HwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-10-29\n", + "Title: The Pragmatic Programmer: Your Journey to Mastery\n", + "Summary: A guide to pragmatic programming for software engineers and developers\n", + "Publisher: addison-wesley\n", + "Reviews: 30\n", + "Authors: ['andrew hunt', 'david thomas']\n", + "Score: 0.62065494\n", + "\n", + "ID: KAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2012-06-27\n", + "Title: Introduction to the Theory of Computation\n", + "Summary: Introduction to the theory of computation and complexity theory\n", + "Publisher: cengage learning\n", + "Reviews: 33\n", + "Authors: ['michael sipser']\n", + "Score: 0.6008769\n", + "\n", + "ID: JgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2011-05-13\n", + "Title: The Clean Coder: A Code of Conduct for Professional Programmers\n", + "Summary: A guide to professional conduct in the field of software engineering\n", + "Publisher: prentice hall\n", + "Reviews: 20\n", + "Authors: ['robert c. martin']\n", + "Score: 0.571234\n", + "\n", + "ID: JQOa7osBiUNHLMdf3q2r\n", + "Publication date: 1994-10-31\n", + "Title: Design Patterns: Elements of Reusable Object-Oriented Software\n", + "Summary: Guide to design patterns that can be used in any object-oriented language\n", + "Publisher: addison-wesley\n", + "Reviews: 45\n", + "Authors: ['erich gamma', 'richard helm', 'ralph johnson', 'john vlissides']\n", + "Score: 0.56499225\n", + "\n", + "ID: IQOa7osBiUNHLMdf3q2r\n", + "Publication date: 2020-04-06\n", + "Title: Artificial Intelligence: A Modern Approach\n", + "Summary: Comprehensive introduction to the theory and practice of artificial intelligence\n", + "Publisher: pearson\n", + "Reviews: 39\n", + "Authors: ['stuart russell', 'peter norvig']\n", + "Score: 0.56054837\n", + "\n", + "ID: IgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-08-11\n", + "Title: Clean Code: A Handbook of Agile Software Craftsmanship\n", + "Summary: A guide to writing code that is easy to read, understand and maintain\n", + "Publisher: prentice hall\n", + "Reviews: 55\n", + "Authors: ['robert c. martin']\n", + "Score: 0.54226947\n", + "\n", + "ID: IAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-05-03\n", + "Title: Python Crash Course\n", + "Summary: A fast-paced, no-nonsense guide to programming in Python\n", + "Publisher: no starch press\n", + "Reviews: 42\n", + "Authors: ['eric matthes']\n", + "Score: 0.5254088\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " knn={\n", + " \"field\": \"title_vector\",\n", + " \"query_vector\": model.encode(\"javascript books\"),\n", + " \"k\": 10,\n", + " \"num_candidates\": 100,\n", + " },\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "id": "LdJCpbQMeml5", + "metadata": { + "id": "LdJCpbQMeml5" + }, + "source": [ + "## Filtering\n", + "\n", + "Filter context is mostly used for filtering structured data. For example, use filter context to answer questions like:\n", + "\n", + "- _Does this timestamp fall into the range 2015 to 2016?_\n", + "- _Is the status field set to \"published\"?_\n", + "\n", + "Filter context is in effect whenever a query clause is passed to a filter parameter, such as the `filter` or `must_not` parameters in a `bool` query.\n", + "\n", + "[Learn more](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html#filter-context) about filter context in the Elasticsearch docs." + ] + }, + { + "cell_type": "markdown", + "id": "dRSrPMyFf7w7", + "metadata": { + "id": "dRSrPMyFf7w7" + }, + "source": [ + "### Example: Keyword Filtering\n", + "\n", + "This is an example of adding a keyword filter to the query.\n", + "\n", + "The example retrieves the top books that are similar to \"javascript books\" based on their title vectors, and also Addison-Wesley as publisher." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "WoE0yTchfj3A", + "metadata": { + "id": "WoE0yTchfj3A" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: HwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-10-29\n", + "Title: The Pragmatic Programmer: Your Journey to Mastery\n", + "Summary: A guide to pragmatic programming for software engineers and developers\n", + "Publisher: addison-wesley\n", + "Reviews: 30\n", + "Authors: ['andrew hunt', 'david thomas']\n", + "Score: 0.62065494\n", + "\n", + "ID: JQOa7osBiUNHLMdf3q2r\n", + "Publication date: 1994-10-31\n", + "Title: Design Patterns: Elements of Reusable Object-Oriented Software\n", + "Summary: Guide to design patterns that can be used in any object-oriented language\n", + "Publisher: addison-wesley\n", + "Reviews: 45\n", + "Authors: ['erich gamma', 'richard helm', 'ralph johnson', 'john vlissides']\n", + "Score: 0.56499225\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " knn={\n", + " \"field\": \"title_vector\",\n", + " \"query_vector\": model.encode(\"javascript books\"),\n", + " \"k\": 10,\n", + " \"num_candidates\": 100,\n", + " \"filter\": {\"term\": {\"publisher.keyword\": \"addison-wesley\"}},\n", + " },\n", + ")\n", + "\n", + "pretty_response(response)" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/x-pack/plugins/search_notebooks/server/data/01_keyword_querying_filtering.json b/x-pack/plugins/search_notebooks/server/data/01_keyword_querying_filtering.json new file mode 100644 index 00000000000000..84f1830ecebf06 --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/data/01_keyword_querying_filtering.json @@ -0,0 +1,912 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "83LdOUCwwHzs" + }, + "source": [ + "# Keyword querying and filtering\n", + "\n", + "\"Open\n", + "\n", + "This interactive notebook will introduce you to the basic Elasticsearch queries, using the official Elasticsearch Python client. Before getting started on this section you should work through our [quick start](https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/00-quick-start.ipynb), as you will be using the same dataset." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Install and import libraries" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -qU elasticsearch pandas" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from elasticsearch import Elasticsearch\n", + "from getpass import getpass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create the client instance\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id\n", + "ELASTIC_CLOUD_ID = getpass(\"Elastic Cloud ID: \")\n", + "\n", + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#creating-an-api-key\n", + "ELASTIC_API_KEY = getpass(\"Elastic Api Key: \")\n", + "\n", + "# Create the client instance\n", + "client = Elasticsearch(\n", + " # For local development\n", + " # hosts=[\"http://localhost:9200\"]\n", + " cloud_id=ELASTIC_CLOUD_ID,\n", + " api_key=ELASTIC_API_KEY,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pretty printing Elasticsearch responses\n", + "\n", + "Let's add a helper function to print Elasticsearch responses in a readable format. This function is similar to the one that was used in the [quickstart](https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/00-quick-start.ipynb) guide." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "def pretty_response(response):\n", + " if len(response[\"hits\"][\"hits\"]) == 0:\n", + " print(\"Your search returned no results.\")\n", + " else:\n", + " for hit in response[\"hits\"][\"hits\"]:\n", + " id = hit[\"_id\"]\n", + " publication_date = hit[\"_source\"][\"publish_date\"]\n", + " score = hit[\"_score\"]\n", + " title = hit[\"_source\"][\"title\"]\n", + " summary = hit[\"_source\"][\"summary\"]\n", + " publisher = hit[\"_source\"][\"publisher\"]\n", + " num_reviews = hit[\"_source\"][\"num_reviews\"]\n", + " authors = hit[\"_source\"][\"authors\"]\n", + " pretty_output = f\"\\nID: {id}\\nPublication date: {publication_date}\\nTitle: {title}\\nSummary: {summary}\\nPublisher: {publisher}\\nReviews: {num_reviews}\\nAuthors: {authors}\\nScore: {score}\"\n", + " print(pretty_output)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "22onltbgxxGm" + }, + "source": [ + "## Querying\n", + "🔐 NOTE: to run the queries that follow you need the `book_index` dataset from our [quick start](https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/00-quick-start.ipynb). If you haven't worked through the quick start, please follow the steps described there to create an Elasticsearch deployment with the dataset in it, and then come back to run the queries here.\n", + "\n", + "In the query context, a query clause answers the question _“How well does this document match this query clause?”_. In addition to deciding whether or not the document matches, the query clause also calculates a relevance score in the `_score `metadata field.\n", + "\n", + "### Full text queries\n", + "\n", + "Full text queries enable you to search analyzed text fields such as the body of an email. The query string is processed using the same analyzer that was applied to the field during indexing.\n", + "\n", + "* **match**.\n", + " The standard query for performing full text queries, including fuzzy matching and phrase or proximity queries.\n", + "* **multi-match**.\n", + " The multi-field version of the match query." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "clXQwoFQ6x61" + }, + "source": [ + "#### Match query\n", + "Returns documents that `match` a provided text, number, date or boolean value. The provided text is analyzed before matching.\n", + "\n", + "The `match` query is the standard query for performing a full-text search, including options for fuzzy matching.\n", + "\n", + "[Read more](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html#match-query-ex-request).\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 197 + }, + "id": "q_OE0XVx6_qX", + "outputId": "6a1d7760-5fb9-4809-e060-e35a398ed3c4" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: HwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-10-29\n", + "Title: The Pragmatic Programmer: Your Journey to Mastery\n", + "Summary: A guide to pragmatic programming for software engineers and developers\n", + "Publisher: addison-wesley\n", + "Reviews: 30\n", + "Authors: ['andrew hunt', 'david thomas']\n", + "Score: 0.7042277\n", + "\n", + "ID: IAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-05-03\n", + "Title: Python Crash Course\n", + "Summary: A fast-paced, no-nonsense guide to programming in Python\n", + "Publisher: no starch press\n", + "Reviews: 42\n", + "Authors: ['eric matthes']\n", + "Score: 0.7042277\n", + "\n", + "ID: JgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2011-05-13\n", + "Title: The Clean Coder: A Code of Conduct for Professional Programmers\n", + "Summary: A guide to professional conduct in the field of software engineering\n", + "Publisher: prentice hall\n", + "Reviews: 20\n", + "Authors: ['robert c. martin']\n", + "Score: 0.6771651\n", + "\n", + "ID: IgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-08-11\n", + "Title: Clean Code: A Handbook of Agile Software Craftsmanship\n", + "Summary: A guide to writing code that is easy to read, understand and maintain\n", + "Publisher: prentice hall\n", + "Reviews: 55\n", + "Authors: ['robert c. martin']\n", + "Score: 0.62883455\n", + "\n", + "ID: JQOa7osBiUNHLMdf3q2r\n", + "Publication date: 1994-10-31\n", + "Title: Design Patterns: Elements of Reusable Object-Oriented Software\n", + "Summary: Guide to design patterns that can be used in any object-oriented language\n", + "Publisher: addison-wesley\n", + "Reviews: 45\n", + "Authors: ['erich gamma', 'richard helm', 'ralph johnson', 'john vlissides']\n", + "Score: 0.62883455\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\", query={\"match\": {\"summary\": {\"query\": \"guide\"}}}\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "H-n6hoVsfAqc" + }, + "source": [ + "#### Multi-match query\n", + "\n", + "The `multi_match` query builds on the match query to allow multi-field queries.\n", + "\n", + "[Read more](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 154 + }, + "id": "TRmGYM94gCtb", + "outputId": "dc58b19f-e585-4d0a-d065-ac3fc18ae123" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2018-12-04\n", + "Title: Eloquent JavaScript\n", + "Summary: A modern introduction to programming\n", + "Publisher: no starch press\n", + "Reviews: 38\n", + "Authors: ['marijn haverbeke']\n", + "Score: 2.0307527\n", + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 1.7064086\n", + "\n", + "ID: IwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2015-03-27\n", + "Title: You Don't Know JS: Up & Going\n", + "Summary: Introduction to JavaScript and programming as a whole\n", + "Publisher: oreilly\n", + "Reviews: 36\n", + "Authors: ['kyle simpson']\n", + "Score: 1.6360576\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " query={\"multi_match\": {\"query\": \"javascript\", \"fields\": [\"summary\", \"title\"]}},\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "FnBeBIVKiPnS" + }, + "source": [ + "Individual fields can be boosted with the caret (^) notation. Note in the following query how the score of the results that have \"JavaScript\" in their title is multiplied." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 154 + }, + "id": "_aI7hnH0ixkG", + "outputId": "2af27f3d-f9fd-4c7a-cab5-7cb06132582c" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2018-12-04\n", + "Title: Eloquent JavaScript\n", + "Summary: A modern introduction to programming\n", + "Publisher: no starch press\n", + "Reviews: 38\n", + "Authors: ['marijn haverbeke']\n", + "Score: 6.0922585\n", + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 5.1192265\n", + "\n", + "ID: IwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2015-03-27\n", + "Title: You Don't Know JS: Up & Going\n", + "Summary: Introduction to JavaScript and programming as a whole\n", + "Publisher: oreilly\n", + "Reviews: 36\n", + "Authors: ['kyle simpson']\n", + "Score: 1.6360576\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " query={\"multi_match\": {\"query\": \"javascript\", \"fields\": [\"summary\", \"title^3\"]}},\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "yXipv0xSk-nK" + }, + "source": [ + "### Term-level Queries\n", + "\n", + "You can use term-level queries to find documents based on precise values in structured data. Examples of structured data include date ranges, IP addresses, prices, or product IDs." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Term search\n", + "\n", + "Returns document that contain exactly the search term." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: HwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-10-29\n", + "Title: The Pragmatic Programmer: Your Journey to Mastery\n", + "Summary: A guide to pragmatic programming for software engineers and developers\n", + "Publisher: addison-wesley\n", + "Reviews: 30\n", + "Authors: ['andrew hunt', 'david thomas']\n", + "Score: 1.4816045\n", + "\n", + "ID: JQOa7osBiUNHLMdf3q2r\n", + "Publication date: 1994-10-31\n", + "Title: Design Patterns: Elements of Reusable Object-Oriented Software\n", + "Summary: Guide to design patterns that can be used in any object-oriented language\n", + "Publisher: addison-wesley\n", + "Reviews: 45\n", + "Authors: ['erich gamma', 'richard helm', 'ralph johnson', 'john vlissides']\n", + "Score: 1.4816045\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\", query={\"term\": {\"publisher.keyword\": \"addison-wesley\"}}\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Range search\n", + "\n", + "Returns documents that contain terms within a provided range.\n", + "\n", + "The following example returns books that have at least 45 reviews." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: IgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-08-11\n", + "Title: Clean Code: A Handbook of Agile Software Craftsmanship\n", + "Summary: A guide to writing code that is easy to read, understand and maintain\n", + "Publisher: prentice hall\n", + "Reviews: 55\n", + "Authors: ['robert c. martin']\n", + "Score: 1.0\n", + "\n", + "ID: JQOa7osBiUNHLMdf3q2r\n", + "Publication date: 1994-10-31\n", + "Title: Design Patterns: Elements of Reusable Object-Oriented Software\n", + "Summary: Guide to design patterns that can be used in any object-oriented language\n", + "Publisher: addison-wesley\n", + "Reviews: 45\n", + "Authors: ['erich gamma', 'richard helm', 'ralph johnson', 'john vlissides']\n", + "Score: 1.0\n", + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 1.0\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\", query={\"range\": {\"num_reviews\": {\"gte\": 45}}}\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Prefix search\n", + "\n", + "Returns documents that contain a specific prefix in a provided field.\n", + "\n", + "[Read more](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-prefix-query.html)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 133 + }, + "id": "dCr1pwlqlOE7", + "outputId": "ae55cd66-0ded-4868-dac5-5815ea317c44" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2018-12-04\n", + "Title: Eloquent JavaScript\n", + "Summary: A modern introduction to programming\n", + "Publisher: no starch press\n", + "Reviews: 38\n", + "Authors: ['marijn haverbeke']\n", + "Score: 1.0\n", + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 1.0\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\", query={\"prefix\": {\"title\": {\"value\": \"java\"}}}\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "a606YcCmmLHW" + }, + "source": [ + "#### Fuzzy search\n", + "\n", + "Returns documents that contain terms similar to the search term, as measured by a Levenshtein edit distance.\n", + "\n", + "An edit distance is the number of one-character changes needed to turn one term into another. These changes can include:\n", + "\n", + "* Changing a character (box → fox)\n", + "* Removing a character (black → lack)\n", + "* Inserting a character (sic → sick)\n", + "* Transposing two adjacent characters (act → cat)\n", + "\n", + "[Read more](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-fuzzy-query.html)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 133 + }, + "id": "dTMc-IxPmbtC", + "outputId": "9acf74fd-bc16-45df-80f3-49504860b10a" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2018-12-04\n", + "Title: Eloquent JavaScript\n", + "Summary: A modern introduction to programming\n", + "Publisher: no starch press\n", + "Reviews: 38\n", + "Authors: ['marijn haverbeke']\n", + "Score: 1.6246022\n", + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 1.3651271\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\", query={\"fuzzy\": {\"title\": {\"value\": \"pyvascript\"}}}\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Combining Query Conditions\n", + "\n", + "Compound queries wrap other compound or leaf queries, either to combine their results and scores, or to change their behaviour. They also allow you to switch from query to filter context, but that will be covered later in the Filtering section." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "7do0lmxA_v25" + }, + "source": [ + "#### bool.must (AND)\n", + "The clauses must appear in matching documents and will contribute to the score. This effectively performs an \"AND\" logical operation on the given sub-queries." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 111 + }, + "id": "8_C-JHRQFDl7", + "outputId": "be59d18b-5e20-4db0-8697-2e7746251742" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JQOa7osBiUNHLMdf3q2r\n", + "Publication date: 1994-10-31\n", + "Title: Design Patterns: Elements of Reusable Object-Oriented Software\n", + "Summary: Guide to design patterns that can be used in any object-oriented language\n", + "Publisher: addison-wesley\n", + "Reviews: 45\n", + "Authors: ['erich gamma', 'richard helm', 'ralph johnson', 'john vlissides']\n", + "Score: 3.788629\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " query={\n", + " \"bool\": {\n", + " \"must\": [\n", + " {\"term\": {\"publisher.keyword\": \"addison-wesley\"}},\n", + " {\"term\": {\"authors.keyword\": \"richard helm\"}},\n", + " ]\n", + " }\n", + " },\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "eNlncytRIl9h" + }, + "source": [ + "#### bool.should (OR)\n", + "\n", + "The clause should appear in the matching document. This performs an \"OR\" logical operation on the given sub-queries." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 219 + }, + "id": "GRm9T1vfIsmF", + "outputId": "d9fb6936-3ffb-4fff-9467-1f7ac7b41490" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 2.3070245\n", + "\n", + "ID: HwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-10-29\n", + "Title: The Pragmatic Programmer: Your Journey to Mastery\n", + "Summary: A guide to pragmatic programming for software engineers and developers\n", + "Publisher: addison-wesley\n", + "Reviews: 30\n", + "Authors: ['andrew hunt', 'david thomas']\n", + "Score: 1.4816045\n", + "\n", + "ID: JQOa7osBiUNHLMdf3q2r\n", + "Publication date: 1994-10-31\n", + "Title: Design Patterns: Elements of Reusable Object-Oriented Software\n", + "Summary: Guide to design patterns that can be used in any object-oriented language\n", + "Publisher: addison-wesley\n", + "Reviews: 45\n", + "Authors: ['erich gamma', 'richard helm', 'ralph johnson', 'john vlissides']\n", + "Score: 1.4816045\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " query={\n", + " \"bool\": {\n", + " \"should\": [\n", + " {\"term\": {\"publisher.keyword\": \"addison-wesley\"}},\n", + " {\"term\": {\"authors.keyword\": \"douglas crockford\"}},\n", + " ]\n", + " }\n", + " },\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "PG9TYqL-8H29" + }, + "source": [ + "## Filtering\n", + "\n", + "In a filter context, a query clause answers the question *“Does this document match this query clause?”* The answer is a simple Yes or No — no scores are calculated. Filter context is mostly used for filtering structured data, for example:\n", + "* Does this `timestamp` fall into the range 2015 to 2016?\n", + "* Is the `status` field set to `\"published\"`?\n", + "\n", + "Filter context is in effect whenever a query clause is passed to a `filter` parameter, such as the `filter` or `must_not` parameters in the `bool` query.\n", + "\n", + "[Read more](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "PGTFXUIkJG4t" + }, + "source": [ + "### bool.filter\n", + "\n", + "The clause (query) must appear for the document to be included in the results. Unlike query context searches such as `term`, `bool.must` or `bool.should`, a matching `score` isn't calculated because filter clauses are executed in filter context." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 197 + }, + "id": "6RH0OALLJPHv", + "outputId": "338419b0-3e60-4ac9-ddeb-67cac6202ca2" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: IgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-08-11\n", + "Title: Clean Code: A Handbook of Agile Software Craftsmanship\n", + "Summary: A guide to writing code that is easy to read, understand and maintain\n", + "Publisher: prentice hall\n", + "Reviews: 55\n", + "Authors: ['robert c. martin']\n", + "Score: 0.0\n", + "\n", + "ID: JgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2011-05-13\n", + "Title: The Clean Coder: A Code of Conduct for Professional Programmers\n", + "Summary: A guide to professional conduct in the field of software engineering\n", + "Publisher: prentice hall\n", + "Reviews: 20\n", + "Authors: ['robert c. martin']\n", + "Score: 0.0\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " query={\"bool\": {\"filter\": [{\"term\": {\"publisher.keyword\": \"prentice hall\"}}]}},\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### bool.must_not\n", + "The clause (query) must not appear in the matching documents. Because this query also runs in filter context, no scores are calculated; the filter just determines if a document is included in the results or not." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: IgOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-08-11\n", + "Title: Clean Code: A Handbook of Agile Software Craftsmanship\n", + "Summary: A guide to writing code that is easy to read, understand and maintain\n", + "Publisher: prentice hall\n", + "Reviews: 55\n", + "Authors: ['robert c. martin']\n", + "Score: 0.0\n", + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 0.0\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " query={\"bool\": {\"must_not\": [{\"range\": {\"num_reviews\": {\"lte\": 45}}}]}},\n", + ")\n", + "\n", + "pretty_response(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Using Filters with Queries\n", + "Filters are often added to search queries with the intention of limiting the search to a subset of the documents. A filter can cleanly eliminate documents from a search, without altering the relevance scores of the results.\n", + "\n", + "The next example returns books that have the word \"javascript\" in their title, only among the books that have more than 45 reviews." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: JwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2008-05-15\n", + "Title: JavaScript: The Good Parts\n", + "Summary: A deep dive into the parts of JavaScript that are essential to writing maintainable code\n", + "Publisher: oreilly\n", + "Reviews: 51\n", + "Authors: ['douglas crockford']\n", + "Score: 1.7064086\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " query={\n", + " \"bool\": {\n", + " \"must\": [{\"match\": {\"title\": {\"query\": \"javascript\"}}}],\n", + " \"must_not\": [{\"range\": {\"num_reviews\": {\"lte\": 45}}}],\n", + " }\n", + " },\n", + ")\n", + "\n", + "pretty_response(response)" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.3" + }, + "vscode": { + "interpreter": { + "hash": "b0fa6594d8f4cbf19f97940f81e996739fb7646882a419484c72d19e05852a7e" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/x-pack/plugins/search_notebooks/server/data/02_hybrid_search.json b/x-pack/plugins/search_notebooks/server/data/02_hybrid_search.json new file mode 100644 index 00000000000000..c5e9fd22caad68 --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/data/02_hybrid_search.json @@ -0,0 +1,306 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "s49gpkvZ7q53" + }, + "source": [ + "# Hybrid Search using RRF\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/elastic/elasticsearch-labs/blob/main/notebooks/search/02-hybrid-search.ipynb)\n", + "\n", + "In this example we'll use the reciprocal rank fusion algorithm to combine the results of BM25 and kNN semantic search.\n", + "We'll use the same dataset we used in our [quickstart](https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/00-quick-start.ipynb) guide.\n", + "\n", + "You can use RRF for hybrid search out of the box, without any additional configuration. This example demonstrates how RRF ranking works at a basic level." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "gaTFHLJC-Mgi" + }, + "source": [ + "# Install packages and initialize the Elasticsearch Python client\n", + "\n", + "To get started, we'll need to connect to our Elastic deployment using the Python client.\n", + "Because we're using an Elastic Cloud deployment, we'll use the **Cloud ID** to identify our deployment.\n", + "\n", + "First we need to `pip` install the packages we need for this example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "K9Q1p2C9-wce", + "outputId": "204d5aee-571e-4363-be6e-f87d058f2d29" + }, + "outputs": [], + "source": [ + "!pip install -qU elasticsearch sentence_transformers" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "gEzq2Z1wBs3M" + }, + "source": [ + "Next we need to import the `elasticsearch` module and the `getpass` module.\n", + "`getpass` is part of the Python standard library and is used to securely prompt for credentials." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "uP_GTVRi-d96" + }, + "outputs": [], + "source": [ + "from elasticsearch import Elasticsearch\n", + "from sentence_transformers import SentenceTransformer\n", + "from getpass import getpass\n", + "\n", + "model = SentenceTransformer(\"all-MiniLM-L6-v2\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "AMSePFiZCRqX" + }, + "source": [ + "Now we can instantiate the Python Elasticsearch client.\n", + "First we prompt the user for their password and Cloud ID.\n", + "\n", + "🔐 NOTE: `getpass` enables us to securely prompt the user for credentials without echoing them to the terminal, or storing it in memory.\n", + "\n", + "Then we create a `client` object that instantiates an instance of the `Elasticsearch` class." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "h0MdAZ53CdKL", + "outputId": "96ea6f81-f935-4d51-c4a7-af5a896180f1" + }, + "outputs": [], + "source": [ + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id\n", + "ELASTIC_CLOUD_ID = getpass(\"Elastic Cloud ID: \")\n", + "\n", + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#creating-an-api-key\n", + "ELASTIC_API_KEY = getpass(\"Elastic Api Key: \")\n", + "\n", + "# Create the client instance\n", + "client = Elasticsearch(\n", + " # For local development\n", + " # hosts=[\"http://localhost:9200\"]\n", + " cloud_id=ELASTIC_CLOUD_ID,\n", + " api_key=ELASTIC_API_KEY,\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "bRHbecNeEDL3" + }, + "source": [ + "Confirm that the client has connected with this test" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "rdiUKqZbEKfF", + "outputId": "43b6f1cd-a43e-4dbe-caa5-7fd170464881" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'name': 'instance-0000000011', 'cluster_name': 'd1bd36862ce54c7b903e2aacd4cd7f0a', 'cluster_uuid': 'tIkh0X_UQKmMFQKSfUw-VQ', 'version': {'number': '8.9.0', 'build_flavor': 'default', 'build_type': 'docker', 'build_hash': '8aa461beb06aa0417a231c345a1b8c38fb498a0d', 'build_date': '2023-07-19T14:43:58.555259655Z', 'build_snapshot': False, 'lucene_version': '9.7.0', 'minimum_wire_compatibility_version': '7.17.0', 'minimum_index_compatibility_version': '7.0.0'}, 'tagline': 'You Know, for Search'}\n" + ] + } + ], + "source": [ + "print(client.info())" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "enHQuT57DhD1" + }, + "source": [ + "Refer to https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/connecting.html#connect-self-managed-new to learn how to connect to a self-managed deployment.\n", + "\n", + "Read https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/connecting.html#connect-self-managed-new to learn how to connect using API keys.\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "WgWDMgf9NkHL" + }, + "source": [ + "## Pretty printing Elasticsearch responses\n", + "\n", + "Let's add a helper function to print Elasticsearch responses in a readable format. This function is similar to the one that was used in the [quickstart](https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/00-quick-start.ipynb) guide." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "def pretty_response(response):\n", + " if len(response[\"hits\"][\"hits\"]) == 0:\n", + " print(\"Your search returned no results.\")\n", + " else:\n", + " for hit in response[\"hits\"][\"hits\"]:\n", + " id = hit[\"_id\"]\n", + " publication_date = hit[\"_source\"][\"publish_date\"]\n", + " rank = hit[\"_rank\"]\n", + " title = hit[\"_source\"][\"title\"]\n", + " summary = hit[\"_source\"][\"summary\"]\n", + " pretty_output = f\"\\nID: {id}\\nPublication date: {publication_date}\\nTitle: {title}\\nSummary: {summary}\\nRank: {rank}\"\n", + " print(pretty_output)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "MrBCHdH1u8Wd" + }, + "source": [ + "# Querying Documents with Hybrid Search\n", + "\n", + "🔐 NOTE: Before you can run the query in this section, you need the `book_index` dataset from our [quick start](https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/00-quick-start.ipynb). If you haven't worked through the quick start, please follow the steps described there to create an Elasticsearch deployment with the dataset in it, and then come back to run the query here.\n", + "\n", + "Now we need to perform a query using two different search strategies:\n", + "- Semantic search using the \"all-MiniLM-L6-v2\" embedding model\n", + "- Keyword search using the \"title\" field\n", + "\n", + "We then use [Reciprocal Rank Fusion (RRF)](https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html) to balance the scores to provide a final list of documents, ranked in order of relevance. RRF is a ranking algorithm for combining results from different information retrieval strategies.\n", + "\n", + "Note that _score is null, and we instead use _rank to show our top-ranked documents." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: IAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-05-03\n", + "Title: Python Crash Course\n", + "Summary: A fast-paced, no-nonsense guide to programming in Python\n", + "Rank: 1\n", + "\n", + "ID: HwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2019-10-29\n", + "Title: The Pragmatic Programmer: Your Journey to Mastery\n", + "Summary: A guide to pragmatic programming for software engineers and developers\n", + "Rank: 2\n", + "\n", + "ID: JAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2018-12-04\n", + "Title: Eloquent JavaScript\n", + "Summary: A modern introduction to programming\n", + "Rank: 3\n", + "\n", + "ID: IwOa7osBiUNHLMdf3q2r\n", + "Publication date: 2015-03-27\n", + "Title: You Don't Know JS: Up & Going\n", + "Summary: Introduction to JavaScript and programming as a whole\n", + "Rank: 4\n", + "\n", + "ID: KAOa7osBiUNHLMdf3q2r\n", + "Publication date: 2012-06-27\n", + "Title: Introduction to the Theory of Computation\n", + "Summary: Introduction to the theory of computation and complexity theory\n", + "Rank: 5\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"book_index\",\n", + " size=5,\n", + " query={\"match\": {\"summary\": \"python programming\"}},\n", + " knn={\n", + " \"field\": \"title_vector\",\n", + " \"query_vector\": model.encode(\n", + " \"python programming\"\n", + " ).tolist(), # generate embedding for query so it can be compared to `title_vector`\n", + " \"k\": 5,\n", + " \"num_candidates\": 10,\n", + " },\n", + " rank={\"rrf\": {}},\n", + ")\n", + "\n", + "pretty_response(response)" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.3" + }, + "vscode": { + "interpreter": { + "hash": "b0fa6594d8f4cbf19f97940f81e996739fb7646882a419484c72d19e05852a7e" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/x-pack/plugins/search_notebooks/server/data/03_elser.json b/x-pack/plugins/search_notebooks/server/data/03_elser.json new file mode 100644 index 00000000000000..c6c5e6afcbc3fc --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/data/03_elser.json @@ -0,0 +1,552 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "s49gpkvZ7q53" + }, + "source": [ + "# Semantic Search using ELSER v2 text expansion\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/elastic/elasticsearch-labs/blob/main/notebooks/search/03-ELSER.ipynb)\n", + "\n", + "\n", + "Learn how to use the [ELSER](https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html) for text expansion-powered semantic search.\n", + "\n", + "**`Note:`** This notebook demonstrates how to use ELSER model `.elser_model_2` model which offers an improved retrieval accuracy. \n", + "\n", + "If you have set up an index with ELSER model `.elser_model_1`, and would like to upgrade to ELSER v2 model - `.elser_model_2`, Please follow instructions from the notebook on [how to upgrade an index to use elser model](../model-upgrades/upgrading-index-to-use-elser.ipynb)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "gaTFHLJC-Mgi" + }, + "source": [ + "# Install and Connect\n", + "\n", + "To get started, we'll need to connect to our Elastic deployment using the Python client.\n", + "Because we're using an Elastic Cloud deployment, we'll use the **Cloud ID** to identify our deployment.\n", + "\n", + "First we need to `pip` install the following packages:\n", + "\n", + "- `elasticsearch`\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "K9Q1p2C9-wce", + "outputId": "204d5aee-571e-4363-be6e-f87d058f2d29" + }, + "outputs": [], + "source": [ + "!pip install -qU elasticsearch" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "gEzq2Z1wBs3M" + }, + "source": [ + "Next, we need to import the modules we need.\n", + "🔐 NOTE: `getpass` enables us to securely prompt the user for credentials without echoing them to the terminal, or storing it in memory." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "uP_GTVRi-d96" + }, + "outputs": [], + "source": [ + "from elasticsearch import Elasticsearch, helpers, exceptions\n", + "from urllib.request import urlopen\n", + "from getpass import getpass\n", + "import json\n", + "import time" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "AMSePFiZCRqX" + }, + "source": [ + "Now we can instantiate the Python Elasticsearch client.\n", + "\n", + "First we prompt the user for their password and Cloud ID.\n", + "Then we create a `client` object that instantiates an instance of the `Elasticsearch` class." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "h0MdAZ53CdKL", + "outputId": "96ea6f81-f935-4d51-c4a7-af5a896180f1" + }, + "outputs": [], + "source": [ + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id\n", + "ELASTIC_CLOUD_ID = getpass(\"Elastic Cloud ID: \")\n", + "\n", + "# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#creating-an-api-key\n", + "ELASTIC_API_KEY = getpass(\"Elastic Api Key: \")\n", + "\n", + "# Create the client instance\n", + "client = Elasticsearch(\n", + " # For local development\n", + " # hosts=[\"http://localhost:9200\"]\n", + " cloud_id=ELASTIC_CLOUD_ID,\n", + " api_key=ELASTIC_API_KEY,\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "bRHbecNeEDL3" + }, + "source": [ + "Confirm that the client has connected with this test" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "rdiUKqZbEKfF", + "outputId": "43b6f1cd-a43e-4dbe-caa5-7fd170464881" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'name': 'instance-0000000011', 'cluster_name': 'd1bd36862ce54c7b903e2aacd4cd7f0a', 'cluster_uuid': 'tIkh0X_UQKmMFQKSfUw-VQ', 'version': {'number': '8.11.1', 'build_flavor': 'default', 'build_type': 'docker', 'build_hash': '6f9ff581fbcde658e6f69d6ce03050f060d1fd0c', 'build_date': '2023-11-11T10:05:59.421038163Z', 'build_snapshot': False, 'lucene_version': '9.8.0', 'minimum_wire_compatibility_version': '7.17.0', 'minimum_index_compatibility_version': '7.0.0'}, 'tagline': 'You Know, for Search'}\n" + ] + } + ], + "source": [ + "print(client.info())" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "enHQuT57DhD1" + }, + "source": [ + "Refer to https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/connecting.html#connect-self-managed-new to learn how to connect to a self-managed deployment.\n", + "\n", + "Read https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/connecting.html#connect-self-managed-new to learn how to connect using API keys.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Download and Deploy ELSER Model\n", + "\n", + "In this example, we are going to download and deploy the ELSER model in our ML node. Make sure you have an ML node in order to run the ELSER model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# delete model if already downloaded and deployed\n", + "try:\n", + " client.ml.delete_trained_model(model_id=\".elser_model_2\", force=True)\n", + " print(\"Model deleted successfully, We will proceed with creating one\")\n", + "except exceptions.NotFoundError:\n", + " print(\"Model doesn't exist, but We will proceed with creating one\")\n", + "\n", + "# Creates the ELSER model configuration. Automatically downloads the model if it doesn't exist.\n", + "client.ml.put_trained_model(\n", + " model_id=\".elser_model_2\", input={\"field_names\": [\"text_field\"]}\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The above command will download the ELSER model. This will take a few minutes to complete. Use the following command to check the status of the model download." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "while True:\n", + " status = client.ml.get_trained_models(\n", + " model_id=\".elser_model_2\", include=\"definition_status\"\n", + " )\n", + "\n", + " if status[\"trained_model_configs\"][0][\"fully_defined\"]:\n", + " print(\"ELSER Model is downloaded and ready to be deployed.\")\n", + " break\n", + " else:\n", + " print(\"ELSER Model is downloaded but not ready to be deployed.\")\n", + " time.sleep(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once the model is downloaded, we can deploy the model in our ML node. Use the following command to deploy the model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Start trained model deployment if not already deployed\n", + "client.ml.start_trained_model_deployment(\n", + " model_id=\".elser_model_2\", number_of_allocations=1, wait_for=\"starting\"\n", + ")\n", + "\n", + "while True:\n", + " status = client.ml.get_trained_models_stats(\n", + " model_id=\".elser_model_2\",\n", + " )\n", + " if status[\"trained_model_stats\"][0][\"deployment_stats\"][\"state\"] == \"started\":\n", + " print(\"ELSER Model has been successfully deployed.\")\n", + " break\n", + " else:\n", + " print(\"ELSER Model is currently being deployed.\")\n", + " time.sleep(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This also will take a few minutes to complete." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "EmELvr_JK_22" + }, + "source": [ + "# Indexing Documents with ELSER\n", + "\n", + "In order to use ELSER on our Elastic Cloud deployment we'll need to create an ingest pipeline that contains an inference processor that runs the ELSER model.\n", + "Let's add that pipeline using the [`put_pipeline`](https://www.elastic.co/guide/en/elasticsearch/reference/master/put-pipeline-api.html) method." + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "XhRng99KLQsd", + "outputId": "00ea73b5-45a4-472b-f4bc-2c2c790ab94d" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "ObjectApiResponse({'acknowledged': True})" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "client.ingest.put_pipeline(\n", + " id=\"elser-ingest-pipeline\",\n", + " description=\"Ingest pipeline for ELSER\",\n", + " processors=[\n", + " {\n", + " \"inference\": {\n", + " \"model_id\": \".elser_model_2\",\n", + " \"input_output\": [\n", + " {\"input_field\": \"plot\", \"output_field\": \"plot_embedding\"}\n", + " ],\n", + " }\n", + " }\n", + " ],\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "0wCH7YHLNW3i" + }, + "source": [ + "Let's note a few important parameters from that API call:\n", + "\n", + "- `inference`: A processor that performs inference using a machine learning model.\n", + "- `model_id`: Specifies the ID of the machine learning model to be used. In this example, the model ID is set to `.elser_model_2`.\n", + "- `input_output`: Specifies input and output fields\n", + "- `input_field`: Field name from which the `sparse_vector` representation are created.\n", + "- `output_field`: Field name which contains inference results. " + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "TF_wxIAhD07a" + }, + "source": [ + "## Create index\n", + "\n", + "To use the ELSER model at index time, we'll need to create an index mapping that supports a [`text_expansion`](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-text-expansion-query.html) query.\n", + "The mapping includes a field of type [`sparse_vector`](https://www.elastic.co/guide/en/elasticsearch/reference/master/sparse-vector.html) to work with our feature vectors of interest.\n", + "This field contains the token-weight pairs the ELSER model created based on the input text.\n", + "\n", + "Let's create an index named `elser-example-movies` with the mappings we need.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "cvYECABJJs_2", + "outputId": "18fb51e4-c4f6-4d1b-cb2d-bc6f8ec1aa84" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "ObjectApiResponse({'acknowledged': True, 'shards_acknowledged': True, 'index': 'elser-example-movies'})" + ] + }, + "execution_count": 46, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "client.indices.delete(index=\"elser-example-movies\", ignore_unavailable=True)\n", + "client.indices.create(\n", + " index=\"elser-example-movies\",\n", + " settings={\"index\": {\"default_pipeline\": \"elser-ingest-pipeline\"}},\n", + " mappings={\n", + " \"properties\": {\n", + " \"plot\": {\n", + " \"type\": \"text\",\n", + " \"fields\": {\"keyword\": {\"type\": \"keyword\", \"ignore_above\": 256}},\n", + " },\n", + " \"plot_embedding\": {\"type\": \"sparse_vector\"},\n", + " }\n", + " },\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "lFHgRUYVpNKP" + }, + "source": [ + "## Insert Documents\n", + "Let's insert our example dataset of 12 movies.\n", + "\n", + "If you get an error, check the model has been deployed and is available in the ML node. In newer versions of Elastic Cloud, ML node is autoscaled and the ML node may not be ready yet. Wait for a few minutes and try again." + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "IBfqgdAcuKRG", + "outputId": "3b86daa1-ade1-4ff3-da81-4207fa814d30" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Done indexing documents into `elser-example-movies` index!\n" + ] + } + ], + "source": [ + "url = \"https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/notebooks/search/movies.json\"\n", + "response = urlopen(url)\n", + "\n", + "# Load the response data into a JSON object\n", + "data_json = json.loads(response.read())\n", + "\n", + "# Prepare the documents to be indexed\n", + "documents = []\n", + "for doc in data_json:\n", + " documents.append(\n", + " {\n", + " \"_index\": \"elser-example-movies\",\n", + " \"_source\": doc,\n", + " }\n", + " )\n", + "\n", + "# Use helpers.bulk to index\n", + "helpers.bulk(client, documents)\n", + "\n", + "print(\"Done indexing documents into `elser-example-movies` index!\")\n", + "time.sleep(3)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "oCj3jHHML4Tn" + }, + "source": [ + "Inspect a new document to confirm that it now has an `plot_embedding` field that contains a list of new, additional terms.\n", + "These terms are the **text expansion** of the field(s) you targeted for ELSER inference in `input_field` while creating the pipeline. \n", + "ELSER essentially creates a tree of expanded terms to improve the semantic searchability of your documents.\n", + "We'll be able to search these documents using a `text_expansion` query.\n", + "\n", + "But first let's start with a simple keyword search, to see how ELSER delivers semantically relevant results out of the box." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "Zy5GT2xb38oz" + }, + "source": [ + "# Searching Documents\n", + "\n", + "Let's test out semantic search using ELSER." + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "bAZRxja-5Q6X", + "outputId": "37a26a2c-4284-4e51-c34e-9a55edf77cb8" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Score: 12.763346\n", + "Title: Fight Club\n", + "Plot: An insomniac office worker and a devil-may-care soapmaker form an underground fight club that evolves into something much, much more.\n", + "\n", + "Score: 9.930427\n", + "Title: Pulp Fiction\n", + "Plot: The lives of two mob hitmen, a boxer, a gangster and his wife, and a pair of diner bandits intertwine in four tales of violence and redemption.\n", + "\n", + "Score: 9.4883375\n", + "Title: The Matrix\n", + "Plot: A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.\n", + "\n" + ] + } + ], + "source": [ + "response = client.search(\n", + " index=\"elser-example-movies\",\n", + " size=3,\n", + " query={\n", + " \"text_expansion\": {\n", + " \"plot_embedding\": {\n", + " \"model_id\": \".elser_model_2\",\n", + " \"model_text\": \"fighting movie\",\n", + " }\n", + " }\n", + " },\n", + ")\n", + "\n", + "for hit in response[\"hits\"][\"hits\"]:\n", + " doc_id = hit[\"_id\"]\n", + " score = hit[\"_score\"]\n", + " title = hit[\"_source\"][\"title\"]\n", + " plot = hit[\"_source\"][\"plot\"]\n", + " print(f\"Score: {score}\\nTitle: {title}\\nPlot: {plot}\\n\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next Steps\n", + "Now that we have a working example of semantic search using ELSER, you can try it out on your own data. Don't forget to scale down the ML node when you are done. " + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + }, + "vscode": { + "interpreter": { + "hash": "b0fa6594d8f4cbf19f97940f81e996739fb7646882a419484c72d19e05852a7e" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/x-pack/plugins/search_notebooks/server/data/04_multilingual.json b/x-pack/plugins/search_notebooks/server/data/04_multilingual.json new file mode 100644 index 00000000000000..9b41984a69035e --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/data/04_multilingual.json @@ -0,0 +1,666 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "s49gpkvZ7q53" + }, + "source": [ + "# Multilingual semantic search\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/elastic/elasticsearch-labs/blob/main/notebooks/search/04-multilingual.ipynb)\n", + "\n", + "In this example we'll use a multilingual embedding model\n", + "[multilingual-e5-base](https://huggingface.co/intfloat/multilingual-e5-base) to perform search on a dataset of mixed\n", + "language documents. Using this model, we can search in two ways:\n", + " * Across languages, for example using a query in German to find documents in English\n", + " * Within a non-English language, for example using a query in German to find documents in German\n", + "\n", + " While this example is using dense retrieval only, it's possible to also combine dense and traditional lexical retrieval\n", + " with hybrid search. For more information on lexical multilingual search, please see the blog post\n", + " [Multilingual search using language identification in Elasticsearch](https://www.elastic.co/search-labs/multilingual-vector-search-e5-embedding-model).\n", + "\n", + " The dataset used contains snippets of Wikipedia passages from the [MIRACL](https://project-miracl.github.io/) dataset." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "Y01AXpELkygt" + }, + "source": [ + "# 🧰 Requirements\n", + "\n", + "For this example, you will need:\n", + "\n", + "- Python 3.6 or later\n", + "- An Elastic deployment with a machine learning node\n", + " - We'll be using [Elastic Cloud](https://www.elastic.co/guide/en/cloud/current/ec-getting-started.html) for this example (available with a [free trial](https://cloud.elastic.co/registration?utm_source=github&utm_content=elasticsearch-labs-notebook))\n", + "- The [Elastic Python client](https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/installation.html)\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "N4pI1-eIvWrI" + }, + "source": [ + "## Create Elastic Cloud deployment\n", + "\n", + "If you don't have an Elastic Cloud deployment, sign up [here](https://cloud.elastic.co/registration?utm_source=github&utm_content=elasticsearch-labs-notebook) for a free trial.\n", + "\n", + "Once logged in to your Elastic Cloud account, go to the [Create deployment](https://cloud.elastic.co/deployments/create) page and select **Create deployment**. Leave all settings with their default values." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "gaTFHLJC-Mgi" + }, + "source": [ + "# Install packages and initialize the Elasticsearch Python client\n", + "\n", + "To get started, we'll need to connect to our Elastic deployment using the Python client.\n", + "Because we're using an Elastic Cloud deployment, we'll use the **Cloud ID** to identify our deployment.\n", + "\n", + "First we need to `pip` install the packages we need for this example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "K9Q1p2C9-wce", + "outputId": "204d5aee-571e-4363-be6e-f87d058f2d29" + }, + "outputs": [], + "source": [ + "!pip install -qU elasticsearch sentence_transformers" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "gEzq2Z1wBs3M" + }, + "source": [ + "Next we need to import the `elasticsearch` module and the `getpass` module.\n", + "`getpass` is part of the Python standard library and is used to securely prompt for credentials." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "uP_GTVRi-d96" + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "7ea79a149aaf42cd8e178597038deb3c", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Downloading pytorch_model.bin: 0%| | 0.00/1.11G [00:00 \"Gesundheit\"\n", + " * \"wall\" -> \"Mauer\"\n", + "\n", + "The first example searches for a word in English." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: 9002#0\n", + "Language: de\n", + "Title: Gesundheits- und Krankenpflege\n", + "Passage: Die Gesundheits- und Krankenpflege als Berufsfeld umfasst die Versorgung und Betreuung von Menschen aller Altersgruppen,\n", + "insbesondere kranke, behinderte und sterbende Erwachsene. Die Gesundheits- und Kinderkrankenpflege hat ihren Schwerpunkt\n", + "in der Versorgung von Kindern und Jugendlichen. In beiden Fachrichtungen gehört die Verhütung von Krankheiten und\n", + "Gesunderhaltung zum Aufgabengebiet der professionellen Pflege.\n", + "Score: 0.8986236\n", + "\n", + "ID: 8881#0\n", + "Language: en\n", + "Title: Doctor (title)\n", + "Passage: Doctor is an academic title that originates from the Latin word of the same spelling and meaning. The word is originally\n", + "an agentive noun of the Latin verb \"docēre\" [dɔˈkeːrɛ] 'to teach'. It has been used as an academic title in Europe since\n", + "the 13th century, when the first Doctorates were awarded at the University of Bologna and the University of Paris.\n", + "Having become established in European universities, this usage spread around the world. Contracted \"Dr\" or \"Dr.\", it is\n", + "used as a designation for a person who has obtained a Doctorate (e.g. PhD). In many parts of the world it is also used\n", + "by medical practitioners, regardless of whether or not they hold a doctoral-level degree.\n", + "Score: 0.8904184\n" + ] + } + ], + "source": [ + "pretty_response(query(\"health\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that in the results above, we see that the document about healthcare,\n", + "even though it's in German, matches better to the query \"health\",\n", + "versus the English document which doesn't talk about health specifically but about doctors more generally.\n", + "This is the power of a multilingual embedding which embeds meaning across languages.\n", + "\n", + "The next example also searches for a word in English, but only retrieves results in German." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: 2270104#0\n", + "Language: de\n", + "Title: London Wall\n", + "Passage: London Wall ist die strategische Stadtmauer, die die Römer um Londinium gebaut haben, um die Stadt zu schützen, die über\n", + "den wichtigen Hafen an der Themse verfügte. Bis ins späte Mittelalter hinein bildete diese Stadtmauer die Grenzen von\n", + "London. Heute ist \"London Wall\" auch der Name einer Straße, die an einem noch bestehenden Abschnitt der Stadtmauer\n", + "verläuft.\n", + "Score: 0.8941858\n", + "\n", + "ID: 2270104#1\n", + "Language: de\n", + "Title: London Wall\n", + "Passage: Die Mauer wurde Ende des zweiten oder Anfang des dritten Jahrhunderts erbaut, wahrscheinlich zwischen 190 und 225,\n", + "vermutlich zwischen 200 und 220. Sie entstand somit etwa achtzig Jahre nach dem im Jahr 120 erfolgten Bau der Festung,\n", + "deren nördliche und westliche Mauern verstärkt und in der Höhe verdoppelt wurden, um einen Teil der neuen Stadtmauer zu\n", + "bilden. Die Anlage wurde zumindest bis zum Ende des vierten Jahrhunderts weiter ausgebaut. Sie zählt zu den letzten\n", + "großen Bauprojekten der Römer vor deren Rückzug aus Britannien im Jahr 410.\n", + "Score: 0.870095\n" + ] + } + ], + "source": [ + "pretty_response(query(\"wall\", language=\"de\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the final example, the query is given in German, and only German results are requested." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "ID: 2270104#1\n", + "Language: de\n", + "Title: London Wall\n", + "Passage: Die Mauer wurde Ende des zweiten oder Anfang des dritten Jahrhunderts erbaut, wahrscheinlich zwischen 190 und 225,\n", + "vermutlich zwischen 200 und 220. Sie entstand somit etwa achtzig Jahre nach dem im Jahr 120 erfolgten Bau der Festung,\n", + "deren nördliche und westliche Mauern verstärkt und in der Höhe verdoppelt wurden, um einen Teil der neuen Stadtmauer zu\n", + "bilden. Die Anlage wurde zumindest bis zum Ende des vierten Jahrhunderts weiter ausgebaut. Sie zählt zu den letzten\n", + "großen Bauprojekten der Römer vor deren Rückzug aus Britannien im Jahr 410.\n", + "Score: 0.88160384\n", + "\n", + "ID: 2270104#0\n", + "Language: de\n", + "Title: London Wall\n", + "Passage: London Wall ist die strategische Stadtmauer, die die Römer um Londinium gebaut haben, um die Stadt zu schützen, die über\n", + "den wichtigen Hafen an der Themse verfügte. Bis ins späte Mittelalter hinein bildete diese Stadtmauer die Grenzen von\n", + "London. Heute ist \"London Wall\" auch der Name einer Straße, die an einem noch bestehenden Abschnitt der Stadtmauer\n", + "verläuft.\n", + "Score: 0.876139\n" + ] + } + ], + "source": [ + "pretty_response(query(\"Mauer\", language=\"de\"))" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.3" + }, + "vscode": { + "interpreter": { + "hash": "b0fa6594d8f4cbf19f97940f81e996739fb7646882a419484c72d19e05852a7e" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/x-pack/plugins/search_notebooks/server/index.ts b/x-pack/plugins/search_notebooks/server/index.ts new file mode 100644 index 00000000000000..fa0b8596ee7f98 --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '@kbn/core/server'; + +export { config } from './config'; + +export async function plugin(context: PluginInitializerContext) { + const { SearchNotebooksPlugin } = await import('./plugin'); + return new SearchNotebooksPlugin(context); +} + +/** @public */ +export type { SearchNotebooksPluginSetup, SearchNotebooksPluginStart } from './types'; diff --git a/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.test.ts b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.test.ts new file mode 100644 index 00000000000000..13e79c4ed34221 --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fs from 'fs/promises'; +import type { Logger } from '@kbn/logging'; + +// Mocking dependencies +jest.mock('fs/promises'); + +const mockLogger: Logger = { + warn: jest.fn(), + error: jest.fn(), +} as Partial as Logger; + +import { getNotebook, DEFAULT_NOTEBOOKS } from './notebook_catalog'; + +describe('getNotebook', () => { + const options = { logger: mockLogger }; + beforeEach(() => { + // Reset mocks and cache before each test + jest.clearAllMocks(); + }); + + it('throws an error if given an unknown notebook id', () => { + expect(getNotebook('some-fake-id', options)).rejects.toThrow('Unknown Notebook ID'); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + }); + + it('throws an error if the file is not found', () => { + const notebookId = DEFAULT_NOTEBOOKS.notebooks[0].id; + jest.mocked(fs.access).mockReset().mockRejectedValue(new Error('Boom')); + + expect(getNotebook(notebookId, options)).rejects.toThrow('Failed to fetch notebook.'); + }); + + it('Reads notebook', () => { + const notebookId = DEFAULT_NOTEBOOKS.notebooks[0].id; + + jest.mocked(fs.access).mockReset().mockResolvedValue(undefined); + + expect(getNotebook(notebookId, options)).resolves.toMatchObject({ + cells: expect.anything(), + metadata: expect.anything(), + }); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(fs.access).toHaveBeenCalledWith(expect.stringContaining(`${notebookId}.json`), 0); + }); +}); diff --git a/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.ts b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.ts new file mode 100644 index 00000000000000..9056404ee9760a --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/lib/notebook_catalog.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { i18n } from '@kbn/i18n'; +import type { Logger } from '@kbn/logging'; + +import { NotebookCatalog, NotebookInformation, NotebookDefinition } from '../types'; + +const NOTEBOOKS_DATA_DIR = '../data'; + +export const DEFAULT_NOTEBOOKS: NotebookCatalog = { + notebooks: [ + { + id: '00_quick_start', + title: i18n.translate('xpack.searchNotebooks.notebooksCatalog.quickStart.title', { + defaultMessage: 'Semantic search quick start', + }), + description: i18n.translate('xpack.searchNotebooks.notebooksCatalog.quickStart.description', { + defaultMessage: + "This interactive notebook will introduce you to some basic operations with Elasticsearch, using the official Elasticsearch Python client. You'll perform semantic search using Sentence Transformers for text embedding. Learn how to integrate traditional text-based search with semantic search, for a hybrid search system.", + }), + }, + { + id: '01_keyword_querying_filtering', + title: i18n.translate('xpack.searchNotebooks.notebooksCatalog.keywordQueryFiltering.title', { + defaultMessage: 'Keyword querying and filtering', + }), + description: i18n.translate( + 'xpack.searchNotebooks.notebooksCatalog.keywordQueryFiltering.description', + { + defaultMessage: + 'This interactive notebook will introduce you to the basic Elasticsearch queries, using the official Elasticsearch Python client. Before getting started on this section you should work through our quick start, as you will be using the same dataset.', + } + ), + }, + { + id: '02_hybrid_search', + title: i18n.translate('xpack.searchNotebooks.notebooksCatalog.hybridSearch.title', { + defaultMessage: 'Hybrid Search using RRF', + }), + description: i18n.translate( + 'xpack.searchNotebooks.notebooksCatalog.hybridSearch.description', + { + defaultMessage: + 'This interactive notebook will use the reciprocal rank fusion algorithm to combine the results of BM25 and kNN semantic search.', + } + ), + }, + { + id: '03_elser', + title: i18n.translate('xpack.searchNotebooks.notebooksCatalog.elser.title', { + defaultMessage: 'Semantic Search using ELSER v2 text expansion', + }), + description: i18n.translate('xpack.searchNotebooks.notebooksCatalog.elser.description', { + defaultMessage: 'Learn how to use ELSER for text expansion-powered semantic search.', + }), + }, + { + id: '04_multilingual', + title: i18n.translate('xpack.searchNotebooks.notebooksCatalog.multilingual.title', { + defaultMessage: 'Multilingual semantic search', + }), + description: i18n.translate( + 'xpack.searchNotebooks.notebooksCatalog.multilingual.description', + { + defaultMessage: + "In this example we'll use a multilingual embedding model 'multilingual-e5-base' to perform search on a dataset of mixed language documents.", + } + ), + }, + ], +}; +export const NOTEBOOKS_MAP: Record = + DEFAULT_NOTEBOOKS.notebooks.reduce((nbMap, nb) => { + nbMap[nb.id] = nb; + return nbMap; + }, {} as Record); + +const NOTEBOOK_IDS = DEFAULT_NOTEBOOKS.notebooks.map(({ id }) => id); + +export const getNotebook = async ( + notebookId: string, + { logger }: { logger: Logger } +): Promise => { + // Only server pre-defined notebooks, since we're reading files from disk only allow IDs + // for the known notebooks so that we aren't attempting to read any file from disk given user input + if (!NOTEBOOK_IDS.includes(notebookId)) { + logger.warn(`Unknown search notebook requested ${notebookId}`); + throw new Error( + i18n.translate('xpack.searchNotebooks.notebooksCatalog.errors.unknownId', { + defaultMessage: 'Unknown Notebook ID', + }) + ); + } + + const notebookPath = path.join(__dirname, NOTEBOOKS_DATA_DIR, `${notebookId}.json`); + try { + await fs.access(notebookPath, fs.constants.F_OK); + const notebook = (await import(notebookPath)).default; + return notebook; + } catch (err) { + logger.error(`Error reading search notebook ${notebookId}`, err); + throw new Error( + i18n.translate('xpack.searchNotebooks.notebooksCatalog.errors.notebookImportFailure', { + defaultMessage: 'Failed to fetch notebook.', + }) + ); + } +}; diff --git a/x-pack/plugins/search_notebooks/server/plugin.ts b/x-pack/plugins/search_notebooks/server/plugin.ts new file mode 100644 index 00000000000000..247c62d5ec7c61 --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/plugin.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '@kbn/core/server'; + +import { SearchNotebooksPluginSetup, SearchNotebooksPluginStart } from './types'; +import { defineRoutes } from './routes'; +import { SearchNotebooksConfig } from './config'; + +export class SearchNotebooksPlugin + implements Plugin +{ + private readonly config: SearchNotebooksConfig; + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + if (!this.config.enabled) return {}; + + this.logger.debug('searchNotebooks: Setup'); + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router, this.logger); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/search_notebooks/server/routes/index.ts b/x-pack/plugins/search_notebooks/server/routes/index.ts new file mode 100644 index 00000000000000..2509290c0e0618 --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/routes/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import type { IRouter } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +import { DEFAULT_NOTEBOOKS, NOTEBOOKS_MAP, getNotebook } from '../lib/notebook_catalog'; +import { NotebookDefinition } from '../types'; + +export function defineRoutes(router: IRouter, logger: Logger) { + router.get( + { + path: '/internal/search_notebooks/notebooks', + validate: {}, + }, + async (_context, _request, response) => { + return response.ok({ + body: DEFAULT_NOTEBOOKS, + headers: { 'content-type': 'application/json' }, + }); + } + ); + + router.get( + { + path: '/internal/search_notebooks/notebooks/{notebookId}', + validate: { + params: schema.object({ + notebookId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const notebookId = request.params.notebookId; + + if (!NOTEBOOKS_MAP.hasOwnProperty(notebookId)) { + logger.warn(`Unknown search notebook requested ${notebookId}`); + return response.notFound(); + } + + const notebookMetadata = NOTEBOOKS_MAP[notebookId]; + let notebook: NotebookDefinition; + try { + notebook = await getNotebook(notebookId, { logger }); + } catch (e) { + return response.customError(e.message); + } + return response.ok({ + body: { + ...notebookMetadata, + notebook, + }, + headers: { 'content-type': 'application/json' }, + }); + } + ); +} diff --git a/x-pack/plugins/search_notebooks/server/types.ts b/x-pack/plugins/search_notebooks/server/types.ts new file mode 100644 index 00000000000000..506eb9126f940a --- /dev/null +++ b/x-pack/plugins/search_notebooks/server/types.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SearchNotebooksPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SearchNotebooksPluginStart {} + +export interface NotebookInformation { + id: string; + title: string; + description: string; +} +export interface NotebookCatalog { + notebooks: NotebookInformation[]; +} + +export interface Notebook extends NotebookInformation { + link?: { + title: string; + url: string; + }; + notebook: NotebookDefinition; +} + +export interface NotebookDefinition { + cells: NotebookCellType[]; + metadata?: NotebookMetadataType; + nbformat?: number; + nbformat_minor?: number; +} + +export interface NotebookMetadataType { + kernelspec?: { + display_name?: string; + language?: string; + name?: string; + }; + language_info?: { + mimetype?: string; + name?: string; + version?: string; + }; +} + +export interface NotebookCellType { + auto_number?: number; + cell_type?: string; + execution_count?: number | null; + id?: string; + inputs?: string[]; + metadata?: { + id?: string; + }; + outputs?: NotebookOutputType[]; + prompt_number?: number; + source?: string[]; +} + +export interface NotebookOutputType { + name?: string; + ename?: string; + evalue?: string; + traceback?: string[]; + data?: { + 'text/plain'?: string[]; + 'text/html'?: string[]; + 'text/latex'?: string[]; + 'image/png'?: string; + 'image/jpeg'?: string; + 'image/gif'?: string; + 'image/svg+xml'?: string; + 'application/javascript'?: string[]; + }; + output_type?: string; + png?: string; + jpeg?: string; + gif?: string; + svg?: string; + text?: string[]; + execution_count?: number; + metadata?: { + scrolled?: boolean; + }; +} diff --git a/x-pack/plugins/search_notebooks/tsconfig.json b/x-pack/plugins/search_notebooks/tsconfig.json new file mode 100644 index 00000000000000..17fad2e1a53989 --- /dev/null +++ b/x-pack/plugins/search_notebooks/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "index.ts", + "common/**/*.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../typings/**/*" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/config-schema", + "@kbn/core", + "@kbn/i18n", + "@kbn/logging", + ] +} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 693f98cc170e33..58af3a0dbd9d92 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5581,7 +5581,6 @@ "searchConnectors.index.syncJobs.documents.added": "Ajouté", "searchConnectors.index.syncJobs.documents.removed": "Retiré", "searchConnectors.index.syncJobs.documents.title": "Documents", - "searchConnectors.index.syncJobs.documents.total": "Total", "searchConnectors.index.syncJobs.documents.value": "Valeur", "searchConnectors.index.syncJobs.documents.volume": "Volume", "searchConnectors.index.syncJobs.documents.volume.lessThanOneMBLabel": "Inférieur à 1 Mo", @@ -25569,7 +25568,6 @@ "xpack.ml.settings.filterLists.filterWithIdExistsErrorMessage": "Un filtre ayant l'ID {filterId} existe déjà", "xpack.ml.settings.filterLists.listHeader.filterListsContainsNotAllowedValuesDescription": "Les listes de filtres contiennent des valeurs que vous pouvez utiliser pour inclure ou exclure des événements dans l'analyse de Machine Learning. Vous pouvez utiliser une même liste de filtres dans plusieurs tâches.{br}{learnMoreLink}", "xpack.ml.settings.filterLists.listHeader.filterListsDescription": "{totalCount} en tout", - "xpack.ml.singleMetricViewerEmbeddable.title": "Graphique de visionneuse d'indicateur unique pour {jobIds}", "xpack.ml.splom.arrayFieldsWarningMessage": "{filteredDocsCount} sur {originalDocsCount} documents récupérés incluent des champs avec des tableaux de valeurs et ne peuvent pas être visualisés.", "xpack.ml.stepDefineForm.queryPlaceholderKql": "Rechercher par ex. {example}", "xpack.ml.stepDefineForm.queryPlaceholderLucene": "Rechercher par ex. {example}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b972c17909d9c2..4918a1045616d3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5574,7 +5574,6 @@ "searchConnectors.index.syncJobs.documents.added": "追加", "searchConnectors.index.syncJobs.documents.removed": "削除しました", "searchConnectors.index.syncJobs.documents.title": "ドキュメント", - "searchConnectors.index.syncJobs.documents.total": "合計", "searchConnectors.index.syncJobs.documents.value": "値", "searchConnectors.index.syncJobs.documents.volume": "量", "searchConnectors.index.syncJobs.documents.volume.lessThanOneMBLabel": "1mb未満", @@ -25543,7 +25542,6 @@ "xpack.ml.settings.filterLists.filterWithIdExistsErrorMessage": "ID {filterId} のフィルターがすでに存在します", "xpack.ml.settings.filterLists.listHeader.filterListsContainsNotAllowedValuesDescription": "フィルターリストには、イベントを機械学習分析に含める、または除外するのに使用する値が含まれています。同じフィルターリストを複数ジョブに使用できます。{br}{learnMoreLink}", "xpack.ml.settings.filterLists.listHeader.filterListsDescription": "合計 {totalCount}", - "xpack.ml.singleMetricViewerEmbeddable.title": "{jobIds}のMLシングルメトリックビューアーグラフ", "xpack.ml.splom.arrayFieldsWarningMessage": "{originalDocsCount}件中{filteredDocsCount}件の取得されたドキュメントには配列の値のフィールドが含まれ、可視化できません。", "xpack.ml.stepDefineForm.queryPlaceholderKql": "{example}の検索", "xpack.ml.stepDefineForm.queryPlaceholderLucene": "{example}の検索", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 55fc9de23b316f..673ab2ba535a46 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5585,7 +5585,6 @@ "searchConnectors.index.syncJobs.documents.added": "已添加", "searchConnectors.index.syncJobs.documents.removed": "已移除", "searchConnectors.index.syncJobs.documents.title": "文档", - "searchConnectors.index.syncJobs.documents.total": "合计", "searchConnectors.index.syncJobs.documents.value": "值", "searchConnectors.index.syncJobs.documents.volume": "卷", "searchConnectors.index.syncJobs.documents.volume.lessThanOneMBLabel": "小于 1mb", @@ -25580,7 +25579,6 @@ "xpack.ml.settings.filterLists.filterWithIdExistsErrorMessage": "ID 为 {filterId} 的筛选已存在", "xpack.ml.settings.filterLists.listHeader.filterListsContainsNotAllowedValuesDescription": "筛选列表包含可用于在 Machine Learning 分析中包括或排除事件的值。您可以在多个作业中使用相同的筛选列表。{br}{learnMoreLink}", "xpack.ml.settings.filterLists.listHeader.filterListsDescription": "合计 {totalCount} 个", - "xpack.ml.singleMetricViewerEmbeddable.title": "{jobIds} 的 ML Single Metric Viewer 图表", "xpack.ml.splom.arrayFieldsWarningMessage": "{originalDocsCount} 个提取的文档中有 {filteredDocsCount} 个包含具有值数组的字段,无法可视化。", "xpack.ml.stepDefineForm.queryPlaceholderKql": "搜索,如 {example})", "xpack.ml.stepDefineForm.queryPlaceholderLucene": "搜索,如 {example})", diff --git a/x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts b/x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts index 86c69d8ccd4133..2ed6607630060d 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts @@ -12,7 +12,10 @@ import { INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '@kbn/infra-plugin/ser import { enableInfrastructureAssetCustomDashboards } from '@kbn/observability-plugin/common'; import { FtrProviderContext } from '../../ftr_provider_context'; -const CUSTOM_DASHBOARDS_API_URL = '/api/infra/custom-dashboards'; +const getCustomDashboardsUrl = (assetType: string, dashboardSavedObjectId?: string) => + dashboardSavedObjectId + ? `/api/infra/${assetType}/custom-dashboards/${dashboardSavedObjectId}` + : `/api/infra/${assetType}/custom-dashboards`; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -31,7 +34,7 @@ export default function ({ getService }: FtrProviderContext) { [enableInfrastructureAssetCustomDashboards]: false, }); - await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(403); + await supertest.get(getCustomDashboardsUrl('host')).expect(403); }); it('responds with an error when trying to request a custom dashboard for unsupported asset type', async () => { @@ -39,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { [enableInfrastructureAssetCustomDashboards]: false, }); - await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/unsupported-asset-type`).expect(400); + await supertest.get(getCustomDashboardsUrl('unsupported-asset-type')).expect(400); }); it('responds with an empty configuration if custom dashboard saved object does not exist', async () => { @@ -50,18 +53,16 @@ export default function ({ getService }: FtrProviderContext) { types: [INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE], }); - const response = await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(200); + const response = await supertest.get(getCustomDashboardsUrl('host')).expect(200); - expect(response.body).to.be.eql({ - assetType: 'host', - dashboardIdList: [], - }); + expect(response.body).to.be.eql([]); }); it('responds with the custom dashboard configuration for a given asset type when it exists', async () => { const customDashboard: InfraCustomDashboard = { assetType: 'host', - dashboardIdList: ['123'], + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, }; await kibanaServer.uiSettings.update({ [enableInfrastructureAssetCustomDashboards]: true, @@ -72,24 +73,27 @@ export default function ({ getService }: FtrProviderContext) { overwrite: true, }); - const response = await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(200); + const response = await supertest.get(getCustomDashboardsUrl('host')).expect(200); - expect(response.body).to.be.eql(customDashboard); + expect(response.body).to.have.length(1); + expect(response.body[0]).to.have.property('dashboardFilterAssetIdEnabled', true); + expect(response.body[0]).to.have.property('assetType', 'host'); + expect(response.body[0]).to.have.property('dashboardSavedObjectId', '123'); }); }); - describe('POST endpoint for saving (creating or updating) custom dashboard', () => { + describe('POST endpoint for saving custom dashboard', () => { it('responds with an error if Custom Dashboards UI setting is not enabled', async () => { const payload: InfraSaveCustomDashboardsRequestPayload = { - assetType: 'host', - dashboardIdList: ['123'], + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, }; await kibanaServer.uiSettings.update({ [enableInfrastructureAssetCustomDashboards]: false, }); await supertest - .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .post(getCustomDashboardsUrl('host')) .set('kbn-xsrf', 'xxx') .send(payload) .expect(403); @@ -97,15 +101,15 @@ export default function ({ getService }: FtrProviderContext) { it('responds with an error when trying to update a custom dashboard for unsupported asset type', async () => { const payload = { - assetType: 'unsupported-asset-type', - dashboardIdList: ['123'], + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, }; await kibanaServer.uiSettings.update({ [enableInfrastructureAssetCustomDashboards]: true, }); await supertest - .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .post(getCustomDashboardsUrl('unsupported-asset-type')) .set('kbn-xsrf', 'xxx') .send(payload) .expect(400); @@ -113,8 +117,8 @@ export default function ({ getService }: FtrProviderContext) { it('creates a new dashboard configuration when saving for the first time', async () => { const payload: InfraSaveCustomDashboardsRequestPayload = { - assetType: 'host', - dashboardIdList: ['123'], + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, }; await kibanaServer.uiSettings.update({ [enableInfrastructureAssetCustomDashboards]: true, @@ -124,15 +128,23 @@ export default function ({ getService }: FtrProviderContext) { }); const response = await supertest - .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .post(getCustomDashboardsUrl('host')) .set('kbn-xsrf', 'xxx') .send(payload) .expect(200); - expect(response.body).to.be.eql(payload); + expect(response.body).to.have.property('id'); + expect(response.body).to.have.property('dashboardFilterAssetIdEnabled', true); + expect(response.body).to.have.property('assetType', 'host'); + expect(response.body).to.have.property('dashboardSavedObjectId', '123'); }); - it('updates existing dashboard configuration when for a given asset type', async () => { + it('returns 400 when the dashboard already exist and tries to create it again', async () => { + const payload: InfraSaveCustomDashboardsRequestPayload = { + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, + }; + await kibanaServer.uiSettings.update({ [enableInfrastructureAssetCustomDashboards]: true, }); @@ -143,24 +155,160 @@ export default function ({ getService }: FtrProviderContext) { type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, attributes: { assetType: 'host', - dashboardIdList: ['123'], + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, }, overwrite: true, }); + const response = await supertest + .post(getCustomDashboardsUrl('host')) + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(400); + + expect(response.body.error).to.be.eql('Bad Request'); + expect(response.body.message).to.be.eql( + 'Dashboard with id 123 has already been linked to host' + ); + }); + }); + + describe('PUT endpoint for updating custom dashboard', () => { + it('responds with an error if Custom Dashboards UI setting is not enabled', async () => { const payload: InfraSaveCustomDashboardsRequestPayload = { - assetType: 'host', - dashboardIdList: ['123', '456'], + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, + }; + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: false, + }); + + await supertest + .put(getCustomDashboardsUrl('host', '123')) + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(403); + }); + + it('responds with an error when trying to update not existing dashboard', async () => { + const payload: InfraSaveCustomDashboardsRequestPayload = { + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, + }; + + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + + await supertest + .put(getCustomDashboardsUrl('host', '000')) + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(404); + }); + + it('updates existing dashboard configuration for a given asset type', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + await kibanaServer.savedObjects.clean({ + types: [INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE], + }); + await kibanaServer.savedObjects.create({ + type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + attributes: { + assetType: 'host', + dashboardSavedObjectId: '456', + dashboardFilterAssetIdEnabled: true, + }, + overwrite: true, + }); + const existingDashboardSavedObject = await kibanaServer.savedObjects.create({ + type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + attributes: { + assetType: 'host', + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, + }, + overwrite: true, + }); + + const payload: InfraSaveCustomDashboardsRequestPayload = { + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: false, }; const updateResponse = await supertest - .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .put(getCustomDashboardsUrl('host', existingDashboardSavedObject.id)) .set('kbn-xsrf', 'xxx') .send(payload) .expect(200); - const getResponse = await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(200); + const getResponse = await supertest.get(getCustomDashboardsUrl('host')).expect(200); + + expect(updateResponse.body).to.be.eql({ + ...payload, + assetType: 'host', + id: updateResponse.body.id, + }); + + expect(getResponse.body).to.have.length(2); + expect(getResponse.body[0]).to.have.property('dashboardSavedObjectId', '123'); + expect(getResponse.body[0]).to.have.property('dashboardFilterAssetIdEnabled', false); + expect(getResponse.body[0]).to.have.property('assetType', 'host'); + expect(getResponse.body[1]).to.have.property('dashboardSavedObjectId', '456'); + expect(getResponse.body[1]).to.have.property('dashboardFilterAssetIdEnabled', true); + expect(getResponse.body[1]).to.have.property('assetType', 'host'); + }); + }); + + describe('DELETE endpoint for removing a custom dashboard', () => { + it('responds with an error if Custom Dashboards UI setting is not enabled', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: false, + }); + + await supertest + .delete(getCustomDashboardsUrl('host', '123')) + .set('kbn-xsrf', 'xxx') + .expect(403); + }); + + it('responds with an error when trying to delete not existing dashboard', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + + await supertest + .delete(getCustomDashboardsUrl('host', '000')) + .set('kbn-xsrf', 'xxx') + .expect(404); + }); + + it('deletes an existing dashboard', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + await kibanaServer.savedObjects.clean({ + types: [INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE], + }); + const existingDashboardSavedObject = await kibanaServer.savedObjects.create({ + type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + attributes: { + assetType: 'host', + dashboardSavedObjectId: '123', + dashboardFilterAssetIdEnabled: true, + }, + overwrite: true, + }); + + await supertest + .delete(getCustomDashboardsUrl('host', existingDashboardSavedObject.id)) + .set('kbn-xsrf', 'xxx') + .expect(200); + + const afterDeleteResponse = await supertest.get(getCustomDashboardsUrl('host')).expect(200); - expect(updateResponse.body).to.be.eql(payload); - expect(getResponse.body).to.be.eql(payload); + expect(afterDeleteResponse.body).to.be.eql([]); }); }); }); diff --git a/x-pack/test/cloud_security_posture_functional/pages/rules.ts b/x-pack/test/cloud_security_posture_functional/pages/rules.ts index b734c391bf27c7..b1f83f55a49fba 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/rules.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/rules.ts @@ -28,7 +28,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'findings', ]); - describe('Cloud Posture Rules Page', function () { + // FLAKY: https://github.com/elastic/kibana/issues/178413 + describe.skip('Cloud Posture Rules Page', function () { this.tags(['cloud_security_posture_rules_page']); let rule: typeof pageObjects.rule; let findings: typeof pageObjects.findings; diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts index 7fa27e206a498f..0aedab4496616f 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -34,7 +34,8 @@ export default function (providerContext: FtrProviderContext) { .expect(201); } - describe('fleet_final_pipeline', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/180071 + describe.skip('fleet_final_pipeline', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); diff --git a/x-pack/test/functional/apps/lens/group1/smokescreen.ts b/x-pack/test/functional/apps/lens/group1/smokescreen.ts index a9b491ebe47fa3..befed343f12951 100644 --- a/x-pack/test/functional/apps/lens/group1/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/group1/smokescreen.ts @@ -164,7 +164,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'bytes', }); - await PageObjects.lens.createLayer(); + await PageObjects.lens.createLayer('data', undefined, 'bar'); + expect(await PageObjects.lens.getLayerType(1)).to.eql('Bar vertical'); + await PageObjects.lens.configureDimension({ dimension: 'lns-layerPanel-1 > lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'terms', diff --git a/x-pack/test/functional/apps/ml/data_visualizer/esql_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/esql_data_visualizer.ts index 61320617c07f79..06449ded19c64d 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/esql_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/esql_data_visualizer.ts @@ -314,7 +314,8 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { runTests(esqlFarequoteData); }); - describe('with module_sample_logs ', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/180072 + describe.skip('with module_sample_logs ', function () { runTests(esqlSampleLogData); }); }); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 5d02876235f1f4..e7bc05dbedc375 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -988,7 +988,8 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont */ async createLayer( layerType: 'data' | 'referenceLine' | 'annotations' = 'data', - annotationFromLibraryTitle?: string + annotationFromLibraryTitle?: string, + seriesType = 'bar_stacked' ) { await testSubjects.click('lnsLayerAddButton'); const layerCount = await this.getLayerCount(); @@ -1003,6 +1004,9 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont if (await testSubjects.exists(`lnsLayerAddButton-${layerType}`)) { await testSubjects.click(`lnsLayerAddButton-${layerType}`); + if (layerType === 'data') { + await testSubjects.click(`lnsXY_seriesType-${seriesType}`); + } if (layerType === 'annotations') { if (!annotationFromLibraryTitle) { await testSubjects.click('lnsAnnotationLayer_new'); diff --git a/x-pack/test/localization/tests/lens/smokescreen.ts b/x-pack/test/localization/tests/lens/smokescreen.ts index 85dd1e18d83eae..afd0822290f2c1 100644 --- a/x-pack/test/localization/tests/lens/smokescreen.ts +++ b/x-pack/test/localization/tests/lens/smokescreen.ts @@ -336,7 +336,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'bytes', }); - await PageObjects.lens.createLayer(); + await PageObjects.lens.createLayer('data', undefined, 'bar'); + expect(await PageObjects.lens.getLayerType(1)).to.eql(termTranslator('bar')); await PageObjects.lens.configureDimension({ dimension: 'lns-layerPanel-1 > lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'terms', diff --git a/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts b/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts index 571a57e25f450a..5dfeb08bbc5ad9 100644 --- a/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts +++ b/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts @@ -302,7 +302,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'bytes', }); - await PageObjects.lens.createLayer(); + await PageObjects.lens.createLayer('data', undefined, 'bar'); + expect(await PageObjects.lens.getLayerType(1)).to.eql('Bar vertical'); await PageObjects.lens.configureDimension({ dimension: 'lns-layerPanel-1 > lnsXY_xDimensionPanel > lns-empty-dimension', diff --git a/yarn.lock b/yarn.lock index 3d00f2c85b17b2..2829d06df74fed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5802,6 +5802,10 @@ version "0.0.0" uid "" +"@kbn/search-notebooks@link:x-pack/plugins/search_notebooks": + version "0.0.0" + uid "" + "@kbn/search-playground@link:x-pack/plugins/search_playground": version "0.0.0" uid ""