diff --git a/.github/relabel.yml b/.github/relabel.yml index a737be356ce81..eb9a2fd557f45 100644 --- a/.github/relabel.yml +++ b/.github/relabel.yml @@ -1,3 +1,3 @@ issues: - missingLabel: needs-team - regex: ^(\:ml)|(Team:.*)$ \ No newline at end of file + regex: (^\:ml$)|(^Team:.+$)|(^EUI$) diff --git a/.github/workflows/add-fleet-issues-to-ingest-project.yml b/.github/workflows/add-fleet-issues-to-ingest-project.yml new file mode 100644 index 0000000000000..117ec649cd8a1 --- /dev/null +++ b/.github/workflows/add-fleet-issues-to-ingest-project.yml @@ -0,0 +1,51 @@ +name: Add Fleet issue to Platform Ingest project + +on: + issues: + types: + - labeled + +env: + INGEST_PROJECT_ID: 'PVT_kwDOAGc3Zs4AEzn4' + FLEET_LABEL: 'Team:Fleet' + AREA_FIELD_ID: 'PVTSSF_lADOAGc3Zs4AEzn4zgEgZSo' + FLEET_UI_OPTION_ID: '411a7b86' + +jobs: + add_to_ingest_project: + runs-on: ubuntu-latest + steps: + - uses: octokit/graphql-action@v2.x + id: add_to_project + if: ${{ github.event.label.name == env.FLEET_LABEL }} + with: + query: | + # Variables have to be snake cased because of https://github.com/octokit/graphql-action/issues/164 + mutation AddToIngestProject($project_id: ID!, $content_id: ID!) { + addProjectV2ItemById(input: { projectId: $project_id, contentId: $content_id }) { + item { + id + } + } + } + project_id: ${{ env.INGEST_PROJECT_ID }} + content_id: ${{ github.event.issue.node_id }} + env: + GITHUB_TOKEN: ${{ secrets.FLEET_TECH_KIBANA_USER_TOKEN }} + - uses: octokit/graphql-action@v2.x + id: set_fleet_ui_area + if: github.event.label.name == env.FLEET_LABEL + with: + query: | + mutation updateIngestArea($item_id: ID!, $project_id: ID!, $area_field_id: ID!, $area_id: String) { + updateProjectV2ItemFieldValue( + input: { itemId: $item_id, projectId: $project_id, fieldId: $area_field_id, value: { singleSelectOptionId: $area_id } }) { + clientMutationId + } + } + item_id: ${{ fromJSON(steps.add_to_project.outputs.data).addProjectV2ItemById.item.id }} + project_id: ${{ env.INGEST_PROJECT_ID }} + area_field_id: ${{ env.AREA_FIELD_ID }} + area_id: ${{ env.FLEET_UI_OPTION_ID }} + env: + GITHUB_TOKEN: ${{ secrets.FLEET_TECH_KIBANA_USER_TOKEN }} diff --git a/.github/workflows/add-to-fleet-project.yml b/.github/workflows/add-to-fleet-project.yml deleted file mode 100644 index e828a3a5b637e..0000000000000 --- a/.github/workflows/add-to-fleet-project.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Add to Fleet:Quality project -on: - issues: - types: - - labeled -jobs: - add_to_project: - runs-on: ubuntu-latest - if: | - contains(github.event.issue.labels.*.name, 'Team:Fleet') && ( - contains(github.event.issue.labels.*.name, 'technical debt') || - contains(github.event.issue.labels.*.name, 'bug') || - contains(github.event.issue.labels.*.name, 'performance') || - contains(github.event.issue.labels.*.name, 'failed-test') || - contains(github.event.issue.labels.*.name, 'chore') - ) - steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project - with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - mutation add_to_project($projectid: ID!, $contentid: ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { - id - } - } - } - projectid: ${{ env.PROJECT_ID }} - contentid: ${{ github.event.issue.node_id }} - env: - # https://github.com/orgs/elastic/projects/763 - PROJECT_ID: "PN_kwDOAGc3Zs4AAsH6" - # Token with `write:org` access - GITHUB_TOKEN: ${{ secrets.FLEET_TECH_KIBANA_USER_TOKEN }} diff --git a/docs/osquery/images/live-query-check-results.png b/docs/osquery/images/live-query-check-results.png index 6b84a3bf9f7ca..f52a96a3d64f6 100644 Binary files a/docs/osquery/images/live-query-check-results.png and b/docs/osquery/images/live-query-check-results.png differ diff --git a/docs/osquery/osquery.asciidoc b/docs/osquery/osquery.asciidoc index e854904b6baf4..c4781affd744d 100644 --- a/docs/osquery/osquery.asciidoc +++ b/docs/osquery/osquery.asciidoc @@ -43,7 +43,7 @@ then view the results. and you'll get suggestions for agents by name, ID, platform, and policy. . Specify the query or pack to run: ** *Query*: Select a saved query or enter a new one in the text box. After you enter the query, you can expand the **Advanced** section to view or set <> included in the results from the live query. Mapping ECS fields is optional. -** *Pack*: Select from query packs that have been loaded and activated. After you select a pack, all of the queries in the pack are displayed. +** *Pack*: Select from available query packs. After you select a pack, all of the queries in the pack are displayed. + TIP: Refer to <> to learn about using and managing Elastic prebuilt packs. + @@ -173,13 +173,14 @@ For information about the prebuilt packs that are available, refer to < - + diff --git a/examples/guided_onboarding_example/public/components/main.tsx b/examples/guided_onboarding_example/public/components/main.tsx index a65fd2324d34b..4c9481d423e4c 100644 --- a/examples/guided_onboarding_example/public/components/main.tsx +++ b/examples/guided_onboarding_example/public/components/main.tsx @@ -259,6 +259,7 @@ export const Main = (props: MainProps) => { { value: 'observability', text: 'observability' }, { value: 'security', text: 'security' }, { value: 'search', text: 'search' }, + { value: 'testGuide', text: 'test guide' }, ]} value={selectedGuide} onChange={(e) => { @@ -294,7 +295,7 @@ export const Main = (props: MainProps) => {

@@ -316,6 +317,14 @@ export const Main = (props: MainProps) => { /> + + history.push('stepThree')}> + + + diff --git a/examples/guided_onboarding_example/public/components/step_one.tsx b/examples/guided_onboarding_example/public/components/step_one.tsx index 3441b4d8e5d99..fd5cb132b6b91 100644 --- a/examples/guided_onboarding_example/public/components/step_one.tsx +++ b/examples/guided_onboarding_example/public/components/step_one.tsx @@ -32,7 +32,7 @@ export const StepOne = ({ guidedOnboarding }: GuidedOnboardingExampleAppDeps) => const [isTourStepOpen, setIsTourStepOpen] = useState(false); const isTourActive = useObservable( - guidedOnboardingApi!.isGuideStepActive$('search', 'add_data'), + guidedOnboardingApi!.isGuideStepActive$('testGuide', 'step1'), false ); useEffect(() => { @@ -45,7 +45,7 @@ export const StepOne = ({ guidedOnboarding }: GuidedOnboardingExampleAppDeps) =>

@@ -56,7 +56,7 @@ export const StepOne = ({ guidedOnboarding }: GuidedOnboardingExampleAppDeps) =>

@@ -72,12 +72,12 @@ export const StepOne = ({ guidedOnboarding }: GuidedOnboardingExampleAppDeps) => onFinish={() => setIsTourStepOpen(false)} step={1} stepsTotal={1} - title="Step Add data" + title="Step 1" anchorPosition="rightUp" > { - await guidedOnboardingApi?.completeGuideStep('search', 'add_data'); + await guidedOnboardingApi?.completeGuideStep('testGuide', 'step1'); }} > Complete step 1 diff --git a/examples/guided_onboarding_example/public/components/step_three.tsx b/examples/guided_onboarding_example/public/components/step_three.tsx index ffe9d87993611..eefb38165beed 100644 --- a/examples/guided_onboarding_example/public/components/step_three.tsx +++ b/examples/guided_onboarding_example/public/components/step_three.tsx @@ -30,7 +30,7 @@ export const StepThree = (props: StepThreeProps) => { useEffect(() => { const subscription = guidedOnboardingApi - ?.isGuideStepActive$('search', 'search_experience') + ?.isGuideStepActive$('testGuide', 'step3') .subscribe((isStepActive) => { setIsTourStepOpen(isStepActive); }); @@ -53,9 +53,17 @@ export const StepThree = (props: StepThreeProps) => {

+

+

+

@@ -73,12 +81,12 @@ export const StepThree = (props: StepThreeProps) => { }} step={1} stepsTotal={1} - title="Step Build search experience" + title="Step 3" anchorPosition="rightUp" > { - await guidedOnboardingApi?.completeGuideStep('search', 'search_experience'); + await guidedOnboardingApi?.completeGuideStep('testGuide', 'step3'); }} > Complete step 3 diff --git a/examples/guided_onboarding_example/public/components/step_two.tsx b/examples/guided_onboarding_example/public/components/step_two.tsx index 07f4fd7e63e0c..89c0c37e46e4a 100644 --- a/examples/guided_onboarding_example/public/components/step_two.tsx +++ b/examples/guided_onboarding_example/public/components/step_two.tsx @@ -6,37 +6,17 @@ * Side Public License, v 1. */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; -import { EuiButton, EuiSpacer, EuiText, EuiTitle, EuiTourStep } from '@elastic/eui'; +import { EuiText, EuiTitle } from '@elastic/eui'; -import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPageContentHeader_Deprecated as EuiPageContentHeader, EuiPageContentBody_Deprecated as EuiPageContentBody, } from '@elastic/eui'; -interface StepTwoProps { - guidedOnboarding: GuidedOnboardingPluginStart; -} - -export const StepTwo = (props: StepTwoProps) => { - const { - guidedOnboarding: { guidedOnboardingApi }, - } = props; - - const [isTourStepOpen, setIsTourStepOpen] = useState(false); - - useEffect(() => { - const subscription = guidedOnboardingApi - ?.isGuideStepActive$('search', 'browse_docs') - .subscribe((isStepActive) => { - setIsTourStepOpen(isStepActive); - }); - return () => subscription?.unsubscribe(); - }, [guidedOnboardingApi]); - +export const StepTwo = () => { return ( <> @@ -54,36 +34,11 @@ export const StepTwo = (props: StepTwoProps) => {

- - -

Click this button to complete step 2.

- - } - isStepOpen={isTourStepOpen} - minWidth={300} - onFinish={() => { - setIsTourStepOpen(false); - }} - step={1} - stepsTotal={1} - title="Step Browse documents" - anchorPosition="rightUp" - > - { - await guidedOnboardingApi?.completeGuideStep('search', 'browse_docs'); - }} - > - Complete step 2 - -
); diff --git a/package.json b/package.json index bb7cd79238c33..6b392f582dc14 100644 --- a/package.json +++ b/package.json @@ -1319,7 +1319,7 @@ "callsites": "^3.1.0", "chance": "1.0.18", "chokidar": "^3.5.3", - "chromedriver": "^105.0.1", + "chromedriver": "^107.0.0", "clean-webpack-plugin": "^3.0.0", "compression-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^6.0.2", @@ -1328,13 +1328,13 @@ "cssnano": "^5.1.12", "cssnano-preset-default": "^5.2.12", "csstype": "^3.0.2", - "cypress": "^10.9.0", + "cypress": "^10.10.0", "cypress-axe": "^1.0.0", "cypress-file-upload": "^5.0.8", "cypress-multi-reporters": "^1.6.1", "cypress-pipe": "^2.0.0", "cypress-react-selector": "^3.0.0", - "cypress-real-events": "^1.7.1", + "cypress-real-events": "^1.7.2", "cypress-recurse": "^1.23.0", "debug": "^2.6.9", "delete-empty": "^2.0.0", diff --git a/packages/kbn-apm-config-loader/src/apm_config.ts b/packages/kbn-apm-config-loader/src/apm_config.ts index 0e7b1b9546288..2127d612d583b 100644 --- a/packages/kbn-apm-config-loader/src/apm_config.ts +++ b/packages/kbn-apm-config-loader/src/apm_config.ts @@ -8,14 +8,12 @@ import { schema } from '@kbn/config-schema'; -export const apmConfigSchema = schema.object({ - apm: schema.object( - { - active: schema.maybe(schema.boolean()), - serverUrl: schema.maybe(schema.uri()), - secretToken: schema.maybe(schema.string()), - globalLabels: schema.object({}, { unknowns: 'allow' }), - }, - { unknowns: 'allow' } - ), -}); +export const apmConfigSchema = schema.object( + { + active: schema.maybe(schema.boolean()), + serverUrl: schema.maybe(schema.uri()), + secretToken: schema.maybe(schema.string()), + globalLabels: schema.object({}, { unknowns: 'allow' }), + }, + { unknowns: 'allow' } +); diff --git a/packages/kbn-apm-synthtrace/index.ts b/packages/kbn-apm-synthtrace/index.ts index 170c5ed6206c1..1ff59bdd7d16a 100644 --- a/packages/kbn-apm-synthtrace/index.ts +++ b/packages/kbn-apm-synthtrace/index.ts @@ -8,6 +8,7 @@ export { timerange } from './src/lib/timerange'; export { apm } from './src/lib/apm'; +export { dedot } from './src/lib/utils/dedot'; export { stackMonitoring } from './src/lib/stack_monitoring'; export { observer } from './src/lib/agent_config'; export { cleanWriteTargets } from './src/lib/utils/clean_write_targets'; diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/apm_error.ts b/packages/kbn-apm-synthtrace/src/lib/apm/apm_error.ts index 334c0f296851d..216397f1e1b40 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/apm_error.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/apm_error.ts @@ -27,4 +27,10 @@ export class ApmError extends Serializable { ); return [data]; } + + timestamp(value: number) { + const ret = super.timestamp(value); + this.fields['timestamp.us'] = value * 1000; + return ret; + } } diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts b/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts index 0cfe5940405a2..b74604c39c242 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts @@ -88,4 +88,10 @@ export class BaseSpan extends Serializable { }); return this; } + + override timestamp(timestamp: number) { + const ret = super.timestamp(timestamp); + this.fields['timestamp.us'] = timestamp * 1000; + return ret; + } } diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts b/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts index f69e54b3e300b..9a7fff73b64a7 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts @@ -20,24 +20,49 @@ export type SpanParams = { } & ApmFields; export class Instance extends Entity { - transaction({ - transactionName, - transactionType = 'request', - }: { - transactionName: string; - transactionType?: string; - }) { + transaction( + ...options: + | [{ transactionName: string; transactionType?: string }] + | [string] + | [string, string] + ) { + let transactionName: string; + let transactionType: string | undefined; + if (options.length === 2) { + transactionName = options[0]; + transactionType = options[1]; + } else if (typeof options[0] === 'string') { + transactionName = options[0]; + } else { + transactionName = options[0].transactionName; + transactionType = options[0].transactionType; + } + return new Transaction({ ...this.fields, 'transaction.name': transactionName, - 'transaction.type': transactionType, + 'transaction.type': transactionType || 'request', }); } - span({ spanName, spanType, spanSubtype, ...apmFields }: SpanParams) { + span(...options: [string, string] | [string, string, string] | [SpanParams]) { + let spanName: string; + let spanType: string; + let spanSubtype: string; + let fields: ApmFields; + + if (options.length === 3 || options.length === 2) { + spanName = options[0]; + spanType = options[1]; + spanSubtype = options[2] || 'unknown'; + fields = {}; + } else { + ({ spanName, spanType, spanSubtype = 'unknown', ...fields } = options[0]); + } + return new Span({ ...this.fields, - ...apmFields, + ...fields, 'span.name': spanName, 'span.type': spanType, 'span.subtype': spanSubtype, diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/service.ts b/packages/kbn-apm-synthtrace/src/lib/apm/service.ts index 0939535a87135..1925c0cdcfd13 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/service.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/service.ts @@ -20,17 +20,18 @@ export class Service extends Entity { } } -export function service({ - name, - environment, - agentName, -}: { - name: string; - environment: string; - agentName: string; -}) { +export function service(name: string, environment: string, agentName: string): Service; + +export function service(options: { name: string; environment: string; agentName: string }): Service; + +export function service( + ...args: [{ name: string; environment: string; agentName: string }] | [string, string, string] +) { + const [serviceName, environment, agentName] = + args.length === 1 ? [args[0].name, args[0].environment, args[0].agentName] : args; + return new Service({ - 'service.name': name, + 'service.name': serviceName, 'service.environment': environment, 'agent.name': agentName, }); diff --git a/packages/kbn-apm-synthtrace/src/lib/stream_processor.ts b/packages/kbn-apm-synthtrace/src/lib/stream_processor.ts index 0d7d0ff5dfa51..84f0dbb0a62bf 100644 --- a/packages/kbn-apm-synthtrace/src/lib/stream_processor.ts +++ b/packages/kbn-apm-synthtrace/src/lib/stream_processor.ts @@ -187,10 +187,7 @@ export class StreamProcessor { document['service.node.name'] = document['service.node.name'] || document['container.id'] || document['host.name']; document['ecs.version'] = '1.4'; - // TODO this non standard field should not be enriched here - if (document['processor.event'] !== 'metric') { - document['timestamp.us'] = document['@timestamp']! * 1000; - } + return document; } diff --git a/packages/kbn-apm-synthtrace/src/lib/utils/dedot.ts b/packages/kbn-apm-synthtrace/src/lib/utils/dedot.ts index 4f38a7025f3b5..5d0f57fb5840b 100644 --- a/packages/kbn-apm-synthtrace/src/lib/utils/dedot.ts +++ b/packages/kbn-apm-synthtrace/src/lib/utils/dedot.ts @@ -13,4 +13,5 @@ export function dedot(source: Record, target: Record) const val = source[key as keyof typeof source]; set(target, key, val); } + return target; } diff --git a/packages/kbn-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts b/packages/kbn-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts index afafcc0c49665..edb20c4768ee5 100644 --- a/packages/kbn-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts +++ b/packages/kbn-apm-synthtrace/src/test/apm_events_to_elasticsearch_output.test.ts @@ -59,9 +59,6 @@ describe('output apm events to elasticsearch', () => { "name": "instance-a", }, }, - "timestamp": Object { - "us": 1609455600000000, - }, } `); }); diff --git a/packages/kbn-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts b/packages/kbn-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts index a278997ecdf73..a14ae076e8186 100644 --- a/packages/kbn-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts +++ b/packages/kbn-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts @@ -84,6 +84,7 @@ describe('simple trace', () => { 'service.environment': 'production', 'service.name': 'opbeans-java', 'service.node.name': 'instance-1', + 'timestamp.us': 1609459200000000, 'trace.id': '00000000000000000000000000000241', 'transaction.duration.us': 1000000, 'transaction.id': '0000000000000240', @@ -113,6 +114,7 @@ describe('simple trace', () => { 'span.name': 'GET apm-*/_search', 'span.subtype': 'elasticsearch', 'span.type': 'db', + 'timestamp.us': 1609459200050000, 'trace.id': '00000000000000000000000000000301', 'transaction.id': '0000000000000300', }); diff --git a/packages/kbn-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap b/packages/kbn-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap index 1a5fca39e9fd9..8b3306d2d3a4b 100644 --- a/packages/kbn-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap +++ b/packages/kbn-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap @@ -13,6 +13,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459200000000, "trace.id": "00000000000000000000000000000001", "transaction.duration.us": 1000000, "transaction.id": "0000000000000000", @@ -37,6 +38,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459200050000, "trace.id": "00000000000000000000000000000001", "transaction.id": "0000000000000000", }, @@ -51,6 +53,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459260000000, "trace.id": "00000000000000000000000000000005", "transaction.duration.us": 1000000, "transaction.id": "0000000000000004", @@ -75,6 +78,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459260050000, "trace.id": "00000000000000000000000000000005", "transaction.id": "0000000000000004", }, @@ -89,6 +93,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459320000000, "trace.id": "00000000000000000000000000000009", "transaction.duration.us": 1000000, "transaction.id": "0000000000000008", @@ -113,6 +118,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459320050000, "trace.id": "00000000000000000000000000000009", "transaction.id": "0000000000000008", }, @@ -127,6 +133,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459380000000, "trace.id": "00000000000000000000000000000013", "transaction.duration.us": 1000000, "transaction.id": "0000000000000012", @@ -151,6 +158,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459380050000, "trace.id": "00000000000000000000000000000013", "transaction.id": "0000000000000012", }, @@ -165,6 +173,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459440000000, "trace.id": "00000000000000000000000000000017", "transaction.duration.us": 1000000, "transaction.id": "0000000000000016", @@ -189,6 +198,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459440050000, "trace.id": "00000000000000000000000000000017", "transaction.id": "0000000000000016", }, @@ -203,6 +213,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459500000000, "trace.id": "00000000000000000000000000000021", "transaction.duration.us": 1000000, "transaction.id": "0000000000000020", @@ -227,6 +238,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459500050000, "trace.id": "00000000000000000000000000000021", "transaction.id": "0000000000000020", }, @@ -241,6 +253,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459560000000, "trace.id": "00000000000000000000000000000025", "transaction.duration.us": 1000000, "transaction.id": "0000000000000024", @@ -265,6 +278,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459560050000, "trace.id": "00000000000000000000000000000025", "transaction.id": "0000000000000024", }, @@ -279,6 +293,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459620000000, "trace.id": "00000000000000000000000000000029", "transaction.duration.us": 1000000, "transaction.id": "0000000000000028", @@ -303,6 +318,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459620050000, "trace.id": "00000000000000000000000000000029", "transaction.id": "0000000000000028", }, @@ -317,6 +333,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459680000000, "trace.id": "00000000000000000000000000000033", "transaction.duration.us": 1000000, "transaction.id": "0000000000000032", @@ -341,6 +358,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459680050000, "trace.id": "00000000000000000000000000000033", "transaction.id": "0000000000000032", }, @@ -355,6 +373,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459740000000, "trace.id": "00000000000000000000000000000037", "transaction.duration.us": 1000000, "transaction.id": "0000000000000036", @@ -379,6 +398,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459740050000, "trace.id": "00000000000000000000000000000037", "transaction.id": "0000000000000036", }, @@ -393,6 +413,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459800000000, "trace.id": "00000000000000000000000000000041", "transaction.duration.us": 1000000, "transaction.id": "0000000000000040", @@ -417,6 +438,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459800050000, "trace.id": "00000000000000000000000000000041", "transaction.id": "0000000000000040", }, @@ -431,6 +453,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459860000000, "trace.id": "00000000000000000000000000000045", "transaction.duration.us": 1000000, "transaction.id": "0000000000000044", @@ -455,6 +478,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459860050000, "trace.id": "00000000000000000000000000000045", "transaction.id": "0000000000000044", }, @@ -469,6 +493,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459920000000, "trace.id": "00000000000000000000000000000049", "transaction.duration.us": 1000000, "transaction.id": "0000000000000048", @@ -493,6 +518,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459920050000, "trace.id": "00000000000000000000000000000049", "transaction.id": "0000000000000048", }, @@ -507,6 +533,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609459980000000, "trace.id": "00000000000000000000000000000053", "transaction.duration.us": 1000000, "transaction.id": "0000000000000052", @@ -531,6 +558,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609459980050000, "trace.id": "00000000000000000000000000000053", "transaction.id": "0000000000000052", }, @@ -545,6 +573,7 @@ Array [ "service.environment": "production", "service.name": "opbeans-java", "service.node.name": "instance-1", + "timestamp.us": 1609460040000000, "trace.id": "00000000000000000000000000000057", "transaction.duration.us": 1000000, "transaction.id": "0000000000000056", @@ -569,6 +598,7 @@ Array [ "span.name": "GET apm-*/_search", "span.subtype": "elasticsearch", "span.type": "db", + "timestamp.us": 1609460040050000, "trace.id": "00000000000000000000000000000057", "transaction.id": "0000000000000056", }, diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx index cc16977d966a7..574b9b18bf2b3 100644 --- a/packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx +++ b/packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx @@ -9,7 +9,6 @@ import React, { ReactNode } from 'react'; import { EuiCard, EuiText, EuiImage } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { GuideId } from '../../types'; type UseCaseConstants = { [key in UseCase]: { @@ -53,7 +52,7 @@ const constants: UseCaseConstants = { export type UseCase = 'search' | 'observability' | 'security'; export interface UseCaseCardProps { - useCase: GuideId; + useCase: UseCase; title: string; description: string; footer: ReactNode; diff --git a/packages/kbn-guided-onboarding/src/types.ts b/packages/kbn-guided-onboarding/src/types.ts index 9a307464cefb8..6b919835da2e7 100644 --- a/packages/kbn-guided-onboarding/src/types.ts +++ b/packages/kbn-guided-onboarding/src/types.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -export type GuideId = 'observability' | 'security' | 'search'; +export type GuideId = 'observability' | 'security' | 'search' | 'testGuide'; -export type ObservabilityStepIds = 'add_data' | 'view_dashboard' | 'tour_observability'; -export type SecurityStepIds = 'add_data' | 'rules' | 'alertsCases'; -export type SearchStepIds = 'add_data' | 'browse_docs' | 'search_experience'; +type ObservabilityStepIds = 'add_data' | 'view_dashboard' | 'tour_observability'; +type SecurityStepIds = 'add_data' | 'rules' | 'alertsCases'; +type SearchStepIds = 'add_data' | 'browse_docs' | 'search_experience'; +type TestGuideIds = 'step1' | 'step2' | 'step3'; -export type GuideStepIds = ObservabilityStepIds | SecurityStepIds | SearchStepIds; +export type GuideStepIds = ObservabilityStepIds | SecurityStepIds | SearchStepIds | TestGuideIds; export interface GuideState { guideId: GuideId; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap index 96b70e33021f4..1b644ef0a4938 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap @@ -77,6 +77,7 @@ Object { "xAccessor": "col-1-2", "yAccessor": undefined, }, + "canNavigateToLens": false, "data": Object { "columns": Array [ Object { diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts index 548d4ec0ab49e..f0c309de19236 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts @@ -232,6 +232,7 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({ }, syncTooltips: handlers?.isSyncTooltipsEnabled?.() ?? false, syncCursor: handlers?.isSyncCursorEnabled?.() ?? true, + canNavigateToLens: Boolean(handlers?.variables?.canNavigateToLens), }, }; }, diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts index 5aa1507f30b03..1bf5fe3bbb36b 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts @@ -94,6 +94,7 @@ export interface HeatmapExpressionProps { args: HeatmapArguments; syncTooltips: boolean; syncCursor: boolean; + canNavigateToLens?: boolean; } export interface HeatmapRender { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/heatmap_renderer.tsx b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/heatmap_renderer.tsx index 4b813fb93416f..b14ee1382deb2 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/heatmap_renderer.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/heatmap_renderer.tsx @@ -61,9 +61,14 @@ export const heatmapRenderer: ( const visualizationType = extractVisualizationType(executionContext); if (containerType && visualizationType) { - plugins.usageCollection?.reportUiCounter(containerType, METRIC_TYPE.COUNT, [ + const events = [ `render_${visualizationType}_${EXPRESSION_HEATMAP_NAME}`, - ]); + config.canNavigateToLens + ? `render_${visualizationType}_${EXPRESSION_HEATMAP_NAME}_convertable` + : undefined, + ].filter((event): event is string => Boolean(event)); + + plugins.usageCollection?.reportUiCounter(containerType, METRIC_TYPE.COUNT, events); } handlers.done(); diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/index.ts b/src/plugins/guided_onboarding/public/constants/guides_config/index.ts index 9ce81cf9d4698..e2ab4f7e7747f 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/index.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/index.ts @@ -10,9 +10,11 @@ import type { GuidesConfig } from '../../types'; import { securityConfig } from './security'; import { observabilityConfig } from './observability'; import { searchConfig } from './search'; +import { testGuideConfig } from './test_guide'; export const guidesConfig: GuidesConfig = { security: securityConfig, observability: observabilityConfig, search: searchConfig, + testGuide: testGuideConfig, }; diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts b/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts new file mode 100644 index 0000000000000..b357ad497c6b4 --- /dev/null +++ b/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { GuideConfig } from '../../types'; + +export const testGuideConfig: GuideConfig = { + title: 'Test guide for development', + description: `This guide is used to test the guided onboarding UI while in development and to run automated tests for the API and UI components.`, + guideName: 'Testing example', + docs: { + text: 'Testing example docs', + url: 'example.com', + }, + steps: [ + { + id: 'step1', + title: 'Step 1 (completed via an API request)', + descriptionList: [ + `This step is directly completed by clicking the button that uses the API function 'completeGuideStep`, + 'Navigate to /guidedOnboardingExample/stepOne to complete the step.', + ], + location: { + appID: 'guidedOnboardingExample', + path: 'stepOne', + }, + integration: 'testIntegration', + }, + { + id: 'step2', + title: 'Step 2 (manual completion after navigation)', + descriptionList: [ + 'This step is set to ready_to_complete on page navigation.', + 'After that click the popover on the guide button in the header and mark the step done', + ], + location: { + appID: 'guidedOnboardingExample', + path: 'stepTwo', + }, + manualCompletion: { + title: 'Manual completion step title', + description: + 'Mark the step complete by opening the panel and clicking the button "Mark done"', + readyToCompleteOnNavigation: true, + }, + }, + { + id: 'step3', + title: 'Step 3 (manual completion after click)', + descriptionList: [ + 'This step is completed by clicking a button on the page and then clicking the popover on the guide button in the header and marking the step done', + ], + manualCompletion: { + title: 'Manual completion step title', + description: + 'Mark the step complete by opening the panel and clicking the button "Mark done"', + }, + location: { + appID: 'guidedOnboardingExample', + path: 'stepThree', + }, + }, + ], +}; diff --git a/src/plugins/guided_onboarding/public/services/api.mocks.ts b/src/plugins/guided_onboarding/public/services/api.mocks.ts index 21bb257cad68f..2294607f91b38 100644 --- a/src/plugins/guided_onboarding/public/services/api.mocks.ts +++ b/src/plugins/guided_onboarding/public/services/api.mocks.ts @@ -6,84 +6,78 @@ * Side Public License, v 1. */ -import type { GuideState } from '@kbn/guided-onboarding'; +import type { GuideState, GuideId, GuideStepIds } from '@kbn/guided-onboarding'; -export const searchAddDataActiveState: GuideState = { - guideId: 'search', +export const testGuide: GuideId = 'testGuide'; +export const testGuideFirstStep: GuideStepIds = 'step1'; +export const testGuideManualCompletionStep = 'step2'; +export const testGuideLastStep: GuideStepIds = 'step3'; +export const testIntegration = 'testIntegration'; +export const wrongIntegration = 'notTestIntegration'; + +export const testGuideStep1ActiveState: GuideState = { + guideId: 'testGuide', isActive: true, status: 'in_progress', steps: [ { - id: 'add_data', + id: 'step1', status: 'active', }, { - id: 'browse_docs', + id: 'step2', status: 'inactive', }, { - id: 'search_experience', + id: 'step3', status: 'inactive', }, ], }; -export const securityAddDataInProgressState: GuideState = { - guideId: 'security', - status: 'in_progress', - isActive: true, +export const testGuideStep1InProgressState: GuideState = { + ...testGuideStep1ActiveState, steps: [ { - id: 'add_data', - status: 'in_progress', - }, - { - id: 'rules', - status: 'inactive', - }, - { - id: 'alertsCases', - status: 'inactive', + id: testGuideStep1ActiveState.steps[0].id, + status: 'in_progress', // update the first step status }, + testGuideStep1ActiveState.steps[1], + testGuideStep1ActiveState.steps[2], ], }; -export const securityRulesActiveState: GuideState = { - guideId: 'security', - isActive: true, - status: 'in_progress', +export const testGuideStep2ActiveState: GuideState = { + ...testGuideStep1ActiveState, steps: [ { - id: 'add_data', + ...testGuideStep1ActiveState.steps[0], status: 'complete', }, { - id: 'rules', + id: testGuideStep1ActiveState.steps[1].id, status: 'active', }, - { - id: 'alertsCases', - status: 'inactive', - }, + testGuideStep1ActiveState.steps[2], ], }; -export const noGuideActiveState: GuideState = { - guideId: 'security', - status: 'in_progress', - isActive: false, +export const testGuideStep2InProgressState: GuideState = { + ...testGuideStep1ActiveState, steps: [ { - id: 'add_data', - status: 'in_progress', - }, - { - id: 'rules', - status: 'inactive', + ...testGuideStep1ActiveState.steps[0], + status: 'complete', }, { - id: 'alertsCases', - status: 'inactive', + id: testGuideStep1ActiveState.steps[1].id, + status: 'in_progress', }, + testGuideStep1ActiveState.steps[2], ], }; + +export const testGuideNotActiveState: GuideState = { + ...testGuideStep1ActiveState, + isActive: false, +}; diff --git a/src/plugins/guided_onboarding/public/services/api.test.ts b/src/plugins/guided_onboarding/public/services/api.test.ts index 2296304166648..56a5755f0ee55 100644 --- a/src/plugins/guided_onboarding/public/services/api.test.ts +++ b/src/plugins/guided_onboarding/public/services/api.test.ts @@ -12,20 +12,20 @@ import type { GuideState } from '@kbn/guided-onboarding'; import { firstValueFrom, Subscription } from 'rxjs'; import { API_BASE_PATH } from '../../common/constants'; -import { guidesConfig } from '../constants/guides_config'; import { ApiService } from './api'; import { - noGuideActiveState, - searchAddDataActiveState, - securityAddDataInProgressState, - securityRulesActiveState, + testGuide, + testGuideFirstStep, + testGuideManualCompletionStep, + testGuideStep1ActiveState, + testGuideStep1InProgressState, + testGuideStep2ActiveState, + testGuideNotActiveState, + testIntegration, + wrongIntegration, + testGuideStep2InProgressState, } from './api.mocks'; -const searchGuide = 'search'; -const firstStep = guidesConfig[searchGuide].steps[0].id; -const endpointIntegration = 'endpoint'; -const kubernetesIntegration = 'kubernetes'; - describe('GuidedOnboarding ApiService', () => { let httpClient: jest.Mocked; let apiService: ApiService; @@ -34,7 +34,7 @@ describe('GuidedOnboarding ApiService', () => { beforeEach(() => { httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' }); httpClient.get.mockResolvedValue({ - state: [securityAddDataInProgressState], + state: [testGuideStep1ActiveState], }); apiService = new ApiService(); apiService.setup(httpClient); @@ -57,10 +57,10 @@ describe('GuidedOnboarding ApiService', () => { }); it('broadcasts the updated state', async () => { - await apiService.activateGuide(searchGuide, searchAddDataActiveState); + await apiService.activateGuide(testGuide, testGuideStep1ActiveState); const state = await firstValueFrom(apiService.fetchActiveGuideState$()); - expect(state).toEqual(searchAddDataActiveState); + expect(state).toEqual(testGuideStep1ActiveState); }); }); @@ -74,12 +74,12 @@ describe('GuidedOnboarding ApiService', () => { describe('deactivateGuide', () => { it('deactivates an existing guide', async () => { - await apiService.deactivateGuide(searchAddDataActiveState); + await apiService.deactivateGuide(testGuideStep1ActiveState); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ - ...searchAddDataActiveState, + ...testGuideStep1ActiveState, isActive: false, }), }); @@ -88,17 +88,7 @@ describe('GuidedOnboarding ApiService', () => { describe('updateGuideState', () => { it('sends a request to the put API', async () => { - const updatedState: GuideState = { - ...searchAddDataActiveState, - steps: [ - { - id: searchAddDataActiveState.steps[0].id, - status: 'in_progress', // update the first step status - }, - searchAddDataActiveState.steps[1], - searchAddDataActiveState.steps[2], - ], - }; + const updatedState: GuideState = testGuideStep1InProgressState; await apiService.updateGuideState(updatedState, false); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { @@ -109,20 +99,11 @@ describe('GuidedOnboarding ApiService', () => { describe('isGuideStepActive$', () => { it('returns true if the step has been started', (done) => { - const updatedState: GuideState = { - ...searchAddDataActiveState, - steps: [ - { - id: searchAddDataActiveState.steps[0].id, - status: 'in_progress', - }, - searchAddDataActiveState.steps[1], - searchAddDataActiveState.steps[2], - ], - }; + const updatedState: GuideState = testGuideStep1InProgressState; apiService.updateGuideState(updatedState, false); + subscription = apiService - .isGuideStepActive$(searchGuide, firstStep) + .isGuideStepActive$(testGuide, testGuideFirstStep) .subscribe((isStepActive) => { if (isStepActive) { done(); @@ -131,9 +112,8 @@ describe('GuidedOnboarding ApiService', () => { }); it('returns false if the step is not been started', (done) => { - apiService.updateGuideState(searchAddDataActiveState, false); subscription = apiService - .isGuideStepActive$(searchGuide, firstStep) + .isGuideStepActive$(testGuide, testGuideFirstStep) .subscribe((isStepActive) => { if (!isStepActive) { done(); @@ -144,56 +124,44 @@ describe('GuidedOnboarding ApiService', () => { describe('activateGuide', () => { it('activates a new guide', async () => { - await apiService.activateGuide(searchGuide); + // update the mock to no active guides + httpClient.get.mockResolvedValue({ + state: [], + }); + apiService.setup(httpClient); + + await apiService.activateGuide(testGuide); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify({ - isActive: true, - status: 'not_started', - steps: [ - { - id: 'add_data', - status: 'active', - }, - { - id: 'browse_docs', - status: 'inactive', - }, - { - id: 'search_experience', - status: 'inactive', - }, - ], - guideId: searchGuide, - }), + body: JSON.stringify({ ...testGuideStep1ActiveState, status: 'not_started' }), }); }); it('reactivates a guide that has already been started', async () => { - await apiService.activateGuide(searchGuide, searchAddDataActiveState); + await apiService.activateGuide(testGuide, testGuideStep1ActiveState); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify(searchAddDataActiveState), + body: JSON.stringify(testGuideStep1ActiveState), }); }); }); describe('completeGuide', () => { const readyToCompleteGuideState: GuideState = { - ...searchAddDataActiveState, + ...testGuideStep1ActiveState, steps: [ { - id: 'add_data', + ...testGuideStep1ActiveState.steps[0], status: 'complete', }, { - id: 'browse_docs', + ...testGuideStep1ActiveState.steps[1], status: 'complete', }, { - id: 'search_experience', + ...testGuideStep1ActiveState.steps[2], status: 'complete', }, ], @@ -204,7 +172,7 @@ describe('GuidedOnboarding ApiService', () => { }); it('updates the selected guide and marks it as complete', async () => { - await apiService.completeGuide(searchGuide); + await apiService.completeGuide(testGuide); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ @@ -222,51 +190,39 @@ describe('GuidedOnboarding ApiService', () => { it('returns undefined if the selected guide has uncompleted steps', async () => { const incompleteGuideState: GuideState = { - ...searchAddDataActiveState, + ...testGuideStep1ActiveState, steps: [ { - id: 'add_data', + ...testGuideStep1ActiveState.steps[0], status: 'complete', }, { - id: 'browse_docs', + ...testGuideStep1ActiveState.steps[1], status: 'complete', }, { - id: 'search_experience', + ...testGuideStep1ActiveState.steps[2], status: 'in_progress', }, ], }; await apiService.updateGuideState(incompleteGuideState, false); - const completedState = await apiService.completeGuide(searchGuide); + const completedState = await apiService.completeGuide(testGuide); expect(completedState).not.toBeDefined(); }); }); describe('startGuideStep', () => { beforeEach(async () => { - await apiService.updateGuideState(searchAddDataActiveState, false); + await apiService.updateGuideState(testGuideStep1ActiveState, false); }); it('updates the selected step and marks it as in_progress', async () => { - await apiService.startGuideStep(searchGuide, firstStep); + await apiService.startGuideStep(testGuide, testGuideFirstStep); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify({ - ...searchAddDataActiveState, - isActive: true, - status: 'in_progress', - steps: [ - { - id: searchAddDataActiveState.steps[0].id, - status: 'in_progress', - }, - searchAddDataActiveState.steps[1], - searchAddDataActiveState.steps[2], - ], - }), + body: JSON.stringify(testGuideStep1InProgressState), }); }); @@ -278,76 +234,35 @@ describe('GuidedOnboarding ApiService', () => { describe('completeGuideStep', () => { it(`completes the step when it's in progress`, async () => { - const updatedState: GuideState = { - ...searchAddDataActiveState, - steps: [ - { - id: searchAddDataActiveState.steps[0].id, - status: 'in_progress', // Mark a step as in_progress in order to test the "completeGuideStep" behavior - }, - searchAddDataActiveState.steps[1], - searchAddDataActiveState.steps[2], - ], - }; - await apiService.updateGuideState(updatedState, false); + await apiService.updateGuideState(testGuideStep1InProgressState, false); - await apiService.completeGuideStep(searchGuide, firstStep); + await apiService.completeGuideStep(testGuide, testGuideFirstStep); // Once on update, once on complete expect(httpClient.put).toHaveBeenCalledTimes(2); // Verify the completed step now has a "complete" status, and the subsequent step is "active" expect(httpClient.put).toHaveBeenLastCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify({ - ...updatedState, - steps: [ - { - id: searchAddDataActiveState.steps[0].id, - status: 'complete', - }, - { - id: searchAddDataActiveState.steps[1].id, - status: 'active', - }, - searchAddDataActiveState.steps[2], - ], - }), + body: JSON.stringify({ ...testGuideStep2ActiveState }), }); }); it(`marks the step as 'ready_to_complete' if it's configured for manual completion`, async () => { - const securityRulesInProgressState = { - ...securityRulesActiveState, - steps: [ - securityRulesActiveState.steps[0], - { - id: securityRulesActiveState.steps[1].id, - status: 'in_progress', - }, - securityRulesActiveState.steps[2], - ], - }; httpClient.get.mockResolvedValue({ - state: [securityRulesInProgressState], + state: [testGuideStep2InProgressState], }); apiService.setup(httpClient); - await apiService.completeGuideStep('security', 'rules'); + await apiService.completeGuideStep(testGuide, testGuideManualCompletionStep); expect(httpClient.put).toHaveBeenCalledTimes(1); // Verify the completed step now has a "ready_to_complete" status, and the subsequent step is "inactive" expect(httpClient.put).toHaveBeenLastCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ - ...securityRulesInProgressState, + ...testGuideStep2InProgressState, steps: [ - securityRulesInProgressState.steps[0], - { - id: securityRulesInProgressState.steps[1].id, - status: 'ready_to_complete', - }, - { - id: securityRulesInProgressState.steps[2].id, - status: 'inactive', - }, + testGuideStep2InProgressState.steps[0], + { ...testGuideStep2InProgressState.steps[1], status: 'ready_to_complete' }, + testGuideStep2InProgressState.steps[2], ], }), }); @@ -359,12 +274,8 @@ describe('GuidedOnboarding ApiService', () => { }); it('does nothing if the step is not in progress', async () => { - httpClient.get.mockResolvedValue({ - state: [searchAddDataActiveState], - }); - apiService.setup(httpClient); - - await apiService.completeGuideStep(searchGuide, firstStep); + // by default the state set in beforeEach is test guide, step 1 active + await apiService.completeGuideStep(testGuide, testGuideFirstStep); expect(httpClient.put).toHaveBeenCalledTimes(0); }); }); @@ -372,11 +283,11 @@ describe('GuidedOnboarding ApiService', () => { describe('isGuidedOnboardingActiveForIntegration$', () => { it('returns true if the integration is part of the active step', (done) => { httpClient.get.mockResolvedValue({ - state: [securityAddDataInProgressState], + state: [testGuideStep1InProgressState], }); apiService.setup(httpClient); subscription = apiService - .isGuidedOnboardingActiveForIntegration$(endpointIntegration) + .isGuidedOnboardingActiveForIntegration$(testIntegration) .subscribe((isIntegrationInGuideStep) => { if (isIntegrationInGuideStep) { done(); @@ -384,13 +295,13 @@ describe('GuidedOnboarding ApiService', () => { }); }); - it('returns false if another integration is part of the active step', (done) => { + it('returns false if the current step has a different integration', (done) => { httpClient.get.mockResolvedValue({ - state: [securityAddDataInProgressState], + state: [testGuideStep1InProgressState], }); apiService.setup(httpClient); subscription = apiService - .isGuidedOnboardingActiveForIntegration$(kubernetesIntegration) + .isGuidedOnboardingActiveForIntegration$(wrongIntegration) .subscribe((isIntegrationInGuideStep) => { if (!isIntegrationInGuideStep) { done(); @@ -400,11 +311,11 @@ describe('GuidedOnboarding ApiService', () => { it('returns false if no guide is active', (done) => { httpClient.get.mockResolvedValue({ - state: [noGuideActiveState], + state: [testGuideNotActiveState], }); apiService.setup(httpClient); subscription = apiService - .isGuidedOnboardingActiveForIntegration$(endpointIntegration) + .isGuidedOnboardingActiveForIntegration$(testIntegration) .subscribe((isIntegrationInGuideStep) => { if (!isIntegrationInGuideStep) { done(); @@ -416,35 +327,35 @@ describe('GuidedOnboarding ApiService', () => { describe('completeGuidedOnboardingForIntegration', () => { it(`completes the step if it's active for the integration`, async () => { httpClient.get.mockResolvedValue({ - state: [securityAddDataInProgressState], + state: [testGuideStep1InProgressState], }); apiService.setup(httpClient); - await apiService.completeGuidedOnboardingForIntegration(endpointIntegration); + await apiService.completeGuidedOnboardingForIntegration(testIntegration); expect(httpClient.put).toHaveBeenCalledTimes(1); // this assertion depends on the guides config expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify(securityRulesActiveState), + body: JSON.stringify(testGuideStep2ActiveState), }); }); it(`does nothing if the step has a different integration`, async () => { httpClient.get.mockResolvedValue({ - state: [securityAddDataInProgressState], + state: [testGuideStep1InProgressState], }); apiService.setup(httpClient); - await apiService.completeGuidedOnboardingForIntegration(kubernetesIntegration); + await apiService.completeGuidedOnboardingForIntegration(wrongIntegration); expect(httpClient.put).not.toHaveBeenCalled(); }); it(`does nothing if no guide is active`, async () => { httpClient.get.mockResolvedValue({ - state: [noGuideActiveState], + state: [testGuideNotActiveState], }); apiService.setup(httpClient); - await apiService.completeGuidedOnboardingForIntegration(endpointIntegration); + await apiService.completeGuidedOnboardingForIntegration(testIntegration); expect(httpClient.put).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/guided_onboarding/public/services/api.ts b/src/plugins/guided_onboarding/public/services/api.ts index 688e72fa83243..cd33f9505c546 100644 --- a/src/plugins/guided_onboarding/public/services/api.ts +++ b/src/plugins/guided_onboarding/public/services/api.ts @@ -147,10 +147,10 @@ export class ApiService implements GuidedOnboardingApi { }); const updatedGuide: GuideState = { + guideId, isActive: true, status: 'not_started', steps: updatedSteps, - guideId, }; return await this.updateGuideState(updatedGuide, true); diff --git a/src/plugins/guided_onboarding/public/services/helpers.test.ts b/src/plugins/guided_onboarding/public/services/helpers.test.ts index 9dc7519a02019..82720c4f9d223 100644 --- a/src/plugins/guided_onboarding/public/services/helpers.test.ts +++ b/src/plugins/guided_onboarding/public/services/helpers.test.ts @@ -6,51 +6,50 @@ * Side Public License, v 1. */ -import { guidesConfig } from '../constants/guides_config'; import { isIntegrationInGuideStep, isLastStep } from './helpers'; import { - noGuideActiveState, - securityAddDataInProgressState, - securityRulesActiveState, + testGuide, + testGuideFirstStep, + testGuideLastStep, + testGuideNotActiveState, + testGuideStep1InProgressState, + testGuideStep2InProgressState, + testIntegration, + wrongIntegration, } from './api.mocks'; -const searchGuide = 'search'; -const firstStep = guidesConfig[searchGuide].steps[0].id; -const lastStep = guidesConfig[searchGuide].steps[guidesConfig[searchGuide].steps.length - 1].id; - describe('GuidedOnboarding ApiService helpers', () => { - // this test suite depends on the guides config describe('isLastStepActive', () => { it('returns true if the passed params are for the last step', () => { - const result = isLastStep(searchGuide, lastStep); + const result = isLastStep(testGuide, testGuideLastStep); expect(result).toBe(true); }); it('returns false if the passed params are not for the last step', () => { - const result = isLastStep(searchGuide, firstStep); + const result = isLastStep(testGuide, testGuideFirstStep); expect(result).toBe(false); }); }); describe('isIntegrationInGuideStep', () => { it('return true if the integration is defined in the guide step config', () => { - const result = isIntegrationInGuideStep(securityAddDataInProgressState, 'endpoint'); + const result = isIntegrationInGuideStep(testGuideStep1InProgressState, testIntegration); expect(result).toBe(true); }); it('returns false if a different integration is defined in the guide step', () => { - const result = isIntegrationInGuideStep(securityAddDataInProgressState, 'kubernetes'); + const result = isIntegrationInGuideStep(testGuideStep1InProgressState, wrongIntegration); expect(result).toBe(false); }); it('returns false if no integration is defined in the guide step', () => { - const result = isIntegrationInGuideStep(securityRulesActiveState, 'endpoint'); + const result = isIntegrationInGuideStep(testGuideStep2InProgressState, testIntegration); expect(result).toBe(false); }); it('returns false if no guide is active', () => { - const result = isIntegrationInGuideStep(noGuideActiveState, 'endpoint'); + const result = isIntegrationInGuideStep(testGuideNotActiveState, testIntegration); expect(result).toBe(false); }); it('returns false if no integration passed', () => { - const result = isIntegrationInGuideStep(securityAddDataInProgressState); + const result = isIntegrationInGuideStep(testGuideStep1InProgressState); expect(result).toBe(false); }); }); diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 3311f42bff55d..0d01bd9e0dcd2 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -72,6 +72,7 @@ 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 { reactToUiComponent, uiToReactComponent } from './adapters'; diff --git a/src/plugins/kibana_react/public/markdown/index.tsx b/src/plugins/kibana_react/public/markdown/index.tsx index 99da8a3c8898c..d0c72d8db8d76 100644 --- a/src/plugins/kibana_react/public/markdown/index.tsx +++ b/src/plugins/kibana_react/public/markdown/index.tsx @@ -17,6 +17,7 @@ const Fallback = () => ( ); +/** @deprecated use `Markdown` from `@kbn/shared-ux-markdown` */ const LazyMarkdownSimple = React.lazy(() => import('./markdown_simple')); export const MarkdownSimple = (props: MarkdownSimpleProps) => ( }> @@ -24,6 +25,7 @@ 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_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 41df488839358..22b2a5de751f5 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -546,6 +546,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:apmEnableCriticalPath': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'observability:enableInfrastructureHostsView': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 2bd59dc69084f..6957323103545 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -147,6 +147,7 @@ export interface UsageStats { 'observability:apmServiceGroupMaxNumberOfServices': number; 'observability:apmServiceInventoryOptimizedSorting': boolean; 'observability:apmTraceExplorerTab': boolean; + 'observability:apmEnableCriticalPath': boolean; 'securitySolution:enableGroupedNav': boolean; 'securitySolution:showRelatedIntegrations': boolean; 'visualization:visualize:legacyGaugeChartsLibrary': boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 1a97586dffa62..14db4bca74d4a 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8864,6 +8864,12 @@ "description": "Non-default value of setting." } }, + "observability:apmEnableCriticalPath": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "observability:enableInfrastructureHostsView": { "type": "boolean", "_meta": { diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx index c70f1df820252..07d35b78b58a2 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx @@ -75,7 +75,7 @@ export interface FieldStatsProps { 'data-test-subj'?: string; overrideMissingContent?: (params: { element: JSX.Element; - noDataFound?: boolean; + reason: 'no-data' | 'unsupported'; }) => JSX.Element | null; overrideFooter?: (params: { element: JSX.Element; @@ -304,7 +304,7 @@ const FieldStatsComponent: React.FC = ({ return overrideMissingContent ? overrideMissingContent({ - noDataFound: false, + reason: 'unsupported', element: messageNoAnalysis, }) : messageNoAnalysis; @@ -338,7 +338,7 @@ const FieldStatsComponent: React.FC = ({ return overrideMissingContent ? overrideMissingContent({ - noDataFound: true, + reason: 'no-data', element: messageNoData, }) : messageNoData; @@ -358,12 +358,14 @@ const FieldStatsComponent: React.FC = ({ defaultMessage: 'Top values', }), id: 'topValues', + 'data-test-subj': `${dataTestSubject}-buttonGroup-topValuesButton`, }, { label: i18n.translate('unifiedFieldList.fieldStats.fieldDistributionLabel', { defaultMessage: 'Distribution', }), id: 'histogram', + 'data-test-subj': `${dataTestSubject}-buttonGroup-distributionButton`, }, ]} onChange={(optionId: string) => { diff --git a/src/plugins/vis_types/heatmap/kibana.json b/src/plugins/vis_types/heatmap/kibana.json index c8df98e2b343a..b7f4a3bacbb90 100644 --- a/src/plugins/vis_types/heatmap/kibana.json +++ b/src/plugins/vis_types/heatmap/kibana.json @@ -1,14 +1,27 @@ { - "id": "visTypeHeatmap", - "version": "kibana", - "ui": true, - "server": true, - "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection", "fieldFormats"], - "requiredBundles": ["visDefaultEditor"], - "extraPublicDirs": ["common/index"], - "owner": { - "name": "Vis Editors", - "githubTeam": "kibana-vis-editors" - }, - "description": "Contains the heatmap implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy heatmap charts library advanced setting." - } + "id": "visTypeHeatmap", + "version": "kibana", + "ui": true, + "server": true, + "requiredPlugins": [ + "charts", + "data", + "expressions", + "visualizations", + "usageCollection", + "fieldFormats", + "dataViews" + ], + "requiredBundles": [ + "visDefaultEditor", + "kibanaUtils" + ], + "extraPublicDirs": [ + "common/index" + ], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + }, + "description": "Contains the heatmap implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy heatmap charts library advanced setting." +} \ No newline at end of file diff --git a/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/index.test.ts b/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/index.test.ts new file mode 100644 index 0000000000000..3f60b6fde0a94 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/index.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { AvgColumn, DateHistogramColumn } from '@kbn/visualizations-plugin/common/convert_to_lens'; +import { Vis } from '@kbn/visualizations-plugin/public'; +import { getConfiguration } from '.'; +import { sampleHeatmapVis } from '../../sample_vis.test.mocks'; +import { HeatmapVisParams } from '../../types'; + +describe('getConfiguration', () => { + const layerId = 'layer-id'; + let vis: Vis; + + const metric: AvgColumn = { + sourceField: 'price', + columnId: 'column-1', + operationType: 'average', + isBucketed: false, + isSplit: false, + dataType: 'string', + params: {}, + }; + const xColumn: DateHistogramColumn = { + sourceField: 'price', + columnId: 'column-2', + operationType: 'date_histogram', + isBucketed: true, + isSplit: false, + dataType: 'string', + params: { + interval: '1h', + }, + }; + + const yColumn: DateHistogramColumn = { + sourceField: 'price', + columnId: 'column-3', + operationType: 'date_histogram', + isBucketed: true, + isSplit: true, + dataType: 'string', + params: { + interval: '1h', + }, + }; + + beforeEach(() => { + vis = sampleHeatmapVis as unknown as Vis; + }); + + test('should return valid configuration', async () => { + const result = await getConfiguration(layerId, vis, { + metrics: [metric.columnId], + buckets: [xColumn.columnId, yColumn.columnId], + }); + expect(result).toEqual({ + gridConfig: { + isCellLabelVisible: true, + isXAxisLabelVisible: true, + isXAxisTitleVisible: true, + isYAxisLabelVisible: true, + isYAxisTitleVisible: true, + type: 'heatmap_grid', + }, + layerId, + layerType: 'data', + legend: { isVisible: undefined, position: 'right', type: 'heatmap_legend' }, + palette: { + accessor: 'column-1', + name: 'custom', + params: { + colorStops: [ + { color: '#F7FBFF', stop: 0 }, + { color: '#DEEBF7', stop: 12.5 }, + { color: '#C3DBEE', stop: 25 }, + { color: '#9CC8E2', stop: 37.5 }, + { color: '#6DAED5', stop: 50 }, + { color: '#4391C6', stop: 62.5 }, + { color: '#2271B3', stop: 75 }, + { color: '#0D5097', stop: 87.5 }, + ], + continuity: 'none', + maxSteps: 5, + name: 'custom', + progression: 'fixed', + rangeMax: 100, + rangeMin: 0, + rangeType: 'number', + reverse: false, + stops: [ + { color: '#F7FBFF', stop: 12.5 }, + { color: '#DEEBF7', stop: 25 }, + { color: '#C3DBEE', stop: 37.5 }, + { color: '#9CC8E2', stop: 50 }, + { color: '#6DAED5', stop: 62.5 }, + { color: '#4391C6', stop: 75 }, + { color: '#2271B3', stop: 87.5 }, + { color: '#0D5097', stop: 100 }, + ], + }, + type: 'palette', + }, + shape: 'heatmap', + valueAccessor: metric.columnId, + xAccessor: xColumn.columnId, + yAccessor: yColumn.columnId, + }); + }); +}); diff --git a/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/index.ts b/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/index.ts new file mode 100644 index 0000000000000..2e7a3f161514a --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { HeatmapConfiguration } from '@kbn/visualizations-plugin/common'; +import { Vis } from '@kbn/visualizations-plugin/public'; +import { HeatmapVisParams } from '../../types'; +import { getPaletteForHeatmap } from './palette'; + +export const getConfiguration = async ( + layerId: string, + vis: Vis, + { + metrics, + buckets, + }: { + metrics: string[]; + buckets: string[]; + } +): Promise => { + const [valueAccessor] = metrics; + const [xAccessor, yAccessor] = buckets; + + const { params, uiState } = vis; + const state = uiState.get('vis', {}) ?? {}; + + const palette = await getPaletteForHeatmap(params); + return { + layerId, + layerType: 'data', + shape: 'heatmap', + legend: { + type: 'heatmap_legend', + isVisible: state.legendOpen, + position: params.legendPosition, + }, + gridConfig: { + type: 'heatmap_grid', + isCellLabelVisible: params.valueAxes?.[0].labels.show ?? false, + isXAxisLabelVisible: true, + isYAxisLabelVisible: true, + isYAxisTitleVisible: true, + isXAxisTitleVisible: true, + }, + valueAccessor, + xAccessor, + yAccessor, + palette: palette ? { ...palette, accessor: valueAccessor } : undefined, + }; +}; diff --git a/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/palette.ts b/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/palette.ts new file mode 100644 index 0000000000000..32187e184d4ef --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/convert_to_lens/configurations/palette.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Range } from '@kbn/expressions-plugin/common'; +import { convertToLensModule } from '@kbn/visualizations-plugin/public'; +import { HeatmapVisParams } from '../../types'; +import { getStopsWithColorsFromColorsNumber } from '../../utils/palette'; + +type HeatmapVisParamsWithRanges = Omit & { + colorsRange: Exclude; +}; + +const isHeatmapVisParamsWithRanges = ( + params: HeatmapVisParams | HeatmapVisParamsWithRanges +): params is HeatmapVisParamsWithRanges => { + return Boolean(params.setColorRange && params.colorsRange && params.colorsRange.length); +}; + +export const getPaletteForHeatmap = async (params: HeatmapVisParams) => { + const { getPalette, getPaletteFromStopsWithColors, getPercentageModeConfig } = + await convertToLensModule; + + if (isHeatmapVisParamsWithRanges(params)) { + const percentageModeConfig = getPercentageModeConfig(params, false); + return getPalette(params, percentageModeConfig, params.percentageMode); + } + + const { color, stop = [] } = getStopsWithColorsFromColorsNumber( + params.colorsNumber, + params.colorSchema, + params.invertColors, + true + ); + const colorsRange: Range[] = [{ from: stop[0], to: stop[stop.length - 1], type: 'range' }]; + const { colorSchema, invertColors, percentageMode } = params; + const percentageModeConfig = getPercentageModeConfig( + { + colorsRange, + colorSchema, + invertColors, + percentageMode, + }, + false + ); + + return getPaletteFromStopsWithColors({ color, stop: stop ?? [] }, percentageModeConfig, true); +}; diff --git a/src/plugins/vis_types/heatmap/public/convert_to_lens/index.test.ts b/src/plugins/vis_types/heatmap/public/convert_to_lens/index.test.ts new file mode 100644 index 0000000000000..ef86b3829c248 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/convert_to_lens/index.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright 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 { ColorSchemas } from '@kbn/charts-plugin/common'; +import { Vis } from '@kbn/visualizations-plugin/public'; +import { convertToLens } from '.'; +import { HeatmapVisParams } from '../types'; + +const mockGetColumnsFromVis = jest.fn(); +const mockGetConfiguration = jest.fn().mockReturnValue({}); +const mockGetDataViewByIndexPatternId = jest.fn(); +const mockConvertToFiltersColumn = jest.fn(); + +jest.mock('../services', () => ({ + getDataViewsStart: jest.fn(() => ({ get: () => ({}), getDefault: () => ({}) })), +})); + +jest.mock('@kbn/visualizations-plugin/public', () => ({ + convertToLensModule: Promise.resolve({ + getColumnsFromVis: jest.fn(() => mockGetColumnsFromVis()), + convertToFiltersColumn: jest.fn(() => mockConvertToFiltersColumn()), + }), + getDataViewByIndexPatternId: jest.fn(() => mockGetDataViewByIndexPatternId()), +})); + +jest.mock('./configurations', () => ({ + getConfiguration: jest.fn(() => mockGetConfiguration()), +})); + +const params: HeatmapVisParams = { + addTooltip: false, + addLegend: false, + enableHover: true, + legendPosition: 'bottom', + lastRangeIsRightOpen: false, + percentageMode: false, + valueAxes: [], + colorSchema: ColorSchemas.Blues, + invertColors: false, + colorsNumber: 4, + setColorRange: true, +}; + +const vis = { + isHierarchical: () => false, + type: {}, + params, + data: {}, +} as unknown as Vis; + +const timefilter = { + getAbsoluteTime: () => {}, +} as any; + +describe('convertToLens', () => { + beforeEach(() => { + mockGetDataViewByIndexPatternId.mockReturnValue({ id: 'index-pattern' }); + mockConvertToFiltersColumn.mockReturnValue({ columnId: 'column-id-1' }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return null if timefilter is undefined', async () => { + const result = await convertToLens(vis); + expect(result).toBeNull(); + }); + + test('should return null if mockGetDataViewByIndexPatternId returns null', async () => { + mockGetDataViewByIndexPatternId.mockReturnValue(null); + const result = await convertToLens(vis, timefilter); + expect(mockGetDataViewByIndexPatternId).toBeCalledTimes(1); + expect(mockGetColumnsFromVis).toBeCalledTimes(0); + expect(result).toBeNull(); + }); + + test('should return null if getColumnsFromVis returns null', async () => { + mockGetColumnsFromVis.mockReturnValue(null); + const result = await convertToLens(vis, timefilter); + expect(mockGetColumnsFromVis).toBeCalledTimes(1); + expect(result).toBeNull(); + }); + + test('should return null if metrics count is more than 1', async () => { + mockGetColumnsFromVis.mockReturnValue([ + { + metrics: ['1', '2'], + buckets: { all: [] }, + columns: [{ columnId: '2' }, { columnId: '1' }], + }, + ]); + const result = await convertToLens(vis, timefilter); + expect(mockGetColumnsFromVis).toBeCalledTimes(1); + expect(result).toBeNull(); + }); + + test('should return empty filters for x-axis if no buckets are specified', async () => { + mockGetColumnsFromVis.mockReturnValue([ + { + metrics: ['1'], + buckets: { all: [] }, + columns: [{ columnId: '1', dataType: 'number' }], + columnsWithoutReferenced: [ + { columnId: '1', meta: { aggId: 'agg-1' } }, + { columnId: '2', meta: { aggId: 'agg-2' } }, + { columnId: 'column-id-1' }, + ], + }, + ]); + const result = await convertToLens(vis, timefilter); + expect(mockGetColumnsFromVis).toBeCalledTimes(1); + expect(result).toEqual( + expect.objectContaining({ + configuration: {}, + indexPatternIds: ['index-pattern'], + layers: [ + expect.objectContaining({ + columnOrder: [], + columns: [{ columnId: '1', dataType: 'number' }, { columnId: 'column-id-1' }], + indexPatternId: 'index-pattern', + }), + ], + type: 'lnsHeatmap', + }) + ); + }); + + test('should return correct state for valid vis', async () => { + const config = { + layerType: 'data', + }; + + mockGetColumnsFromVis.mockReturnValue([ + { + metrics: ['1'], + buckets: { all: ['2'] }, + columns: [{ columnId: '1', dataType: 'number' }], + columnsWithoutReferenced: [ + { columnId: '1', meta: { aggId: 'agg-1' } }, + { columnId: '2', meta: { aggId: 'agg-2' } }, + ], + }, + ]); + mockGetConfiguration.mockReturnValue(config); + + const result = await convertToLens(vis, timefilter); + expect(mockGetColumnsFromVis).toBeCalledTimes(1); + expect(mockGetConfiguration).toBeCalledTimes(1); + expect(result?.type).toEqual('lnsHeatmap'); + expect(result?.layers.length).toEqual(1); + expect(result?.layers[0]).toEqual( + expect.objectContaining({ + columnOrder: [], + columns: [{ columnId: '1', dataType: 'number' }, { columnId: 'column-id-1' }], + indexPatternId: 'index-pattern', + }) + ); + expect(result?.configuration).toEqual(config); + }); +}); diff --git a/src/plugins/vis_types/heatmap/public/convert_to_lens/index.ts b/src/plugins/vis_types/heatmap/public/convert_to_lens/index.ts new file mode 100644 index 0000000000000..546d497e80560 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/convert_to_lens/index.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Column, ColumnWithMeta } from '@kbn/visualizations-plugin/common'; +import { + convertToLensModule, + getDataViewByIndexPatternId, +} from '@kbn/visualizations-plugin/public'; +import uuid from 'uuid'; +import { getDataViewsStart } from '../services'; +import { getConfiguration } from './configurations'; +import { ConvertHeatmapToLensVisualization } from './types'; + +export const isColumnWithMeta = (column: Column): column is ColumnWithMeta => { + if ((column as ColumnWithMeta).meta) { + return true; + } + return false; +}; + +export const excludeMetaFromColumn = (column: Column) => { + if (isColumnWithMeta(column)) { + const { meta, ...rest } = column; + return rest; + } + return column; +}; + +export const convertToLens: ConvertHeatmapToLensVisualization = async (vis, timefilter) => { + if (!timefilter) { + return null; + } + + const dataViews = getDataViewsStart(); + const dataView = await getDataViewByIndexPatternId(vis.data.indexPattern?.id, dataViews); + + if (!dataView) { + return null; + } + + const { getColumnsFromVis, convertToFiltersColumn } = await convertToLensModule; + const layers = getColumnsFromVis(vis, timefilter, dataView, { + buckets: ['segment'], + splits: ['group'], + unsupported: ['split_row', 'split_column'], + }); + + if (layers === null) { + return null; + } + + const [layerConfig] = layers; + + const xColumn = layerConfig.columns.find(({ isBucketed, isSplit }) => isBucketed && !isSplit); + const xAxisColumn = + xColumn ?? + convertToFiltersColumn(uuid(), { filters: [{ input: { language: 'lucene', query: '*' } }] })!; + + if (xColumn?.columnId !== xAxisColumn?.columnId) { + layerConfig.buckets.all.push(xAxisColumn.columnId); + layerConfig.columns.push(xAxisColumn); + } + const yColumn = layerConfig.columns.find(({ isBucketed, isSplit }) => isBucketed && isSplit); + + if (!layerConfig.buckets.all.length || layerConfig.metrics.length > 1) { + return null; + } + + const layerId = uuid(); + + const indexPatternId = dataView.id!; + const configuration = await getConfiguration(layerId, vis, { + metrics: layerConfig.metrics, + buckets: [xAxisColumn.columnId, yColumn?.columnId].filter((c): c is string => + Boolean(c) + ), + }); + + return { + type: 'lnsHeatmap', + layers: [ + { + indexPatternId, + layerId, + columns: layerConfig.columns.map(excludeMetaFromColumn), + columnOrder: [], + }, + ], + configuration, + indexPatternIds: [indexPatternId], + }; +}; diff --git a/src/plugins/vis_types/heatmap/public/convert_to_lens/types.ts b/src/plugins/vis_types/heatmap/public/convert_to_lens/types.ts new file mode 100644 index 0000000000000..732b977dd7b59 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/convert_to_lens/types.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 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 { TimefilterContract } from '@kbn/data-plugin/public'; +import { NavigateToLensContext, HeatmapConfiguration } from '@kbn/visualizations-plugin/common'; +import { Vis } from '@kbn/visualizations-plugin/public'; +import { HeatmapVisParams } from '../types'; + +export type ConvertHeatmapToLensVisualization = ( + vis: Vis, + timefilter?: TimefilterContract +) => Promise | null>; diff --git a/src/plugins/vis_types/heatmap/public/plugin.ts b/src/plugins/vis_types/heatmap/public/plugin.ts index 44357cceaa86b..ee7349145e7c6 100644 --- a/src/plugins/vis_types/heatmap/public/plugin.ts +++ b/src/plugins/vis_types/heatmap/public/plugin.ts @@ -6,14 +6,16 @@ * Side Public License, v 1. */ -import { CoreSetup } from '@kbn/core/public'; +import { CoreSetup, CoreStart } from '@kbn/core/public'; import type { VisualizationsSetup } from '@kbn/visualizations-plugin/public'; import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { LEGACY_HEATMAP_CHARTS_LIBRARY } from '../common'; import { heatmapVisType } from './vis_type'; +import { setDataViewsStart } from './services'; /** @internal */ export interface VisTypeHeatmapSetupDependencies { @@ -28,6 +30,11 @@ export interface VisTypeHeatmapPluginStartDependencies { fieldFormats: FieldFormatsStart; } +/** @internal */ +export interface VisTypeHeatmapStartDependencies { + dataViews: DataViewsPublicPluginStart; +} + export class VisTypeHeatmapPlugin { setup( core: CoreSetup, @@ -44,5 +51,7 @@ export class VisTypeHeatmapPlugin { return {}; } - start() {} + start(core: CoreStart, { dataViews }: VisTypeHeatmapStartDependencies) { + setDataViewsStart(dataViews); + } } diff --git a/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts index 6a33feb853221..89ede55b951ef 100644 --- a/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts @@ -5,7 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -export const sampleAreaVis = { + +const mockUiStateGet = jest.fn().mockReturnValue(() => {}); +export const sampleHeatmapVis = { type: { name: 'heatmap', title: 'Heatmap', @@ -1788,5 +1790,10 @@ export const sampleAreaVis = { }, }, isHierarchical: () => false, - uiState: {}, + uiState: { + vis: { + legendOpen: false, + }, + get: mockUiStateGet, + }, }; diff --git a/src/plugins/vis_types/heatmap/public/services.ts b/src/plugins/vis_types/heatmap/public/services.ts new file mode 100644 index 0000000000000..736ad70d49419 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/services.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createGetterSetter } from '@kbn/kibana-utils-plugin/public'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; + +export const [getDataViewsStart, setDataViewsStart] = + createGetterSetter('dataViews'); diff --git a/src/plugins/vis_types/heatmap/public/to_ast.test.ts b/src/plugins/vis_types/heatmap/public/to_ast.test.ts index d1e312755cf49..07585d9f2332f 100644 --- a/src/plugins/vis_types/heatmap/public/to_ast.test.ts +++ b/src/plugins/vis_types/heatmap/public/to_ast.test.ts @@ -7,7 +7,7 @@ */ import { Vis } from '@kbn/visualizations-plugin/public'; -import { sampleAreaVis } from './sample_vis.test.mocks'; +import { sampleHeatmapVis } from './sample_vis.test.mocks'; import { buildExpression } from '@kbn/expressions-plugin/public'; import { toExpressionAst } from './to_ast'; @@ -33,7 +33,7 @@ describe('heatmap vis toExpressionAst function', () => { } as any; beforeEach(() => { - vis = sampleAreaVis as any; + vis = sampleHeatmapVis as any; }); it('should match basic snapshot', () => { diff --git a/src/plugins/vis_types/heatmap/public/utils/palette.ts b/src/plugins/vis_types/heatmap/public/utils/palette.ts index aa978a2954e90..29109a55fd1e7 100644 --- a/src/plugins/vis_types/heatmap/public/utils/palette.ts +++ b/src/plugins/vis_types/heatmap/public/utils/palette.ts @@ -27,13 +27,20 @@ const getColor = ( export const getStopsWithColorsFromColorsNumber = ( colorsNumber: number | '', colorSchema: ColorSchemas, - invertColors: boolean = false + invertColors: boolean = false, + includeZeroElement: boolean = false ) => { const colors = []; const stops = []; if (!colorsNumber) { return { color: [] }; } + + if (includeZeroElement) { + colors.push(TRANSPARENT); + stops.push(0); + } + const step = 100 / colorsNumber; for (let i = 0; i < colorsNumber; i++) { colors.push(getColor(i, colorsNumber, colorSchema, invertColors)); diff --git a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx index e5a92ca03f5cc..336da6e2d8041 100644 --- a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx +++ b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx @@ -16,6 +16,7 @@ import { HeatmapTypeProps, HeatmapVisParams, AxisType, ScaleType } from '../type import { toExpressionAst } from '../to_ast'; import { getHeatmapOptions } from '../editor/components'; import { SplitTooltip } from './split_tooltip'; +import { convertToLens } from '../convert_to_lens'; export const getHeatmapVisTypeDefinition = ({ showElasticChartsOptions = false, @@ -154,4 +155,10 @@ export const getHeatmapVisTypeDefinition = ({ ], }, requiresSearch: true, + navigateToLens: async (vis, timefilter) => (vis ? convertToLens(vis, timefilter) : null), + getExpressionVariables: async (vis, timeFilter) => { + return { + canNavigateToLens: Boolean(vis?.params ? await convertToLens(vis, timeFilter) : null), + }; + }, }); diff --git a/src/plugins/vis_types/pie/kibana.json b/src/plugins/vis_types/pie/kibana.json index 4c5ee6b50579e..d9dca861e33be 100644 --- a/src/plugins/vis_types/pie/kibana.json +++ b/src/plugins/vis_types/pie/kibana.json @@ -1,14 +1,27 @@ { - "id": "visTypePie", - "version": "kibana", - "ui": true, - "server": true, - "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection", "expressionPartitionVis", "dataViews"], - "requiredBundles": ["visDefaultEditor", "kibanaUtils"], - "extraPublicDirs": ["common/index"], - "owner": { - "name": "Vis Editors", - "githubTeam": "kibana-vis-editors" - }, - "description": "Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting." - } + "id": "visTypePie", + "version": "kibana", + "ui": true, + "server": true, + "requiredPlugins": [ + "charts", + "data", + "expressions", + "visualizations", + "usageCollection", + "expressionPartitionVis", + "dataViews" + ], + "requiredBundles": [ + "visDefaultEditor", + "kibanaUtils" + ], + "extraPublicDirs": [ + "common/index" + ], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + }, + "description": "Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting." +} \ No newline at end of file diff --git a/src/plugins/vis_types/table/public/convert_to_lens/index.ts b/src/plugins/vis_types/table/public/convert_to_lens/index.ts index e69faccbfd7ec..ed23d612cb68c 100644 --- a/src/plugins/vis_types/table/public/convert_to_lens/index.ts +++ b/src/plugins/vis_types/table/public/convert_to_lens/index.ts @@ -73,6 +73,7 @@ export const convertToLens: ConvertTableToLensVisualization = async (vis, timefi return null; } const percentageColumn = getPercentageColumnFormulaColumn({ + visType: vis.type.name, agg: metricAgg as SchemaConfig, dataView, aggs: visSchemas.metric as Array>, diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.test.ts index f0a8e4d32f7c3..02a6140625c07 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.test.ts @@ -8,7 +8,7 @@ import { stubLogstashDataView } from '@kbn/data-views-plugin/common/data_view.stub'; import { BUCKET_TYPES, METRIC_TYPES } from '@kbn/data-plugin/common'; -import { convertBucketToColumns } from '.'; +import { BucketAggs, convertBucketToColumns } from '.'; import { DateHistogramColumn, FiltersColumn, RangeColumn, TermsColumn } from '../../types'; import { AggBasedColumn, SchemaConfig } from '../../..'; @@ -27,7 +27,7 @@ jest.mock('../convert', () => ({ describe('convertBucketToColumns', () => { const field = stubLogstashDataView.fields[0].name; const dateField = stubLogstashDataView.fields.find((f) => f.type === 'date')!.name; - const bucketAggs: SchemaConfig[] = [ + const bucketAggs: Array> = [ { accessor: 0, label: '', @@ -152,6 +152,7 @@ describe('convertBucketToColumns', () => { }, }, ]; + const visType = 'heatmap'; afterEach(() => { jest.clearAllMocks(); @@ -167,7 +168,7 @@ describe('convertBucketToColumns', () => { >([ [ 'null if bucket agg type is not supported', - [{ dataView: stubLogstashDataView, agg: bucketAggs[6], aggs, metricColumns }], + [{ dataView: stubLogstashDataView, agg: bucketAggs[6], aggs, metricColumns, visType }], () => {}, null, ], @@ -179,6 +180,7 @@ describe('convertBucketToColumns', () => { agg: { ...bucketAggs[0], aggParams: undefined }, aggs, metricColumns, + visType, }, ], () => {}, @@ -186,7 +188,7 @@ describe('convertBucketToColumns', () => { ], [ 'filters column if bucket agg is valid filters agg', - [{ dataView: stubLogstashDataView, agg: bucketAggs[0], aggs, metricColumns }], + [{ dataView: stubLogstashDataView, agg: bucketAggs[0], aggs, metricColumns, visType }], () => { mockConvertToFiltersColumn.mockReturnValue({ operationType: 'filters', @@ -198,7 +200,7 @@ describe('convertBucketToColumns', () => { ], [ 'date histogram column if bucket agg is valid date histogram agg', - [{ dataView: stubLogstashDataView, agg: bucketAggs[1], aggs, metricColumns }], + [{ dataView: stubLogstashDataView, agg: bucketAggs[1], aggs, metricColumns, visType }], () => { mockConvertToDateHistogramColumn.mockReturnValue({ operationType: 'date_histogram', @@ -210,7 +212,7 @@ describe('convertBucketToColumns', () => { ], [ 'date histogram column if bucket agg is valid terms agg with date field', - [{ dataView: stubLogstashDataView, agg: bucketAggs[3], aggs, metricColumns }], + [{ dataView: stubLogstashDataView, agg: bucketAggs[3], aggs, metricColumns, visType }], () => { mockConvertToDateHistogramColumn.mockReturnValue({ operationType: 'date_histogram', @@ -222,7 +224,7 @@ describe('convertBucketToColumns', () => { ], [ 'terms column if bucket agg is valid terms agg with no date field', - [{ dataView: stubLogstashDataView, agg: bucketAggs[2], aggs, metricColumns }], + [{ dataView: stubLogstashDataView, agg: bucketAggs[2], aggs, metricColumns, visType }], () => { mockConvertToTermsColumn.mockReturnValue({ operationType: 'terms', @@ -234,7 +236,7 @@ describe('convertBucketToColumns', () => { ], [ 'range column if bucket agg is valid histogram agg', - [{ dataView: stubLogstashDataView, agg: bucketAggs[4], aggs, metricColumns }], + [{ dataView: stubLogstashDataView, agg: bucketAggs[4], aggs, metricColumns, visType }], () => { mockConvertToRangeColumn.mockReturnValue({ operationType: 'range', @@ -246,7 +248,7 @@ describe('convertBucketToColumns', () => { ], [ 'range column if bucket agg is valid range agg', - [{ dataView: stubLogstashDataView, agg: bucketAggs[5], aggs, metricColumns }], + [{ dataView: stubLogstashDataView, agg: bucketAggs[5], aggs, metricColumns, visType }], () => { mockConvertToRangeColumn.mockReturnValue({ operationType: 'range', diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.ts b/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.ts index 0f929189f3369..db02b1e09fdce 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/buckets/index.ts @@ -9,9 +9,8 @@ import { BUCKET_TYPES, IAggConfig, METRIC_TYPES } from '@kbn/data-plugin/common'; import type { DataView } from '@kbn/data-views-plugin/common'; import { convertToSchemaConfig } from '../../../vis_schemas'; -import { SchemaConfig } from '../../..'; +import { AggBasedColumn, SchemaConfig } from '../../..'; import { - AggBasedColumn, CommonBucketConverterArgs, convertToDateHistogramColumn, convertToFiltersColumn, @@ -26,6 +25,7 @@ export type BucketAggs = | BUCKET_TYPES.FILTERS | BUCKET_TYPES.RANGE | BUCKET_TYPES.HISTOGRAM; + const SUPPORTED_BUCKETS: string[] = [ BUCKET_TYPES.TERMS, BUCKET_TYPES.DATE_HISTOGRAM, @@ -39,7 +39,7 @@ const isSupportedBucketAgg = (agg: SchemaConfig): agg is SchemaConfig, + { agg, dataView, metricColumns, aggs, visType }: CommonBucketConverterArgs, { label, isSplit = false, @@ -76,7 +76,7 @@ export const getBucketColumns = ( if (field.type !== 'date') { return convertToTermsColumn( agg.aggId ?? '', - { agg, dataView, metricColumns, aggs }, + { agg, dataView, metricColumns, aggs, visType }, label, isSplit ); @@ -102,7 +102,9 @@ export const convertBucketToColumns = ( dataView, metricColumns, aggs, + visType, }: { + visType: string; agg: SchemaConfig | IAggConfig; dataView: DataView; metricColumns: AggBasedColumn[]; @@ -116,7 +118,7 @@ export const convertBucketToColumns = ( return null; } return getBucketColumns( - { agg: currentAgg, dataView, metricColumns, aggs }, + { agg: currentAgg, dataView, metricColumns, aggs, visType }, { label: getLabel(currentAgg), isSplit, diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/configurations/index.ts b/src/plugins/visualizations/common/convert_to_lens/lib/configurations/index.ts index c4592f50836c5..b4934d0bb0c85 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/configurations/index.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/configurations/index.ts @@ -6,5 +6,5 @@ * Side Public License, v 1. */ -export { getPalette } from './palette'; +export { getPalette, getPaletteFromStopsWithColors } from './palette'; export { getPercentageModeConfig } from './percentage_mode'; diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/configurations/palette.ts b/src/plugins/visualizations/common/convert_to_lens/lib/configurations/palette.ts index a89177c914996..3f81291fab201 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/configurations/palette.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/configurations/palette.ts @@ -74,6 +74,21 @@ const convertToPercentColorStops = ( return { ...colorStops, stop }; }; +export const getPaletteFromStopsWithColors = ( + config: PaletteConfig, + percentageModeConfig: PercentageModeConfig, + isPercentPaletteSupported: boolean = false +) => { + const percentStopsWithColors = percentageModeConfig.isPercentageMode + ? convertToPercentColorStops(config, percentageModeConfig, isPercentPaletteSupported) + : config; + + return buildCustomPalette( + buildPaletteParams(percentStopsWithColors), + isPercentPaletteSupported && percentageModeConfig.isPercentageMode + ); +}; + export const getPalette = ( params: PaletteParams, percentageModeConfig: PercentageModeConfig, @@ -86,12 +101,10 @@ export const getPalette = ( } const stopsWithColors = getStopsWithColorsFromRanges(colorsRange, colorSchema, invertColors); - const percentStopsWithColors = percentageModeConfig.isPercentageMode - ? convertToPercentColorStops(stopsWithColors, percentageModeConfig, isPercentPaletteSupported) - : stopsWithColors; - return buildCustomPalette( - buildPaletteParams(percentStopsWithColors), - isPercentPaletteSupported && percentageModeConfig.isPercentageMode + return getPaletteFromStopsWithColors( + stopsWithColors, + percentageModeConfig, + isPercentPaletteSupported ); }; diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/formula.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/formula.ts index 0ad2a4072e19d..e79be2ba51516 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/formula.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/formula.ts @@ -21,6 +21,7 @@ export const createFormulaColumn = (formula: string, agg: SchemaConfig): Formula operationType: 'formula', ...createColumn(agg), references: [], + dataType: 'number', params: { ...params, ...getFormat() }, timeShift: agg.aggParams?.timeShift, meta: { aggId: createAggregationId(agg) }, diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.test.ts index 55ba1e8b5e09d..c46055ca6a9ab 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.test.ts @@ -22,6 +22,7 @@ jest.mock('../utils', () => ({ })); describe('convertToLastValueColumn', () => { + const visType = 'heatmap'; const dataView = stubLogstashDataView; const sortField = dataView.fields[0]; @@ -59,7 +60,13 @@ describe('convertToLastValueColumn', () => { test.each<[string, Parameters, Partial | null]>([ [ 'null if top hits size is more than 1', - [{ agg: { ...topHitAgg, aggParams: { ...topHitAgg.aggParams!, size: 2 } }, dataView }], + [ + { + agg: { ...topHitAgg, aggParams: { ...topHitAgg.aggParams!, size: 2 } }, + dataView, + visType, + }, + ], null, ], [ @@ -74,6 +81,7 @@ describe('convertToLastValueColumn', () => { }, }, dataView, + visType, }, ], null, @@ -88,7 +96,7 @@ describe('convertToLastValueColumn', () => { test('should skip if top hit field is not specified', () => { mockGetFieldNameFromField.mockReturnValue(null); - expect(convertToLastValueColumn({ agg: topHitAgg, dataView })).toBeNull(); + expect(convertToLastValueColumn({ agg: topHitAgg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(0); }); @@ -97,14 +105,14 @@ describe('convertToLastValueColumn', () => { mockGetFieldByName.mockReturnValue(null); dataView.getFieldByName = mockGetFieldByName; - expect(convertToLastValueColumn({ agg: topHitAgg, dataView })).toBeNull(); + expect(convertToLastValueColumn({ agg: topHitAgg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(1); expect(mockGetLabel).toBeCalledTimes(0); }); test('should return top hit column if top hit field is not present in index pattern', () => { - expect(convertToLastValueColumn({ agg: topHitAgg, dataView })).toEqual( + expect(convertToLastValueColumn({ agg: topHitAgg, dataView, visType })).toEqual( expect.objectContaining({ dataType: 'number', label: 'someLabel', diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.ts index 3162cf14e71c3..9525f4b41b7eb 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/last_value.ts @@ -25,7 +25,11 @@ const convertToLastValueParams = ( }; export const convertToLastValueColumn = ( - { agg, dataView }: CommonColumnConverterArgs, + { + visType, + agg, + dataView, + }: CommonColumnConverterArgs, reducedTimeRange?: string ): LastValueColumn | null => { const { aggParams } = agg; @@ -43,7 +47,7 @@ export const convertToLastValueColumn = ( } const field = dataView.getFieldByName(fieldName); - if (!isFieldValid(field, SUPPORTED_METRICS[agg.aggType])) { + if (!isFieldValid(visType, field, SUPPORTED_METRICS[agg.aggType])) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.test.ts index 3be17abc46ac1..a0419d46df6b5 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.test.ts @@ -16,6 +16,7 @@ const mockGetFieldByName = jest.fn(); describe('convertToLastValueColumn', () => { const dataView = stubLogstashDataView; + const visType = 'heatmap'; const agg: SchemaConfig = { accessor: 0, @@ -42,6 +43,7 @@ describe('convertToLastValueColumn', () => { convertMetricAggregationColumnWithoutSpecialParams(SUPPORTED_METRICS[METRIC_TYPES.TOP_HITS], { agg, dataView, + visType, }) ).toBeNull(); }); @@ -54,6 +56,7 @@ describe('convertToLastValueColumn', () => { convertMetricAggregationColumnWithoutSpecialParams(SUPPORTED_METRICS[METRIC_TYPES.AVG], { agg, dataView, + visType, }) ).toBeNull(); expect(dataView.getFieldByName).toBeCalledTimes(1); @@ -67,6 +70,7 @@ describe('convertToLastValueColumn', () => { convertMetricAggregationColumnWithoutSpecialParams(SUPPORTED_METRICS[METRIC_TYPES.COUNT], { agg, dataView, + visType, }) ).toEqual(expect.objectContaining({ operationType: 'count' })); expect(dataView.getFieldByName).toBeCalledTimes(1); @@ -80,6 +84,7 @@ describe('convertToLastValueColumn', () => { convertMetricAggregationColumnWithoutSpecialParams(SUPPORTED_METRICS[METRIC_TYPES.AVG], { agg, dataView, + visType, }) ).toEqual( expect.objectContaining({ diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.ts index eb21b9f0fe91d..dd6c8b02687b0 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/metric.ts @@ -78,7 +78,7 @@ export const isMetricWithField = ( export const convertMetricAggregationColumnWithoutSpecialParams = ( aggregation: SupportedMetric, - { agg, dataView }: CommonColumnConverterArgs, + { visType, agg, dataView }: CommonColumnConverterArgs, reducedTimeRange?: string ): MetricAggregationColumnWithoutSpecialParams | null => { if (!isSupportedAggregationWithoutParams(aggregation.name)) { @@ -94,7 +94,7 @@ export const convertMetricAggregationColumnWithoutSpecialParams = ( } const field = dataView.getFieldByName(sourceField); - if (!isFieldValid(field, aggregation)) { + if (!isFieldValid(visType, field, aggregation)) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.test.ts index c28324533c837..65dd1cf40aaef 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.test.ts @@ -40,6 +40,7 @@ jest.mock('../metrics', () => ({ })); describe('convertToOtherParentPipelineAggColumns', () => { + const visType = 'heatmap'; const field = stubLogstashDataView.fields[0].name; const aggs: Array> = [ { @@ -81,6 +82,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -95,6 +97,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -112,6 +115,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -129,6 +133,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -147,6 +152,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -170,6 +176,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -188,6 +195,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -229,6 +237,7 @@ describe('convertToOtherParentPipelineAggColumns', () => { }); describe('convertToCumulativeSumAggColumn', () => { + const visType = 'heatmap'; const field = stubLogstashDataView.fields[0].name; const aggs: Array> = [ { @@ -280,6 +289,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: { ...aggs[1], aggParams: undefined } as SchemaConfig, + visType, }, ], () => { @@ -294,6 +304,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -308,6 +319,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -325,6 +337,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -342,6 +355,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -360,6 +374,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -383,6 +398,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { @@ -401,6 +417,7 @@ describe('convertToCumulativeSumAggColumn', () => { dataView: stubLogstashDataView, aggs, agg: aggs[1] as SchemaConfig, + visType, }, ], () => { diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.ts index ab41ceb259adb..0e0aef11316b2 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/parent_pipeline.ts @@ -38,7 +38,7 @@ export const convertToMovingAverageParams = ( }); export const convertToOtherParentPipelineAggColumns = ( - { agg, dataView, aggs }: ExtendedColumnConverterArgs, + { agg, dataView, aggs, visType }: ExtendedColumnConverterArgs, reducedTimeRange?: string ): FormulaColumn | [ParentPipelineAggColumn, AggBasedColumn] | null => { const { aggType } = agg; @@ -63,7 +63,7 @@ export const convertToOtherParentPipelineAggColumns = ( } if (PIPELINE_AGGS.includes(metric.aggType)) { - const formula = getFormulaForPipelineAgg({ agg, aggs, dataView }); + const formula = getFormulaForPipelineAgg({ agg, aggs, dataView, visType }); if (!formula) { return null; } @@ -71,7 +71,7 @@ export const convertToOtherParentPipelineAggColumns = ( return createFormulaColumn(formula, agg); } - const subMetric = convertMetricToColumns(metric, dataView, aggs); + const subMetric = convertMetricToColumns({ agg: metric, dataView, aggs, visType }); if (subMetric === null) { return null; @@ -90,7 +90,7 @@ export const convertToOtherParentPipelineAggColumns = ( }; export const convertToCumulativeSumAggColumn = ( - { agg, dataView, aggs }: ExtendedColumnConverterArgs, + { agg, dataView, aggs, visType }: ExtendedColumnConverterArgs, reducedTimeRange?: string ): | FormulaColumn @@ -119,7 +119,7 @@ export const convertToCumulativeSumAggColumn = ( // create column for sum or count const subMetric = convertMetricAggregationColumnWithoutSpecialParams( subAgg, - { agg: metric as SchemaConfig, dataView }, + { agg: metric as SchemaConfig, dataView, visType }, reducedTimeRange ); @@ -144,7 +144,7 @@ export const convertToCumulativeSumAggColumn = ( subMetric, ]; } else { - const formula = getFormulaForPipelineAgg({ agg, aggs, dataView }); + const formula = getFormulaForPipelineAgg({ agg, aggs, dataView, visType }); if (!formula) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentage_mode.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentage_mode.test.ts index 3b7e8ad7e797f..0ef5d07236d60 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentage_mode.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentage_mode.test.ts @@ -18,6 +18,7 @@ jest.mock('../metrics/formula', () => ({ })); describe('convertToColumnInPercentageMode', () => { + const visType = 'heatmap'; const formula = 'average(some_field)'; const dataView = stubLogstashDataView; @@ -42,7 +43,7 @@ describe('convertToColumnInPercentageMode', () => { test('should return null if it is not possible to build the valid formula', () => { mockGetFormulaForAgg.mockReturnValue(null); - expect(convertToColumnInPercentageMode({ agg, dataView, aggs: [agg] }, {})).toBeNull(); + expect(convertToColumnInPercentageMode({ agg, dataView, aggs: [agg], visType }, {})).toBeNull(); }); test('should return percentage mode over range formula if min and max was passed', () => { @@ -51,7 +52,7 @@ describe('convertToColumnInPercentageMode', () => { params: { format: { id: 'percent' }, formula: `((${formula}) - 0) / (100 - 0)` }, }; expect( - convertToColumnInPercentageMode({ agg, dataView, aggs: [agg] }, { min: 0, max: 100 }) + convertToColumnInPercentageMode({ agg, dataView, aggs: [agg], visType }, { min: 0, max: 100 }) ).toEqual(expect.objectContaining(formulaColumn)); }); @@ -60,7 +61,7 @@ describe('convertToColumnInPercentageMode', () => { operationType: 'formula', params: { format: { id: 'percent' }, formula: `(${formula}) / 10000` }, }; - expect(convertToColumnInPercentageMode({ agg, dataView, aggs: [agg] }, {})).toEqual( + expect(convertToColumnInPercentageMode({ agg, dataView, aggs: [agg], visType }, {})).toEqual( expect.objectContaining(formulaColumn) ); }); diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.test.ts index b4cf7f141e928..adfab7f55d1c4 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.test.ts @@ -24,6 +24,7 @@ jest.mock('../utils', () => ({ })); describe('convertToPercentileColumn', () => { + const visType = 'heatmap'; const dataView = stubLogstashDataView; const field = dataView.fields[0].displayName; const aggId = 'pr.10'; @@ -67,23 +68,27 @@ describe('convertToPercentileColumn', () => { test.each< [string, Parameters, Partial | null] >([ - ['null if no percents', [{ agg: { ...agg, aggId: 'pr' }, dataView }], null], + ['null if no percents', [{ agg: { ...agg, aggId: 'pr' }, dataView, visType }], null], [ 'null if no value', - [{ agg: { ...singlePercentileRankAgg, aggParams: undefined }, dataView }], + [{ agg: { ...singlePercentileRankAgg, aggParams: undefined }, dataView, visType }], + null, + ], + ['null if no aggId', [{ agg: { ...agg, aggId: undefined }, dataView, visType }], null], + ['null if no aggParams', [{ agg: { ...agg, aggParams: undefined }, dataView, visType }], null], + [ + 'null if aggId is invalid', + [{ agg: { ...agg, aggId: 'pr.invalid' }, dataView, visType }], null, ], - ['null if no aggId', [{ agg: { ...agg, aggId: undefined }, dataView }], null], - ['null if no aggParams', [{ agg: { ...agg, aggParams: undefined }, dataView }], null], - ['null if aggId is invalid', [{ agg: { ...agg, aggId: 'pr.invalid' }, dataView }], null], [ 'null if values are undefined', - [{ agg: { ...agg, aggParams: { percents: undefined, field } }, dataView }], + [{ agg: { ...agg, aggParams: { percents: undefined, field } }, dataView, visType }], null, ], [ 'null if values are empty', - [{ agg: { ...agg, aggParams: { percents: [], field } }, dataView }], + [{ agg: { ...agg, aggParams: { percents: [], field } }, dataView, visType }], null, ], ])('should return %s', (_, input, expected) => { @@ -96,7 +101,7 @@ describe('convertToPercentileColumn', () => { test('should return null if field is not specified', () => { mockGetFieldNameFromField.mockReturnValue(null); - expect(convertToPercentileColumn({ agg, dataView })).toBeNull(); + expect(convertToPercentileColumn({ agg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(0); }); @@ -105,13 +110,13 @@ describe('convertToPercentileColumn', () => { mockGetFieldByName.mockReturnValueOnce(null); dataView.getFieldByName = mockGetFieldByName; - expect(convertToPercentileColumn({ agg, dataView })).toBeNull(); + expect(convertToPercentileColumn({ agg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(1); }); test('should return percentile rank column for percentiles', () => { - expect(convertToPercentileColumn({ agg, dataView })).toEqual( + expect(convertToPercentileColumn({ agg, dataView, visType })).toEqual( expect.objectContaining({ dataType: 'number', label: 'someOtherLabel', @@ -126,7 +131,7 @@ describe('convertToPercentileColumn', () => { }); test('should return percentile rank column for single percentile', () => { - expect(convertToPercentileColumn({ agg: singlePercentileRankAgg, dataView })).toEqual( + expect(convertToPercentileColumn({ agg: singlePercentileRankAgg, dataView, visType })).toEqual( expect.objectContaining({ dataType: 'number', label: 'someOtherLabel', diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.ts index de9d4e088b636..9989db1c5dda7 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile.ts @@ -51,6 +51,7 @@ const getPercent = ( export const convertToPercentileColumn = ( { + visType, agg, dataView, }: CommonColumnConverterArgs, @@ -74,7 +75,7 @@ export const convertToPercentileColumn = ( } const field = dataView.getFieldByName(fieldName); - if (!isFieldValid(field, SUPPORTED_METRICS[agg.aggType])) { + if (!isFieldValid(visType, field, SUPPORTED_METRICS[agg.aggType])) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.test.ts index 8a696d51d871b..afeaa9899d107 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.test.ts @@ -24,6 +24,7 @@ jest.mock('../utils', () => ({ })); describe('convertToPercentileRankColumn', () => { + const visType = 'heatmap'; const dataView = stubLogstashDataView; const field = dataView.fields[0].displayName; const aggId = 'pr.10'; @@ -71,23 +72,27 @@ describe('convertToPercentileRankColumn', () => { Partial | null ] >([ - ['null if no percents', [{ agg: { ...agg, aggId: 'pr' }, dataView }], null], + ['null if no percents', [{ agg: { ...agg, aggId: 'pr' }, dataView, visType }], null], [ 'null if no value', - [{ agg: { ...singlePercentileRankAgg, aggParams: undefined }, dataView }], + [{ agg: { ...singlePercentileRankAgg, aggParams: undefined }, dataView, visType }], + null, + ], + ['null if no aggId', [{ agg: { ...agg, aggId: undefined }, dataView, visType }], null], + ['null if no aggParams', [{ agg: { ...agg, aggParams: undefined }, dataView, visType }], null], + [ + 'null if aggId is invalid', + [{ agg: { ...agg, aggId: 'pr.invalid' }, dataView, visType }], null, ], - ['null if no aggId', [{ agg: { ...agg, aggId: undefined }, dataView }], null], - ['null if no aggParams', [{ agg: { ...agg, aggParams: undefined }, dataView }], null], - ['null if aggId is invalid', [{ agg: { ...agg, aggId: 'pr.invalid' }, dataView }], null], [ 'null if values are undefined', - [{ agg: { ...agg, aggParams: { values: undefined, field } }, dataView }], + [{ agg: { ...agg, aggParams: { values: undefined, field } }, dataView, visType }], null, ], [ 'null if values are empty', - [{ agg: { ...agg, aggParams: { values: [], field } }, dataView }], + [{ agg: { ...agg, aggParams: { values: [], field } }, dataView, visType }], null, ], ])('should return %s', (_, input, expected) => { @@ -100,7 +105,7 @@ describe('convertToPercentileRankColumn', () => { test('should return null if field is not specified', () => { mockGetFieldNameFromField.mockReturnValue(null); - expect(convertToPercentileRankColumn({ agg, dataView })).toBeNull(); + expect(convertToPercentileRankColumn({ agg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(0); }); @@ -109,13 +114,13 @@ describe('convertToPercentileRankColumn', () => { mockGetFieldByName.mockReturnValueOnce(null); dataView.getFieldByName = mockGetFieldByName; - expect(convertToPercentileRankColumn({ agg, dataView })).toBeNull(); + expect(convertToPercentileRankColumn({ agg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(1); }); test('should return percentile rank column for percentile ranks', () => { - expect(convertToPercentileRankColumn({ agg, dataView })).toEqual( + expect(convertToPercentileRankColumn({ agg, dataView, visType })).toEqual( expect.objectContaining({ dataType: 'number', label: 'someOtherLabel', @@ -130,7 +135,9 @@ describe('convertToPercentileRankColumn', () => { }); test('should return percentile rank column for single percentile rank', () => { - expect(convertToPercentileRankColumn({ agg: singlePercentileRankAgg, dataView })).toEqual( + expect( + convertToPercentileRankColumn({ agg: singlePercentileRankAgg, dataView, visType }) + ).toEqual( expect.objectContaining({ dataType: 'number', label: 'someOtherLabel', diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.ts index 5124a26543552..8fb55789dd6a7 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/percentile_rank.ts @@ -50,6 +50,7 @@ const getPercent = ( export const convertToPercentileRankColumn = ( { + visType, agg, dataView, }: CommonColumnConverterArgs, @@ -69,7 +70,7 @@ export const convertToPercentileRankColumn = ( } const field = dataView.getFieldByName(fieldName); - if (!isFieldValid(field, SUPPORTED_METRICS[agg.aggType])) { + if (!isFieldValid(visType, field, SUPPORTED_METRICS[agg.aggType])) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.test.ts index 8f535c28c8264..5a754fd1c9466 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.test.ts @@ -60,7 +60,6 @@ describe('convertToRangeColumn', () => { params: { type: RANGE_MODES.Histogram, maxBars: 'auto', - ranges: [], }, }, ], diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.ts index 6a9f96fd5ad1e..98200c321935c 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/range.ts @@ -27,18 +27,17 @@ export const convertToRangeParams = ( return { type: RANGE_MODES.Histogram, maxBars: aggParams.maxBars ?? 'auto', - ranges: [], + includeEmptyRows: aggParams.min_doc_count, }; } else { return { type: RANGE_MODES.Range, maxBars: 'auto', - ranges: - aggParams.ranges?.map((range) => ({ - label: range.label, - from: range.from ?? null, - to: range.to ?? null, - })) ?? [], + ranges: aggParams.ranges?.map((range) => ({ + label: range.label, + from: range.from ?? null, + to: range.to ?? null, + })), }; } }; diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.test.ts index 759620650b8a6..6adde7004b69a 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.test.ts @@ -23,6 +23,7 @@ jest.mock('../../../vis_schemas', () => ({ })); describe('convertToSiblingPipelineColumns', () => { + const visType = 'heatmap'; const dataView = stubLogstashDataView; const aggId = 'agg-id-1'; const agg: SchemaConfig = { @@ -46,7 +47,12 @@ describe('convertToSiblingPipelineColumns', () => { test('should return null if aggParams are not defined', () => { expect( - convertToSiblingPipelineColumns({ agg: { ...agg, aggParams: undefined }, aggs: [], dataView }) + convertToSiblingPipelineColumns({ + agg: { ...agg, aggParams: undefined }, + aggs: [], + dataView, + visType, + }) ).toBeNull(); expect(mockConvertMetricToColumns).toBeCalledTimes(0); }); @@ -57,6 +63,7 @@ describe('convertToSiblingPipelineColumns', () => { agg: { ...agg, aggParams: { customMetric: undefined } }, aggs: [], dataView, + visType, }) ).toBeNull(); expect(mockConvertMetricToColumns).toBeCalledTimes(0); @@ -64,7 +71,7 @@ describe('convertToSiblingPipelineColumns', () => { test('should return null if sibling agg is not supported', () => { mockConvertMetricToColumns.mockReturnValue(null); - expect(convertToSiblingPipelineColumns({ agg, aggs: [], dataView })).toBeNull(); + expect(convertToSiblingPipelineColumns({ agg, aggs: [], dataView, visType })).toBeNull(); expect(mockConvertToSchemaConfig).toBeCalledTimes(1); expect(mockConvertMetricToColumns).toBeCalledTimes(1); }); @@ -72,7 +79,7 @@ describe('convertToSiblingPipelineColumns', () => { test('should return column', () => { const column = { operationType: 'formula' }; mockConvertMetricToColumns.mockReturnValue([column]); - expect(convertToSiblingPipelineColumns({ agg, aggs: [], dataView })).toEqual(column); + expect(convertToSiblingPipelineColumns({ agg, aggs: [], dataView, visType })).toEqual(column); expect(mockConvertToSchemaConfig).toBeCalledTimes(1); expect(mockConvertMetricToColumns).toBeCalledTimes(1); }); diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.ts index a8389cb8601e4..c77500a55d5d1 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/sibling_pipeline.ts @@ -22,11 +22,12 @@ export const convertToSiblingPipelineColumns = ( return null; } - const customMetricColumn = convertMetricToColumns( - { ...convertToSchemaConfig(aggParams.customMetric), label, aggId }, - columnConverterArgs.dataView, - columnConverterArgs.aggs - ); + const customMetricColumn = convertMetricToColumns({ + agg: { ...convertToSchemaConfig(aggParams.customMetric), label, aggId }, + dataView: columnConverterArgs.dataView, + aggs: columnConverterArgs.aggs, + visType: columnConverterArgs.visType, + }); if (!customMetricColumn) { return null; diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.test.ts index cbb1f03a6dc2e..c786d6b8c3a6f 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.test.ts @@ -22,6 +22,7 @@ jest.mock('../utils', () => ({ })); describe('convertToStdDeviationFormulaColumns', () => { + const visType = 'heatmap'; const dataView = stubLogstashDataView; const stdLowerAggId = 'agg-id.std_lower'; const stdUpperAggId = 'agg-id.std_upper'; @@ -51,22 +52,25 @@ describe('convertToStdDeviationFormulaColumns', () => { test.each< [string, Parameters, Partial | null] - >([['null if no aggId is passed', [{ agg: { ...agg, aggId: undefined }, dataView }], null]])( - 'should return %s', - (_, input, expected) => { - if (expected === null) { - expect(convertToStdDeviationFormulaColumns(...input)).toBeNull(); - } else { - expect(convertToStdDeviationFormulaColumns(...input)).toEqual( - expect.objectContaining(expected) - ); - } + >([ + [ + 'null if no aggId is passed', + [{ agg: { ...agg, aggId: undefined }, dataView, visType }], + null, + ], + ])('should return %s', (_, input, expected) => { + if (expected === null) { + expect(convertToStdDeviationFormulaColumns(...input)).toBeNull(); + } else { + expect(convertToStdDeviationFormulaColumns(...input)).toEqual( + expect.objectContaining(expected) + ); } - ); + }); test('should return null if field is not present', () => { mockGetFieldNameFromField.mockReturnValue(null); - expect(convertToStdDeviationFormulaColumns({ agg, dataView })).toBeNull(); + expect(convertToStdDeviationFormulaColumns({ agg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(0); }); @@ -74,14 +78,14 @@ describe('convertToStdDeviationFormulaColumns', () => { test("should return null if field doesn't exist in dataView", () => { mockGetFieldByName.mockReturnValue(null); dataView.getFieldByName = mockGetFieldByName; - expect(convertToStdDeviationFormulaColumns({ agg, dataView })).toBeNull(); + expect(convertToStdDeviationFormulaColumns({ agg, dataView, visType })).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(1); }); test('should return null if agg id is invalid', () => { expect( - convertToStdDeviationFormulaColumns({ agg: { ...agg, aggId: 'some-id' }, dataView }) + convertToStdDeviationFormulaColumns({ agg: { ...agg, aggId: 'some-id' }, dataView, visType }) ).toBeNull(); expect(mockGetFieldNameFromField).toBeCalledTimes(1); expect(dataView.getFieldByName).toBeCalledTimes(1); @@ -89,7 +93,11 @@ describe('convertToStdDeviationFormulaColumns', () => { test('should return formula column for lower std deviation', () => { expect( - convertToStdDeviationFormulaColumns({ agg: { ...agg, aggId: stdLowerAggId }, dataView }) + convertToStdDeviationFormulaColumns({ + agg: { ...agg, aggId: stdLowerAggId }, + dataView, + visType, + }) ).toEqual( expect.objectContaining({ label, @@ -102,7 +110,11 @@ describe('convertToStdDeviationFormulaColumns', () => { test('should return formula column for upper std deviation', () => { expect( - convertToStdDeviationFormulaColumns({ agg: { ...agg, aggId: stdUpperAggId }, dataView }) + convertToStdDeviationFormulaColumns({ + agg: { ...agg, aggId: stdUpperAggId }, + dataView, + visType, + }) ).toEqual( expect.objectContaining({ label, diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.ts index f2c218d429bdf..fe4e854759d8f 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/std_deviation.ts @@ -50,7 +50,7 @@ export const getStdDeviationFormula = ( }; export const convertToStdDeviationFormulaColumns = ( - { agg, dataView }: CommonColumnConverterArgs, + { visType, agg, dataView }: CommonColumnConverterArgs, reducedTimeRange?: string ) => { const { aggId } = agg; @@ -64,7 +64,7 @@ export const convertToStdDeviationFormulaColumns = ( return null; } const field = dataView.getFieldByName(fieldName); - if (!isFieldValid(field, SUPPORTED_METRICS[agg.aggType])) { + if (!isFieldValid(visType, field, SUPPORTED_METRICS[agg.aggType])) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/supported_metrics.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/supported_metrics.ts index 17a8ccf26c369..61f3f3961b6dc 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/supported_metrics.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/supported_metrics.ts @@ -18,10 +18,12 @@ interface AggWithFormula { formula: string; } +type SupportedDataTypes = { [key: string]: readonly string[] } & { default: readonly string[] }; + export type AggOptions = { isFullReference: boolean; isFieldRequired: boolean; - supportedDataTypes: readonly string[]; + supportedDataTypes: SupportedDataTypes; } & (T extends Exclude ? Agg : AggWithFormula); // list of supported TSVB aggregation types in Lens @@ -62,9 +64,9 @@ export type SupportedMetrics = LocalSupportedMetrics & { [Key in UnsupportedSupportedMetrics]?: null; }; -const supportedDataTypesWithDate = ['number', 'date', 'histogram'] as const; -const supportedDataTypes = ['number', 'histogram'] as const; -const extendedSupportedDataTypes = [ +const supportedDataTypesWithDate: readonly string[] = ['number', 'date', 'histogram']; +const supportedDataTypes: readonly string[] = ['number', 'histogram']; +const extendedSupportedDataTypes: readonly string[] = [ 'string', 'boolean', 'number', @@ -74,44 +76,44 @@ const extendedSupportedDataTypes = [ 'date', 'date_range', 'murmur3', -] as const; +]; export const SUPPORTED_METRICS: SupportedMetrics = { avg: { name: 'average', isFullReference: false, isFieldRequired: true, - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, cardinality: { name: 'unique_count', isFullReference: false, isFieldRequired: true, - supportedDataTypes: extendedSupportedDataTypes, + supportedDataTypes: { default: extendedSupportedDataTypes }, }, count: { name: 'count', isFullReference: false, isFieldRequired: false, - supportedDataTypes: [], + supportedDataTypes: { default: ['number'] }, }, moving_avg: { name: 'moving_average', isFullReference: true, isFieldRequired: true, - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, derivative: { name: 'differences', isFullReference: true, isFieldRequired: true, - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, cumulative_sum: { name: 'cumulative_sum', isFullReference: true, isFieldRequired: true, - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, avg_bucket: { name: 'formula', @@ -119,7 +121,7 @@ export const SUPPORTED_METRICS: SupportedMetrics = { isFieldRequired: true, isFormula: true, formula: 'overall_average', - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, max_bucket: { name: 'formula', @@ -127,7 +129,7 @@ export const SUPPORTED_METRICS: SupportedMetrics = { isFieldRequired: true, isFormula: true, formula: 'overall_max', - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, min_bucket: { name: 'formula', @@ -135,7 +137,7 @@ export const SUPPORTED_METRICS: SupportedMetrics = { isFieldRequired: true, isFormula: true, formula: 'overall_min', - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, sum_bucket: { name: 'formula', @@ -143,79 +145,91 @@ export const SUPPORTED_METRICS: SupportedMetrics = { isFieldRequired: true, isFormula: true, formula: 'overall_sum', - supportedDataTypes: ['number'], + supportedDataTypes: { default: ['number'] }, }, max: { name: 'max', isFullReference: false, isFieldRequired: true, - supportedDataTypes: supportedDataTypesWithDate, + supportedDataTypes: { + default: ['number'], + heatmap: ['number'], + line: ['number'], + area: ['number'], + histogram: ['number'], + }, }, min: { name: 'min', isFullReference: false, isFieldRequired: true, - supportedDataTypes: supportedDataTypesWithDate, + supportedDataTypes: { + default: supportedDataTypesWithDate, + heatmap: ['number'], + line: ['number'], + area: ['number'], + histogram: ['number'], + }, }, percentiles: { name: 'percentile', isFullReference: false, isFieldRequired: true, - supportedDataTypes, + supportedDataTypes: { default: supportedDataTypes }, }, single_percentile: { name: 'percentile', isFullReference: false, isFieldRequired: true, - supportedDataTypes, + supportedDataTypes: { default: supportedDataTypes }, }, percentile_ranks: { name: 'percentile_rank', isFullReference: false, isFieldRequired: true, - supportedDataTypes, + supportedDataTypes: { default: supportedDataTypes }, }, single_percentile_rank: { name: 'percentile_rank', isFullReference: false, isFieldRequired: true, - supportedDataTypes, + supportedDataTypes: { default: supportedDataTypes }, }, sum: { name: 'sum', isFullReference: false, isFieldRequired: true, - supportedDataTypes, + supportedDataTypes: { default: supportedDataTypes }, }, top_hits: { name: 'last_value', isFullReference: false, isFieldRequired: true, - supportedDataTypes: extendedSupportedDataTypes, + supportedDataTypes: { default: extendedSupportedDataTypes }, }, top_metrics: { name: 'last_value', isFullReference: false, isFieldRequired: true, - supportedDataTypes: extendedSupportedDataTypes, + supportedDataTypes: { default: extendedSupportedDataTypes }, }, value_count: { name: 'count', isFullReference: false, isFieldRequired: true, - supportedDataTypes: extendedSupportedDataTypes, + supportedDataTypes: { default: extendedSupportedDataTypes }, }, std_dev: { name: 'standard_deviation', isFullReference: false, isFieldRequired: true, - supportedDataTypes, + supportedDataTypes: { default: supportedDataTypes }, }, median: { name: 'median', isFullReference: false, isFieldRequired: true, - supportedDataTypes, + supportedDataTypes: { default: supportedDataTypes }, }, } as const; diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.test.ts index d214ec74b09b1..516ad6b196095 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.test.ts @@ -23,6 +23,7 @@ jest.mock('../../../vis_schemas', () => ({ })); describe('convertToDateHistogramColumn', () => { + const visType = 'heatmap'; const aggId = `some-id`; const aggParams: AggParamsTerms = { field: stubLogstashDataView.fields[0].name, @@ -79,6 +80,7 @@ describe('convertToDateHistogramColumn', () => { dataView: stubLogstashDataView, aggs, metricColumns, + visType, }, '', false, @@ -95,6 +97,7 @@ describe('convertToDateHistogramColumn', () => { dataView: stubLogstashDataView, aggs, metricColumns, + visType, }, '', false, @@ -107,6 +110,8 @@ describe('convertToDateHistogramColumn', () => { size: 5, include: [], exclude: [], + includeIsRegex: false, + excludeIsRegex: false, parentFormat: { id: 'terms' }, orderBy: { type: 'alphabetical' }, orderDirection: 'asc', @@ -123,6 +128,7 @@ describe('convertToDateHistogramColumn', () => { dataView: stubLogstashDataView, aggs, metricColumns, + visType, }, '', false, @@ -135,6 +141,8 @@ describe('convertToDateHistogramColumn', () => { size: 5, include: [], exclude: [], + includeIsRegex: false, + excludeIsRegex: false, parentFormat: { id: 'terms' }, orderBy: { type: 'column', columnId: metricColumns[0].columnId }, orderAgg: metricColumns[0], @@ -152,6 +160,7 @@ describe('convertToDateHistogramColumn', () => { dataView: stubLogstashDataView, aggs, metricColumns, + visType, }, '', false, @@ -170,6 +179,7 @@ describe('convertToDateHistogramColumn', () => { dataView: stubLogstashDataView, aggs, metricColumns, + visType, }, '', false, @@ -188,6 +198,7 @@ describe('convertToDateHistogramColumn', () => { dataView: stubLogstashDataView, aggs, metricColumns, + visType, }, '', false, @@ -208,6 +219,7 @@ describe('convertToDateHistogramColumn', () => { dataView: stubLogstashDataView, aggs, metricColumns, + visType, }, '', false, @@ -220,6 +232,8 @@ describe('convertToDateHistogramColumn', () => { size: 5, include: [], exclude: [], + includeIsRegex: false, + excludeIsRegex: false, parentFormat: { id: 'terms' }, orderBy: { type: 'custom' }, orderAgg: metricColumns[0], diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.ts index 0a50390ec469e..a54a3857e20f6 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/terms.ts @@ -23,6 +23,7 @@ const getOrderByWithAgg = ({ agg, dataView, aggs, + visType, metricColumns, }: CommonBucketConverterArgs): OrderByWithAgg | null => { if (!agg.aggParams) { @@ -37,11 +38,12 @@ const getOrderByWithAgg = ({ if (!agg.aggParams.orderAgg) { return null; } - const orderMetricColumn = convertMetricToColumns( - convertToSchemaConfig(agg.aggParams.orderAgg), + const orderMetricColumn = convertMetricToColumns({ + agg: convertToSchemaConfig(agg.aggParams.orderAgg), dataView, - aggs - ); + aggs, + visType, + }); if (!orderMetricColumn) { return null; } @@ -68,35 +70,43 @@ const getOrderByWithAgg = ({ }; }; +const filterOutEmptyValues = (values: string | Array): number[] | string[] => { + if (typeof values === 'string') { + return Boolean(values) ? [values] : []; + } + + return values.filter((v): v is string | number => { + if (typeof v === 'string') { + return Boolean(v); + } + return true; + }) as string[] | number[]; +}; + export const convertToTermsParams = ({ agg, dataView, aggs, metricColumns, + visType, }: CommonBucketConverterArgs): TermsParams | null => { if (!agg.aggParams) { return null; } - const orderByWithAgg = getOrderByWithAgg({ agg, dataView, aggs, metricColumns }); + const orderByWithAgg = getOrderByWithAgg({ agg, dataView, aggs, metricColumns, visType }); if (orderByWithAgg === null) { return null; } + const exclude = agg.aggParams.exclude ? filterOutEmptyValues(agg.aggParams.exclude) : []; + const include = agg.aggParams.include ? filterOutEmptyValues(agg.aggParams.include) : []; return { size: agg.aggParams.size ?? 10, - include: agg.aggParams.include - ? Array.isArray(agg.aggParams.include) - ? agg.aggParams.include - : [agg.aggParams.include] - : [], - includeIsRegex: agg.aggParams.includeIsRegex, - exclude: agg.aggParams.exclude - ? Array.isArray(agg.aggParams.exclude) - ? agg.aggParams.exclude - : [agg.aggParams.exclude] - : [], - excludeIsRegex: agg.aggParams.excludeIsRegex, + include, + exclude, + includeIsRegex: Boolean(include.length && agg.aggParams.includeIsRegex), + excludeIsRegex: Boolean(exclude.length && agg.aggParams.excludeIsRegex), otherBucket: agg.aggParams.otherBucket, orderDirection: agg.aggParams.order?.value ?? 'desc', parentFormat: { id: 'terms' }, @@ -107,7 +117,7 @@ export const convertToTermsParams = ({ export const convertToTermsColumn = ( aggId: string, - { agg, dataView, aggs, metricColumns }: CommonBucketConverterArgs, + { agg, dataView, aggs, metricColumns, visType }: CommonBucketConverterArgs, label: string, isSplit: boolean ): TermsColumn | null => { @@ -121,7 +131,7 @@ export const convertToTermsColumn = ( return null; } - const params = convertToTermsParams({ agg, dataView, aggs, metricColumns }); + const params = convertToTermsParams({ agg, dataView, aggs, metricColumns, visType }); if (!params) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/convert/types.ts b/src/plugins/visualizations/common/convert_to_lens/lib/convert/types.ts index 8e6f9ec9443bb..97ccba39303fc 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/convert/types.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/convert/types.ts @@ -64,6 +64,7 @@ export interface CommonColumnConverterArgs< > { agg: SchemaConfig; dataView: DataView; + visType: string; } export interface ExtendedColumnConverterArgs< @@ -75,6 +76,7 @@ export interface ExtendedColumnConverterArgs< export interface CommonBucketConverterArgs< Agg extends SupportedAggregation = SupportedAggregation > { + visType: string; agg: SchemaConfig; dataView: DataView; metricColumns: AggBasedColumn[]; diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.test.ts index 95e128e22b092..72cd07ba03f7c 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.test.ts @@ -29,7 +29,7 @@ jest.mock('../utils', () => ({ })); const dataView = stubLogstashDataView; - +const visType = 'heatmap'; const field = stubLogstashDataView.fields[0].name; const aggs: Array> = [ { @@ -97,7 +97,7 @@ describe('getFormulaForPipelineAgg', () => { test.each<[string, Parameters, () => void, string | null]>([ [ 'null if custom metric is invalid', - [{ agg: aggs[0] as SchemaConfig, aggs, dataView }], + [{ agg: aggs[0] as SchemaConfig, aggs, dataView, visType }], () => { mockGetMetricFromParentPipelineAgg.mockReturnValue(null); }, @@ -105,7 +105,7 @@ describe('getFormulaForPipelineAgg', () => { ], [ 'null if custom metric type is not supported', - [{ agg: aggs[0] as SchemaConfig, aggs, dataView }], + [{ agg: aggs[0] as SchemaConfig, aggs, dataView, visType }], () => { mockGetMetricFromParentPipelineAgg.mockReturnValue({ aggType: METRIC_TYPES.GEO_BOUNDS, @@ -115,7 +115,7 @@ describe('getFormulaForPipelineAgg', () => { ], [ 'correct formula if agg is parent pipeline agg and custom metric is valid and supported pipeline agg', - [{ agg: aggs[0] as SchemaConfig, aggs, dataView }], + [{ agg: aggs[0] as SchemaConfig, aggs, dataView, visType }], () => { mockGetMetricFromParentPipelineAgg .mockReturnValueOnce({ @@ -135,7 +135,7 @@ describe('getFormulaForPipelineAgg', () => { ], [ 'correct formula if agg is parent pipeline agg and custom metric is valid and supported not pipeline agg', - [{ agg: aggs[0] as SchemaConfig, aggs, dataView }], + [{ agg: aggs[0] as SchemaConfig, aggs, dataView, visType }], () => { mockGetMetricFromParentPipelineAgg.mockReturnValueOnce({ aggType: METRIC_TYPES.AVG, @@ -149,7 +149,7 @@ describe('getFormulaForPipelineAgg', () => { ], [ 'correct formula if agg is parent pipeline agg and custom metric is valid and supported percentile rank agg', - [{ agg: aggs[0] as SchemaConfig, aggs, dataView }], + [{ agg: aggs[0] as SchemaConfig, aggs, dataView, visType }], () => { mockGetMetricFromParentPipelineAgg.mockReturnValueOnce({ aggType: METRIC_TYPES.PERCENTILE_RANKS, @@ -163,7 +163,7 @@ describe('getFormulaForPipelineAgg', () => { ], [ 'correct formula if agg is sibling pipeline agg and custom metric is valid and supported agg', - [{ agg: aggs[1] as SchemaConfig, aggs, dataView }], + [{ agg: aggs[1] as SchemaConfig, aggs, dataView, visType }], () => { mockGetMetricFromParentPipelineAgg.mockReturnValueOnce({ aggType: METRIC_TYPES.AVG, @@ -212,6 +212,7 @@ describe('getFormulaForPipelineAgg', () => { agg: aggs[1] as SchemaConfig, aggs, dataView, + visType, }); expect(agg).toBeNull(); }); @@ -244,6 +245,7 @@ describe('getFormulaForPipelineAgg', () => { agg: aggs[1] as SchemaConfig, aggs, dataView, + visType, }); expect(agg).toBeNull(); }); @@ -270,6 +272,7 @@ describe('getFormulaForAgg', () => { agg: { ...aggs[0], aggType: METRIC_TYPES.GEO_BOUNDS, aggParams: { field } }, aggs, dataView, + visType, }, ], () => {}, @@ -277,7 +280,7 @@ describe('getFormulaForAgg', () => { ], [ 'correct pipeline formula if agg is valid pipeline agg', - [{ agg: aggs[0], aggs, dataView }], + [{ agg: aggs[0], aggs, dataView, visType }], () => { mockIsPipeline.mockReturnValue(true); mockGetMetricFromParentPipelineAgg.mockReturnValueOnce({ @@ -292,7 +295,7 @@ describe('getFormulaForAgg', () => { ], [ 'correct percentile formula if agg is valid percentile agg', - [{ agg: aggs[2], aggs, dataView }], + [{ agg: aggs[2], aggs, dataView, visType }], () => { mockIsPercentileAgg.mockReturnValue(true); }, @@ -300,7 +303,7 @@ describe('getFormulaForAgg', () => { ], [ 'correct percentile rank formula if agg is valid percentile rank agg', - [{ agg: aggs[3], aggs, dataView }], + [{ agg: aggs[3], aggs, dataView, visType }], () => { mockIsPercentileRankAgg.mockReturnValue(true); }, @@ -308,7 +311,7 @@ describe('getFormulaForAgg', () => { ], [ 'correct standart deviation formula if agg is valid standart deviation agg', - [{ agg: aggs[4], aggs, dataView }], + [{ agg: aggs[4], aggs, dataView, visType }], () => { mockIsStdDevAgg.mockReturnValue(true); }, @@ -316,7 +319,7 @@ describe('getFormulaForAgg', () => { ], [ 'correct metric formula if agg is valid other metric agg', - [{ agg: aggs[5], aggs, dataView }], + [{ agg: aggs[5], aggs, dataView, visType }], () => {}, 'average(bytes)', ], @@ -395,6 +398,7 @@ describe('getFormulaForAgg', () => { >, aggs, dataView, + visType, }); expect(result).toBeNull(); }); @@ -467,6 +471,7 @@ describe('getFormulaForAgg', () => { >, aggs, dataView, + visType, }); expect(result).toBeNull(); } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.ts b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.ts index 276ac54e2fc3d..4492cd58ac230 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/formula.ts @@ -66,7 +66,7 @@ const isDataViewField = (field: string | DataViewField): field is DataViewField return false; }; -const isValidAgg = (agg: SchemaConfig, dataView: DataView) => { +const isValidAgg = (visType: string, agg: SchemaConfig, dataView: DataView) => { const aggregation = SUPPORTED_METRICS[agg.aggType]; if (!aggregation) { return false; @@ -77,7 +77,7 @@ const isValidAgg = (agg: SchemaConfig, dataView: DataView) => { } const sourceField = getFieldNameFromField(agg.aggParams?.field); const field = dataView.getFieldByName(sourceField!); - if (!isFieldValid(field, aggregation)) { + if (!isFieldValid(visType, field, aggregation)) { return false; } } @@ -86,13 +86,14 @@ const isValidAgg = (agg: SchemaConfig, dataView: DataView) => { }; const getFormulaForAggsWithoutParams = ( + visType: string, agg: SchemaConfig, dataView: DataView, selector: string | undefined, reducedTimeRange?: string ) => { const op = SUPPORTED_METRICS[agg.aggType]; - if (!isValidAgg(agg, dataView) || !op) { + if (!isValidAgg(visType, agg, dataView) || !op) { return null; } @@ -101,6 +102,7 @@ const getFormulaForAggsWithoutParams = ( }; const getFormulaForPercentileRanks = ( + visType: string, agg: SchemaConfig, dataView: DataView, selector: string | undefined, @@ -108,7 +110,7 @@ const getFormulaForPercentileRanks = ( ) => { const value = Number(agg.aggId?.split('.')[1]); const op = SUPPORTED_METRICS[agg.aggType]; - if (!isValidAgg(agg, dataView) || !op) { + if (!isValidAgg(visType, agg, dataView) || !op) { return null; } @@ -117,6 +119,7 @@ const getFormulaForPercentileRanks = ( }; const getFormulaForPercentile = ( + visType: string, agg: SchemaConfig, dataView: DataView, selector: string, @@ -124,7 +127,7 @@ const getFormulaForPercentile = ( ) => { const percentile = Number(agg.aggId?.split('.')[1]); const op = SUPPORTED_METRICS[agg.aggType]; - if (!isValidAgg(agg, dataView) || !op) { + if (!isValidAgg(visType, agg, dataView) || !op) { return null; } @@ -138,6 +141,7 @@ const getFormulaForSubMetric = ({ agg, dataView, aggs, + visType, }: ExtendedColumnConverterArgs): string | null => { const op = SUPPORTED_METRICS[agg.aggType]; if (!op) { @@ -148,12 +152,13 @@ const getFormulaForSubMetric = ({ PARENT_PIPELINE_OPS.includes(op.name) || SIBLING_PIPELINE_AGGS.includes(agg.aggType as METRIC_TYPES) ) { - return getFormulaForPipelineAgg({ agg: agg as PipelineAggs, aggs, dataView }); + return getFormulaForPipelineAgg({ agg: agg as PipelineAggs, aggs, dataView, visType }); } if (METRIC_OPS_WITHOUT_PARAMS.includes(op.name)) { const metricAgg = agg as MetricAggsWithoutParams; return getFormulaForAggsWithoutParams( + visType, metricAgg, dataView, metricAgg.aggParams && 'field' in metricAgg.aggParams @@ -168,6 +173,7 @@ const getFormulaForSubMetric = ({ const percentileRanksAgg = agg as SchemaConfig; return getFormulaForPercentileRanks( + visType, percentileRanksAgg, dataView, percentileRanksAgg.aggParams?.field @@ -181,6 +187,7 @@ export const getFormulaForPipelineAgg = ({ agg, dataView, aggs, + visType, }: ExtendedColumnConverterArgs< | METRIC_TYPES.CUMULATIVE_SUM | METRIC_TYPES.DERIVATIVE @@ -205,6 +212,7 @@ export const getFormulaForPipelineAgg = ({ agg: metricAgg, aggs, dataView, + visType, }); if (subFormula === null) { return null; @@ -222,13 +230,15 @@ export const getFormulaForAgg = ({ agg, aggs, dataView, + visType, }: ExtendedColumnConverterArgs) => { if (isPipeline(agg)) { - return getFormulaForPipelineAgg({ agg, aggs, dataView }); + return getFormulaForPipelineAgg({ agg, aggs, dataView, visType }); } if (isPercentileAgg(agg)) { return getFormulaForPercentile( + visType, agg, dataView, getFieldNameFromField(agg.aggParams?.field) ?? '' @@ -237,6 +247,7 @@ export const getFormulaForAgg = ({ if (isPercentileRankAgg(agg)) { return getFormulaForPercentileRanks( + visType, agg, dataView, getFieldNameFromField(agg.aggParams?.field) ?? '' @@ -244,13 +255,14 @@ export const getFormulaForAgg = ({ } if (isStdDevAgg(agg) && agg.aggId) { - if (!isValidAgg(agg, dataView)) { + if (!isValidAgg(visType, agg, dataView)) { return null; } return getStdDeviationFormula(agg.aggId, getFieldNameFromField(agg.aggParams?.field) ?? ''); } return getFormulaForAggsWithoutParams( + visType, agg, dataView, isMetricWithField(agg) ? getFieldNameFromField(agg.aggParams?.field) ?? '' : '' diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.test.ts index 1cf3ff0b84064..c7674bf6603c0 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.test.ts @@ -9,6 +9,7 @@ import { METRIC_TYPES } from '@kbn/data-plugin/common'; import { stubLogstashDataView } from '@kbn/data-views-plugin/common/data_view.stub'; import { SchemaConfig } from '../../..'; +import { ExtendedColumnConverterArgs } from '../convert'; import { convertMetricToColumns } from './metrics'; const mockConvertMetricAggregationColumnWithoutSpecialParams = jest.fn(); @@ -37,6 +38,8 @@ jest.mock('../convert', () => ({ convertToColumnInPercentageMode: jest.fn(() => mockConvertToColumnInPercentageMode()), })); +const visType = 'heatmap'; + describe('convertMetricToColumns invalid cases', () => { const dataView = stubLogstashDataView; @@ -55,13 +58,18 @@ describe('convertMetricToColumns invalid cases', () => { mockConvertToCumulativeSumAggColumn.mockReturnValue(null); }); + const aggs: ExtendedColumnConverterArgs['aggs'] = []; + test.each<[string, Parameters, null, jest.Mock | undefined]>([ [ 'null if agg is not supported', [ - { aggType: METRIC_TYPES.GEO_BOUNDS } as unknown as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.GEO_BOUNDS } as unknown as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -70,9 +78,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg AVG is not valid', [ - { aggType: METRIC_TYPES.AVG } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.AVG } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -81,9 +92,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg MIN is not valid', [ - { aggType: METRIC_TYPES.MIN } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MIN } as SchemaConfig, + dataView, + aggs: [], + visType, + }, { isPercentageMode: false }, ], null, @@ -92,9 +106,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg MAX is not valid', [ - { aggType: METRIC_TYPES.MAX } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MAX } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -103,9 +120,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg SUM is not valid', [ - { aggType: METRIC_TYPES.SUM } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SUM } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -114,9 +134,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg COUNT is not valid', [ - { aggType: METRIC_TYPES.COUNT } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.COUNT } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -125,9 +148,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg CARDINALITY is not valid', [ - { aggType: METRIC_TYPES.CARDINALITY } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.CARDINALITY } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -136,9 +162,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg VALUE_COUNT is not valid', [ - { aggType: METRIC_TYPES.VALUE_COUNT } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.VALUE_COUNT } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -147,9 +176,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg MEDIAN is not valid', [ - { aggType: METRIC_TYPES.MEDIAN } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MEDIAN } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -158,9 +190,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg STD_DEV is not valid', [ - { aggType: METRIC_TYPES.STD_DEV } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.STD_DEV } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -169,9 +204,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg PERCENTILES is not valid', [ - { aggType: METRIC_TYPES.PERCENTILES } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.PERCENTILES } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -180,9 +218,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg SINGLE_PERCENTILE is not valid', [ - { aggType: METRIC_TYPES.SINGLE_PERCENTILE } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SINGLE_PERCENTILE } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -191,9 +232,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg PERCENTILE_RANKS is not valid', [ - { aggType: METRIC_TYPES.PERCENTILE_RANKS } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.PERCENTILE_RANKS } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -202,9 +246,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg SINGLE_PERCENTILE_RANK is not valid', [ - { aggType: METRIC_TYPES.SINGLE_PERCENTILE_RANK } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SINGLE_PERCENTILE_RANK } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -213,9 +260,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg TOP_HITS is not valid', [ - { aggType: METRIC_TYPES.TOP_HITS } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.TOP_HITS } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -224,9 +274,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg TOP_METRICS is not valid', [ - { aggType: METRIC_TYPES.TOP_METRICS } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.TOP_METRICS } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -235,9 +288,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg CUMULATIVE_SUM is not valid', [ - { aggType: METRIC_TYPES.CUMULATIVE_SUM } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.CUMULATIVE_SUM } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -246,9 +302,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg DERIVATIVE is not valid', [ - { aggType: METRIC_TYPES.DERIVATIVE } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.DERIVATIVE } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -257,9 +316,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg MOVING_FN is not valid', [ - { aggType: METRIC_TYPES.MOVING_FN } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MOVING_FN } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -268,9 +330,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg SUM_BUCKET is not valid', [ - { aggType: METRIC_TYPES.SUM_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SUM_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -279,9 +344,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg MIN_BUCKET is not valid', [ - { aggType: METRIC_TYPES.MIN_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MIN_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -290,9 +358,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg MAX_BUCKET is not valid', [ - { aggType: METRIC_TYPES.MAX_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MAX_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -301,9 +372,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg AVG_BUCKET is not valid', [ - { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, + dataView, + aggs: [], + visType, + }, { isPercentageMode: false }, ], null, @@ -312,9 +386,12 @@ describe('convertMetricToColumns invalid cases', () => { [ 'null if supported agg SERIAL_DIFF is not valid', [ - { aggType: METRIC_TYPES.SERIAL_DIFF } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SERIAL_DIFF } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], null, @@ -330,6 +407,7 @@ describe('convertMetricToColumns invalid cases', () => { }); describe('convertMetricToColumns valid cases', () => { const dataView = stubLogstashDataView; + const aggs: ExtendedColumnConverterArgs['aggs'] = []; beforeEach(() => { jest.clearAllMocks(); @@ -353,9 +431,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg AVG is valid', [ - { aggType: METRIC_TYPES.AVG } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.AVG } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -364,9 +445,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg MIN is valid', [ - { aggType: METRIC_TYPES.MIN } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MIN } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -375,9 +459,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg MAX is valid', [ - { aggType: METRIC_TYPES.MAX } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MAX } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -386,9 +473,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg SUM is valid', [ - { aggType: METRIC_TYPES.SUM } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SUM } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -397,9 +487,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg COUNT is valid', [ - { aggType: METRIC_TYPES.COUNT } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.COUNT } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -408,9 +501,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg CARDINALITY is valid', [ - { aggType: METRIC_TYPES.CARDINALITY } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.CARDINALITY } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -419,9 +515,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg VALUE_COUNT is valid', [ - { aggType: METRIC_TYPES.VALUE_COUNT } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.VALUE_COUNT } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -430,9 +529,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg MEDIAN is valid', [ - { aggType: METRIC_TYPES.MEDIAN } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MEDIAN } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -441,9 +543,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg STD_DEV is valid', [ - { aggType: METRIC_TYPES.STD_DEV } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.STD_DEV } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -452,9 +557,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg PERCENTILES is valid', [ - { aggType: METRIC_TYPES.PERCENTILES } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.PERCENTILES } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -463,9 +571,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg SINGLE_PERCENTILE is valid', [ - { aggType: METRIC_TYPES.SINGLE_PERCENTILE } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SINGLE_PERCENTILE } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -474,9 +585,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg PERCENTILE_RANKS is valid', [ - { aggType: METRIC_TYPES.PERCENTILE_RANKS } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.PERCENTILE_RANKS } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -485,9 +599,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg SINGLE_PERCENTILE_RANK is valid', [ - { aggType: METRIC_TYPES.SINGLE_PERCENTILE_RANK } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SINGLE_PERCENTILE_RANK } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -496,9 +613,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg TOP_HITS is valid', [ - { aggType: METRIC_TYPES.TOP_HITS } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.TOP_HITS } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -507,9 +627,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg TOP_METRICS is valid', [ - { aggType: METRIC_TYPES.TOP_METRICS } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.TOP_METRICS } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -518,9 +641,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg CUMULATIVE_SUM is valid', [ - { aggType: METRIC_TYPES.CUMULATIVE_SUM } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.CUMULATIVE_SUM } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -529,9 +655,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg DERIVATIVE is valid', [ - { aggType: METRIC_TYPES.DERIVATIVE } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.DERIVATIVE } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -540,9 +669,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg MOVING_FN is valid', [ - { aggType: METRIC_TYPES.MOVING_FN } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MOVING_FN } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -551,9 +683,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg SUM_BUCKET is valid', [ - { aggType: METRIC_TYPES.SUM_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.SUM_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -562,9 +697,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg MIN_BUCKET is valid', [ - { aggType: METRIC_TYPES.MIN_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MIN_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -573,9 +711,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg MAX_BUCKET is valid', [ - { aggType: METRIC_TYPES.MAX_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.MAX_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -584,9 +725,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'array of columns if supported agg AVG_BUCKET is valid', [ - { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: false }, ], result, @@ -595,9 +739,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'column in percentage mode without range if percentageMode is enabled ', [ - { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: true, min: 0, max: 100 }, ], result, @@ -606,9 +753,12 @@ describe('convertMetricToColumns valid cases', () => { [ 'column in percentage mode with range if percentageMode is enabled ', [ - { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, - dataView, - [], + { + agg: { aggType: METRIC_TYPES.AVG_BUCKET } as SchemaConfig, + dataView, + aggs, + visType, + }, { isPercentageMode: true, min: 0, max: 100 }, ], result, diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.ts b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.ts index be4c92cd4ec7f..5d765a6f286ba 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/metrics.ts @@ -7,8 +7,7 @@ */ import { METRIC_TYPES } from '@kbn/data-plugin/common'; -import type { DataView } from '@kbn/data-views-plugin/common'; -import { PercentageModeConfig, SchemaConfig } from '../../..'; +import { PercentageModeConfig } from '../../..'; import { convertMetricAggregationColumnWithoutSpecialParams, convertToOtherParentPipelineAggColumns, @@ -20,14 +19,13 @@ import { convertToCumulativeSumAggColumn, AggBasedColumn, convertToColumnInPercentageMode, + ExtendedColumnConverterArgs, } from '../convert'; import { SUPPORTED_METRICS } from '../convert/supported_metrics'; import { getValidColumns } from '../utils'; export const convertMetricToColumns = ( - agg: SchemaConfig, - dataView: DataView, - aggs: Array>, + { agg, dataView, aggs, visType }: ExtendedColumnConverterArgs, percentageModeConfig: PercentageModeConfig = { isPercentageMode: false } ): AggBasedColumn[] | null => { const supportedAgg = SUPPORTED_METRICS[agg.aggType]; @@ -38,7 +36,7 @@ export const convertMetricToColumns = ( if (percentageModeConfig.isPercentageMode) { const { isPercentageMode, ...minMax } = percentageModeConfig; - const formulaColumn = convertToColumnInPercentageMode({ agg, dataView, aggs }, minMax); + const formulaColumn = convertToColumnInPercentageMode({ agg, dataView, aggs, visType }, minMax); return getValidColumns(formulaColumn); } @@ -54,6 +52,7 @@ export const convertMetricToColumns = ( const columns = convertMetricAggregationColumnWithoutSpecialParams(supportedAgg, { agg, dataView, + visType, }); return getValidColumns(columns); } @@ -61,6 +60,7 @@ export const convertMetricToColumns = ( const columns = convertToStdDeviationFormulaColumns({ agg, dataView, + visType, }); return getValidColumns(columns); } @@ -68,6 +68,7 @@ export const convertMetricToColumns = ( const columns = convertToPercentileColumn({ agg, dataView, + visType, }); return getValidColumns(columns); } @@ -75,6 +76,7 @@ export const convertMetricToColumns = ( const columns = convertToPercentileColumn({ agg, dataView, + visType, }); return getValidColumns(columns); } @@ -82,6 +84,7 @@ export const convertMetricToColumns = ( const columns = convertToPercentileRankColumn({ agg, dataView, + visType, }); return getValidColumns(columns); } @@ -89,6 +92,7 @@ export const convertMetricToColumns = ( const columns = convertToPercentileRankColumn({ agg, dataView, + visType, }); return getValidColumns(columns); } @@ -97,6 +101,7 @@ export const convertMetricToColumns = ( const columns = convertToLastValueColumn({ agg, dataView, + visType, }); return getValidColumns(columns); } @@ -105,6 +110,7 @@ export const convertMetricToColumns = ( agg, dataView, aggs, + visType, }); return getValidColumns(columns); } @@ -114,6 +120,7 @@ export const convertMetricToColumns = ( agg, dataView, aggs, + visType, }); return getValidColumns(columns); } @@ -125,6 +132,7 @@ export const convertMetricToColumns = ( agg, dataView, aggs, + visType, }); return getValidColumns(columns); } diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.test.ts b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.test.ts index 9855ce44b6602..fe6204d1fb2a1 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.test.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.test.ts @@ -24,6 +24,7 @@ jest.mock('../convert', () => ({ })); describe('getPercentageColumnFormulaColumn', () => { + const visType = 'heatmap'; const dataView = stubLogstashDataView; const field = stubLogstashDataView.fields[0].name; const aggs: Array> = [ @@ -52,7 +53,7 @@ describe('getPercentageColumnFormulaColumn', () => { >([ [ 'null if cannot build formula for provided agg', - [{ agg: aggs[0], aggs, dataView }], + [{ agg: aggs[0], aggs, dataView, visType }], () => { mockGetFormulaForAgg.mockReturnValue(null); }, @@ -60,7 +61,7 @@ describe('getPercentageColumnFormulaColumn', () => { ], [ 'null if cannot create formula column for provided arguments', - [{ agg: aggs[0], aggs, dataView }], + [{ agg: aggs[0], aggs, dataView, visType }], () => { mockGetFormulaForAgg.mockReturnValue('test-formula'); mockCreateFormulaColumn.mockReturnValue(null); @@ -69,7 +70,7 @@ describe('getPercentageColumnFormulaColumn', () => { ], [ 'formula column if provided arguments are valid', - [{ agg: aggs[0], aggs, dataView }], + [{ agg: aggs[0], aggs, dataView, visType }], () => { mockGetFormulaForAgg.mockReturnValue('test-formula'); mockCreateFormulaColumn.mockImplementation((formula) => ({ diff --git a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.ts b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.ts index 773851a770db4..8d7194d5c25df 100644 --- a/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.ts +++ b/src/plugins/visualizations/common/convert_to_lens/lib/metrics/percentage_formula.ts @@ -14,8 +14,9 @@ export const getPercentageColumnFormulaColumn = ({ agg, aggs, dataView, + visType, }: ExtendedColumnConverterArgs): FormulaColumn | null => { - const metricFormula = getFormulaForAgg({ agg, aggs, dataView }); + const metricFormula = getFormulaForAgg({ agg, aggs, dataView, visType }); if (!metricFormula) { return null; } diff --git a/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts b/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts index f62f61f0c50ab..8a6e70669dcf4 100644 --- a/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts +++ b/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts @@ -28,6 +28,9 @@ import { GaugeCentralMajorModes, CollapseFunctions, } from '../constants'; +import { ExpressionValueVisDimension } from '../../expression_functions'; + +export type ChartShapes = 'heatmap'; export type CollapseFunction = typeof CollapseFunctions[number]; @@ -277,9 +280,63 @@ export type GaugeVisConfiguration = GaugeState & { layerType: typeof LayerTypes.DATA; }; +export interface HeatmapLegendConfig { + isVisible: boolean; + position: Position; + maxLines?: number; + shouldTruncate?: boolean; + legendSize?: LegendSize; + type: 'heatmap_legend'; +} + +export interface HeatmapGridConfig { + strokeWidth?: number; + strokeColor?: string; + isCellLabelVisible: boolean; + isYAxisLabelVisible: boolean; + isYAxisTitleVisible: boolean; + yTitle?: string; + isXAxisLabelVisible: boolean; + isXAxisTitleVisible: boolean; + xTitle?: string; + type: 'heatmap_grid'; +} +export interface HeatmapArguments { + percentageMode?: boolean; + lastRangeIsRightOpen?: boolean; + showTooltip?: boolean; + highlightInHover?: boolean; + palette?: PaletteOutput; + xAccessor?: string | ExpressionValueVisDimension; + yAccessor?: string | ExpressionValueVisDimension; + valueAccessor?: string | ExpressionValueVisDimension; + splitRowAccessor?: string | ExpressionValueVisDimension; + splitColumnAccessor?: string | ExpressionValueVisDimension; + legend: HeatmapLegendConfig; + gridConfig: HeatmapGridConfig; + ariaLabel?: string; +} + +export type HeatmapLayerState = HeatmapArguments & { + layerId: string; + layerType: LayerType; + valueAccessor?: string; + xAccessor?: string; + yAccessor?: string; + shape: ChartShapes; +}; + +export type Palette = PaletteOutput & { accessor: string }; + +export type HeatmapConfiguration = HeatmapLayerState & { + // need to store the current accessor to reset the color stops at accessor change + palette?: Palette; +}; + export type Configuration = | XYConfiguration | TableVisConfiguration | PartitionVisConfiguration | MetricVisConfiguration - | GaugeVisConfiguration; + | GaugeVisConfiguration + | HeatmapConfiguration; diff --git a/src/plugins/visualizations/common/convert_to_lens/types/params.ts b/src/plugins/visualizations/common/convert_to_lens/types/params.ts index d66822921fb19..4623506496382 100644 --- a/src/plugins/visualizations/common/convert_to_lens/types/params.ts +++ b/src/plugins/visualizations/common/convert_to_lens/types/params.ts @@ -55,7 +55,7 @@ interface Range { export interface RangeParams extends FormatParams { type: RangeMode; maxBars: 'auto' | number; - ranges: Range[]; + ranges?: Range[]; includeEmptyRows?: boolean; parentFormat?: { id: string; diff --git a/src/plugins/visualizations/common/convert_to_lens/utils.ts b/src/plugins/visualizations/common/convert_to_lens/utils.ts index 6a875bf63bea4..88c2802c421ec 100644 --- a/src/plugins/visualizations/common/convert_to_lens/utils.ts +++ b/src/plugins/visualizations/common/convert_to_lens/utils.ts @@ -18,7 +18,17 @@ export const isAnnotationsLayer = ( export const getIndexPatternIds = (layers: Layer[]) => layers.map(({ indexPatternId }) => indexPatternId); +const isValidFieldType = ( + visType: string, + { supportedDataTypes }: SupportedMetric, + field: DataViewField +) => { + const availableDataTypes = supportedDataTypes[visType] ?? supportedDataTypes.default; + return availableDataTypes.includes(field.type); +}; + export const isFieldValid = ( + visType: string, field: DataViewField | undefined, aggregation: SupportedMetric ): field is DataViewField => { @@ -26,7 +36,7 @@ export const isFieldValid = ( return false; } - if (field && (!field.aggregatable || !aggregation.supportedDataTypes.includes(field.type))) { + if (field && (!field.aggregatable || !isValidFieldType(visType, aggregation, field))) { return false; } diff --git a/src/plugins/visualizations/public/convert_to_lens/index.ts b/src/plugins/visualizations/public/convert_to_lens/index.ts index 73509d49157ae..46fca64199ae1 100644 --- a/src/plugins/visualizations/public/convert_to_lens/index.ts +++ b/src/plugins/visualizations/public/convert_to_lens/index.ts @@ -8,8 +8,10 @@ export { getColumnsFromVis } from './schemas'; export { + convertToFiltersColumn, getPercentageColumnFormulaColumn, getPalette, + getPaletteFromStopsWithColors, getPercentageModeConfig, createStaticValueColumn, } from '../../common/convert_to_lens/lib'; diff --git a/src/plugins/visualizations/public/convert_to_lens/schemas.test.ts b/src/plugins/visualizations/public/convert_to_lens/schemas.test.ts index 54975d08b8486..aa338db367988 100644 --- a/src/plugins/visualizations/public/convert_to_lens/schemas.test.ts +++ b/src/plugins/visualizations/public/convert_to_lens/schemas.test.ts @@ -70,7 +70,9 @@ describe('getColumnsFromVis', () => { ); const aggConfig = new AggConfig(aggConfigs, {} as AggConfigOptions); - const vis = {} as Vis; + const vis = { + type: { name: 'heatmap' }, + } as Vis; beforeEach(() => { jest.clearAllMocks(); mockGetVisSchemas.mockReturnValue({}); diff --git a/src/plugins/visualizations/public/convert_to_lens/schemas.ts b/src/plugins/visualizations/public/convert_to_lens/schemas.ts index 3a225e540faae..1b44f7cdffda1 100644 --- a/src/plugins/visualizations/public/convert_to_lens/schemas.ts +++ b/src/plugins/visualizations/public/convert_to_lens/schemas.ts @@ -33,6 +33,7 @@ const areVisSchemasValid = (visSchemas: Schemas, unsupported: Array>, metricsForLayer: Array>, @@ -52,7 +53,7 @@ const createLayer = ( dropEmptyRowsInDateHistogram?: boolean ) => { const metricColumns = metricsForLayer.flatMap((m) => - convertMetricToColumns(m, dataView, allMetrics, percentageModeConfig) + convertMetricToColumns({ agg: m, dataView, aggs: allMetrics, visType }, percentageModeConfig) ); if (metricColumns.includes(null)) { return null; @@ -60,6 +61,7 @@ const createLayer = ( const metricColumnsWithoutNull = metricColumns as AggBasedColumn[]; const { customBucketColumns, customBucketsMap } = getCustomBucketColumns( + visType, customBucketsWithMetricIds, metricColumnsWithoutNull, dataView, @@ -72,6 +74,7 @@ const createLayer = ( } const bucketColumns = getBucketColumns( + visType, visSchemas, buckets, dataView, @@ -84,6 +87,7 @@ const createLayer = ( } const splitBucketColumns = getBucketColumns( + visType, visSchemas, splits, dataView, @@ -181,6 +185,7 @@ export const getColumnsFromVis = ( c.metricIds.some((m) => metricAggIds.includes(m)) ); const layer = createLayer( + vis.type.name, visSchemas, aggs, metrics, @@ -197,6 +202,7 @@ export const getColumnsFromVis = ( } } else { const layer = createLayer( + vis.type.name, visSchemas, aggs, aggs, diff --git a/src/plugins/visualizations/public/convert_to_lens/utils.test.ts b/src/plugins/visualizations/public/convert_to_lens/utils.test.ts index 50f667430a8cb..8c36b28452271 100644 --- a/src/plugins/visualizations/public/convert_to_lens/utils.test.ts +++ b/src/plugins/visualizations/public/convert_to_lens/utils.test.ts @@ -213,6 +213,7 @@ describe('getBucketCollapseFn', () => { describe('getBucketColumns', () => { const dataView = stubLogstashDataView; + const visType = 'heatmap'; beforeEach(() => { jest.clearAllMocks(); @@ -228,7 +229,7 @@ describe('getBucketColumns', () => { [bucketKey]: [], }; - expect(getBucketColumns(visSchemas, keys, dataView, false, [])).toEqual([]); + expect(getBucketColumns(visType, visSchemas, keys, dataView, false, [])).toEqual([]); expect(mockConvertBucketToColumns).toBeCalledTimes(0); }); @@ -254,7 +255,7 @@ describe('getBucketColumns', () => { }; mockConvertBucketToColumns.mockReturnValueOnce(null); - expect(getBucketColumns(visSchemas, keys, dataView, false, [])).toBeNull(); + expect(getBucketColumns(visType, visSchemas, keys, dataView, false, [])).toBeNull(); expect(mockConvertBucketToColumns).toBeCalledTimes(1); }); @@ -280,7 +281,7 @@ describe('getBucketColumns', () => { }; mockConvertBucketToColumns.mockReturnValueOnce([null]); - expect(getBucketColumns(visSchemas, keys, dataView, false, [])).toBeNull(); + expect(getBucketColumns(visType, visSchemas, keys, dataView, false, [])).toBeNull(); expect(mockConvertBucketToColumns).toBeCalledTimes(1); }); test('should return columns', () => { @@ -319,7 +320,7 @@ describe('getBucketColumns', () => { mockConvertBucketToColumns.mockReturnValue(returnValue); - expect(getBucketColumns(visSchemas, keys, dataView, false, [])).toEqual([ + expect(getBucketColumns(visType, visSchemas, keys, dataView, false, [])).toEqual([ ...returnValue, ...returnValue, ]); @@ -592,6 +593,8 @@ describe('sortColumns', () => { }); describe('getColumnIds', () => { + const visType = 'heatmap'; + const colId1 = '0_agg_id'; const colId2 = '1_agg_id'; const colId3 = '2_agg_id'; @@ -694,6 +697,7 @@ describe('getColumnIds', () => { }); expect( getCustomBucketColumns( + visType, customBucketsWithMetricIds, [ { columnId: 'col-3', meta: { aggId: '3' } }, diff --git a/src/plugins/visualizations/public/convert_to_lens/utils.ts b/src/plugins/visualizations/public/convert_to_lens/utils.ts index ba05d29cdeea9..531746ff86d87 100644 --- a/src/plugins/visualizations/public/convert_to_lens/utils.ts +++ b/src/plugins/visualizations/public/convert_to_lens/utils.ts @@ -63,6 +63,7 @@ export const getBucketCollapseFn = ( }; export const getBucketColumns = ( + visType: string, visSchemas: Schemas, keys: Array, dataView: DataView, @@ -78,6 +79,7 @@ export const getBucketColumns = ( { agg: m, dataView, + visType, metricColumns, aggs: visSchemas.metric as Array>, }, @@ -154,6 +156,7 @@ export const sortColumns = ( export const getColumnIds = (columns: AggBasedColumn[]) => columns.map(({ columnId }) => columnId); export const getCustomBucketColumns = ( + visType: string, customBucketsWithMetricIds: Array<{ customBucket: IAggConfig; metricIds: string[]; @@ -167,7 +170,7 @@ export const getCustomBucketColumns = ( const customBucketsMap: Record = {}; customBucketsWithMetricIds.forEach((customBucketWithMetricIds) => { const customBucketColumn = convertBucketToColumns( - { agg: customBucketWithMetricIds.customBucket, dataView, metricColumns, aggs }, + { agg: customBucketWithMetricIds.customBucket, dataView, metricColumns, aggs, visType }, true, dropEmptyRowsInDateHistogram ); diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx index b9ff8d98f2ced..8cc220e77c8bc 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_byvalue_editor.tsx @@ -110,6 +110,7 @@ export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { visEditorRef={visEditorRef} embeddableId={embeddableId} onAppLeave={onAppLeave} + eventEmitter={eventEmitter} /> ); }; diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx index 480f0c3d36ee1..221cdcc9d8e10 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor.tsx @@ -110,6 +110,7 @@ export const VisualizeEditor = ({ onAppLeave }: VisualizeAppProps) => { visEditorRef={visEditorRef} onAppLeave={onAppLeave} embeddableId={embeddableIdValue} + eventEmitter={eventEmitter} /> ); }; diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx index 4598d2d23e613..7fa6418aa261b 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx @@ -7,6 +7,7 @@ */ import './visualize_editor.scss'; +import { EventEmitter } from 'events'; import React, { RefObject, useCallback, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -48,6 +49,7 @@ interface VisualizeEditorCommonProps { originatingPath?: string; visualizationIdFromUrl?: string; embeddableId?: string; + eventEmitter?: EventEmitter; } export const VisualizeEditorCommon = ({ @@ -66,6 +68,7 @@ export const VisualizeEditorCommon = ({ visualizationIdFromUrl, embeddableId, visEditorRef, + eventEmitter, }: VisualizeEditorCommonProps) => { const { services } = useKibana(); @@ -148,6 +151,7 @@ export const VisualizeEditorCommon = ({ visualizationIdFromUrl={visualizationIdFromUrl} embeddableId={embeddableId} onAppLeave={onAppLeave} + eventEmitter={eventEmitter} /> )} {visInstance?.vis?.type?.stage === 'experimental' && diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index 0111c9026397d..2deffa0c511b3 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -7,7 +7,7 @@ */ import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; - +import { EventEmitter } from 'events'; import { AppMountParameters, OverlayRef } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import useLocalStorage from 'react-use/lib/useLocalStorage'; @@ -40,6 +40,7 @@ interface VisualizeTopNavProps { visualizationIdFromUrl?: string; embeddableId?: string; onAppLeave: AppMountParameters['onAppLeave']; + eventEmitter?: EventEmitter; } const TopNav = ({ @@ -57,6 +58,7 @@ const TopNav = ({ visualizationIdFromUrl, embeddableId, onAppLeave, + eventEmitter, }: VisualizeTopNavProps) => { const { services } = useKibana(); const { TopNavMenu } = services.navigation.ui; @@ -116,6 +118,7 @@ const TopNav = ({ uiStateJSON?.vis, uiStateJSON?.table, vis.data.indexPattern, + eventEmitter, ]); const displayEditInLensItem = Boolean(vis.type.navigateToLens && editInLensConfig); @@ -140,6 +143,7 @@ const TopNav = ({ hideLensBadge, setNavigateToLens, showBadge: !hideTryInLensBadge && displayEditInLensItem, + eventEmitter, }, services ); @@ -162,6 +166,7 @@ const TopNav = ({ displayEditInLensItem, hideLensBadge, hideTryInLensBadge, + eventEmitter, ]); const [indexPatterns, setIndexPatterns] = useState([]); const showDatePicker = () => { diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx index 36b92585f1096..cab3d41ff8266 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx @@ -8,6 +8,7 @@ import React from 'react'; import moment from 'moment'; +import EventEmitter from 'events'; import { i18n } from '@kbn/i18n'; import { EuiBetaBadgeProps } from '@elastic/eui'; import { parse } from 'query-string'; @@ -71,6 +72,7 @@ export interface TopNavConfigParams { hideLensBadge: () => void; setNavigateToLens: (flag: boolean) => void; showBadge: boolean; + eventEmitter?: EventEmitter; } const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); @@ -102,6 +104,7 @@ export const getTopNavConfig = ( hideLensBadge, setNavigateToLens, showBadge, + eventEmitter, }: TopNavConfigParams, { data, @@ -301,6 +304,10 @@ export const getTopNavConfig = ( }, }), run: async () => { + // lens doesn't support saved searches, should unlink before transition + if (eventEmitter && visInstance.vis.data.savedSearchId) { + eventEmitter.emit('unlinkFromSavedSearch', false); + } const updatedWithMeta = { ...editInLensConfig, savedObjectId: visInstance.vis.id, diff --git a/src/plugins/visualizations/public/visualize_app/utils/use/use_linked_search_updates.ts b/src/plugins/visualizations/public/visualize_app/utils/use/use_linked_search_updates.ts index 8d7f2a8ef61f4..ffd23ec06aea6 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/use/use_linked_search_updates.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/use/use_linked_search_updates.ts @@ -29,7 +29,7 @@ export const useLinkedSearchUpdates = ( // SearchSource is a promise-based stream of search results that can inherit from other search sources. const { searchSource } = visInstance.vis.data; - const unlinkFromSavedSearch = () => { + const unlinkFromSavedSearch = (showToast: boolean = true) => { const searchSourceParent = savedSearch.searchSource; const searchSourceGrandparent = searchSourceParent?.getParent(); const currentIndex = searchSourceParent?.getField('index'); @@ -44,14 +44,16 @@ export const useLinkedSearchUpdates = ( parentFilters: (searchSourceParent?.getOwnField('filter') as Filter[]) || [], }); - services.toastNotifications.addSuccess( - i18n.translate('visualizations.linkedToSearch.unlinkSuccessNotificationText', { - defaultMessage: `Unlinked from saved search '{searchTitle}'`, - values: { - searchTitle: savedSearch.title, - }, - }) - ); + if (showToast) { + services.toastNotifications.addSuccess( + i18n.translate('visualizations.linkedToSearch.unlinkSuccessNotificationText', { + defaultMessage: `Unlinked from saved search '{searchTitle}'`, + values: { + searchTitle: savedSearch.title, + }, + }) + ); + } }; eventEmitter.on('unlinkFromSavedSearch', unlinkFromSavedSearch); diff --git a/x-pack/examples/screenshotting_example/server/plugin.ts b/x-pack/examples/screenshotting_example/server/plugin.ts index 9ca74c6e16353..16a766558ff3f 100644 --- a/x-pack/examples/screenshotting_example/server/plugin.ts +++ b/x-pack/examples/screenshotting_example/server/plugin.ts @@ -38,6 +38,7 @@ export class ScreenshottingExamplePlugin implements Plugin { ); return response.ok({ + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ metrics, image: results[0]?.screenshots[0]?.data.toString('base64'), diff --git a/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_items.ts b/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_items.ts index aaf9af283c3e1..362cae07273e5 100644 --- a/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_items.ts +++ b/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_items.ts @@ -13,6 +13,8 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { Logger } from '@kbn/logging'; import type { ChangePoint, FieldValuePair } from '@kbn/ml-agg-utils'; +const FREQUENT_ITEMS_FIELDS_LIMIT = 15; + interface FrequentItemsAggregation extends estypes.AggregationsSamplerAggregation { fi: { buckets: Array<{ key: Record; doc_count: number; support: number }>; @@ -59,10 +61,19 @@ export async function fetchFrequentItems( emitError: (m: string) => void, abortSignal?: AbortSignal ) { - // get unique fields from change points - const fields = [...new Set(changePoints.map((t) => t.fieldName))]; + // Sort change points by ascending p-value, necessary to apply the field limit correctly. + const sortedChangePoints = changePoints.slice().sort((a, b) => { + return (a.pValue ?? 0) - (b.pValue ?? 0); + }); + + // Get up to 15 unique fields from change points with retained order + const fields = sortedChangePoints.reduce((p, c) => { + if (p.length < FREQUENT_ITEMS_FIELDS_LIMIT && !p.some((d) => d === c.fieldName)) { + p.push(c.fieldName); + } + return p; + }, []); - // TODO add query params const query = { bool: { minimum_should_match: 2, @@ -77,7 +88,7 @@ export async function fetchFrequentItems( }, }, ], - should: changePoints.map((t) => { + should: sortedChangePoints.map((t) => { return { term: { [t.fieldName]: t.fieldValue } }; }), }, @@ -117,16 +128,18 @@ export async function fetchFrequentItems( }, }; + const esBody = { + query, + aggs, + size: 0, + track_total_hits: true, + }; + const body = await client.search( { index, size: 0, - body: { - query, - aggs, - size: 0, - track_total_hits: true, - }, + body: esBody, }, { signal: abortSignal, maxRetries: 0 } ); @@ -167,7 +180,7 @@ export async function fetchFrequentItems( Object.entries(fis.key).forEach(([key, value]) => { result.set[key] = value[0]; - const pValue = changePoints.find( + const pValue = sortedChangePoints.find( (t) => t.fieldName === key && t.fieldValue === value[0] )?.pValue; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index f1917a079a26d..9326f30dd7828 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -90,21 +90,21 @@ export interface RuleExecutorOptions< InstanceContext extends AlertInstanceContext = never, ActionGroupIds extends string = never > { - alertId: string; + alertId: string; // Is actually the Rule ID. Will be updated as part of https://github.com/elastic/kibana/issues/100115 + createdBy: string | null; executionId: string; - startedAt: Date; - previousStartedAt: Date | null; - services: RuleExecutorServices; + logger: Logger; + name: string; params: Params; - state: State; + previousStartedAt: Date | null; rule: SanitizedRuleConfig; + services: RuleExecutorServices; spaceId: string; - namespace?: string; - name: string; + startedAt: Date; + state: State; tags: string[]; - createdBy: string | null; updatedBy: string | null; - logger: Logger; + namespace?: string; } export interface RuleParamsAndRefs { diff --git a/x-pack/plugins/apm/common/critical_path/get_critical_path.test.ts b/x-pack/plugins/apm/common/critical_path/get_critical_path.test.ts new file mode 100644 index 0000000000000..38d1b0a3da1ca --- /dev/null +++ b/x-pack/plugins/apm/common/critical_path/get_critical_path.test.ts @@ -0,0 +1,274 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { apm, ApmFields, dedot } from '@kbn/apm-synthtrace'; +import { getWaterfall } from '../../public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; +import { Span } from '../../typings/es_schemas/ui/span'; +import { Transaction } from '../../typings/es_schemas/ui/transaction'; +import { getCriticalPath } from './get_critical_path'; + +describe('getCriticalPath', () => { + function getCriticalPathFromEvents(events: ApmFields[]) { + const waterfall = getWaterfall( + { + traceDocs: events.map( + (event) => dedot(event, {}) as Transaction | Span + ), + errorDocs: [], + exceedsMax: false, + linkedChildrenOfSpanCountBySpanId: {}, + }, + events[0]['transaction.id']! + ); + + return { + waterfall, + criticalPath: getCriticalPath(waterfall), + }; + } + it('adds the only active span to the critical path', () => { + const service = apm.service('a', 'development', 'java').instance('a'); + + const { + criticalPath: { segments }, + waterfall, + } = getCriticalPathFromEvents( + service + .transaction('/service-a') + .timestamp(1) + .duration(100) + .children( + service.span('foo', 'external', 'db').duration(100).timestamp(1) + ) + .serialize() + ); + + expect(segments).toEqual([ + { self: false, duration: 100000, item: waterfall.items[0], offset: 0 }, + { self: false, duration: 100000, item: waterfall.items[1], offset: 0 }, + { self: true, duration: 100000, item: waterfall.items[1], offset: 0 }, + ]); + }); + + it('adds the span that ended last', () => { + const service = apm.service('a', 'development', 'java').instance('a'); + + const { + criticalPath: { segments }, + waterfall, + } = getCriticalPathFromEvents( + service + .transaction('/service-a') + .timestamp(1) + .duration(100) + .children( + service.span('foo', 'external', 'db').duration(99).timestamp(1), + service.span('bar', 'external', 'db').duration(100).timestamp(1) + ) + .serialize() + ); + + const longerSpan = waterfall.items.find( + (item) => (item.doc as Span).span?.name === 'bar' + ); + + expect(segments).toEqual([ + { self: false, duration: 100000, item: waterfall.items[0], offset: 0 }, + { + self: false, + duration: 100000, + item: longerSpan, + offset: 0, + }, + { self: true, duration: 100000, item: longerSpan, offset: 0 }, + ]); + }); + + it('adds segment for uninstrumented gaps in the parent', () => { + const service = apm.service('a', 'development', 'java').instance('a'); + + const { + criticalPath: { segments }, + waterfall, + } = getCriticalPathFromEvents( + service + .transaction('/service-a') + .timestamp(1) + .duration(100) + .children( + service.span('foo', 'external', 'db').duration(50).timestamp(11) + ) + .serialize() + ); + + expect( + segments.map((segment) => ({ + self: segment.self, + duration: segment.duration, + id: segment.item.id, + offset: segment.offset, + })) + ).toEqual([ + { self: false, duration: 100000, id: waterfall.items[0].id, offset: 0 }, + { + self: true, + duration: 40000, + id: waterfall.items[0].id, + offset: 60000, + }, + { + self: false, + duration: 50000, + id: waterfall.items[1].id, + offset: 10000, + }, + { + self: true, + duration: 50000, + id: waterfall.items[1].id, + offset: 10000, + }, + { + self: true, + duration: 10000, + offset: 0, + id: waterfall.items[0].id, + }, + ]); + }); + + it('only considers a single child to be active at the same time', () => { + const service = apm.service('a', 'development', 'java').instance('a'); + + const { + criticalPath: { segments }, + waterfall, + } = getCriticalPathFromEvents( + service + .transaction('s1') + .timestamp(1) + .duration(100) + .children( + service.span('s2', 'external', 'db').duration(1).timestamp(1), + service.span('s3', 'external', 'db').duration(1).timestamp(2), + service.span('s4', 'external', 'db').duration(98).timestamp(3), + service + .span('s5', 'external', 'db') + .duration(98) + .timestamp(1) + .children( + service.span('s6', 'external', 'db').duration(30).timestamp(5), + service.span('s7', 'external', 'db').duration(30).timestamp(35) + ) + ) + .serialize() + ); + + const [_s1, s2, _s5, _s6, _s7, s3, s4] = waterfall.items; + + expect( + segments + .map((segment) => ({ + self: segment.self, + duration: segment.duration, + id: segment.item.id, + offset: segment.offset, + })) + .filter((segment) => segment.self) + .map((segment) => segment.id) + ).toEqual([s4.id, s3.id, s2.id]); + }); + + // https://www.uber.com/en-NL/blog/crisp-critical-path-analysis-for-microservice-architectures/ + it('correctly returns the critical path for the CRISP example', () => { + const service = apm.service('a', 'development', 'java').instance('a'); + + const { + criticalPath: { segments }, + waterfall, + } = getCriticalPathFromEvents( + service + .transaction('s1') + .timestamp(1) + .duration(100) + .children( + service.span('s2', 'external', 'db').duration(25).timestamp(6), + service + .span('s3', 'external', 'db') + .duration(50) + .timestamp(41) + .children( + service.span('s4', 'external', 'db').duration(20).timestamp(61), + service.span('s5', 'external', 'db').duration(30).timestamp(51) + ) + ) + .serialize() + ); + + const [s1, s2, s3, s5, _s4] = waterfall.items; + + expect( + segments + .map((segment) => ({ + self: segment.self, + duration: segment.duration, + id: segment.item.id, + offset: segment.offset, + })) + .filter((segment) => segment.self) + ).toEqual([ + // T9-T10 + { + self: true, + duration: 10000, + id: s1.id, + offset: 90000, + }, + // T8-T9 + { + self: true, + duration: 10000, + id: s3.id, + offset: 80000, + }, + // T5-T8 + { + self: true, + duration: s5.duration, + id: s5.id, + offset: s5.offset, + }, + // T4-T5 + { + self: true, + duration: 10000, + id: s3.id, + offset: 40000, + }, + // T3-T4 + { + self: true, + duration: 10000, + id: s1.id, + offset: 30000, + }, + // T2-T3 + { + self: true, + duration: 25000, + id: s2.id, + offset: 5000, + }, + // T1-T2 + { + duration: 5000, + id: s1.id, + offset: 0, + self: true, + }, + ]); + }); +}); diff --git a/x-pack/plugins/apm/common/critical_path/get_critical_path.ts b/x-pack/plugins/apm/common/critical_path/get_critical_path.ts new file mode 100644 index 0000000000000..c517548bf3d1f --- /dev/null +++ b/x-pack/plugins/apm/common/critical_path/get_critical_path.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + IWaterfall, + IWaterfallSpanOrTransaction, +} from '../../public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; +import { CriticalPath, CriticalPathSegment } from './types'; + +export function getCriticalPath(waterfall: IWaterfall): CriticalPath { + const segments: CriticalPathSegment[] = []; + + function scan({ + item, + start, + end, + }: { + item: IWaterfallSpanOrTransaction; + start: number; + end: number; + }): void { + segments.push({ + self: false, + duration: end - start, + item, + offset: start, + }); + const directChildren = waterfall.childrenByParentId[item.id]; + + if (directChildren && directChildren.length > 0) { + // We iterate over all the item's direct children. The one that + // ends last is the first item in the array. + const orderedChildren = directChildren.concat().sort((a, b) => { + const endTimeA = a.offset + a.skew + a.duration; + const endTimeB = b.offset + b.skew + b.duration; + return endTimeB - endTimeA; + }); + + // For each point in time, determine what child is on the critical path. + // We start scanning at the end. Once we've decided what the child on the + // critical path is, scan its children, from the start time of that span + // until the end. The next scan time is the start time of the child that was + // on the critical path. + let scanTime = end; + + orderedChildren.forEach((child) => { + const normalizedChildStart = Math.max(child.offset + child.skew, start); + const childEnd = child.offset + child.skew + child.duration; + + // if a span ends before the current scan time, use the current + // scan time as when the child ended. We don't want to scan further + // than the scan time. This prevents overlap in the critical path. + const normalizedChildEnd = Math.min(childEnd, scanTime); + + const isOnCriticalPath = !( + // A span/tx is NOT on the critical path if: + // - The start time is equal to or greater than the current scan time. + // Otherwise, spans that started at the same time will all contribute to + // the critical path, but we only want one to contribute. + // - The span/tx ends before the start of the initial scan period. + // - The span ends _after_ the current scan time. + + ( + normalizedChildStart >= scanTime || + normalizedChildEnd < start || + childEnd > scanTime + ) + ); + + if (!isOnCriticalPath) { + return; + } + + if (normalizedChildEnd < scanTime - 1000) { + // This span is on the critical path, but it ended before the scan time. + // This means that there is a gap, so we add a segment to the critical path + // for the _parent_. There's a slight offset because we don't want really small + // segments that can be reasonably attributed to clock skew. + segments.push({ + item, + duration: scanTime - normalizedChildEnd, + offset: normalizedChildEnd, + self: true, + }); + } + + // scan this child for the period we're considering it to be on the critical path + scan({ + start: normalizedChildStart, + end: childEnd, + item: child, + }); + + // set the scan time to the start of the span, and scan the next child + scanTime = normalizedChildStart; + }); + + // there's an unattributed gap at the start, so add a segment for the parent as well + if (scanTime > start) { + segments.push({ + item, + offset: start, + duration: scanTime - start, + self: true, + }); + } + } else { + // for the entire scan period, add this item to the critical path + segments.push({ + item, + offset: start, + duration: end - start, + self: true, + }); + } + } + + if (waterfall.entryWaterfallTransaction) { + const start = + waterfall.entryWaterfallTransaction.skew + + waterfall.entryWaterfallTransaction.offset; + scan({ + item: waterfall.entryWaterfallTransaction, + start, + end: start + waterfall.entryWaterfallTransaction.duration, + }); + } + + return { segments }; +} diff --git a/x-pack/plugins/apm/common/critical_path/types.ts b/x-pack/plugins/apm/common/critical_path/types.ts new file mode 100644 index 0000000000000..56f3db04e866f --- /dev/null +++ b/x-pack/plugins/apm/common/critical_path/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IWaterfallSpanOrTransaction } from '../../public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; + +export interface CriticalPathSegment { + item: IWaterfallSpanOrTransaction; + offset: number; + duration: number; + self: boolean; +} + +export interface CriticalPath { + segments: CriticalPathSegment[]; +} diff --git a/x-pack/plugins/apm/public/components/app/dependency_detail_operations/dependency_detail_operations_list/index.tsx b/x-pack/plugins/apm/public/components/app/dependency_detail_operations/dependency_detail_operations_list/index.tsx index ecef396bb0c5d..792f3f0aece25 100644 --- a/x-pack/plugins/apm/public/components/app/dependency_detail_operations/dependency_detail_operations_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/dependency_detail_operations/dependency_detail_operations_list/index.tsx @@ -23,6 +23,7 @@ import { ITableColumn, ManagedTable } from '../../../shared/managed_table'; import { getComparisonEnabled } from '../../../shared/time_comparison/get_comparison_enabled'; import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { DependencyOperationDetailLink } from '../../dependency_operation_detail_view/dependency_operation_detail_link'; +import { TransactionTab } from '../../transaction_details/waterfall_with_summary/transaction_tabs'; interface OperationStatisticsItem extends SpanMetricGroup { spanName: string; @@ -35,7 +36,14 @@ function OperationLink({ spanName }: { spanName: string }) { } + content={ + + } /> ); } diff --git a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependency_operation_detail_trace_list.tsx b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependency_operation_detail_trace_list.tsx index da4c603ff283e..4dde4af56ccf3 100644 --- a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependency_operation_detail_trace_list.tsx +++ b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependency_operation_detail_trace_list.tsx @@ -9,22 +9,28 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, + EuiRadio, EuiText, EuiTitle, RIGHT_ALIGNMENT, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useHistory } from 'react-router-dom'; import { ValuesType } from 'utility-types'; import { EventOutcome } from '../../../../common/event_outcome'; import { asMillisecondDuration } from '../../../../common/utils/formatters'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useApmRouter } from '../../../hooks/use_apm_router'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { FetcherResult, FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useTheme } from '../../../hooks/use_theme'; -import { useTimeRange } from '../../../hooks/use_time_range'; import { APIReturnType } from '../../../services/rest/create_call_apm_api'; -import { ITableColumn, ManagedTable } from '../../shared/managed_table'; +import { push } from '../../shared/links/url_helpers'; +import { + ITableColumn, + ManagedTable, + SortFunction, +} from '../../shared/managed_table'; import { ServiceLink } from '../../shared/service_link'; import { TimestampTooltip } from '../../shared/timestamp_tooltip'; @@ -32,15 +38,23 @@ type DependencySpan = ValuesType< APIReturnType<'GET /internal/apm/dependencies/operations/spans'>['spans'] >; -export function DependencyOperationDetailTraceList() { +export function DependencyOperationDetailTraceList({ + spanFetch, + sortFn, +}: { + spanFetch: FetcherResult< + APIReturnType<'GET /internal/apm/dependencies/operations/spans'> + >; + sortFn: SortFunction; +}) { const router = useApmRouter(); + const history = useHistory(); + const theme = useTheme(); const { query: { - dependencyName, - spanName, comparisonEnabled, environment, offset, @@ -49,8 +63,11 @@ export function DependencyOperationDetailTraceList() { refreshInterval, refreshPaused, kuery, - sampleRangeFrom, - sampleRangeTo, + sortField = '@timestamp', + sortDirection = 'desc', + pageSize = 10, + page = 1, + spanId, }, } = useApmParams('/dependencies/operation'); @@ -84,6 +101,7 @@ export function DependencyOperationDetailTraceList() { traceId, transactionId, transactionType, + showCriticalPath: false, }, }) : router.link('/link-to/trace/{traceId}', { @@ -99,9 +117,24 @@ export function DependencyOperationDetailTraceList() { return href; } - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const columns: Array> = [ + { + name: '', + field: 'spanId', + render: (_, { spanId: itemSpanId }) => { + return ( + { + push(history, { + query: { spanId: value ? itemSpanId : '' }, + }); + }} + checked={itemSpanId === spanId} + /> + ); + }, + }, { name: i18n.translate( 'xpack.apm.dependencyOperationDetailTraceListOutcomeColumn', @@ -121,38 +154,6 @@ export function DependencyOperationDetailTraceList() { return {outcome}; }, }, - { - name: i18n.translate( - 'xpack.apm.dependencyOperationDetailTraceListTraceIdColumn', - { defaultMessage: 'Trace' } - ), - field: 'traceId', - truncateText: true, - render: ( - _, - { - serviceName, - traceId, - transactionId, - transactionName, - transactionType, - } - ) => { - const href = getTraceLink({ - serviceName, - traceId, - transactionId, - transactionType, - transactionName, - }); - - return ( - - {traceId.substr(0, 6)} - - ); - }, - }, { name: i18n.translate( 'xpack.apm.dependencyOperationDetailTraceListServiceNameColumn', @@ -190,6 +191,7 @@ export function DependencyOperationDetailTraceList() { ), field: 'transactionName', truncateText: true, + width: '60%', render: ( _, { @@ -239,35 +241,6 @@ export function DependencyOperationDetailTraceList() { }, ]; - const { data = { spans: [] }, status } = useFetcher( - (callApmApi) => { - return callApmApi('GET /internal/apm/dependencies/operations/spans', { - params: { - query: { - dependencyName, - spanName, - start, - end, - environment, - kuery, - sampleRangeFrom, - sampleRangeTo, - }, - }, - }); - }, - [ - dependencyName, - spanName, - start, - end, - environment, - kuery, - sampleRangeFrom, - sampleRangeTo, - ] - ); - return ( @@ -281,15 +254,18 @@ export function DependencyOperationDetailTraceList() { diff --git a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependendecy_operation_distribution_chart.tsx b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependency_operation_distribution_chart.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependendecy_operation_distribution_chart.tsx rename to x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/dependency_operation_distribution_chart.tsx diff --git a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx index c824a61f019b2..9acd060f5fe68 100644 --- a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx @@ -6,25 +6,142 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import { omit, orderBy } from 'lodash'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; +import type { DependencySpan } from '../../../../server/routes/dependencies/get_top_dependency_spans'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { useDependencyDetailOperationsBreadcrumb } from '../../../hooks/use_dependency_detail_operations_breadcrumb'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { useTimeRange } from '../../../hooks/use_time_range'; import { DependencyMetricCharts } from '../../shared/dependency_metric_charts'; import { DetailViewHeader } from '../../shared/detail_view_header'; -import { DependencyOperationDistributionChart } from './dependendecy_operation_distribution_chart'; +import { ResettingHeightRetainer } from '../../shared/height_retainer/resetting_height_container'; +import { push, replace } from '../../shared/links/url_helpers'; +import { SortFunction } from '../../shared/managed_table'; +import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher'; +import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary'; import { DependencyOperationDetailTraceList } from './dependency_operation_detail_trace_list'; +import { DependencyOperationDistributionChart } from './dependency_operation_distribution_chart'; +import { maybeRedirectToAvailableSpanSample } from './maybe_redirect_to_available_span_sample'; export function DependencyOperationDetailView() { const router = useApmRouter(); + const history = useHistory(); + const { - query: { spanName, ...query }, + query, + query: { + spanName, + dependencyName, + sampleRangeFrom, + sampleRangeTo, + kuery, + environment, + rangeFrom, + rangeTo, + spanId, + waterfallItemId, + detailTab, + sortField = '@timestamp', + sortDirection = 'desc', + showCriticalPath, + }, } = useApmParams('/dependencies/operation'); useDependencyDetailOperationsBreadcrumb(); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const queryWithoutSpanName = omit(query, 'spanName'); + + const spanFetch = useFetcher( + (callApmApi) => { + return callApmApi('GET /internal/apm/dependencies/operations/spans', { + params: { + query: { + dependencyName, + spanName, + start, + end, + environment, + kuery, + sampleRangeFrom, + sampleRangeTo, + }, + }, + }); + }, + [ + dependencyName, + spanName, + start, + end, + environment, + kuery, + sampleRangeFrom, + sampleRangeTo, + ] + ); + + const getSortedSamples: SortFunction = ( + items, + localSortField, + localSortDirection + ) => { + return orderBy(items, localSortField, localSortDirection); + }; + + const samples = useMemo(() => { + return ( + getSortedSamples( + spanFetch.data?.spans ?? [], + sortField, + sortDirection + ).map((span) => ({ + spanId: span.spanId, + traceId: span.traceId, + transactionId: span.transactionId, + })) || [] + ); + }, [spanFetch.data?.spans, sortField, sortDirection]); + + const selectedSample = useMemo(() => { + return samples.find((sample) => sample.spanId === spanId); + }, [samples, spanId]); + + const waterfallFetch = useWaterfallFetcher({ + traceId: selectedSample?.traceId, + transactionId: selectedSample?.transactionId, + start, + end, + }); + + const queryRef = useRef(query); + + queryRef.current = query; + + useEffect(() => { + maybeRedirectToAvailableSpanSample({ + history, + page: queryRef.current.page ?? 0, + pageSize: queryRef.current.pageSize ?? 10, + replace, + samples, + spanFetchStatus: spanFetch.status, + spanId, + }); + }, [samples, spanId, history, queryRef, router, spanFetch.status]); + + const isWaterfallLoading = + spanFetch.status === FETCH_STATUS.NOT_INITIATED || + (spanFetch.status === FETCH_STATUS.LOADING && samples.length === 0) || + waterfallFetch.status === FETCH_STATUS.LOADING || + !waterfallFetch.waterfall.entryWaterfallTransaction; + return ( @@ -33,7 +150,9 @@ export function DependencyOperationDetailView() { 'xpack.apm.dependecyOperationDetailView.header.backLinkLabel', { defaultMessage: 'All operations' } )} - backHref={router.link('/dependencies/operations', { query })} + backHref={router.link('/dependencies/operations', { + query: queryWithoutSpanName, + })} title={spanName} /> @@ -50,7 +169,47 @@ export function DependencyOperationDetailView() { - + + + + + + + { + push(history, { query: { spanId: sample.spanId } }); + }} + onTabClick={(tab) => { + push(history, { + query: { + detailTab: tab, + }, + }); + }} + serviceName={ + waterfallFetch.waterfall.entryWaterfallTransaction?.doc.service + .name + } + waterfallItemId={waterfallItemId} + detailTab={detailTab} + selectedSample={selectedSample || null} + showCriticalPath={showCriticalPath} + onShowCriticalPathChange={(nextShowCriticalPath) => { + push(history, { + query: { + showCriticalPath: nextShowCriticalPath ? 'true' : 'false', + }, + }); + }} + /> + diff --git a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.test.ts b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.test.ts new file mode 100644 index 0000000000000..a7c2b2b669bf3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { range } from 'lodash'; +import { maybeRedirectToAvailableSpanSample } from './maybe_redirect_to_available_span_sample'; +import { replace as urlHelpersReplace } from '../../shared/links/url_helpers'; +import { History } from 'history'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; + +describe('maybeRedirectToAvailableSpanSample', () => { + const samples: Array<{ + spanId: string; + traceId: string; + transactionId: string; + }> = range(11).map((_, index) => ({ + spanId: (index + 1).toString(), + traceId: '', + transactionId: '', + })); + + let defaultParams: Omit< + Parameters[0], + 'replace' + > & { replace: jest.MockedFunction }; + + beforeEach(() => { + defaultParams = { + samples, + page: 0, + pageSize: 10, + history: { + location: { + search: '', + }, + } as History, + spanFetchStatus: FETCH_STATUS.SUCCESS, + replace: jest.fn(), + }; + }); + + it('does not redirect while loading', () => { + maybeRedirectToAvailableSpanSample({ + ...defaultParams, + spanId: undefined, + spanFetchStatus: FETCH_STATUS.LOADING, + }); + expect(defaultParams.replace).not.toHaveBeenCalled(); + }); + + it('redirects to the first available span if no span is selected', () => { + maybeRedirectToAvailableSpanSample({ + ...defaultParams, + spanId: undefined, + page: 1, + spanFetchStatus: FETCH_STATUS.SUCCESS, + }); + expect(defaultParams.replace).toHaveBeenCalled(); + + expect(defaultParams.replace.mock.calls[0][1].query).toEqual({ + spanId: samples[0].spanId, + page: '0', + }); + }); + + it('redirects to the first available span if the currently selected sample is not found', () => { + maybeRedirectToAvailableSpanSample({ + ...defaultParams, + page: 1, + spanId: '12', + spanFetchStatus: FETCH_STATUS.SUCCESS, + }); + expect(defaultParams.replace).toHaveBeenCalled(); + + expect(defaultParams.replace.mock.calls[0][1].query).toEqual({ + spanId: samples[0].spanId, + page: '0', + }); + }); + + it('does not redirect if the sample is found', () => { + maybeRedirectToAvailableSpanSample({ + ...defaultParams, + page: 0, + spanId: '1', + spanFetchStatus: FETCH_STATUS.SUCCESS, + }); + expect(defaultParams.replace).not.toHaveBeenCalled(); + }); + + it('redirects to the page of the currently selected sample', () => { + maybeRedirectToAvailableSpanSample({ + ...defaultParams, + page: 0, + spanId: '11', + spanFetchStatus: FETCH_STATUS.SUCCESS, + }); + + expect(defaultParams.replace).toHaveBeenCalled(); + + expect(defaultParams.replace.mock.calls[0][1].query).toEqual({ + page: '1', + spanId: '11', + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.ts b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.ts new file mode 100644 index 0000000000000..00ce95a255ea2 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { History } from 'history'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { replace as urlHelpersReplace } from '../../shared/links/url_helpers'; + +export function maybeRedirectToAvailableSpanSample({ + spanFetchStatus, + spanId, + pageSize, + page, + replace, + samples, + history, +}: { + spanFetchStatus: FETCH_STATUS; + spanId?: string; + pageSize: number; + page: number; + replace: typeof urlHelpersReplace; + history: History; + samples: Array<{ spanId: string; traceId: string; transactionId: string }>; +}) { + if (spanFetchStatus !== FETCH_STATUS.SUCCESS) { + // we're still loading, don't do anything + return; + } + + const nextSpanId = + samples.find((sample) => sample.spanId === spanId)?.spanId || + samples[0]?.spanId || + ''; + + const indexOfNextSample = + samples.findIndex((sample) => sample.spanId === nextSpanId) ?? 0; + + const nextPageIndex = Math.floor((indexOfNextSample + 1) / (pageSize ?? 10)); + + if (page !== nextPageIndex || (spanId ?? '') !== nextSpanId) { + replace(history, { + query: { spanId: nextSpanId, page: nextPageIndex.toString() }, + }); + } +} diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx index 220f276f62152..4b41099240f54 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx @@ -99,6 +99,7 @@ export function DetailView({ errorGroup, urlParams, kuery }: Props) { const traceExplorerLink = router.link('/traces/explorer', { query: { ...query, + showCriticalPath: false, query: `${ERROR_GROUP_ID}:${groupId}`, type: TraceSearchType.kql, traceId: '', diff --git a/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx index 4bcc8bb8ca53b..add6c48fef8e5 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/popover/edge_contents.tsx @@ -58,6 +58,7 @@ export function EdgeContents({ elementData }: ContentsProps) { traceId: '', transactionId: '', detailTab: TransactionTab.timeline, + showCriticalPath: false, }, }); diff --git a/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx b/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx index 47287084386ad..1b6c35adffc04 100644 --- a/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx @@ -20,10 +20,6 @@ import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetche import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary'; import { TraceSearchBox } from './trace_search_box'; -const INITIAL_DATA = { - traceSamples: [], -}; - export function TraceExplorer() { const [query, setQuery] = useState({ query: '', @@ -41,6 +37,7 @@ export function TraceExplorer() { transactionId, waterfallItemId, detailTab, + showCriticalPath, }, } = useApmParams('/traces/explorer'); @@ -58,11 +55,7 @@ export function TraceExplorer() { rangeTo, }); - const { - data = INITIAL_DATA, - status, - error, - } = useFetcher( + const { data, status, error } = useFetcher( (callApmApi) => { return callApmApi('GET /internal/apm/traces/find', { params: { @@ -80,7 +73,7 @@ export function TraceExplorer() { ); useEffect(() => { - const nextSample = data.traceSamples[0]; + const nextSample = data?.traceSamples[0]; const nextWaterfallItemId = ''; history.replace({ ...history.location, @@ -141,7 +134,8 @@ export function TraceExplorer() { { push(history, { @@ -165,6 +159,14 @@ export function TraceExplorer() { waterfallFetchResult.waterfall.entryWaterfallTransaction?.doc .service.name } + showCriticalPath={showCriticalPath} + onShowCriticalPathChange={(nextShowCriticalPath) => { + push(history, { + query: { + showCriticalPath: nextShowCriticalPath ? 'true' : 'false', + }, + }); + }} /> diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx index 1093a74f6bc2f..4c176527d49f6 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx @@ -44,6 +44,7 @@ export function TraceOverview({ children }: { children: React.ReactElement }) { traceId: '', transactionId: '', detailTab: TransactionTab.timeline, + showCriticalPath: false, }, }); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx index 73b224d60aaa4..72bf5a048e9e7 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx @@ -21,7 +21,7 @@ import { useApmParams } from '../../../../hooks/use_apm_params'; import { useTimeRange } from '../../../../hooks/use_time_range'; import { DurationDistributionChartWithScrubber } from '../../../shared/charts/duration_distribution_chart_with_scrubber'; import { HeightRetainer } from '../../../shared/height_retainer'; -import { fromQuery, toQuery } from '../../../shared/links/url_helpers'; +import { fromQuery, push, toQuery } from '../../../shared/links/url_helpers'; import { TransactionTab } from '../waterfall_with_summary/transaction_tabs'; import { useTransactionDistributionChartData } from './use_transaction_distribution_chart_data'; import { TraceSamplesFetchResult } from '../../../../hooks/use_transaction_trace_samples_fetcher'; @@ -43,7 +43,7 @@ export function TransactionDistribution({ const { traceId, transactionId } = urlParams; const { - query: { rangeFrom, rangeTo }, + query: { rangeFrom, rangeTo, showCriticalPath }, } = useApmParams('/services/{serviceName}/transactions/view'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -117,7 +117,16 @@ export function TransactionDistribution({ waterfallItemId={waterfallItemId} detailTab={detailTab as TransactionTab | undefined} waterfallFetchResult={waterfallFetchResult} - traceSamplesFetchResult={traceSamplesFetchResult} + traceSamplesFetchStatus={traceSamplesFetchResult.status} + traceSamples={traceSamplesFetchResult.data?.traceSamples} + showCriticalPath={showCriticalPath} + onShowCriticalPathChange={(nextShowCriticalPath) => { + push(history, { + query: { + showCriticalPath: nextShowCriticalPath ? 'true' : 'false', + }, + }); + }} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx index 57d630393f9a9..4ef0bb54319a0 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx @@ -22,55 +22,71 @@ import { MaybeViewTraceLink } from './maybe_view_trace_link'; import { TransactionTab, TransactionTabs } from './transaction_tabs'; import { Environment } from '../../../../../common/environment_rt'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; -import { TraceSamplesFetchResult } from '../../../../hooks/use_transaction_trace_samples_fetcher'; import { WaterfallFetchResult } from '../use_waterfall_fetcher'; -interface Props { +interface Props { waterfallFetchResult: WaterfallFetchResult; - traceSamplesFetchResult: TraceSamplesFetchResult; + traceSamples?: TSample[]; + traceSamplesFetchStatus: FETCH_STATUS; environment: Environment; - onSampleClick: (sample: { transactionId: string; traceId: string }) => void; - onTabClick: (tab: string) => void; + onSampleClick: (sample: TSample) => void; + onTabClick: (tab: TransactionTab) => void; serviceName?: string; waterfallItemId?: string; detailTab?: TransactionTab; + showCriticalPath: boolean; + onShowCriticalPathChange: (showCriticalPath: boolean) => void; + selectedSample?: TSample | null; } -export function WaterfallWithSummary({ +export function WaterfallWithSummary({ waterfallFetchResult, - traceSamplesFetchResult, + traceSamples, + traceSamplesFetchStatus, environment, onSampleClick, onTabClick, serviceName, waterfallItemId, detailTab, -}: Props) { + showCriticalPath, + onShowCriticalPathChange, + selectedSample, +}: Props) { const [sampleActivePage, setSampleActivePage] = useState(0); + const isControlled = selectedSample !== undefined; + + const isLoading = + waterfallFetchResult.status === FETCH_STATUS.LOADING || + traceSamplesFetchStatus === FETCH_STATUS.LOADING; + const isSucceded = + waterfallFetchResult.status === FETCH_STATUS.SUCCESS && + traceSamplesFetchStatus === FETCH_STATUS.SUCCESS; + useEffect(() => { - setSampleActivePage(0); - }, [traceSamplesFetchResult.data.traceSamples]); + if (!isControlled) { + setSampleActivePage(0); + } + }, [traceSamples, isControlled]); const goToSample = (index: number) => { - setSampleActivePage(index); - const sample = traceSamplesFetchResult.data.traceSamples[index]; + const sample = traceSamples![index]; + if (!isControlled) { + setSampleActivePage(index); + } onSampleClick(sample); }; + const samplePageIndex = isControlled + ? selectedSample + ? traceSamples?.indexOf(selectedSample) + : 0 + : sampleActivePage; + const { entryWaterfallTransaction } = waterfallFetchResult.waterfall; - const isLoading = - waterfallFetchResult.status === FETCH_STATUS.LOADING || - traceSamplesFetchResult.status === FETCH_STATUS.LOADING; - const isSucceded = - waterfallFetchResult.status === FETCH_STATUS.SUCCESS && - traceSamplesFetchResult.status === FETCH_STATUS.SUCCESS; - if ( - !entryWaterfallTransaction && - traceSamplesFetchResult.data.traceSamples.length === 0 && - isSucceded - ) { + if (!entryWaterfallTransaction && traceSamples?.length === 0 && isSucceded) { return ( - {traceSamplesFetchResult.data.traceSamples.length > 0 && ( + {!!traceSamples?.length && ( @@ -159,6 +175,8 @@ export function WaterfallWithSummary({ onTabClick={onTabClick} waterfall={waterfallFetchResult.waterfall} isLoading={isLoading} + showCriticalPath={showCriticalPath} + onShowCriticalPathChange={onShowCriticalPathChange} /> ); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx index 1621ea72b39a1..40b1944605b58 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx @@ -53,7 +53,8 @@ export function MaybeViewTraceLink({ query: { comparisonEnabled, offset }, } = useAnyOfApmParams( '/services/{serviceName}/transactions/view', - '/traces/explorer' + '/traces/explorer', + '/dependencies/operation' ); const latencyAggregationType = diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/transaction_tabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/transaction_tabs.tsx index e3fdaeea24846..85e8b36942936 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/transaction_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/transaction_tabs.tsx @@ -22,6 +22,8 @@ interface Props { serviceName?: string; waterfallItemId?: string; onTabClick: (tab: TransactionTab) => void; + showCriticalPath: boolean; + onShowCriticalPathChange: (showCriticalPath: boolean) => void; } export function TransactionTabs({ @@ -32,6 +34,8 @@ export function TransactionTabs({ waterfallItemId, serviceName, onTabClick, + showCriticalPath, + onShowCriticalPathChange, }: Props) { const tabs = [timelineTab, metadataTab, logsTab]; const currentTab = tabs.find(({ key }) => key === detailTab) ?? timelineTab; @@ -64,6 +68,8 @@ export function TransactionTabs({ serviceName={serviceName} waterfall={waterfall} transaction={transaction} + showCriticalPath={showCriticalPath} + onShowCriticalPathChange={onShowCriticalPathChange} /> )} @@ -104,16 +110,22 @@ function TimelineTabContent({ waterfall, waterfallItemId, serviceName, + showCriticalPath, + onShowCriticalPathChange, }: { waterfallItemId?: string; serviceName?: string; waterfall: IWaterfall; + showCriticalPath: boolean; + onShowCriticalPathChange: (showCriticalPath: boolean) => void; }) { return ( ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx index 2dd74aeae3eef..b9f149c32e491 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx @@ -5,26 +5,36 @@ * 2.0. */ -import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSwitch } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { keyBy } from 'lodash'; +import React from 'react'; +import { useCriticalPathFeatureEnabledSetting } from '../../../../../hooks/use_critical_path_feature_enabled_setting'; +import { TechnicalPreviewBadge } from '../../../../shared/technical_preview_badge'; +import { Waterfall } from './waterfall'; import { IWaterfall, WaterfallLegendType, } from './waterfall/waterfall_helpers/waterfall_helpers'; -import { Waterfall } from './waterfall'; import { WaterfallLegends } from './waterfall_legends'; interface Props { waterfallItemId?: string; serviceName?: string; waterfall: IWaterfall; + showCriticalPath: boolean; + onShowCriticalPathChange: (showCriticalPath: boolean) => void; } export function WaterfallContainer({ serviceName, waterfallItemId, waterfall, + showCriticalPath, + onShowCriticalPathChange, }: Props) { + const isCriticalPathFeatureEnabled = useCriticalPathFeatureEnabledSetting(); + if (!waterfall) { return null; } @@ -74,9 +84,40 @@ export function WaterfallContainer({ }); return ( -
- - -
+ + {isCriticalPathFeatureEnabled ? ( + + + + {i18n.translate('xpack.apm.waterfall.showCriticalPath', { + defaultMessage: 'Show critical path', + })} + + + + + + } + checked={showCriticalPath} + onChange={(event) => { + onShowCriticalPathChange(event.target.checked); + }} + /> +
+ ) : null} + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx index c0932e041de1a..3b996bfb3cdd1 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx @@ -15,12 +15,16 @@ import { } from '@elastic/eui'; import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { groupBy } from 'lodash'; +import { transparentize } from 'polished'; import { Margins } from '../../../../../shared/charts/timeline'; import { IWaterfall, IWaterfallSpanOrTransaction, } from './waterfall_helpers/waterfall_helpers'; import { WaterfallItem } from './waterfall_item'; +import { getCriticalPath } from '../../../../../../../common/critical_path/get_critical_path'; +import { useTheme } from '../../../../../../hooks/use_theme'; interface AccordionWaterfallProps { isOpen: boolean; @@ -32,6 +36,7 @@ interface AccordionWaterfallProps { waterfall: IWaterfall; timelineMargins: Margins; onClickWaterfallItem: (item: IWaterfallSpanOrTransaction) => void; + showCriticalPath: boolean; } const ACCORDION_HEIGHT = '48px'; @@ -85,8 +90,11 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { setMaxLevel, timelineMargins, onClickWaterfallItem, + showCriticalPath, } = props; + const theme = useTheme(); + const [isOpen, setIsOpen] = useState(props.isOpen); const [nextLevel] = useState(level + 1); @@ -94,7 +102,26 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { setMaxLevel(nextLevel); }, [nextLevel, setMaxLevel]); - const children = waterfall.childrenByParentId[item.id] || []; + let children = waterfall.childrenByParentId[item.id] || []; + + const criticalPath = showCriticalPath + ? getCriticalPath(waterfall) + : undefined; + + const criticalPathSegmentsById = groupBy( + criticalPath?.segments, + (segment) => segment.item.id + ); + + let displayedColor = item.color; + + if (showCriticalPath) { + children = children.filter( + (child) => criticalPathSegmentsById[child.id]?.length + ); + displayedColor = transparentize(0.5, item.color); + } + const errorCount = waterfall.getErrorCount(item.id); // To indent the items creating the parent/child tree @@ -131,7 +158,7 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { { onClickWaterfallItem(item); }} + segments={criticalPathSegmentsById[item.id] + ?.filter((segment) => segment.self) + .map((segment) => ({ + color: theme.eui.euiColorAccent, + left: + (segment.offset - item.offset - item.skew) / item.duration, + width: segment.duration / item.duration, + }))} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/index.tsx index 04c3734eebaff..d117cb2d982c1 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/index.tsx @@ -52,8 +52,14 @@ const WaterfallItemsContainer = euiStyled.div` interface Props { waterfallItemId?: string; waterfall: IWaterfall; + showCriticalPath: boolean; } -export function Waterfall({ waterfall, waterfallItemId }: Props) { + +export function Waterfall({ + waterfall, + waterfallItemId, + showCriticalPath, +}: Props) { const history = useHistory(); const [isAccordionOpen, setIsAccordionOpen] = useState(true); const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found @@ -119,6 +125,7 @@ export function Waterfall({ waterfall, waterfallItemId }: Props) { onClickWaterfallItem={(item: IWaterfallItem) => toggleFlyout({ history, item }) } + showCriticalPath={showCriticalPath} /> )} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx index 0b500cb79a746..59abacb1c325c 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx @@ -33,7 +33,8 @@ interface Props { export function StickySpanProperties({ span, transaction }: Props) { const { query } = useAnyOfApmParams( '/services/{serviceName}/transactions/view', - '/traces/explorer' + '/traces/explorer', + '/dependencies/operation' ); const { environment, comparisonEnabled, offset } = query; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx index 9bfff6a4ea89b..8057ee3a32b7d 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx @@ -83,6 +83,31 @@ const ItemText = euiStyled.span` } `; +const CriticalPathItemBar = euiStyled.div` + box-sizing: border-box; + position: relative; + height: ${({ theme }) => theme.eui.euiSizeS}; + top : ${({ theme }) => theme.eui.euiSizeS}; + min-width: 2px; + background-color: transparent; + display: flex; + flex-direction: row; +`; + +const CriticalPathItemSegment = euiStyled.div<{ + left: number; + width: number; + color: string; +}>` + box-sizing: border-box; + position: absolute; + height: ${({ theme }) => theme.eui.euiSizeS}; + left: ${(props) => props.left * 100}%; + width: ${(props) => props.width * 100}%; + min-width: 2px; + background-color: ${(props) => props.color}; +`; + interface IWaterfallItemProps { timelineMargins: Margins; totalDuration?: number; @@ -92,6 +117,11 @@ interface IWaterfallItemProps { isSelected: boolean; errorCount: number; marginLeftLevel: number; + segments?: Array<{ + left: number; + width: number; + color: string; + }>; onClick: () => unknown; } @@ -194,6 +224,7 @@ export function WaterfallItem({ errorCount, marginLeftLevel, onClick, + segments, }: IWaterfallItemProps) { const [widthFactor, setWidthFactor] = useState(1); const waterfallItemRef: React.RefObject = useRef(null); @@ -217,7 +248,9 @@ export function WaterfallItem({ 100; const isCompositeSpan = item.docType === 'span' && item.doc.span.composite; + const itemBarStyle = getItemBarStyle(item, color, width, left); + const isServerlessColdstart = item.docType === 'transaction' && item.doc.faas?.coldstart; @@ -237,7 +270,19 @@ export function WaterfallItem({ style={itemBarStyle} color={isCompositeSpan ? 'transparent' : color} type={item.docType} - /> + > + {segments?.length ? ( + + {segments?.map((segment) => ( + + ))} + + ) : null} + @@ -277,7 +322,8 @@ function RelatedErrors({ const theme = useTheme(); const { query } = useAnyOfApmParams( '/services/{serviceName}/transactions/view', - '/traces/explorer' + '/traces/explorer', + '/dependencies/operation' ); let kuery = `${TRACE_ID} : "${item.doc.trace.id}"`; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx index a10518ab58e4c..0a08dcb166048 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx @@ -8,6 +8,7 @@ import { Meta, Story } from '@storybook/react'; import React, { ComponentProps } from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { noop } from 'lodash'; import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context'; import { WaterfallContainer } from '.'; import { getWaterfall } from './waterfall/waterfall_helpers/waterfall_helpers'; @@ -59,6 +60,8 @@ export const Example: Story = ({ serviceName={serviceName} waterfallItemId={waterfallItemId} waterfall={waterfall} + showCriticalPath={false} + onShowCriticalPathChange={noop} /> ); }; @@ -76,6 +79,8 @@ export const WithErrors: Story = ({ serviceName={serviceName} waterfallItemId={waterfallItemId} waterfall={waterfall} + showCriticalPath={false} + onShowCriticalPathChange={noop} /> ); }; @@ -93,6 +98,8 @@ export const ChildStartsBeforeParent: Story = ({ serviceName={serviceName} waterfallItemId={waterfallItemId} waterfall={waterfall} + showCriticalPath={false} + onShowCriticalPathChange={noop} /> ); }; @@ -110,6 +117,8 @@ export const InferredSpans: Story = ({ serviceName={serviceName} waterfallItemId={waterfallItemId} waterfall={waterfall} + showCriticalPath={false} + onShowCriticalPathChange={noop} /> ); }; @@ -127,6 +136,8 @@ export const ManyChildrenWithSameLength: Story = ({ serviceName={serviceName} waterfallItemId={waterfallItemId} waterfall={waterfall} + showCriticalPath={false} + onShowCriticalPathChange={noop} /> ); }; diff --git a/x-pack/plugins/apm/public/components/routing/home/dependencies.tsx b/x-pack/plugins/apm/public/components/routing/home/dependencies.tsx index a20634c31912c..7b0d93d7550e1 100644 --- a/x-pack/plugins/apm/public/components/routing/home/dependencies.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/dependencies.tsx @@ -20,6 +20,7 @@ import { DependencyDetailView } from '../../app/dependency_detail_view'; import { DependenciesInventory } from '../../app/dependencies_inventory'; import { DependencyOperationDetailView } from '../../app/dependency_operation_detail_view'; import { useApmParams } from '../../../hooks/use_apm_params'; +import { TransactionTab } from '../../app/transaction_details/waterfall_with_summary/transaction_tabs'; export const DependenciesInventoryTitle = i18n.translate( 'xpack.apm.views.dependenciesInventory.title', @@ -73,13 +74,27 @@ export const dependencies = { query: t.intersection([ t.type({ spanName: t.string, + detailTab: t.union([ + t.literal(TransactionTab.timeline), + t.literal(TransactionTab.metadata), + t.literal(TransactionTab.logs), + ]), + showCriticalPath: toBooleanRt, }), t.partial({ + spanId: t.string, sampleRangeFrom: toNumberRt, sampleRangeTo: toNumberRt, + waterfallItemId: t.string, }), ]), }), + defaults: { + query: { + detailTab: TransactionTab.timeline, + showCriticalPath: '', + }, + }, element: , }, '/dependencies/overview': { diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx index 51a68488f9d81..36ead4f7b36c7 100644 --- a/x-pack/plugins/apm/public/components/routing/home/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -213,6 +213,7 @@ export const home = { t.literal(TransactionTab.metadata), t.literal(TransactionTab.logs), ]), + showCriticalPath: toBooleanRt, }), }), defaults: { @@ -223,6 +224,7 @@ export const home = { traceId: '', transactionId: '', detailTab: TransactionTab.timeline, + showCriticalPath: '', }, }, }, diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index fdd1aedfa0022..7cc2f7b113fe9 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -180,6 +180,7 @@ export const serviceDetail = { t.type({ transactionName: t.string, comparisonEnabled: toBooleanRt, + showCriticalPath: toBooleanRt, }), t.partial({ traceId: t.string, @@ -188,6 +189,11 @@ export const serviceDetail = { offsetRt, ]), }), + defaults: { + query: { + showCriticalPath: '', + }, + }, }, '/services/{serviceName}/transactions': { element: , diff --git a/x-pack/plugins/apm/public/components/shared/height_retainer/resetting_height_container.tsx b/x-pack/plugins/apm/public/components/shared/height_retainer/resetting_height_container.tsx new file mode 100644 index 0000000000000..00a1b9dd40c31 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/height_retainer/resetting_height_container.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useRef } from 'react'; + +export function ResettingHeightRetainer( + props: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > & { reset?: boolean } +) { + const { reset, ...containerProps } = props; + const resetRef = useRef(reset); + const containerRef = useRef(null); + + const minHeightRef = useRef(0); + + if (resetRef.current !== reset) { + minHeightRef.current = reset ? 0 : containerRef.current?.clientHeight ?? 0; + + resetRef.current = reset; + } + + return ( +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index a4814211399e3..41512f00d22b6 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -36,11 +36,7 @@ interface Props { showPerPageOptions?: boolean; noItemsMessage?: React.ReactNode; sortItems?: boolean; - sortFn?: ( - items: T[], - sortField: string, - sortDirection: 'asc' | 'desc' - ) => T[]; + sortFn?: SortFunction; pagination?: boolean; isLoading?: boolean; error?: boolean; @@ -57,6 +53,12 @@ function defaultSortFn( return orderBy(items, sortField, sortDirection); } +export type SortFunction = ( + items: T[], + sortField: string, + sortDirection: 'asc' | 'desc' +) => T[]; + function UnoptimizedManagedTable(props: Props) { const history = useHistory(); const { diff --git a/x-pack/plugins/apm/public/components/shared/redirect_with_offset/index.test.tsx b/x-pack/plugins/apm/public/components/shared/redirect_with_offset/index.test.tsx index a47d3c76b500b..2a04856a7a1c4 100644 --- a/x-pack/plugins/apm/public/components/shared/redirect_with_offset/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/redirect_with_offset/index.test.tsx @@ -31,6 +31,11 @@ describe('RedirectWithOffset', () => { .spyOn(useApmPluginContextExports, 'useApmPluginContext') .mockReturnValue({ core: { + http: { + basePath: { + prepend: () => {}, + }, + }, uiSettings: { get: () => defaultSetting, }, diff --git a/x-pack/plugins/apm/public/hooks/use_apm_router.ts b/x-pack/plugins/apm/public/hooks/use_apm_router.ts index d10b6da857802..d479b3f1af414 100644 --- a/x-pack/plugins/apm/public/hooks/use_apm_router.ts +++ b/x-pack/plugins/apm/public/hooks/use_apm_router.ts @@ -6,6 +6,7 @@ */ import { useRouter } from '@kbn/typed-react-router-config'; +import { useMemo } from 'react'; import type { ApmRouter } from '../components/routing/apm_route_config'; import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; @@ -13,12 +14,13 @@ export function useApmRouter() { const router = useRouter(); const { core } = useApmPluginContext(); - const link = (...args: [any]) => { - return core.http.basePath.prepend('/app/apm' + router.link(...args)); - }; - - return { - ...router, - link, - } as unknown as ApmRouter; + return useMemo( + () => + ({ + ...router, + link: (...args: [any]) => + core.http.basePath.prepend('/app/apm' + router.link(...args)), + } as unknown as ApmRouter), + [core.http.basePath, router] + ); } diff --git a/x-pack/plugins/apm/public/hooks/use_critical_path_feature_enabled_setting.ts b/x-pack/plugins/apm/public/hooks/use_critical_path_feature_enabled_setting.ts new file mode 100644 index 0000000000000..29c6d10ea2d69 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_critical_path_feature_enabled_setting.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 { enableCriticalPath } from '@kbn/observability-plugin/common'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; + +export function useCriticalPathFeatureEnabledSetting() { + const { core } = useApmPluginContext(); + + const isCriticalPathFeatureEnabled = + core.uiSettings.get(enableCriticalPath); + + return isCriticalPathFeatureEnabled; +} diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_trace_samples_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_trace_samples_fetcher.ts index 9bb2b68266827..510118b6a191c 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_trace_samples_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_trace_samples_fetcher.ts @@ -12,10 +12,6 @@ import { useApmServiceContext } from '../context/apm_service/use_apm_service_con import { useApmParams } from './use_apm_params'; import { useTimeRange } from './use_time_range'; -const INITIAL_DATA = { - traceSamples: [], -}; - export type TraceSamplesFetchResult = ReturnType< typeof useTransactionTraceSamplesFetcher >; @@ -41,11 +37,7 @@ export function useTransactionTraceSamplesFetcher({ urlParams: { transactionId, traceId, sampleRangeFrom, sampleRangeTo }, } = useLegacyUrlParams(); - const { - data = INITIAL_DATA, - status, - error, - } = useFetcher( + const { data, status, error } = useFetcher( (callApmApi) => { if (serviceName && start && end && transactionType && transactionName) { return callApmApi( diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts index 7a1c4da71b788..fcce003d89206 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts @@ -17,7 +17,11 @@ type ISavedObjectsClient = Pick; type TelemetryTaskExecutor = (params: { indices: ApmIndicesConfig; - search( + search< + TSearchRequest extends ESSearchRequest & { index: string | string[] } & { + body: { timeout: string }; + } + >( params: TSearchRequest ): Promise>; indicesStats( diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts index 253c8ed9e44d2..26c0caac5e2d3 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts @@ -434,7 +434,7 @@ describe('data telemetry collection tasks', () => { } }); - expect(await task?.executor({ search } as any)).toEqual({ + expect(await task?.executor({ search, indices } as any)).toEqual({ cardinality: { client: { geo: { country_iso_code: { rum: { '1d': 5 } } } }, transaction: { name: { all_agents: { '1d': 3 }, rum: { '1d': 1 } } }, diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts index d114b31d75e3c..37c7dad804a4d 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -148,6 +148,7 @@ export const tasks: TelemetryTask[] = [ await search({ index: indices.transaction, body: { + timeout, query: { bool: { filter: [ @@ -355,6 +356,7 @@ export const tasks: TelemetryTask[] = [ const response = await search({ index: [indices.transaction], body: { + timeout, query: { bool: { filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }], @@ -1032,8 +1034,9 @@ export const tasks: TelemetryTask[] = [ }, { name: 'cardinality', - executor: async ({ search }) => { + executor: async ({ indices, search }) => { const allAgentsCardinalityResponse = await search({ + index: [indices.transaction], body: { size: 0, timeout, @@ -1058,6 +1061,7 @@ export const tasks: TelemetryTask[] = [ }); const rumAgentCardinalityResponse = await search({ + index: [indices.transaction], body: { size: 0, timeout, diff --git a/x-pack/plugins/apm/server/routes/dependencies/get_top_dependency_spans.ts b/x-pack/plugins/apm/server/routes/dependencies/get_top_dependency_spans.ts index 5a2df933c7dba..83d7f2bf52b9e 100644 --- a/x-pack/plugins/apm/server/routes/dependencies/get_top_dependency_spans.ts +++ b/x-pack/plugins/apm/server/routes/dependencies/get_top_dependency_spans.ts @@ -5,14 +5,14 @@ * 2.0. */ +import { ProcessorEvent } from '@kbn/observability-plugin/common'; import { kqlQuery, rangeQuery, termQuery, termsQuery, } from '@kbn/observability-plugin/server'; -import { compact, keyBy } from 'lodash'; -import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { keyBy } from 'lodash'; import { AGENT_NAME, EVENT_OUTCOME, @@ -20,6 +20,7 @@ import { SERVICE_NAME, SPAN_DESTINATION_SERVICE_RESOURCE, SPAN_DURATION, + SPAN_ID, SPAN_NAME, TRACE_ID, TRANSACTION_ID, @@ -29,6 +30,7 @@ import { import { Environment } from '../../../common/environment_rt'; import { EventOutcome } from '../../../common/event_outcome'; import { environmentQuery } from '../../../common/utils/environment_query'; +import { maybe } from '../../../common/utils/maybe'; import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; @@ -36,11 +38,12 @@ const MAX_NUM_SPANS = 1000; export interface DependencySpan { '@timestamp': number; + spanId: string; spanName: string; serviceName: string; agentName: AgentName; traceId: string; - transactionId?: string; + transactionId: string; transactionType?: string; transactionName?: string; duration: number; @@ -84,6 +87,7 @@ export async function getTopDependencySpans({ ...kqlQuery(kuery), ...termQuery(SPAN_DESTINATION_SERVICE_RESOURCE, dependencyName), ...termQuery(SPAN_NAME, spanName), + { exists: { field: TRANSACTION_ID } }, ...((sampleRangeFrom ?? 0) >= 0 && (sampleRangeTo ?? 0) > 0 ? [ { @@ -100,6 +104,7 @@ export async function getTopDependencySpans({ }, }, _source: [ + SPAN_ID, TRACE_ID, TRANSACTION_ID, SPAN_NAME, @@ -114,7 +119,7 @@ export async function getTopDependencySpans({ }) ).hits.hits.map((hit) => hit._source); - const transactionIds = compact(spans.map((span) => span.transaction?.id)); + const transactionIds = spans.map((span) => span.transaction!.id); const transactions = ( await apmEventClient.search('get_transactions_for_dependency_spans', { @@ -143,19 +148,18 @@ export async function getTopDependencySpans({ ); return spans.map((span): DependencySpan => { - const transaction = span.transaction - ? transactionsById[span.transaction.id] - : undefined; + const transaction = maybe(transactionsById[span.transaction!.id]); return { '@timestamp': new Date(span['@timestamp']).getTime(), + spanId: span.span.id, spanName: span.span.name, serviceName: span.service.name, agentName: span.agent.name, duration: span.span.duration.us, traceId: span.trace.id, outcome: (span.event?.outcome || EventOutcome.unknown) as EventOutcome, - transactionId: transaction?.transaction.id, + transactionId: span.transaction!.id, transactionType: transaction?.transaction.type, transactionName: transaction?.transaction.name, }; diff --git a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx index df2ba80901738..effa3d450af89 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx @@ -44,4 +44,24 @@ describe('CreateCaseFlyout', () => { }); expect(onClose).toBeCalled(); }); + + it('renders headerContent when passed', async () => { + const headerContent =

; + const { getByTestId } = mockedContext.render( + + ); + + await act(async () => { + expect(getByTestId('testing123')).toBeTruthy(); + expect(getByTestId('create-case-flyout-header').children.length).toEqual(2); + }); + }); + + it('does not render headerContent when undefined', async () => { + const { getByTestId } = mockedContext.render(); + + await act(async () => { + expect(getByTestId('create-case-flyout-header').children.length).toEqual(1); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx index 75a18f2e70209..8f5e420f6b79d 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx @@ -25,6 +25,7 @@ export interface CreateCaseFlyoutProps { onClose?: () => void; onSuccess?: (theCase: Case) => Promise; attachments?: CaseAttachmentsWithoutOwner; + headerContent?: React.ReactNode; } const StyledFlyout = styled(EuiFlyout)` @@ -71,9 +72,10 @@ const FormWrapper = styled.div` `; export const CreateCaseFlyout = React.memo( - ({ afterCaseCreated, onClose, onSuccess, attachments }) => { + ({ afterCaseCreated, onClose, onSuccess, attachments, headerContent }) => { const handleCancel = onClose || function () {}; const handleOnSuccess = onSuccess || async function () {}; + return ( @@ -83,10 +85,11 @@ export const CreateCaseFlyout = React.memo( // maskProps is needed in order to apply the z-index to the parent overlay element, not to the flyout only maskProps={{ className: maskOverlayClassName }} > - +

{i18n.CREATE_CASE_TITLE}

+ {headerContent && headerContent} diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index 86b03f46bf745..a92046e8c9928 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type React from 'react'; import { useCallback } from 'react'; import type { CaseAttachmentsWithoutOwner } from '../../../types'; import { useCasesToast } from '../../../common/use_cases_toast'; @@ -29,12 +30,16 @@ export const useCasesAddToNewCaseFlyout = (props: AddToNewCaseFlyoutProps = {}) }, [dispatch]); const openFlyout = useCallback( - ({ attachments }: { attachments?: CaseAttachmentsWithoutOwner } = {}) => { + ({ + attachments, + headerContent, + }: { attachments?: CaseAttachmentsWithoutOwner; headerContent?: React.ReactNode } = {}) => { dispatch({ type: CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT, payload: { ...props, attachments, + headerContent, onClose: () => { closeFlyout(); if (props.onClose) { diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts index 538d8016a0a73..b2616ed7615ba 100644 --- a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts @@ -17,6 +17,7 @@ import { getMlModelTypesForModelConfig, getSetProcessorForInferenceType, SUPPORTED_PYTORCH_TASKS as LOCAL_SUPPORTED_PYTORCH_TASKS, + parseMlInferenceParametersFromPipeline, } from '.'; const mockModel: MlTrainedModelConfig = { @@ -198,3 +199,45 @@ describe('generateMlInferencePipelineBody lib function', () => { ); }); }); + +describe('parseMlInferenceParametersFromPipeline', () => { + it('returns pipeline parameters from ingest pipeline', () => { + expect( + parseMlInferenceParametersFromPipeline('unit-test', { + processors: [ + { + inference: { + field_map: { + body: 'text_field', + }, + model_id: 'test-model', + target_field: 'ml.inference.test', + }, + }, + ], + }) + ).toEqual({ + destination_field: 'test', + model_id: 'test-model', + pipeline_name: 'unit-test', + source_field: 'body', + }); + }); + it('return null if pipeline missing inference processor', () => { + expect(parseMlInferenceParametersFromPipeline('unit-test', { processors: [] })).toBeNull(); + }); + it('return null if pipeline missing field_map', () => { + expect( + parseMlInferenceParametersFromPipeline('unit-test', { + processors: [ + { + inference: { + model_id: 'test-model', + target_field: 'test', + }, + }, + ], + }) + ).toBeNull(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts index b5b4526d1723b..4e5b124f8dff0 100644 --- a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts @@ -5,9 +5,13 @@ * 2.0. */ -import { IngestSetProcessor, MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/types'; +import { + IngestPipeline, + IngestSetProcessor, + MlTrainedModelConfig, +} from '@elastic/elasticsearch/lib/api/types'; -import { MlInferencePipeline } from '../types/pipelines'; +import { MlInferencePipeline, CreateMlInferencePipelineParameters } from '../types/pipelines'; // Getting an error importing this from @kbn/ml-plugin/common/constants/data_frame_analytics' // So defining it locally for now with a test to make sure it matches. @@ -151,3 +155,25 @@ export const formatPipelineName = (rawName: string) => .trim() .replace(/\s+/g, '_') // Convert whitespaces to underscores .toLowerCase(); + +export const parseMlInferenceParametersFromPipeline = ( + name: string, + pipeline: IngestPipeline +): CreateMlInferencePipelineParameters | null => { + const processor = pipeline?.processors?.find((proc) => proc.inference !== undefined); + if (!processor || processor?.inference === undefined) { + return null; + } + const { inference: inferenceProcessor } = processor; + const sourceFields = Object.keys(inferenceProcessor.field_map ?? {}); + const sourceField = sourceFields.length === 1 ? sourceFields[0] : null; + if (!sourceField) { + return null; + } + return { + destination_field: inferenceProcessor.target_field.replace('ml.inference.', ''), + model_id: inferenceProcessor.model_id, + pipeline_name: name, + source_field: sourceField, + }; +}; diff --git a/x-pack/plugins/enterprise_search/common/types/pipelines.ts b/x-pack/plugins/enterprise_search/common/types/pipelines.ts index 9b53e98d584d7..38314f6d162de 100644 --- a/x-pack/plugins/enterprise_search/common/types/pipelines.ts +++ b/x-pack/plugins/enterprise_search/common/types/pipelines.ts @@ -64,3 +64,10 @@ export interface DeleteMlInferencePipelineResponse { deleted?: string; updated?: string; } + +export interface CreateMlInferencePipelineParameters { + destination_field?: string; + model_id: string; + pipeline_name: string; + source_field: string; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/attach_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/attach_ml_inference_pipeline.test.ts new file mode 100644 index 0000000000000..4c88466ba32b7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/attach_ml_inference_pipeline.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { mockHttpValues } from '../../../__mocks__/kea_logic'; + +import { + attachMlInferencePipeline, + AttachMlInferencePipelineApiLogicArgs, + AttachMlInferencePipelineResponse, +} from './attach_ml_inference_pipeline'; + +describe('AttachMlInferencePipelineApiLogic', () => { + const { http } = mockHttpValues; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('createMlInferencePipeline', () => { + it('calls the api', async () => { + const response: Promise = Promise.resolve({ + addedToParentPipeline: true, + created: false, + id: 'unit-test', + }); + http.post.mockReturnValue(response); + + const args: AttachMlInferencePipelineApiLogicArgs = { + indexName: 'unit-test-index', + pipelineName: 'unit-test', + }; + const result = await attachMlInferencePipeline(args); + expect(http.post).toHaveBeenCalledWith( + '/internal/enterprise_search/indices/unit-test-index/ml_inference/pipeline_processors/attach', + { + body: '{"pipeline_name":"unit-test"}', + } + ); + expect(result).toEqual({ + addedToParentPipeline: true, + created: false, + id: args.pipelineName, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/attach_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/attach_ml_inference_pipeline.ts new file mode 100644 index 0000000000000..433c41a75ea0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/attach_ml_inference_pipeline.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttachMlInferencePipelineResponse } from '../../../../../common/types/pipelines'; + +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface AttachMlInferencePipelineApiLogicArgs { + indexName: string; + pipelineName: string; +} + +export type { AttachMlInferencePipelineResponse }; + +export const attachMlInferencePipeline = async ( + args: AttachMlInferencePipelineApiLogicArgs +): Promise => { + const route = `/internal/enterprise_search/indices/${args.indexName}/ml_inference/pipeline_processors/attach`; + const params = { + pipeline_name: args.pipelineName, + }; + + return await HttpLogic.values.http.post(route, { + body: JSON.stringify(params), + }); +}; + +export const AttachMlInferencePipelineApiLogic = createApiLogic( + ['attach_ml_inference_pipeline_api_logic'], + attachMlInferencePipeline +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/create_ml_inference_pipeline.test.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_ml_inference_pipeline.test.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/create_ml_inference_pipeline.test.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/create_ml_inference_pipeline.ts similarity index 89% rename from x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_ml_inference_pipeline.ts rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/create_ml_inference_pipeline.ts index ee5e7dd1c4295..78f08c4bc0ee8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_ml_inference_pipeline.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/create_ml_inference_pipeline.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { CreateMlInferencePipelineParameters } from '../../../../../common/types/pipelines'; import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; @@ -23,7 +24,7 @@ export const createMlInferencePipeline = async ( args: CreateMlInferencePipelineApiLogicArgs ): Promise => { const route = `/internal/enterprise_search/indices/${args.indexName}/ml_inference/pipeline_processors`; - const params = { + const params: CreateMlInferencePipelineParameters = { destination_field: args.destinationField, model_id: args.modelId, pipeline_name: args.pipelineName, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_ml_inference_pipeline_processors.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_ml_inference_pipeline_processors.ts index 85f481b513525..2d881a0463bb7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_ml_inference_pipeline_processors.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_ml_inference_pipeline_processors.ts @@ -9,10 +9,18 @@ import { InferencePipeline } from '../../../../../common/types/pipelines'; import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; -export const fetchMlInferencePipelineProcessors = async ({ indexName }: { indexName: string }) => { +export interface FetchMlInferencePipelineProcessorsApiLogicArgs { + indexName: string; +} + +export type FetchMlInferencePipelineProcessorsResponse = InferencePipeline[]; + +export const fetchMlInferencePipelineProcessors = async ({ + indexName, +}: FetchMlInferencePipelineProcessorsApiLogicArgs) => { const route = `/internal/enterprise_search/indices/${indexName}/ml_inference/pipeline_processors`; - return await HttpLogic.values.http.get(route); + return await HttpLogic.values.http.get(route); }; export const FetchMlInferencePipelineProcessorsApiLogic = createApiLogic( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_ml_inference_pipelines.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_ml_inference_pipelines.ts new file mode 100644 index 0000000000000..d5df97d259fda --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/pipelines/fetch_ml_inference_pipelines.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MlInferencePipeline } from '../../../../../common/types/pipelines'; +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export type FetchMlInferencePipelinesArgs = undefined; +export type FetchMlInferencePipelinesResponse = Record; + +export const fetchMlInferencePipelines = async () => { + const route = '/internal/enterprise_search/pipelines/ml_inference'; + + return await HttpLogic.values.http.get(route); +}; + +export const FetchMlInferencePipelinesApiLogic = createApiLogic( + ['fetch_ml_inference_pipelines_api_logic'], + fetchMlInferencePipelines +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx index edbf18f8b009c..cc0cc3eb8f954 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx @@ -92,7 +92,7 @@ const AddProcessorContent: React.FC = ({ onClo ); } - if (supportedMLModels === undefined || supportedMLModels?.length === 0) { + if (supportedMLModels.length === 0) { return ; } return ( @@ -188,8 +188,10 @@ const ModalFooter: React.FC { const { addInferencePipelineModal: modal, isPipelineDataValid } = useValues(MLInferenceLogic); - const { createPipeline, setAddInferencePipelineStep } = useActions(MLInferenceLogic); + const { attachPipeline, createPipeline, setAddInferencePipelineStep } = + useActions(MLInferenceLogic); + const attachExistingPipeline = Boolean(modal.configuration.existingPipeline); let nextStep: AddInferencePipelineSteps | undefined; let previousStep: AddInferencePipelineSteps | undefined; switch (modal.step) { @@ -239,6 +241,21 @@ const ModalFooter: React.FC {CONTINUE_BUTTON_LABEL} + ) : attachExistingPipeline ? ( + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.attach', + { + defaultMessage: 'Attach', + } + )} + ) : ( ( { const { addInferencePipelineModal: { configuration }, formErrors, + existingInferencePipelines, supportedMLModels, sourceFields, } = useValues(MLInferenceLogic); - const { setInferencePipelineConfiguration } = useActions(MLInferenceLogic); + const { selectExistingPipeline, setInferencePipelineConfiguration } = + useActions(MLInferenceLogic); const { ingestionMethod } = useValues(IndexViewLogic); const { destinationField, modelID, pipelineName, sourceField } = configuration; - const models = supportedMLModels ?? []; const nameError = formErrors.pipelineName !== undefined && pipelineName.length > 0; const emptySourceFields = (sourceFields?.length ?? 0) === 0; @@ -76,12 +92,30 @@ export const ConfigurePipeline: React.FC = () => { ), value: MODEL_SELECT_PLACEHOLDER_VALUE, }, - ...models.map((model) => ({ + ...supportedMLModels.map((model) => ({ dropdownDisplay: , inputDisplay: model.model_id, value: model.model_id, })), ]; + const pipelineOptions: Array> = [ + { + disabled: true, + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.placeholder', + { defaultMessage: 'Select one' } + ), + value: PIPELINE_SELECT_PLACEHOLDER_VALUE, + }, + ...(existingInferencePipelines?.map((pipeline) => ({ + disabled: pipeline.disabled, + dropdownDisplay: , + inputDisplay: pipeline.pipelineName, + value: pipeline.pipelineName, + })) ?? []), + ]; + + const inputsDisabled = configuration.existingPipeline !== false; return ( <> @@ -106,45 +140,107 @@ export const ConfigurePipeline: React.FC = () => { - - + + + + setInferencePipelineConfiguration({ + ...EMPTY_PIPELINE_CONFIGURATION, + existingPipeline: e.target.value === 'true', + }) + } + /> + + + + {configuration.existingPipeline === true ? ( + + 0 ? pipelineName : PIPELINE_SELECT_PLACEHOLDER_VALUE + } + options={pipelineOptions} + onChange={(value) => selectExistingPipeline(value)} + /> + + ) : ( + + + setInferencePipelineConfiguration({ + ...configuration, + pipelineName: e.target.value, + }) + } + /> + )} - value={pipelineName} - onChange={(e) => - setInferencePipelineConfiguration({ - ...configuration, - pipelineName: e.target.value, - }) - } - /> - + + { data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureInferencePipeline-selectTrainedModel`} fullWidth hasDividers + disabled={inputsDisabled} itemLayoutAlign="top" onChange={(value) => setInferencePipelineConfiguration({ @@ -185,6 +282,7 @@ export const ConfigurePipeline: React.FC = () => { > { > diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts index c605009d7eb0d..4224c150af904 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts @@ -7,20 +7,27 @@ import { LogicMounter } from '../../../../../__mocks__/kea_logic'; -import { HttpError, Status } from '../../../../../../../common/types/api'; +import { HttpResponse } from '@kbn/core/public'; + +import { ErrorResponse, HttpError, Status } from '../../../../../../../common/types/api'; +import { TrainedModelState } from '../../../../../../../common/types/pipelines'; import { MappingsApiLogic } from '../../../../api/mappings/mappings_logic'; -import { CreateMlInferencePipelineApiLogic } from '../../../../api/ml_models/create_ml_inference_pipeline'; import { MLModelsApiLogic } from '../../../../api/ml_models/ml_models_logic'; +import { AttachMlInferencePipelineApiLogic } from '../../../../api/pipelines/attach_ml_inference_pipeline'; +import { CreateMlInferencePipelineApiLogic } from '../../../../api/pipelines/create_ml_inference_pipeline'; +import { FetchMlInferencePipelineProcessorsApiLogic } from '../../../../api/pipelines/fetch_ml_inference_pipeline_processors'; +import { FetchMlInferencePipelinesApiLogic } from '../../../../api/pipelines/fetch_ml_inference_pipelines'; import { SimulateMlInterfacePipelineApiLogic } from '../../../../api/pipelines/simulate_ml_inference_pipeline_processors'; import { MLInferenceLogic, EMPTY_PIPELINE_CONFIGURATION, AddInferencePipelineSteps, + MLInferenceProcessorsValues, } from './ml_inference_logic'; -const DEFAULT_VALUES = { +const DEFAULT_VALUES: MLInferenceProcessorsValues = { addInferencePipelineModal: { configuration: { ...EMPTY_PIPELINE_CONFIGURATION, @@ -46,6 +53,7 @@ const DEFAULT_VALUES = { step: AddInferencePipelineSteps.Configuration, }, createErrors: [], + existingInferencePipelines: [], formErrors: { modelID: 'Field is required.', pipelineName: 'Field is required.', @@ -57,6 +65,8 @@ const DEFAULT_VALUES = { mappingData: undefined, mappingStatus: 0, mlInferencePipeline: undefined, + mlInferencePipelineProcessors: undefined, + mlInferencePipelinesData: undefined, mlModelsData: undefined, mlModelsStatus: 0, simulatePipelineData: undefined, @@ -64,7 +74,7 @@ const DEFAULT_VALUES = { simulatePipelineResult: undefined, simulatePipelineStatus: 0, sourceFields: undefined, - supportedMLModels: undefined, + supportedMLModels: [], }; describe('MlInferenceLogic', () => { @@ -77,13 +87,25 @@ describe('MlInferenceLogic', () => { const { mount: mountCreateMlInferencePipelineApiLogic } = new LogicMounter( CreateMlInferencePipelineApiLogic ); + const { mount: mountAttachMlInferencePipelineApiLogic } = new LogicMounter( + AttachMlInferencePipelineApiLogic + ); + const { mount: mountFetchMlInferencePipelineProcessorsApiLogic } = new LogicMounter( + FetchMlInferencePipelineProcessorsApiLogic + ); + const { mount: mountFetchMlInferencePipelinesApiLogic } = new LogicMounter( + FetchMlInferencePipelinesApiLogic + ); beforeEach(() => { jest.clearAllMocks(); mountMappingApiLogic(); mountMLModelsApiLogic(); + mountFetchMlInferencePipelineProcessorsApiLogic(); + mountFetchMlInferencePipelinesApiLogic(); mountSimulateMlInterfacePipelineApiLogic(); mountCreateMlInferencePipelineApiLogic(); + mountAttachMlInferencePipelineApiLogic(); mount(); }); @@ -110,6 +132,70 @@ describe('MlInferenceLogic', () => { }); }); }); + describe('attachApiError', () => { + it('updates create errors', () => { + MLInferenceLogic.actions.attachApiError({ + body: { + error: '', + message: 'this is an error', + statusCode: 500, + }, + } as HttpResponse); + + expect(MLInferenceLogic.values.createErrors).toEqual(['this is an error']); + }); + }); + describe('createApiError', () => { + it('updates create errors', () => { + MLInferenceLogic.actions.createApiError({ + body: { + error: '', + message: 'this is an error', + statusCode: 500, + }, + } as HttpResponse); + + expect(MLInferenceLogic.values.createErrors).toEqual(['this is an error']); + }); + }); + describe('makeAttachPipelineRequest', () => { + it('clears existing errors', () => { + MLInferenceLogic.actions.attachApiError({ + body: { + error: '', + message: 'this is an error', + statusCode: 500, + }, + } as HttpResponse); + + expect(MLInferenceLogic.values.createErrors).not.toHaveLength(0); + MLInferenceLogic.actions.makeAttachPipelineRequest({ + indexName: 'test', + pipelineName: 'unit-test', + }); + expect(MLInferenceLogic.values.createErrors).toHaveLength(0); + }); + }); + describe('makeCreatePipelineRequest', () => { + it('clears existing errors', () => { + MLInferenceLogic.actions.createApiError({ + body: { + error: '', + message: 'this is an error', + statusCode: 500, + }, + } as HttpResponse); + + expect(MLInferenceLogic.values.createErrors).not.toHaveLength(0); + MLInferenceLogic.actions.makeCreatePipelineRequest({ + indexName: 'test', + pipelineName: 'unit-test', + modelId: 'test-model', + sourceField: 'body', + }); + expect(MLInferenceLogic.values.createErrors).toHaveLength(0); + }); + }); }); describe('selectors', () => { @@ -162,6 +248,220 @@ describe('MlInferenceLogic', () => { expect(MLInferenceLogic.values.simulatePipelineResult).toEqual(simulateResponse); }); }); + describe('existingInferencePipelines', () => { + beforeEach(() => { + MappingsApiLogic.actions.apiSuccess({ + mappings: { + properties: { + body: { + type: 'text', + }, + }, + }, + }); + }); + it('returns empty list when there is not existing pipelines available', () => { + expect(MLInferenceLogic.values.existingInferencePipelines).toEqual([]); + }); + it('returns existing pipeline option', () => { + FetchMlInferencePipelinesApiLogic.actions.apiSuccess({ + 'unit-test': { + processors: [ + { + inference: { + field_map: { + body: 'text_field', + }, + model_id: 'test-model', + target_field: 'ml.inference.test-field', + }, + }, + ], + version: 1, + }, + }); + + expect(MLInferenceLogic.values.existingInferencePipelines).toEqual([ + { + destinationField: 'test-field', + disabled: false, + pipelineName: 'unit-test', + modelType: '', + modelId: 'test-model', + sourceField: 'body', + }, + ]); + }); + it('returns disabled pipeline option if missing source field', () => { + FetchMlInferencePipelinesApiLogic.actions.apiSuccess({ + 'unit-test': { + processors: [ + { + inference: { + field_map: { + body_content: 'text_field', + }, + model_id: 'test-model', + target_field: 'ml.inference.test-field', + }, + }, + ], + version: 1, + }, + }); + + expect(MLInferenceLogic.values.existingInferencePipelines).toEqual([ + { + destinationField: 'test-field', + disabled: true, + disabledReason: expect.any(String), + pipelineName: 'unit-test', + modelType: '', + modelId: 'test-model', + sourceField: 'body_content', + }, + ]); + }); + it('returns disabled pipeline option if model is redacted', () => { + FetchMlInferencePipelinesApiLogic.actions.apiSuccess({ + 'unit-test': { + processors: [ + { + inference: { + field_map: { + body: 'text_field', + }, + model_id: '', + target_field: 'ml.inference.test-field', + }, + }, + ], + version: 1, + }, + }); + + expect(MLInferenceLogic.values.existingInferencePipelines).toEqual([ + { + destinationField: 'test-field', + disabled: true, + disabledReason: expect.any(String), + pipelineName: 'unit-test', + modelType: '', + modelId: '', + sourceField: 'body', + }, + ]); + }); + it('returns disabled pipeline option if pipeline already attached', () => { + FetchMlInferencePipelineProcessorsApiLogic.actions.apiSuccess([ + { + modelId: 'test-model', + modelState: TrainedModelState.Started, + pipelineName: 'unit-test', + pipelineReferences: ['test@ml-inference'], + types: ['ner', 'pytorch'], + }, + ]); + FetchMlInferencePipelinesApiLogic.actions.apiSuccess({ + 'unit-test': { + processors: [ + { + inference: { + field_map: { + body: 'text_field', + }, + model_id: 'test-model', + target_field: 'ml.inference.test-field', + }, + }, + ], + version: 1, + }, + }); + + expect(MLInferenceLogic.values.existingInferencePipelines).toEqual([ + { + destinationField: 'test-field', + disabled: true, + disabledReason: expect.any(String), + pipelineName: 'unit-test', + modelType: '', + modelId: 'test-model', + sourceField: 'body', + }, + ]); + }); + }); + describe('mlInferencePipeline', () => { + it('returns undefined when configuration is invalid', () => { + MLInferenceLogic.actions.setInferencePipelineConfiguration({ + destinationField: '', + modelID: '', + pipelineName: 'unit-test', + sourceField: '', + }); + + expect(MLInferenceLogic.values.mlInferencePipeline).toBeUndefined(); + }); + it('generates inference pipeline', () => { + MLModelsApiLogic.actions.apiSuccess([ + { + inference_config: { + text_classification: { + classification_labels: ['one', 'two'], + tokenization: { + bert: {}, + }, + }, + }, + input: { + field_names: ['text_field'], + }, + model_id: 'test-model', + model_type: 'pytorch', + tags: [], + version: '1.0.0', + }, + ]); + MLInferenceLogic.actions.setInferencePipelineConfiguration({ + destinationField: '', + modelID: 'test-model', + pipelineName: 'unit-test', + sourceField: 'body', + }); + + expect(MLInferenceLogic.values.mlInferencePipeline).not.toBeUndefined(); + }); + it('returns undefined when existing pipeline not yet selected', () => { + MLInferenceLogic.actions.setInferencePipelineConfiguration({ + existingPipeline: true, + destinationField: '', + modelID: '', + pipelineName: '', + sourceField: '', + }); + expect(MLInferenceLogic.values.mlInferencePipeline).toBeUndefined(); + }); + it('return existing pipeline when selected', () => { + const existingPipeline = { + description: 'this is a test', + processors: [], + version: 1, + }; + FetchMlInferencePipelinesApiLogic.actions.apiSuccess({ + 'unit-test': existingPipeline, + }); + MLInferenceLogic.actions.setInferencePipelineConfiguration({ + existingPipeline: true, + destinationField: '', + modelID: '', + pipelineName: 'unit-test', + sourceField: '', + }); + expect(MLInferenceLogic.values.mlInferencePipeline).not.toBeUndefined(); + expect(MLInferenceLogic.values.mlInferencePipeline).toEqual(existingPipeline); + }); + }); }); describe('listeners', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts index f4a968da1c2a1..fcdad4f66d141 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts @@ -15,6 +15,8 @@ import { TrainedModelConfigResponse } from '@kbn/ml-plugin/common/types/trained_ import { formatPipelineName, generateMlInferencePipelineBody, + getMlModelTypesForModelConfig, + parseMlInferenceParametersFromPipeline, } from '../../../../../../../common/ml_inference_pipeline'; import { Status } from '../../../../../../../common/types/api'; import { MlInferencePipeline } from '../../../../../../../common/types/pipelines'; @@ -30,16 +32,30 @@ import { GetMappingsResponse, MappingsApiLogic, } from '../../../../api/mappings/mappings_logic'; -import { - CreateMlInferencePipelineApiLogic, - CreateMlInferencePipelineApiLogicArgs, - CreateMlInferencePipelineResponse, -} from '../../../../api/ml_models/create_ml_inference_pipeline'; import { GetMlModelsArgs, GetMlModelsResponse, MLModelsApiLogic, } from '../../../../api/ml_models/ml_models_logic'; +import { + AttachMlInferencePipelineApiLogic, + AttachMlInferencePipelineApiLogicArgs, + AttachMlInferencePipelineResponse, +} from '../../../../api/pipelines/attach_ml_inference_pipeline'; +import { + CreateMlInferencePipelineApiLogic, + CreateMlInferencePipelineApiLogicArgs, + CreateMlInferencePipelineResponse, +} from '../../../../api/pipelines/create_ml_inference_pipeline'; +import { + FetchMlInferencePipelineProcessorsApiLogic, + FetchMlInferencePipelineProcessorsResponse, +} from '../../../../api/pipelines/fetch_ml_inference_pipeline_processors'; +import { + FetchMlInferencePipelinesApiLogic, + FetchMlInferencePipelinesArgs, + FetchMlInferencePipelinesResponse, +} from '../../../../api/pipelines/fetch_ml_inference_pipelines'; import { SimulateMlInterfacePipelineApiLogic, SimulateMlInterfacePipelineArgs, @@ -47,11 +63,20 @@ import { } from '../../../../api/pipelines/simulate_ml_inference_pipeline_processors'; import { isConnectorIndex } from '../../../../utils/indices'; -import { isSupportedMLModel, sortSourceFields } from '../../../shared/ml_inference/utils'; +import { + getMLType, + isSupportedMLModel, + sortSourceFields, +} from '../../../shared/ml_inference/utils'; import { AddInferencePipelineFormErrors, InferencePipelineConfiguration } from './types'; -import { validateInferencePipelineConfiguration } from './utils'; +import { + validateInferencePipelineConfiguration, + EXISTING_PIPELINE_DISABLED_MODEL_REDACTED, + EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD, + EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS, +} from './utils'; export const EMPTY_PIPELINE_CONFIGURATION: InferencePipelineConfiguration = { destinationField: '', @@ -69,7 +94,26 @@ export enum AddInferencePipelineSteps { const API_REQUEST_COMPLETE_STATUSES = [Status.SUCCESS, Status.ERROR]; const DEFAULT_CONNECTOR_FIELDS = ['body', 'title', 'id', 'type', 'url']; +export interface MLInferencePipelineOption { + destinationField: string; + disabled: boolean; + disabledReason?: string; + modelId: string; + modelType: string; + pipelineName: string; + sourceField: string; +} + interface MLInferenceProcessorsActions { + attachApiError: Actions< + AttachMlInferencePipelineApiLogicArgs, + AttachMlInferencePipelineResponse + >['apiError']; + attachApiSuccess: Actions< + AttachMlInferencePipelineApiLogicArgs, + AttachMlInferencePipelineResponse + >['apiSuccess']; + attachPipeline: () => void; createApiError: Actions< CreateMlInferencePipelineApiLogicArgs, CreateMlInferencePipelineResponse @@ -79,18 +123,29 @@ interface MLInferenceProcessorsActions { CreateMlInferencePipelineResponse >['apiSuccess']; createPipeline: () => void; + makeAttachPipelineRequest: Actions< + AttachMlInferencePipelineApiLogicArgs, + AttachMlInferencePipelineResponse + >['makeRequest']; makeCreatePipelineRequest: Actions< CreateMlInferencePipelineApiLogicArgs, CreateMlInferencePipelineResponse >['makeRequest']; makeMLModelsRequest: Actions['makeRequest']; makeMappingRequest: Actions['makeRequest']; + makeMlInferencePipelinesRequest: Actions< + FetchMlInferencePipelinesArgs, + FetchMlInferencePipelinesResponse + >['makeRequest']; makeSimulatePipelineRequest: Actions< SimulateMlInterfacePipelineArgs, SimulateMlInterfacePipelineResponse >['makeRequest']; mappingsApiError: Actions['apiError']; mlModelsApiError: Actions['apiError']; + selectExistingPipeline: (pipelineName: string) => { + pipelineName: string; + }; setAddInferencePipelineStep: (step: AddInferencePipelineSteps) => { step: AddInferencePipelineSteps; }; @@ -120,21 +175,24 @@ export interface AddInferencePipelineModal { step: AddInferencePipelineSteps; } -interface MLInferenceProcessorsValues { +export interface MLInferenceProcessorsValues { addInferencePipelineModal: AddInferencePipelineModal; createErrors: string[]; + existingInferencePipelines: MLInferencePipelineOption[]; formErrors: AddInferencePipelineFormErrors; - index: FetchIndexApiResponse; + index: FetchIndexApiResponse | undefined; isLoading: boolean; isPipelineDataValid: boolean; mappingData: typeof MappingsApiLogic.values.data; mappingStatus: Status; - mlInferencePipeline?: MlInferencePipeline; - mlModelsData: TrainedModelConfigResponse[]; + mlInferencePipeline: MlInferencePipeline | undefined; + mlInferencePipelineProcessors: FetchMlInferencePipelineProcessorsResponse | undefined; + mlInferencePipelinesData: FetchMlInferencePipelinesResponse | undefined; + mlModelsData: TrainedModelConfigResponse[] | undefined; mlModelsStatus: Status; simulatePipelineData: typeof SimulateMlInterfacePipelineApiLogic.values.data; simulatePipelineErrors: string[]; - simulatePipelineResult: IngestSimulateResponse; + simulatePipelineResult: IngestSimulateResponse | undefined; simulatePipelineStatus: Status; sourceFields: string[] | undefined; supportedMLModels: TrainedModelConfigResponse[]; @@ -144,8 +202,10 @@ export const MLInferenceLogic = kea< MakeLogicType >({ actions: { + attachPipeline: true, clearFormErrors: true, createPipeline: true, + selectExistingPipeline: (pipelineName: string) => ({ pipelineName }), setAddInferencePipelineStep: (step: AddInferencePipelineSteps) => ({ step }), setFormErrors: (inputErrors: AddInferencePipelineFormErrors) => ({ inputErrors }), setIndexName: (indexName: string) => ({ indexName }), @@ -160,6 +220,8 @@ export const MLInferenceLogic = kea< }, connect: { actions: [ + FetchMlInferencePipelinesApiLogic, + ['makeRequest as makeMlInferencePipelinesRequest'], MappingsApiLogic, ['makeRequest as makeMappingRequest', 'apiError as mappingsApiError'], MLModelsApiLogic, @@ -176,20 +238,43 @@ export const MLInferenceLogic = kea< 'apiSuccess as createApiSuccess', 'makeRequest as makeCreatePipelineRequest', ], + AttachMlInferencePipelineApiLogic, + [ + 'apiError as attachApiError', + 'apiSuccess as attachApiSuccess', + 'makeRequest as makeAttachPipelineRequest', + ], ], values: [ FetchIndexApiLogic, ['data as index'], + FetchMlInferencePipelinesApiLogic, + ['data as mlInferencePipelinesData'], MappingsApiLogic, ['data as mappingData', 'status as mappingStatus'], MLModelsApiLogic, ['data as mlModelsData', 'status as mlModelsStatus'], SimulateMlInterfacePipelineApiLogic, ['data as simulatePipelineData', 'status as simulatePipelineStatus'], + FetchMlInferencePipelineProcessorsApiLogic, + ['data as mlInferencePipelineProcessors'], ], }, events: {}, listeners: ({ values, actions }) => ({ + attachPipeline: () => { + const { + addInferencePipelineModal: { + configuration: { pipelineName }, + indexName, + }, + } = values; + + actions.makeAttachPipelineRequest({ + indexName, + pipelineName, + }); + }, createPipeline: () => { const { addInferencePipelineModal: { configuration, indexName }, @@ -206,7 +291,21 @@ export const MLInferenceLogic = kea< sourceField: configuration.sourceField, }); }, + selectExistingPipeline: ({ pipelineName }) => { + const pipeline = values.mlInferencePipelinesData?.[pipelineName]; + if (!pipeline) return; + const params = parseMlInferenceParametersFromPipeline(pipelineName, pipeline); + if (params === null) return; + actions.setInferencePipelineConfiguration({ + destinationField: params.destination_field ?? '', + existingPipeline: true, + modelID: params.model_id, + pipelineName, + sourceField: params.source_field, + }); + }, setIndexName: ({ indexName }) => { + actions.makeMlInferencePipelinesRequest(undefined); actions.makeMLModelsRequest(undefined); actions.makeMappingRequest({ indexName }); }, @@ -264,7 +363,9 @@ export const MLInferenceLogic = kea< createErrors: [ [], { + attachApiError: (_, error) => getErrorsFromHttpResponse(error), createApiError: (_, error) => getErrorsFromHttpResponse(error), + makeAttachPipelineRequest: () => [], makeCreatePipelineRequest: () => [], }, ], @@ -297,12 +398,24 @@ export const MLInferenceLogic = kea< selectors.isPipelineDataValid, selectors.addInferencePipelineModal, selectors.mlModelsData, + selectors.mlInferencePipelinesData, ], ( - isPipelineDataValid: boolean, - { configuration }: AddInferencePipelineModal, - models: MLInferenceProcessorsValues['mlModelsData'] + isPipelineDataValid: MLInferenceProcessorsValues['isPipelineDataValid'], + { configuration }: MLInferenceProcessorsValues['addInferencePipelineModal'], + models: MLInferenceProcessorsValues['mlModelsData'], + mlInferencePipelinesData: MLInferenceProcessorsValues['mlInferencePipelinesData'] ) => { + if (configuration.existingPipeline) { + if (configuration.pipelineName.length === 0) { + return undefined; + } + const pipeline = mlInferencePipelinesData?.[configuration.pipelineName]; + if (!pipeline) { + return undefined; + } + return pipeline as MlInferencePipeline; + } if (!isPipelineDataValid) return undefined; const model = models?.find((mlModel) => mlModel.model_id === configuration.modelID); if (!model) return undefined; @@ -350,7 +463,69 @@ export const MLInferenceLogic = kea< supportedMLModels: [ () => [selectors.mlModelsData], (mlModelsData: TrainedModelConfigResponse[] | undefined) => { - return mlModelsData?.filter(isSupportedMLModel); + return mlModelsData?.filter(isSupportedMLModel) ?? []; + }, + ], + existingInferencePipelines: [ + () => [ + selectors.mlInferencePipelinesData, + selectors.sourceFields, + selectors.supportedMLModels, + selectors.mlInferencePipelineProcessors, + ], + ( + mlInferencePipelinesData: MLInferenceProcessorsValues['mlInferencePipelinesData'], + sourceFields: MLInferenceProcessorsValues['sourceFields'], + supportedMLModels: MLInferenceProcessorsValues['supportedMLModels'], + mlInferencePipelineProcessors: MLInferenceProcessorsValues['mlInferencePipelineProcessors'] + ) => { + if (!mlInferencePipelinesData) { + return []; + } + const indexProcessorNames = + mlInferencePipelineProcessors?.map((processor) => processor.pipelineName) ?? []; + + const existingPipelines: MLInferencePipelineOption[] = Object.entries( + mlInferencePipelinesData + ) + .map(([pipelineName, pipeline]): MLInferencePipelineOption | undefined => { + if (!pipeline) return undefined; + const pipelineParams = parseMlInferenceParametersFromPipeline(pipelineName, pipeline); + if (!pipelineParams) return undefined; + const { + destination_field: destinationField, + model_id: modelId, + source_field: sourceField, + } = pipelineParams; + + let disabled: boolean = false; + let disabledReason: string | undefined; + if (!(sourceFields?.includes(sourceField) ?? false)) { + disabled = true; + disabledReason = EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD; + } else if (indexProcessorNames.includes(pipelineName)) { + disabled = true; + disabledReason = EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS; + } else if (pipelineParams.model_id.length === 0) { + disabled = true; + disabledReason = EXISTING_PIPELINE_DISABLED_MODEL_REDACTED; + } + const mlModel = supportedMLModels.find((model) => model.model_id === modelId); + const modelType = mlModel ? getMLType(getMlModelTypesForModelConfig(mlModel)) : ''; + + return { + destinationField: destinationField ?? '', + disabled, + disabledReason, + modelId, + modelType, + pipelineName, + sourceField, + }; + }) + .filter((p): p is MLInferencePipelineOption => p !== undefined); + + return existingPipelines; }, ], }), diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/pipeline_select_option.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/pipeline_select_option.tsx new file mode 100644 index 0000000000000..f782c827a9728 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/pipeline_select_option.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTextColor, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { MLInferencePipelineOption } from './ml_inference_logic'; +import { EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD } from './utils'; + +export interface PipelineSelectOptionProps { + pipeline: MLInferencePipelineOption; +} + +const REDACTED_MODE_ID_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.redactedModel', + { + defaultMessage: 'Trained model not available in this space', + } +); + +export const PipelineSelectOption: React.FC = ({ pipeline }) => { + const modelIdDisplay = pipeline.modelId.length > 0 ? pipeline.modelId : REDACTED_MODE_ID_DISPLAY; + return ( + + {pipeline.disabled && ( + + + + + + + + {pipeline.disabledReason ?? EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD} + + + + + )} + + +

{pipeline.pipelineName}

+
+
+ + + + {pipeline.disabled ? ( + modelIdDisplay + ) : ( + {modelIdDisplay} + )} + + {pipeline.modelType.length > 0 && ( + + + {pipeline.modelType} + + + )} + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.sourceField', + { defaultMessage: 'Source field' } + )} + + + {pipeline.sourceField} + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.destinationField', + { defaultMessage: 'Destination field' } + )} + + + {pipeline.destinationField} + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts index 29ad5e9193fdb..9ad288c4b84f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts @@ -7,6 +7,7 @@ export interface InferencePipelineConfiguration { destinationField: string; + existingPipeline?: boolean; modelID: string; pipelineName: string; sourceField: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts index 8db23f5deb7d6..8ad94e5f92da4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts @@ -31,6 +31,12 @@ export const validateInferencePipelineConfiguration = ( config: InferencePipelineConfiguration ): AddInferencePipelineFormErrors => { const errors: AddInferencePipelineFormErrors = {}; + if (config.existingPipeline === true) { + if (config.pipelineName.length === 0) { + errors.pipelineName = FIELD_REQUIRED_ERROR; + } + return errors; + } if (config.pipelineName.trim().length === 0) { errors.pipelineName = FIELD_REQUIRED_ERROR; } else if (!isValidPipelineName(config.pipelineName)) { @@ -45,3 +51,27 @@ export const validateInferencePipelineConfiguration = ( return errors; }; + +export const EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD = i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledSourceFieldDescription', + { + defaultMessage: + 'This pipeline cannot be selected because the source field does not exist on this index.', + } +); + +export const EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS = i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledPipelineExistsDescription', + { + defaultMessage: 'This pipeline cannot be selected because it is already attached.', + } +); + +// TODO: removed when we support attaching pipelines with unavailable models +export const EXISTING_PIPELINE_DISABLED_MODEL_REDACTED = i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledModelRedactedDescription', + { + defaultMessage: + 'This pipeline cannot be selected because it uses a trained model not available in this Kibana space.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts index dca18863cde02..f4c9aad591c72 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts @@ -47,12 +47,21 @@ import { FetchIndexApiParams, FetchIndexApiResponse, } from '../../../api/index/fetch_index_api_logic'; -import { CreateMlInferencePipelineApiLogic } from '../../../api/ml_models/create_ml_inference_pipeline'; import { DeleteMlInferencePipelineApiLogic, DeleteMlInferencePipelineApiLogicArgs, DeleteMlInferencePipelineResponse, } from '../../../api/ml_models/delete_ml_inference_pipeline'; +import { + AttachMlInferencePipelineApiLogic, + AttachMlInferencePipelineApiLogicArgs, + AttachMlInferencePipelineResponse, +} from '../../../api/pipelines/attach_ml_inference_pipeline'; +import { + CreateMlInferencePipelineApiLogic, + CreateMlInferencePipelineApiLogicArgs, + CreateMlInferencePipelineResponse, +} from '../../../api/pipelines/create_ml_inference_pipeline'; import { FetchMlInferencePipelineProcessorsApiLogic } from '../../../api/pipelines/fetch_ml_inference_pipeline_processors'; import { isApiIndex, isConnectorIndex, isCrawlerIndex } from '../../../utils/indices'; @@ -60,6 +69,10 @@ type PipelinesActions = Pick< Actions, 'apiError' | 'apiSuccess' | 'makeRequest' > & { + attachMlInferencePipelineSuccess: Actions< + AttachMlInferencePipelineApiLogicArgs, + AttachMlInferencePipelineResponse + >['apiSuccess']; closeAddMlInferencePipelineModal: () => void; closeModal: () => void; createCustomPipeline: Actions< @@ -74,6 +87,10 @@ type PipelinesActions = Pick< CreateCustomPipelineApiLogicArgs, CreateCustomPipelineApiLogicResponse >['apiSuccess']; + createMlInferencePipelineSuccess: Actions< + CreateMlInferencePipelineApiLogicArgs, + CreateMlInferencePipelineResponse + >['apiSuccess']; deleteMlPipeline: Actions< DeleteMlInferencePipelineApiLogicArgs, DeleteMlInferencePipelineResponse @@ -153,6 +170,8 @@ export const PipelinesLogic = kea { + // Re-fetch processors to ensure we display newly added ml processor + actions.fetchMlInferenceProcessors({ indexName: values.index.name }); + // Needed to ensure correct JSON is available in the JSON configurations tab + actions.fetchCustomPipeline({ indexName: values.index.name }); + }, closeModal: () => actions.setPipelineState( isConnectorIndex(values.index) || isCrawlerIndex(values.index) @@ -287,6 +312,7 @@ export const PipelinesLogic = kea false, closeAddMlInferencePipelineModal: () => false, createMlInferencePipelineSuccess: () => false, openAddMlInferencePipelineModal: () => true, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ml_inference/utils.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ml_inference/utils.ts index 0b2955cb7f30e..f24fe059cc5d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ml_inference/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ml_inference/utils.ts @@ -8,14 +8,9 @@ import { i18n } from '@kbn/i18n'; import { TrainedModelConfigResponse } from '@kbn/ml-plugin/common/types/trained_models'; -export const NLP_CONFIG_KEYS = [ - 'fill_mask', - 'ner', - 'text_classification', - 'text_embedding', - 'question_answering', - 'zero_shot_classification', -]; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../common/ml_inference_pipeline'; + +export const NLP_CONFIG_KEYS: string[] = Object.values(SUPPORTED_PYTORCH_TASKS); export const RECOMMENDED_FIELDS = ['body', 'body_content', 'title']; export const NLP_DISPLAY_TITLES: Record = { diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_container.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_container.tsx new file mode 100644 index 0000000000000..036d22d8b7c5f --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_container.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiSpacer } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { InfraLoadingPanel } from '../../../../components/loading'; +import { useMetricsDataViewContext } from '../hooks/use_data_view'; +import { UnifiedSearchBar } from './unified_search_bar'; +import { HostsTable } from './hosts_table'; + +export const HostContainer = () => { + const { metricsDataView, isDataViewLoading, hasFailedLoadingDataView } = + useMetricsDataViewContext(); + + if (isDataViewLoading) { + return ( + + ); + } + + return hasFailedLoadingDataView || !metricsDataView ? null : ( + <> + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx index d045c594f0ee6..759c65ca84b2e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx @@ -7,16 +7,86 @@ import React from 'react'; import { EuiInMemoryTable } from '@elastic/eui'; -import type { SnapshotNode } from '../../../../../common/http_api'; +import { i18n } from '@kbn/i18n'; import { HostsTableColumns } from './hosts_table_columns'; +import { NoData } from '../../../../components/empty_states'; +import { InfraLoadingPanel } from '../../../../components/loading'; import { useHostTable } from '../hooks/use_host_table'; +import { useSnapshot } from '../../inventory_view/hooks/use_snaphot'; +import type { SnapshotMetricType } from '../../../../../common/inventory_models/types'; +import type { InfraTimerangeInput } from '../../../../../common/http_api'; +import { useUnifiedSearchContext } from '../hooks/use_unified_search'; +import { useSourceContext } from '../../../../containers/metrics_source'; -interface Props { - nodes: SnapshotNode[]; -} +const HOST_METRICS: Array<{ type: SnapshotMetricType }> = [ + { type: 'rx' }, + { type: 'tx' }, + { type: 'memory' }, + { type: 'cpuCores' }, + { type: 'memoryTotal' }, +]; + +export const HostsTable = () => { + const { sourceId } = useSourceContext(); + const { esQuery, dateRangeTimestamp } = useUnifiedSearchContext(); + + const timeRange: InfraTimerangeInput = { + from: dateRangeTimestamp.from, + to: dateRangeTimestamp.to, + interval: '1m', + ignoreLookback: true, + }; + + // Snapshot endpoint internally uses the indices stored in source.configuration.metricAlias. + // For the Unified Search, we create a data view, which for now will be built off of source.configuration.metricAlias too + // if we introduce data view selection, we'll have to change this hook and the endpoint to accept a new parameter for the indices + const { loading, nodes, reload } = useSnapshot( + esQuery && JSON.stringify(esQuery), + HOST_METRICS, + [], + 'host', + sourceId, + dateRangeTimestamp.to, + '', + '', + true, + timeRange + ); -export const HostsTable: React.FunctionComponent = ({ nodes }) => { const items = useHostTable(nodes); + const noData = items.length === 0; - return ; + return ( + <> + {loading ? ( + + ) : noData ? ( +
+ { + reload(); + }} + testString="noMetricsDataPrompt" + /> +
+ ) : ( + + )} + + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx new file mode 100644 index 0000000000000..ec9879579908e --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { Filter, Query, TimeRange } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { SavedQuery } from '@kbn/data-plugin/public'; +import type { InfraClientStartDeps } from '../../../../types'; +import { useUnifiedSearchContext } from '../hooks/use_unified_search'; + +interface Props { + dataView: DataView; +} + +export const UnifiedSearchBar = ({ dataView }: Props) => { + const { + services: { unifiedSearch }, + } = useKibana(); + const { + unifiedSearchDateRange, + unifiedSearchQuery, + submitFilterChange, + saveQuery, + clearSavedQUery, + } = useUnifiedSearchContext(); + + const { SearchBar } = unifiedSearch.ui; + + const onFilterChange = (filters: Filter[]) => { + onQueryChange({ filters }); + }; + + const onQuerySubmit = (payload: { dateRange: TimeRange; query?: Query }) => { + onQueryChange({ payload }); + }; + + const onClearSavedQuery = () => { + clearSavedQUery(); + }; + + const onQuerySave = (savedQuery: SavedQuery) => { + saveQuery(savedQuery); + }; + + const onQueryChange = ({ + payload, + filters, + }: { + payload?: { dateRange: TimeRange; query?: Query }; + filters?: Filter[]; + }) => { + submitFilterChange(payload?.query, payload?.dateRange, filters); + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.test.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.test.ts new file mode 100644 index 0000000000000..2a2bb57b102ff --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useDataView } from './use_data_view'; +import { renderHook } from '@testing-library/react-hooks'; +import { KibanaReactContextValue, useKibana } from '@kbn/kibana-react-plugin/public'; +import { coreMock, notificationServiceMock } from '@kbn/core/public/mocks'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types'; +import { InfraClientStartDeps } from '../../../../types'; +import { CoreStart } from '@kbn/core/public'; + +jest.mock('@kbn/i18n'); +jest.mock('@kbn/kibana-react-plugin/public'); + +let dataViewMock: jest.Mocked; +const useKibanaMock = useKibana as jest.MockedFunction; +const notificationMock = notificationServiceMock.createStartContract(); +const prop = { metricAlias: 'test' }; + +const mockUseKibana = () => { + useKibanaMock.mockReturnValue({ + services: { + ...coreMock.createStart(), + notifications: notificationMock, + dataViews: dataViewMock, + } as Partial & Partial, + } as unknown as KibanaReactContextValue & Partial>); +}; + +const mockDataView = { + id: 'mock-id', + title: 'mock-title', + timeFieldName: 'mock-time-field-name', + isPersisted: () => false, + getName: () => 'mock-data-view', + toSpec: () => ({}), +} as jest.Mocked; + +describe('useHostTable hook', () => { + beforeEach(() => { + dataViewMock = { + createAndSave: jest.fn(), + find: jest.fn(), + } as Partial as jest.Mocked; + + mockUseKibana(); + }); + + it('should find an existing Data view', async () => { + dataViewMock.find.mockReturnValue(Promise.resolve([mockDataView])); + const { result, waitForNextUpdate } = renderHook(() => useDataView(prop)); + + await waitForNextUpdate(); + expect(result.current.isDataViewLoading).toEqual(false); + expect(result.current.hasFailedLoadingDataView).toEqual(false); + expect(result.current.metricsDataView).toEqual(mockDataView); + }); + + it('should create a new Data view', async () => { + dataViewMock.find.mockReturnValue(Promise.resolve([])); + dataViewMock.createAndSave.mockReturnValue(Promise.resolve(mockDataView)); + const { result, waitForNextUpdate } = renderHook(() => useDataView(prop)); + + await waitForNextUpdate(); + expect(result.current.isDataViewLoading).toEqual(false); + expect(result.current.hasFailedLoadingDataView).toEqual(false); + expect(result.current.metricsDataView).toEqual(mockDataView); + }); + + it('should display a toast when it fails to load the data view', async () => { + dataViewMock.find.mockReturnValue(Promise.reject()); + const { result, waitForNextUpdate } = renderHook(() => useDataView(prop)); + + await waitForNextUpdate(); + expect(result.current.isDataViewLoading).toEqual(false); + expect(result.current.hasFailedLoadingDataView).toEqual(true); + expect(result.current.metricsDataView).toBeUndefined(); + expect(notificationMock.toasts.addDanger).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts index b60b2aa89db62..f927afa72890c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { useCallback, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useCallback, useState, useEffect, useMemo } from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import createContainer from 'constate'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -15,7 +16,7 @@ import { useTrackedPromise } from '../../../../utils/use_tracked_promise'; export const useDataView = ({ metricAlias }: { metricAlias: string }) => { const [metricsDataView, setMetricsDataView] = useState(); const { - services: { dataViews }, + services: { dataViews, notifications }, } = useKibana(); const [createDataViewRequest, createDataView] = useTrackedPromise( @@ -33,7 +34,7 @@ export const useDataView = ({ metricAlias }: { metricAlias: string }) => { const [getDataViewRequest, getDataView] = useTrackedPromise( { - createPromise: (indexPattern: string): Promise => { + createPromise: (_indexPattern: string): Promise => { return dataViews.find(metricAlias, 1); }, onResolve: (response: DataView[]) => { @@ -58,17 +59,36 @@ export const useDataView = ({ metricAlias }: { metricAlias: string }) => { } }, [metricAlias, createDataView, getDataView]); - const hasFailedFetchingDataView = getDataViewRequest.state === 'rejected'; - const hasFailedCreatingDataView = createDataViewRequest.state === 'rejected'; + const isDataViewLoading = useMemo( + () => getDataViewRequest.state === 'pending' || createDataViewRequest.state === 'pending', + [getDataViewRequest.state, createDataViewRequest.state] + ); + + const hasFailedLoadingDataView = useMemo( + () => getDataViewRequest.state === 'rejected' || createDataViewRequest.state === 'rejected', + [getDataViewRequest.state, createDataViewRequest.state] + ); useEffect(() => { loadDataView(); }, [metricAlias, loadDataView]); + useEffect(() => { + if (hasFailedLoadingDataView && notifications) { + notifications.toasts.addDanger( + i18n.translate('xpack.infra.hostsTable.errorOnCreateOrLoadDataview', { + defaultMessage: + 'There was an error trying to load or create the Data View: {metricAlias}', + values: { metricAlias }, + }) + ); + } + }, [hasFailedLoadingDataView, notifications, metricAlias]); + return { metricsDataView, - hasFailedCreatingDataView, - hasFailedFetchingDataView, + isDataViewLoading, + hasFailedLoadingDataView, }; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts new file mode 100644 index 0000000000000..4b3d4e7a47df6 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import createContainer from 'constate'; +import { useCallback, useReducer } from 'react'; +import { buildEsQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import DateMath from '@kbn/datemath'; +import type { SavedQuery } from '@kbn/data-plugin/public'; +import type { InfraClientStartDeps } from '../../../../types'; +import { useMetricsDataViewContext } from './use_data_view'; +import { useKibanaTimefilterTime } from '../../../../hooks/use_kibana_timefilter_time'; + +const DEFAULT_FROM_MINUTES_VALUE = 15; + +export const useUnifiedSearch = () => { + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + + const { metricsDataView } = useMetricsDataViewContext(); + const { services } = useKibana(); + const { + data: { query: queryManager }, + } = services; + + const [getTime, setTime] = useKibanaTimefilterTime({ + from: `now-${DEFAULT_FROM_MINUTES_VALUE}m`, + to: 'now', + }); + const { queryString, filterManager } = queryManager; + + const currentDate = new Date(); + const fromTS = + DateMath.parse(getTime().from)?.valueOf() ?? + new Date(currentDate.getMinutes() - DEFAULT_FROM_MINUTES_VALUE).getTime(); + const toTS = DateMath.parse(getTime().to)?.valueOf() ?? currentDate.getTime(); + + const currentTimeRange = { + from: fromTS, + to: toTS, + }; + + const submitFilterChange = useCallback( + (query?: Query, dateRange?: TimeRange, filters?: Filter[]) => { + if (filters) { + filterManager.setFilters(filters); + } + + setTime({ + ...getTime(), + ...dateRange, + }); + + queryString.setQuery({ ...queryString.getQuery(), ...query }); + // Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values + // This can be removed once we get the state from the URL + forceUpdate(); + }, + [filterManager, queryString, getTime, setTime] + ); + + const saveQuery = useCallback( + (newSavedQuery: SavedQuery) => { + const savedQueryFilters = newSavedQuery.attributes.filters ?? []; + const globalFilters = filterManager.getGlobalFilters(); + filterManager.setFilters([...savedQueryFilters, ...globalFilters]); + + // Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values + // This can be removed once we get the state from the URL + forceUpdate(); + }, + [filterManager] + ); + + const clearSavedQUery = useCallback(() => { + filterManager.setFilters(filterManager.getGlobalFilters()); + + // Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values + // This can be removed once we get the state from the URL + forceUpdate(); + }, [filterManager]); + + const buildQuery = useCallback(() => { + if (!metricsDataView) { + return null; + } + return buildEsQuery(metricsDataView, queryString.getQuery(), filterManager.getFilters()); + }, [filterManager, metricsDataView, queryString]); + + return { + dateRangeTimestamp: currentTimeRange, + esQuery: buildQuery(), + submitFilterChange, + saveQuery, + clearSavedQUery, + unifiedSearchQuery: queryString.getQuery() as Query, + unifiedSearchDateRange: getTime(), + unifiedSearchFilters: filterManager.getFilters(), + }; +}; + +export const UnifiedSearch = createContainer(useUnifiedSearch); +export const [UnifiedSearchProvider, useUnifiedSearchContext] = UnifiedSearch; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hosts_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/hosts_content.tsx deleted file mode 100644 index 5ab4a062d7fc9..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hosts_content.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Query, TimeRange } from '@kbn/es-query'; -import { i18n } from '@kbn/i18n'; -import React, { useState, useCallback } from 'react'; -import { SearchBar } from '@kbn/unified-search-plugin/public'; -import { EuiSpacer } from '@elastic/eui'; -import { NoData } from '../../../components/empty_states'; -import { InfraLoadingPanel } from '../../../components/loading'; -import { useMetricsDataViewContext } from './hooks/use_data_view'; -import { HostsTable } from './components/hosts_table'; -import { useSourceContext } from '../../../containers/metrics_source'; -import { useSnapshot } from '../inventory_view/hooks/use_snaphot'; -import type { SnapshotMetricType } from '../../../../common/inventory_models/types'; - -export const HostsContent: React.FunctionComponent = () => { - const { source, sourceId } = useSourceContext(); - const [dateRange, setDateRange] = useState({ from: 'now-15m', to: 'now' }); - const [query, setQuery] = useState({ query: '', language: 'kuery' }); - const { metricsDataView, hasFailedCreatingDataView, hasFailedFetchingDataView } = - useMetricsDataViewContext(); - // needed to refresh the lens table when filters havent changed - - const onQuerySubmit = useCallback( - (payload: { dateRange: TimeRange; query?: Query }) => { - setDateRange(payload.dateRange); - if (payload.query) { - setQuery(payload.query); - } - }, - [setDateRange, setQuery] - ); - - const hostMetrics: Array<{ type: SnapshotMetricType }> = [ - { type: 'rx' }, - { type: 'tx' }, - { type: 'memory' }, - { type: 'cpuCores' }, - { type: 'memoryTotal' }, - ]; - - const { loading, nodes, reload } = useSnapshot( - '', // use the unified search query, supported type? - hostMetrics, - [], - 'host', - sourceId, - 1666710279338, // currentTime. need to add support for TimeRange? - '', - '', - true, - { - from: 1666710279338, // dynamic time range needs to be supported - interval: '1m', - lookbackSize: 5, - to: 1666711479338, - } - ); - - const noData = !loading && nodes && nodes.length === 0; - - return ( -
- {metricsDataView && !loading ? ( - noData ? ( - { - reload(); - }} - testString="noMetricsDataPrompt" - /> - ) : ( - <> - - - - - ) - ) : hasFailedCreatingDataView || hasFailedFetchingDataView ? ( -
-
There was an error trying to load or create the Data View:
- {source?.configuration.metricAlias} -
- ) : ( - - )} -
- ); -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx index a5dfd7f2ddd0f..3321be0af193c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx @@ -9,16 +9,16 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '@kbn/observability-plugin/public'; import { APP_WRAPPER_CLASS } from '@kbn/core/public'; - import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useSourceContext } from '../../../containers/metrics_source'; import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; import { MetricsPageTemplate } from '../page_template'; import { hostsTitle } from '../../../translations'; -import { HostsContent } from './hosts_content'; import { MetricsDataViewProvider } from './hooks/use_data_view'; import { fullHeightContentStyles } from '../../../page_template.styles'; +import { UnifiedSearchProvider } from './hooks/use_unified_search'; +import { HostContainer } from './components/hosts_container'; export const HostsPage = () => { const { @@ -56,7 +56,9 @@ export const HostsPage = () => { }} > - + + +
diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts index 64d389a1c0bf7..55b847d33f87d 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts @@ -22,16 +22,18 @@ import { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { PluginSetupContract as AlertingPluginContract } from '@kbn/alerting-plugin/server'; import { MlPluginSetup } from '@kbn/ml-plugin/server'; import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server'; +import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server'; export interface InfraServerPluginSetupDeps { + alerting: AlertingPluginContract; data: DataPluginSetup; home: HomeServerPluginSetup; + features: FeaturesPluginSetup; + ruleRegistry: RuleRegistryPluginSetupContract; + observability: ObservabilityPluginSetup; spaces: SpacesPluginSetup; usageCollection: UsageCollectionSetup; visTypeTimeseries: VisTypeTimeseriesSetup; - features: FeaturesPluginSetup; - alerting: AlertingPluginContract; - ruleRegistry: RuleRegistryPluginSetupContract; ml?: MlPluginSetup; } diff --git a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts index 80dae7ffac959..644c31813deae 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts @@ -169,6 +169,14 @@ export const alertStateActionVariableDescription = i18n.translate( } ); +export const alertDetailUrlActionVariableDescription = i18n.translate( + 'xpack.infra.metrics.alerting.alertDetailUrlActionVariableDescription', + { + defaultMessage: + 'Link to the view within Elastic that shows further details and context surrounding this alert', + } +); + export const reasonActionVariableDescription = i18n.translate( 'xpack.infra.metrics.alerting.reasonActionVariableDescription', { @@ -211,7 +219,7 @@ export const viewInAppUrlActionVariableDescription = i18n.translate( 'xpack.infra.metrics.alerting.viewInAppUrlActionVariableDescription', { defaultMessage: - 'Link to the view or feature within Elastic that can be used to investigate the alert and its context further', + 'Link to the view or feature within Elastic that can assist with further investigation', } ); diff --git a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts index 2618af72168cd..ced80c75a3ef1 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts @@ -9,8 +9,11 @@ import { isEmpty, isError } from 'lodash'; import { schema } from '@kbn/config-schema'; import { Logger, LogMeta } from '@kbn/logging'; import type { IBasePath } from '@kbn/core/server'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; +import { ObservabilityConfig } from '@kbn/observability-plugin/server'; import { ALERT_RULE_PARAMETERS, TIMESTAMP } from '@kbn/rule-data-utils'; import { parseTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_technical_fields'; +import { LINK_TO_METRICS_EXPLORER } from '../../../../common/alerting/metrics'; import { getInventoryViewInAppUrl } from '../../../../common/alerting/metrics/alert_link'; import { AlertExecutionDetails, @@ -83,18 +86,30 @@ export const createScopedLogger = ( }; }; -export const getViewInAppUrl = (basePath: IBasePath, relativeViewInAppUrl: string) => - basePath.publicBaseUrl - ? new URL(basePath.prepend(relativeViewInAppUrl), basePath.publicBaseUrl).toString() - : relativeViewInAppUrl; +export const getAlertDetailsPageEnabledForApp = ( + config: ObservabilityConfig['unsafe']['alertDetails'] | null, + appName: keyof ObservabilityConfig['unsafe']['alertDetails'] +): boolean => { + if (!config) return false; -export const getViewInAppUrlInventory = ( - criteria: InventoryMetricConditions[], - nodeType: string, - timestamp: string, - basePath: IBasePath -) => { + return config[appName].enabled; +}; + +export const getViewInInventoryAppUrl = ({ + basePath, + criteria, + nodeType, + spaceId, + timestamp, +}: { + basePath: IBasePath; + criteria: InventoryMetricConditions[]; + nodeType: string; + spaceId: string; + timestamp: string; +}) => { const { metric, customMetric } = criteria[0]; + const fields = { [`${ALERT_RULE_PARAMETERS}.criteria.metric`]: [metric], [`${ALERT_RULE_PARAMETERS}.criteria.customMetric.id`]: [customMetric?.id], @@ -104,6 +119,18 @@ export const getViewInAppUrlInventory = ( [TIMESTAMP]: timestamp, }; - const relativeViewInAppUrl = getInventoryViewInAppUrl(parseTechnicalFields(fields, true)); - return getViewInAppUrl(basePath, relativeViewInAppUrl); + return addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + getInventoryViewInAppUrl(parseTechnicalFields(fields, true)) + ); }; + +export const getViewInMetricsAppUrl = (basePath: IBasePath, spaceId: string) => + addSpaceIdToPath(basePath.publicBaseUrl, spaceId, LINK_TO_METRICS_EXPLORER); + +export const getAlertDetailsUrl = ( + basePath: IBasePath, + spaceId: string, + alertUuid: string | null +) => addSpaceIdToPath(basePath.publicBaseUrl, spaceId, `/app/observability/alerts/${alertUuid}`); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 2e51d2e8291b5..20b804a2cc7db 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -30,7 +30,12 @@ import { buildNoDataAlertReason, stateToAlertMessage, } from '../common/messages'; -import { createScopedLogger, getViewInAppUrlInventory } from '../common/utils'; +import { + createScopedLogger, + getAlertDetailsUrl, + getViewInInventoryAppUrl, + UNGROUPED_FACTORY_KEY, +} from '../common/utils'; import { evaluateCondition, ConditionResult } from './evaluate_condition'; type InventoryMetricThresholdAllowedActionGroups = ActionGroupIdsOf< @@ -61,12 +66,18 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = InventoryMetricThresholdAlertState, InventoryMetricThresholdAlertContext, InventoryMetricThresholdAllowedActionGroups - >(async ({ services, params, alertId, executionId, startedAt }) => { + >(async ({ services, params, alertId, executionId, spaceId, startedAt }) => { const startTime = Date.now(); + const { criteria, filterQuery, sourceId = 'default', nodeType, alertOnNoData } = params; + if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions'); + const logger = createScopedLogger(libs.logger, 'inventoryRule', { alertId, executionId }); - const { alertWithLifecycle, savedObjectsClient, getAlertStartedDate } = services; + + const esClient = services.scopedClusterClient.asCurrentUser; + + const { alertWithLifecycle, savedObjectsClient, getAlertStartedDate, getAlertUuid } = services; const alertFactory: InventoryMetricThresholdAlertFactory = (id, reason, additionalContext) => alertWithLifecycle({ id, @@ -85,23 +96,28 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = logger.error(e.message); const actionGroupId = FIRED_ACTIONS.id; // Change this to an Error action group when able const reason = buildInvalidQueryAlertReason(params.filterQueryText); - const alert = alertFactory('*', reason); - const indexedStartedDate = getAlertStartedDate('*') ?? startedAt.toISOString(); - const viewInAppUrl = getViewInAppUrlInventory( - criteria, - nodeType, - indexedStartedDate, - libs.basePath - ); + const alert = alertFactory(UNGROUPED_FACTORY_KEY, reason); + const indexedStartedDate = + getAlertStartedDate(UNGROUPED_FACTORY_KEY) ?? startedAt.toISOString(); + const alertUuid = getAlertUuid(UNGROUPED_FACTORY_KEY); + alert.scheduleActions(actionGroupId, { - group: '*', + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), alertState: stateToAlertMessage[AlertStates.ERROR], + group: UNGROUPED_FACTORY_KEY, + metric: mapToConditionsLookup(criteria, (c) => c.metric), reason, timestamp: startedAt.toISOString(), - viewInAppUrl, value: null, - metric: mapToConditionsLookup(criteria, (c) => c.metric), + viewInAppUrl: getViewInInventoryAppUrl({ + basePath: libs.basePath, + criteria, + nodeType, + timestamp: indexedStartedDate, + spaceId, + }), }); + return {}; } } @@ -109,7 +125,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = const [, , { logViews }] = await libs.getStartServices(); const logQueryFields: LogQueryFields | undefined = await logViews - .getClient(savedObjectsClient, services.scopedClusterClient.asCurrentUser) + .getClient(savedObjectsClient, esClient) .getResolvedLogView(sourceId) .then( ({ indices }) => ({ indexPattern: indices }), @@ -120,18 +136,19 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = const results = await Promise.all( criteria.map((condition) => evaluateCondition({ - condition, - nodeType, - source, - logQueryFields, - esClient: services.scopedClusterClient.asCurrentUser, compositeSize, - filterQuery, + condition, + esClient, executionTimestamp: startedAt, + filterQuery, logger, + logQueryFields, + nodeType, + source, }) ) ); + let scheduledActionsCount = 0; const inventoryItems = Object.keys(first(results)!); for (const group of inventoryItems) { @@ -190,25 +207,28 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = const alert = alertFactory(group, reason, additionalContext); const indexedStartedDate = getAlertStartedDate(group) ?? startedAt.toISOString(); - const viewInAppUrl = getViewInAppUrlInventory( - criteria, - nodeType, - indexedStartedDate, - libs.basePath - ); + const alertUuid = getAlertUuid(group); + scheduledActionsCount++; const context = { - group, + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), alertState: stateToAlertMessage[nextState], + group, reason, + metric: mapToConditionsLookup(criteria, (c) => c.metric), timestamp: startedAt.toISOString(), - viewInAppUrl, + threshold: mapToConditionsLookup(criteria, (c) => c.threshold), value: mapToConditionsLookup(results, (result) => formatMetric(result[group].metric, result[group].currentValue) ), - threshold: mapToConditionsLookup(criteria, (c) => c.threshold), - metric: mapToConditionsLookup(criteria, (c) => c.metric), + viewInAppUrl: getViewInInventoryAppUrl({ + basePath: libs.basePath, + criteria, + nodeType, + timestamp: indexedStartedDate, + spaceId, + }), ...additionalContext, }; alert.scheduleActions(actionGroupId, context); @@ -217,24 +237,27 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = const { getRecoveredAlerts } = services.alertFactory.done(); const recoveredAlerts = getRecoveredAlerts(); + for (const alert of recoveredAlerts) { const recoveredAlertId = alert.getId(); const indexedStartedDate = getAlertStartedDate(recoveredAlertId) ?? startedAt.toISOString(); - const viewInAppUrl = getViewInAppUrlInventory( - criteria, - nodeType, - indexedStartedDate, - libs.basePath - ); - const context = { - group: recoveredAlertId, + const alertUuid = getAlertUuid(recoveredAlertId); + + alert.setContext({ + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), alertState: stateToAlertMessage[AlertStates.OK], - timestamp: startedAt.toISOString(), - viewInAppUrl, - threshold: mapToConditionsLookup(criteria, (c) => c.threshold), + group: recoveredAlertId, metric: mapToConditionsLookup(criteria, (c) => c.metric), - }; - alert.setContext(context); + threshold: mapToConditionsLookup(criteria, (c) => c.threshold), + timestamp: startedAt.toISOString(), + viewInAppUrl: getViewInInventoryAppUrl({ + basePath: libs.basePath, + criteria, + nodeType, + timestamp: indexedStartedDate, + spaceId, + }), + }); } const stopTime = Date.now(); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts index b3b1f5ba21c65..030628f59ad38 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_rule_type.ts @@ -24,6 +24,7 @@ import { } from '../../../../common/inventory_models/types'; import { InfraBackendLibs } from '../../infra_types'; import { + alertDetailUrlActionVariableDescription, alertStateActionVariableDescription, cloudActionVariableDescription, containerActionVariableDescription, @@ -39,7 +40,11 @@ import { valueActionVariableDescription, viewInAppUrlActionVariableDescription, } from '../common/messages'; -import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; +import { + getAlertDetailsPageEnabledForApp, + oneOfLiterals, + validateIsStringElasticsearchJSONFilter, +} from '../common/utils'; import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, @@ -72,6 +77,8 @@ export async function registerMetricInventoryThresholdRuleType( alertingPlugin: PluginSetupContract, libs: InfraBackendLibs ) { + const config = libs.getAlertDetailsConfig(); + alertingPlugin.registerType({ id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, name: i18n.translate('xpack.infra.metrics.inventory.alertName', { @@ -102,6 +109,9 @@ export async function registerMetricInventoryThresholdRuleType( context: [ { name: 'group', description: groupActionVariableDescription }, { name: 'alertState', description: alertStateActionVariableDescription }, + ...(getAlertDetailsPageEnabledForApp(config, 'metrics') + ? [{ name: 'alertDetailsUrl', description: alertDetailUrlActionVariableDescription }] + : []), { name: 'reason', description: reasonActionVariableDescription }, { name: 'timestamp', description: timestampActionVariableDescription }, { name: 'value', description: valueActionVariableDescription }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 14b5fe8e75614..200ad68aa81d1 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -26,8 +26,12 @@ import { // buildRecoveredAlertReason, stateToAlertMessage, } from '../common/messages'; -import { UNGROUPED_FACTORY_KEY, getViewInAppUrl, createScopedLogger } from '../common/utils'; -import { LINK_TO_METRICS_EXPLORER } from '../../../../common/alerting/metrics'; +import { + createScopedLogger, + getAlertDetailsUrl, + getViewInMetricsAppUrl, + UNGROUPED_FACTORY_KEY, +} from '../common/utils'; import { EvaluatedRuleParams, evaluateRule } from './lib/evaluate_rule'; @@ -67,11 +71,16 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => MetricThresholdAllowedActionGroups >(async function (options) { const startTime = Date.now(); - const { services, params, state, startedAt, alertId, executionId } = options; + + const { services, params, state, startedAt, alertId, executionId, spaceId } = options; + const { criteria } = params; if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions'); + const logger = createScopedLogger(libs.logger, 'metricThresholdRule', { alertId, executionId }); - const { alertWithLifecycle, savedObjectsClient } = services; + + const { alertWithLifecycle, savedObjectsClient, getAlertUuid } = services; + const alertFactory: MetricThresholdAlertFactory = (id, reason) => alertWithLifecycle({ id, @@ -100,15 +109,19 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => const actionGroupId = FIRED_ACTIONS.id; // Change this to an Error action group when able const reason = buildInvalidQueryAlertReason(params.filterQueryText); const alert = alertFactory(UNGROUPED_FACTORY_KEY, reason); + const alertUuid = getAlertUuid(UNGROUPED_FACTORY_KEY); + alert.scheduleActions(actionGroupId, { - group: UNGROUPED_FACTORY_KEY, + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), alertState: stateToAlertMessage[AlertStates.ERROR], + group: UNGROUPED_FACTORY_KEY, + metric: mapToConditionsLookup(criteria, (c) => c.metric), reason, - viewInAppUrl: getViewInAppUrl(libs.basePath, LINK_TO_METRICS_EXPLORER), timestamp, value: null, - metric: mapToConditionsLookup(criteria, (c) => c.metric), + viewInAppUrl: getViewInMetricsAppUrl(libs.basePath, spaceId), }); + return { lastRunTimestamp: startedAt.valueOf(), missingGroups: [], @@ -157,6 +170,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => const hasGroups = !isEqual(groups, [UNGROUPED_FACTORY_KEY]); let scheduledActionsCount = 0; + // The key of `groups` is the alert instance ID. for (const group of groups) { // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every((result) => result[group]?.shouldFire); @@ -227,40 +241,45 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => ? WARNING_ACTIONS.id : FIRED_ACTIONS.id; const alert = alertFactory(`${group}`, reason); + const alertUuid = getAlertUuid(group); scheduledActionsCount++; + alert.scheduleActions(actionGroupId, { - group, + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), alertState: stateToAlertMessage[nextState], + group, + metric: mapToConditionsLookup(criteria, (c) => c.metric), reason, - viewInAppUrl: getViewInAppUrl(libs.basePath, LINK_TO_METRICS_EXPLORER), + threshold: mapToConditionsLookup( + alertResults, + (result) => formatAlertResult(result[group]).threshold + ), timestamp, value: mapToConditionsLookup( alertResults, (result) => formatAlertResult(result[group]).currentValue ), - threshold: mapToConditionsLookup( - alertResults, - (result) => formatAlertResult(result[group]).threshold - ), - metric: mapToConditionsLookup(criteria, (c) => c.metric), + viewInAppUrl: getViewInMetricsAppUrl(libs.basePath, spaceId), }); } } const { getRecoveredAlerts } = services.alertFactory.done(); const recoveredAlerts = getRecoveredAlerts(); + for (const alert of recoveredAlerts) { const recoveredAlertId = alert.getId(); - const viewInAppUrl = getViewInAppUrl(libs.basePath, LINK_TO_METRICS_EXPLORER); - const context = { - group: recoveredAlertId, + const alertUuid = getAlertUuid(recoveredAlertId); + + alert.setContext({ + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), alertState: stateToAlertMessage[AlertStates.OK], + group: recoveredAlertId, + metric: mapToConditionsLookup(criteria, (c) => c.metric), timestamp: startedAt.toISOString(), - viewInAppUrl, threshold: mapToConditionsLookup(criteria, (c) => c.threshold), - metric: mapToConditionsLookup(criteria, (c) => c.metric), - }; - alert.setContext(context); + viewInAppUrl: getViewInMetricsAppUrl(libs.basePath, spaceId), + }); } const stopTime = Date.now(); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts index 0ebb427819f74..6538fb25b6c8c 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts @@ -13,6 +13,7 @@ import { Comparator, METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/a import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api'; import { InfraBackendLibs } from '../../infra_types'; import { + alertDetailUrlActionVariableDescription, alertStateActionVariableDescription, groupActionVariableDescription, metricActionVariableDescription, @@ -22,7 +23,11 @@ import { valueActionVariableDescription, viewInAppUrlActionVariableDescription, } from '../common/messages'; -import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; +import { + getAlertDetailsPageEnabledForApp, + oneOfLiterals, + validateIsStringElasticsearchJSONFilter, +} from '../common/utils'; import { createMetricThresholdExecutor, FIRED_ACTIONS, @@ -41,6 +46,8 @@ export async function registerMetricThresholdRuleType( alertingPlugin: PluginSetupContract, libs: InfraBackendLibs ) { + const config = libs.getAlertDetailsConfig(); + const baseCriterion = { threshold: schema.arrayOf(schema.number()), comparator: oneOfLiterals(Object.values(Comparator)), @@ -93,6 +100,9 @@ export async function registerMetricThresholdRuleType( actionVariables: { context: [ { name: 'group', description: groupActionVariableDescription }, + ...(getAlertDetailsPageEnabledForApp(config, 'metrics') + ? [{ name: 'alertDetailsUrl', description: alertDetailUrlActionVariableDescription }] + : []), { name: 'alertState', description: alertStateActionVariableDescription }, { name: 'reason', description: reasonActionVariableDescription }, { name: 'timestamp', description: timestampActionVariableDescription }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts index 37e59700b0488..3d3c7a17cd1dd 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -4,10 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import * as utils from '../common/utils'; -jest - .spyOn(utils, 'getViewInAppUrl') - .mockReturnValue('http://localhost:5601/eyg/app/metrics/explorer'); const bucketsA = (from: number) => [ { diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index a4636daa7986e..4801fb49651f6 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -8,6 +8,7 @@ import { Logger } from '@kbn/logging'; import type { IBasePath } from '@kbn/core/server'; import { handleEsError } from '@kbn/es-ui-shared-plugin/server'; +import { ObservabilityConfig } from '@kbn/observability-plugin/server'; import { RulesServiceSetup } from '../services/rules'; import { InfraConfig, InfraPluginStartServicesAccessor } from '../types'; import { KibanaFramework } from './adapters/framework/kibana_framework_adapter'; @@ -24,14 +25,15 @@ export interface InfraDomainLibs { } export interface InfraBackendLibs extends InfraDomainLibs { + basePath: IBasePath; configuration: InfraConfig; framework: KibanaFramework; - sources: InfraSources; - sourceStatus: InfraSourceStatus; - handleEsError: typeof handleEsError; logsRules: RulesServiceSetup; metricsRules: RulesServiceSetup; + sources: InfraSources; + sourceStatus: InfraSourceStatus; + getAlertDetailsConfig: () => ObservabilityConfig['unsafe']['alertDetails']; getStartServices: InfraPluginStartServicesAccessor; + handleEsError: typeof handleEsError; logger: Logger; - basePath: IBasePath; } diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index bfd7113ec4dc0..a7fa9ceacd3c9 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -170,6 +170,7 @@ export class InfraServerPlugin logsRules: this.logsRules.setup(core, plugins), metricsRules: this.metricsRules.setup(core, plugins), getStartServices: () => core.getStartServices(), + getAlertDetailsConfig: () => plugins.observability.getAlertDetailsConfig(), logger: this.logger, basePath: core.http.basePath, }; diff --git a/x-pack/plugins/lens/public/datasources/form_based/field_item.tsx b/x-pack/plugins/lens/public/datasources/form_based/field_item.tsx index 5ebfecc4cc95a..e2ee0559b3808 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/field_item.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/field_item.tsx @@ -190,6 +190,9 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { initialFocus=".lnsFieldItem__fieldPanel" className="lnsFieldItem__popoverAnchor" data-test-subj="lnsFieldListPanelField" + panelProps={{ + 'data-test-subj': 'lnsFieldListPanelFieldContent', + }} container={document.querySelector('.application') || undefined} button={ { - if (params?.noDataFound) { + if (params.reason === 'no-data') { // TODO: should we replace this with a default message "Analysis is not available for this field?" const isUsingSampling = core.uiSettings.get('lens:useFieldExistenceSampling'); return ( - <> - - {isUsingSampling - ? i18n.translate('xpack.lens.indexPattern.fieldStatsSamplingNoData', { - defaultMessage: - 'Lens is unable to create visualizations with this field because it does not contain data in the first 500 documents that match your filters. To create a visualization, drag and drop a different field.', - }) - : i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { - defaultMessage: - 'Lens is unable to create visualizations with this field because it does not contain data. To create a visualization, drag and drop a different field.', - })} - - + + {isUsingSampling + ? i18n.translate('xpack.lens.indexPattern.fieldStatsSamplingNoData', { + defaultMessage: + 'Lens is unable to create visualizations with this field because it does not contain data in the first 500 documents that match your filters. To create a visualization, drag and drop a different field.', + }) + : i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { + defaultMessage: + 'Lens is unable to create visualizations with this field because it does not contain data. To create a visualization, drag and drop a different field.', + })} + + ); + } + if (params.reason === 'unsupported') { + return ( + + {params.element} + ); } - return params.element; }} /> diff --git a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx b/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx index 105c9583e300d..d6b4c73b51082 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx @@ -169,14 +169,18 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ } if (hasLoaded) { return ( - + {fieldsCount} ); } return ; - }, [showExistenceFetchError, showExistenceFetchTimeout, hasLoaded, isFiltered, fieldsCount]); + }, [showExistenceFetchError, showExistenceFetchTimeout, hasLoaded, isFiltered, id, fieldsCount]); return ( -1 ? samplingIndex : samplingValue.length - 1; return ( + +

+ + + + ), + }} + /> +

+ + } label={ <> {i18n.translate('xpack.lens.xyChart.randomSampling.label', { - defaultMessage: 'Sampling', + defaultMessage: 'Random sampling', })}{' '} } > - { - setState({ - ...state, - layers: { - ...state.layers, - [layerId]: { - ...state.layers[layerId], - sampling: samplingValue[Number(e.currentTarget.value)], - }, - }, - }); - }} - showInput={false} - showRange={false} - showTicks - step={1} - min={0} - max={samplingValue.length - 1} - ticks={samplingValue.map((v, i) => ({ label: `${v}`, value: i }))} - /> + + + + + + + + { + setState({ + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + sampling: samplingValue[Number(e.currentTarget.value)], + }, + }, + }); + }} + showInput={false} + showRange={false} + showTicks + step={1} + min={0} + max={samplingValue.length - 1} + ticks={samplingValue.map((v, i) => ({ label: `${v * 100}%`, value: i }))} + /> + + + + + + +
); } diff --git a/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx b/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx index 23f71e991bc82..d02fd98bc9c87 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx @@ -49,14 +49,18 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ const extraAction = useMemo(() => { if (hasLoaded) { return ( - + {fields.length} ); } return ; - }, [fields.length, hasLoaded, isFiltered]); + }, [fields.length, hasLoaded, id, isFiltered]); return ( <> diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/types.ts b/x-pack/plugins/lens/public/visualizations/heatmap/types.ts index 08913ad25a7d3..6be8d3b6e8d95 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/types.ts +++ b/x-pack/plugins/lens/public/visualizations/heatmap/types.ts @@ -10,7 +10,7 @@ import type { HeatmapArguments } from '@kbn/expression-heatmap-plugin/common'; import type { LayerType } from '../../../common'; export type ChartShapes = 'heatmap'; -export type HeatmapLayerState = HeatmapArguments & { +export type HeatmapLayerState = Omit & { layerId: string; layerType: LayerType; valueAccessor?: string; diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx index b8e98d03843a9..fc8ef976548b4 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx @@ -17,7 +17,8 @@ import { ThemeServiceStart } from '@kbn/core/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; -import type { OperationMetadata, Visualization } from '../../types'; +import { HeatmapConfiguration } from '@kbn/visualizations-plugin/common'; +import type { OperationMetadata, Suggestion, Visualization } from '../../types'; import type { HeatmapVisualizationState } from './types'; import { getSuggestions } from './suggestions'; import { @@ -33,6 +34,7 @@ import { import { HeatmapToolbar } from './toolbar_component'; import { HeatmapDimensionEditor } from './dimension_editor'; import { getSafePaletteParams } from './utils'; +import { FormBasedPersistedState } from '../..'; const groupLabelForHeatmap = i18n.translate('xpack.lens.heatmapVisualization.heatmapGroupLabel', { defaultMessage: 'Magnitude', @@ -525,4 +527,28 @@ export const getHeatmapVisualization = ({ ] : undefined; }, + + getSuggestionFromConvertToLensContext({ suggestions, context }) { + const allSuggestions = suggestions as Array< + Suggestion + >; + const suggestion: Suggestion = { + ...allSuggestions[0], + datasourceState: { + ...allSuggestions[0].datasourceState, + layers: allSuggestions.reduce( + (acc, s) => ({ + ...acc, + ...s.datasourceState?.layers, + }), + {} + ), + }, + visualizationState: { + ...allSuggestions[0].visualizationState, + ...(context.configuration as HeatmapConfiguration), + }, + }; + return suggestion; + }, }); diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.test.ts b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.test.ts index 2006c3eed6c2a..48cd66c47b53d 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.test.ts @@ -42,6 +42,7 @@ describe('createLayerDescriptor', () => { label: 'apm-*-transaction* | Source Point', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -119,6 +120,7 @@ describe('createLayerDescriptor', () => { label: 'apm-*-transaction* | Destination point', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -196,6 +198,7 @@ describe('createLayerDescriptor', () => { label: 'apm-*-transaction* | Line', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -248,6 +251,13 @@ describe('createLayerDescriptor', () => { type: 'GEOJSON_VECTOR', visible: true, }, + { + id: '12345', + label: 'apm-*-transaction*', + sourceDescriptor: null, + type: 'LAYER_GROUP', + visible: true, + }, ]); }); @@ -262,6 +272,7 @@ describe('createLayerDescriptor', () => { label: 'filebeat-* | Source Point', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -339,6 +350,7 @@ describe('createLayerDescriptor', () => { label: 'filebeat-* | Destination point', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -410,6 +422,7 @@ describe('createLayerDescriptor', () => { label: 'filebeat-* | Line', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -462,6 +475,13 @@ describe('createLayerDescriptor', () => { type: 'GEOJSON_VECTOR', visible: true, }, + { + id: '12345', + label: 'filebeat-*', + sourceDescriptor: null, + type: 'LAYER_GROUP', + visible: true, + }, ]); }); @@ -476,6 +496,7 @@ describe('createLayerDescriptor', () => { label: 'traces-apm-opbean-node | Source Point', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -553,6 +574,7 @@ describe('createLayerDescriptor', () => { label: 'traces-apm-opbean-node | Destination point', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -624,6 +646,7 @@ describe('createLayerDescriptor', () => { label: 'traces-apm-opbean-node | Line', maxZoom: 24, minZoom: 0, + parent: '12345', disableTooltips: false, sourceDescriptor: { applyGlobalQuery: true, @@ -676,6 +699,13 @@ describe('createLayerDescriptor', () => { type: 'GEOJSON_VECTOR', visible: true, }, + { + id: '12345', + label: 'traces-apm-opbean-node', + sourceDescriptor: null, + type: 'LAYER_GROUP', + visible: true, + }, ]); }); }); diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts index f295464126c96..792d61b08b9b4 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts @@ -23,6 +23,7 @@ import { VECTOR_STYLES, } from '../../../../../../common/constants'; import { GeoJsonVectorLayer } from '../../../vector_layer'; +import { LayerGroup } from '../../../layer_group'; import { VectorStyle } from '../../../../styles/vector/vector_style'; import { ESSearchSource } from '../../../../sources/es_search_source'; import { ESPewPewSource } from '../../../../sources/es_pew_pew_source'; @@ -48,7 +49,11 @@ function getDestinationField(indexPatternTitle: string) { return isApmIndex(indexPatternTitle) ? 'server.geo.location' : 'destination.geo.location'; } -function createSourceLayerDescriptor(indexPatternId: string, indexPatternTitle: string) { +function createSourceLayerDescriptor( + indexPatternId: string, + indexPatternTitle: string, + parentId: string +) { const sourceDescriptor = ESSearchSource.createDescriptor({ indexPatternId, geoField: getSourceField(indexPatternTitle), @@ -96,12 +101,17 @@ function createSourceLayerDescriptor(indexPatternId: string, indexPatternTitle: defaultMessage: '{indexPatternTitle} | Source Point', values: { indexPatternTitle }, }), + parent: parentId, sourceDescriptor, style: VectorStyle.createDescriptor(styleProperties), }); } -function createDestinationLayerDescriptor(indexPatternId: string, indexPatternTitle: string) { +function createDestinationLayerDescriptor( + indexPatternId: string, + indexPatternTitle: string, + parentId: string +) { const sourceDescriptor = ESSearchSource.createDescriptor({ indexPatternId, geoField: getDestinationField(indexPatternTitle), @@ -149,12 +159,17 @@ function createDestinationLayerDescriptor(indexPatternId: string, indexPatternTi defaultMessage: '{indexPatternTitle} | Destination point', values: { indexPatternTitle }, }), + parent: parentId, sourceDescriptor, style: VectorStyle.createDescriptor(styleProperties), }); } -function createLineLayerDescriptor(indexPatternId: string, indexPatternTitle: string) { +function createLineLayerDescriptor( + indexPatternId: string, + indexPatternTitle: string, + parentId: string +) { const sourceDescriptor = ESPewPewSource.createDescriptor({ indexPatternId, sourceGeoField: getSourceField(indexPatternTitle), @@ -195,6 +210,7 @@ function createLineLayerDescriptor(indexPatternId: string, indexPatternTitle: st defaultMessage: '{indexPatternTitle} | Line', values: { indexPatternTitle }, }), + parent: parentId, sourceDescriptor, style: VectorStyle.createDescriptor(styleProperties), }); @@ -204,9 +220,11 @@ export function createSecurityLayerDescriptors( indexPatternId: string, indexPatternTitle: string ): LayerDescriptor[] { + const layerGroupDescriptor = LayerGroup.createDescriptor({ label: indexPatternTitle }); return [ - createSourceLayerDescriptor(indexPatternId, indexPatternTitle), - createDestinationLayerDescriptor(indexPatternId, indexPatternTitle), - createLineLayerDescriptor(indexPatternId, indexPatternTitle), + createSourceLayerDescriptor(indexPatternId, indexPatternTitle, layerGroupDescriptor.id), + createDestinationLayerDescriptor(indexPatternId, indexPatternTitle, layerGroupDescriptor.id), + createLineLayerDescriptor(indexPatternId, indexPatternTitle, layerGroupDescriptor.id), + layerGroupDescriptor, ]; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 482c214f884a3..c10c3e67be443 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -161,7 +161,7 @@ export const ExplorationPageWrapper: FC = ({ return ( <> - {typeof jobConfig?.description !== 'undefined' && ( + {typeof jobConfig?.description !== 'undefined' && jobConfig?.description !== '' && ( <> {jobConfig?.description} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx index 93ceccf2756dc..67af8f7089210 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -121,7 +121,7 @@ export const OutlierExploration: FC = React.memo(({ jobId }) = return ( <> - {typeof jobConfig?.description !== 'undefined' && ( + {typeof jobConfig?.description !== 'undefined' && jobConfig?.description !== '' && ( <> {jobConfig?.description} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx index b8ed840397675..0550226599eb1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx @@ -25,6 +25,7 @@ import { } from '../components/analytics_selector'; import { AnalyticsEmptyPrompt } from '../analytics_management/components/empty_prompt'; import { useUrlState } from '../../../util/url_state'; +import { SavedObjectsWarning } from '../../../components/saved_objects_warning'; export const Page: FC<{ jobId: string; @@ -41,7 +42,9 @@ export const Page: FC<{ } = useMlApiContext(); const helpLink = docLinks.links.ml.dataFrameAnalytics; const jobIdToUse = jobId ?? analyticsId?.job_id; - const analysisTypeToUse = analysisType || analyticsId?.analysis_type; + const [analysisTypeToUse, setAnalysisTypeToUse] = useState< + DataFrameAnalysisConfigType | undefined + >(analysisType || analyticsId?.analysis_type); const [, setGlobalState] = useUrlState('_g'); @@ -55,6 +58,25 @@ export const Page: FC<{ } }; + // The inner components of the results page don't have a concept of reloading the full page. + // Because we might want to refresh though if a user has to fix unsynced saved objects, + // we achieve this here by unmounting the inner pages first by setting `analysisTypeToUse` + // to `undefined`. The `useEffect()` below will then check if `analysisTypeToUse` doesn't + // match the passed in analyis type and will update it once again, the re-mounted + // page will then again fetch the most recent results. + const refresh = () => { + setAnalysisTypeToUse(undefined); + }; + + useEffect( + function checkRefresh() { + if (analysisTypeToUse !== analysisType || analyticsId?.analysis_type) { + setAnalysisTypeToUse(analysisType || analyticsId?.analysis_type); + } + }, + [analyticsId, analysisType, analysisTypeToUse] + ); + useEffect(function checkJobs() { checkJobsExist(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -126,6 +148,9 @@ export const Page: FC<{ /> )} + + + {jobIdToUse && analysisTypeToUse ? (
{analysisTypeToUse === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 3c64645f9b1e8..123c0639f2d49 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -27,6 +27,7 @@ export { enableInfrastructureHostsView, enableServiceMetrics, enableAwsLambdaMetrics, + enableCriticalPath, } from './ui_settings_keys'; export { diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index f41e492d25050..ab1684c2e5bfe 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -22,3 +22,4 @@ export const apmLabsButton = 'observability:apmLabsButton'; export const enableInfrastructureHostsView = 'observability:enableInfrastructureHostsView'; export const enableAwsLambdaMetrics = 'observability:enableAwsLambdaMetrics'; export const enableServiceMetrics = 'observability:apmEnableServiceMetrics'; +export const enableCriticalPath = 'observability:apmEnableCriticalPath'; diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index ff5fd246bea1b..dd2a07f848db3 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -157,6 +157,9 @@ export class ObservabilityPlugin implements Plugin { }); return { + getAlertDetailsConfig() { + return config.unsafe.alertDetails; + }, getScopedAnnotationsClient: async (...args: Parameters) => { const api = await annotationsApiPromise; return api?.getScopedAnnotationsClient(...args); diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index f272db404b7b8..e979bd6a7fb11 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -25,6 +25,7 @@ import { enableInfrastructureHostsView, enableServiceMetrics, enableAwsLambdaMetrics, + enableCriticalPath, } from '../common/ui_settings_keys'; const technicalPreviewLabel = i18n.translate( @@ -309,4 +310,21 @@ export const uiSettings: Record = { type: 'boolean', showInLabs: true, }, + [enableCriticalPath]: { + category: [observabilityFeatureId], + name: i18n.translate('xpack.observability.enableCriticalPath', { + defaultMessage: 'Critical path', + }), + description: i18n.translate('xpack.observability.enableCriticalPathDescription', { + defaultMessage: '{technicalPreviewLabel} Optionally display the critical path of a trace.', + values: { + technicalPreviewLabel: `[${technicalPreviewLabel}]`, + }, + }), + schema: schema.boolean(), + value: false, + requiresPageReload: true, + type: 'boolean', + showInLabs: true, + }, }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index eb62c4d114640..d287ec58530b9 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -164,11 +164,16 @@ export class CsvGenerator { cell = '-'; } - try { - // expected values are a string of JSON where the value(s) is in an array - cell = JSON.parse(cell); - } catch (e) { - // ignore + const isIdField = tableColumn === '_id'; // _id field can not be formatted or mutated + if (!isIdField) { + try { + // unwrap the value + // expected values are a string of JSON where the value(s) is in an array + // examples: "[""Jan 1, 2020 @ 04:00:00.000""]","[""username""]" + cell = JSON.parse(cell); + } catch (e) { + // ignore + } } // We have to strip singular array values out of their array wrapper, @@ -381,6 +386,7 @@ export class CsvGenerator { break; // empty report with just the header } + // FIXME: make tabifyDocs handle the formatting, to get the same formatting logic as Discover? const formatters = this.getFormatters(table); await this.generateRows(columns, table, builder, formatters, settings); diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts index 160e06d03e92a..615201fe44d99 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts @@ -68,7 +68,8 @@ export interface LifecycleAlertServices< ActionGroupIds extends string = never > { alertWithLifecycle: LifecycleAlertService; - getAlertStartedDate: (alertId: string) => string | null; + getAlertStartedDate: (alertInstanceId: string) => string | null; + getAlertUuid: (alertInstanceId: string) => string | null; } export type LifecycleRuleExecutor< @@ -88,6 +89,12 @@ export type LifecycleRuleExecutor< > ) => Promise; +/* + `alertId` will at some point be renamed to `ruleId` as that more + accurately describes the meaning of the variable. + See https://github.com/elastic/kibana/issues/100115 +*/ + const trackedAlertStateRt = rt.type({ alertId: rt.string, alertUuid: rt.string, @@ -159,6 +166,8 @@ export const createLifecycleExecutor = const currentAlerts: Record = {}; + const newAlertUuids: Record = {}; + const lifecycleAlertServices: LifecycleAlertServices< InstanceState, InstanceContext, @@ -169,6 +178,15 @@ export const createLifecycleExecutor = return alertFactory.create(id); }, getAlertStartedDate: (alertId: string) => state.trackedAlerts[alertId]?.started ?? null, + getAlertUuid: (alertId: string) => { + if (!state.trackedAlerts[alertId]) { + const alertUuid = v4(); + newAlertUuids[alertId] = alertUuid; + return alertUuid; + } + + return state.trackedAlerts[alertId].alertUuid; + }, }; const nextWrappedState = await wrappedExecutor({ @@ -203,9 +221,9 @@ export const createLifecycleExecutor = commonRuleFields ); result.forEach((hit) => { - const alertId = hit._source ? hit._source[ALERT_INSTANCE_ID] : void 0; - if (alertId && hit._source) { - trackedAlertsDataMap[alertId] = { + const alertInstanceId = hit._source ? hit._source[ALERT_INSTANCE_ID] : void 0; + if (alertInstanceId && hit._source) { + trackedAlertsDataMap[alertInstanceId] = { indexName: hit._index, fields: hit._source, }; @@ -226,10 +244,12 @@ export const createLifecycleExecutor = const isRecovered = !currentAlerts[alertId]; const isActive = !isRecovered; - const { alertUuid, started } = state.trackedAlerts[alertId] ?? { - alertUuid: v4(), - started: commonRuleFields[TIMESTAMP], - }; + const { alertUuid, started } = !isNew + ? state.trackedAlerts[alertId] + : { + alertUuid: newAlertUuids[alertId] || v4(), + started: commonRuleFields[TIMESTAMP], + }; const event: ParsedTechnicalFields & ParsedExperimentalFields = { ...alertData?.fields, @@ -249,8 +269,8 @@ export const createLifecycleExecutor = [ALERT_WORKFLOW_STATUS]: alertData?.fields[ALERT_WORKFLOW_STATUS] ?? 'open', [EVENT_KIND]: 'signal', [EVENT_ACTION]: isNew ? 'open' : isActive ? 'active' : 'close', - [VERSION]: ruleDataClient.kibanaVersion, [TAGS]: options.tags, + [VERSION]: ruleDataClient.kibanaVersion, ...(isRecovered ? { [ALERT_END]: commonRuleFields[TIMESTAMP] } : {}), }; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index f71c7391cec77..132eb096e0aaa 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -40,6 +40,11 @@ function createRule(shouldWriteAlerts: boolean = true) { name: 'warning', }, ], + actionVariables: { + context: [], + params: [], + state: [], + }, defaultActionGroupId: 'warning', executor: async ({ services }) => { nextAlerts.forEach((alert) => { @@ -48,17 +53,17 @@ function createRule(shouldWriteAlerts: boolean = true) { nextAlerts = []; }, id: 'ruleTypeId', - minimumLicenseRequired: 'basic', isExportable: true, + minimumLicenseRequired: 'basic', name: 'ruleTypeName', producer: 'producer', - actionVariables: { - context: [], - params: [], - state: [], - }, validate: { - params: schema.object({}, { unknowns: 'allow' }), + params: schema.object( + {}, + { + unknowns: 'allow', + } + ), }, }); @@ -92,10 +97,12 @@ function createRule(shouldWriteAlerts: boolean = true) { state = ((await type.executor({ alertId: 'alertId', createdBy: 'createdBy', + executionId: 'b33f65d7-6e8b-4aae-8d20-c93613dec9f9', + logger: loggerMock.create(), name: 'name', + namespace: 'namespace', params: {}, previousStartedAt, - startedAt, rule: { actions: [], consumer: 'consumer', @@ -118,20 +125,18 @@ function createRule(shouldWriteAlerts: boolean = true) { services: { alertFactory, savedObjectsClient: {} as any, - uiSettingsClient: {} as any, scopedClusterClient: {} as any, - shouldWriteAlerts: () => shouldWriteAlerts, - shouldStopExecution: () => false, search: {} as any, searchSourceClient: {} as ISearchStartSearchSource, + shouldStopExecution: () => false, + shouldWriteAlerts: () => shouldWriteAlerts, + uiSettingsClient: {} as any, }, spaceId: 'spaceId', + startedAt, state, tags: ['tags'], updatedBy: 'updatedBy', - namespace: 'namespace', - executionId: 'b33f65d7-6e8b-4aae-8d20-c93613dec9f9', - logger: loggerMock.create(), })) ?? {}) as Record; previousStartedAt = startedAt; diff --git a/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts b/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts index 5465e7a7922c5..a383110394da7 100644 --- a/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts +++ b/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts @@ -36,4 +36,5 @@ export const createLifecycleAlertServicesMock = < ): LifecycleAlertServices => ({ alertWithLifecycle: ({ id }) => alertServices.alertFactory.create(id), getAlertStartedDate: jest.fn((id: string) => null), + getAlertUuid: jest.fn((id: string) => null), }); diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index aa581971e5f09..a92dde76777f5 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -64,6 +64,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables endpoint package level rbac */ endpointRbacEnabled: false, + + /** + * Enables the Guided Onboarding tour in security + */ + guidedOnboarding: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts index 41e77e8aeac29..0339445bc8240 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts @@ -7,7 +7,6 @@ import { login, visit } from '../../tasks/login'; import { completeTour, goToNextStep, skipTour } from '../../tasks/guided_onboarding'; -import { SECURITY_TOUR_ACTIVE_KEY } from '../../../public/common/components/guided_onboarding'; import { OVERVIEW_URL } from '../../urls/navigation'; import { WELCOME_STEP, @@ -21,11 +20,11 @@ before(() => { login(); }); -describe('Guided onboarding tour', () => { +// need to redo these tests for new implementation +describe.skip('Guided onboarding tour', () => { describe('Tour is enabled', () => { beforeEach(() => { visit(OVERVIEW_URL); - window.localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, 'true'); }); it('can be completed', () => { diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx index 37efdce430317..c5d86011226c3 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -10,7 +10,7 @@ import { EuiHeaderSection, EuiHeaderSectionItem, } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { i18n } from '@kbn/i18n'; @@ -28,7 +28,6 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { getScopeFromPath, showSourcererByPath } from '../../../common/containers/sourcerer'; -import { useTourContext } from '../../../common/components/guided_onboarding'; const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', { defaultMessage: 'Add integrations', @@ -83,12 +82,6 @@ export const GlobalHeader = React.memo( }; }, [portalNode, setHeaderActionMenu, theme.theme$]); - const { isTourShown, endTour } = useTourContext(); - const closeOnboardingTourIfShown = useCallback(() => { - if (isTourShown) { - endTour(); - } - }, [isTourShown, endTour]); return ( @@ -105,7 +98,6 @@ export const GlobalHeader = React.memo( data-test-subj="add-data" href={href} iconType="indexOpen" - onClick={closeOnboardingTourIfShown} > {BUTTON_ADD_DATA} diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 36940cc055645..3711a990ef726 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -22,7 +22,7 @@ import { useUpgradeSecurityPackages } from '../../common/hooks/use_upgrade_secur import { GlobalHeader } from './global_header'; import { ConsoleManager } from '../../management/components/console/components/console_manager'; -import { TourContextProvider } from '../../common/components/guided_onboarding'; +import { TourContextProvider } from '../../common/components/guided_onboarding_tour'; import { useUrlState } from '../../common/hooks/use_url_state'; import { useUpdateBrowserTitle } from '../../common/hooks/use_update_browser_title'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index a0ef3b8904e3f..edfe3e70e6ca0 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -7,19 +7,22 @@ import type { EuiTabbedContentTab } from '@elastic/eui'; import { - EuiHorizontalRule, - EuiTabbedContent, - EuiSpacer, - EuiLoadingContent, - EuiNotificationBadge, EuiFlexGroup, EuiFlexItem, + EuiHorizontalRule, + EuiLoadingContent, EuiLoadingSpinner, + EuiNotificationBadge, + EuiSpacer, + EuiTabbedContent, } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash'; +import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step'; +import { isDetectionsAlertsTable } from '../top_n/helpers'; +import { getTourAnchor, SecurityStepId } from '../guided_onboarding_tour/tour_config'; import type { AlertRawEventData } from './osquery_tab'; import { useOsqueryTab } from './osquery_tab'; import { EventFieldsBrowser } from './event_fields_browser'; @@ -179,6 +182,8 @@ const EventDetailsComponent: React.FC = ({ [detailsEcsData] ); + const isTourAnchor = useMemo(() => isDetectionsAlertsTable(scopeId), [scopeId]); + const showThreatSummary = useMemo(() => { const hasEnrichments = enrichmentCount > 0; const hasRiskInfoWithLicense = isLicenseValid && (hostRisk || userRisk); @@ -401,14 +406,26 @@ const EventDetailsComponent: React.FC = ({ [tabs, selectedTabId] ); + const tourAnchor = useMemo( + () => (isTourAnchor ? { 'tour-step': getTourAnchor(3, SecurityStepId.alertsCases) } : {}), + [isTourAnchor] + ); + return ( - + + + ); }; EventDetailsComponent.displayName = 'EventDetailsComponent'; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.test.tsx deleted file mode 100644 index e2cf3be0ae07d..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.test.tsx +++ /dev/null @@ -1,71 +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 { act, renderHook } from '@testing-library/react-hooks'; - -import { - SECURITY_TOUR_ACTIVE_KEY, - SECURITY_TOUR_STEP_KEY, - TourContextProvider, - useTourContext, -} from './tour'; - -describe('useTourContext', () => { - describe('localStorage', () => { - let localStorageTourActive: string | null; - let localStorageTourStep: string | null; - - beforeAll(() => { - localStorageTourActive = localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY); - localStorage.removeItem(SECURITY_TOUR_ACTIVE_KEY); - localStorageTourStep = localStorage.getItem(SECURITY_TOUR_STEP_KEY); - localStorage.removeItem(SECURITY_TOUR_STEP_KEY); - }); - - afterAll(() => { - if (localStorageTourActive) { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, localStorageTourActive); - } - if (localStorageTourStep) { - localStorage.setItem(SECURITY_TOUR_STEP_KEY, localStorageTourStep); - } - }); - - test('tour is disabled', () => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(false)); - const { result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - }); - expect(result.current.isTourShown).toBe(false); - }); - - test('tour is enabled', () => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(true)); - const { result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - }); - expect(result.current.isTourShown).toBe(true); - }); - test('endTour callback', () => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(true)); - let { result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - }); - expect(result.current.isTourShown).toBe(true); - act(() => { - result.current.endTour(); - }); - const localStorageValue = JSON.parse(localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY)!); - expect(localStorageValue).toBe(false); - - ({ result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - })); - expect(result.current.isTourShown).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.tsx deleted file mode 100644 index 27288bb8a7145..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ReactChild } from 'react'; -import React, { createContext, useContext, useState, useCallback } from 'react'; - -import type { EuiTourStepProps } from '@elastic/eui'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiTourStep, - EuiText, - EuiSpacer, - EuiImage, - useIsWithinBreakpoints, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import type { StepConfig } from './tour_config'; -import { tourConfig } from './tour_config'; - -export const SECURITY_TOUR_ACTIVE_KEY = 'guidedOnboarding.security.tourActive'; -export const SECURITY_TOUR_STEP_KEY = 'guidedOnboarding.security.tourStep'; -const getIsTourActiveFromLocalStorage = (): boolean => { - const localStorageValue = localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY); - return localStorageValue ? JSON.parse(localStorageValue) : false; -}; -export const saveIsTourActiveToLocalStorage = (isTourActive: boolean): void => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(isTourActive)); -}; - -export const getTourStepFromLocalStorage = (): number => { - return Number(localStorage.getItem(SECURITY_TOUR_STEP_KEY) ?? 1); -}; -const saveTourStepToLocalStorage = (step: number): void => { - localStorage.setItem(SECURITY_TOUR_STEP_KEY, JSON.stringify(step)); -}; - -const minWidth: EuiTourStepProps['minWidth'] = 360; -const maxWidth: EuiTourStepProps['maxWidth'] = 360; -const offset: EuiTourStepProps['offset'] = 20; -const repositionOnScroll: EuiTourStepProps['repositionOnScroll'] = true; - -const getSteps = (tourControls: { - activeStep: number; - incrementStep: () => void; - resetTour: () => void; -}) => { - const { activeStep, incrementStep, resetTour } = tourControls; - const footerAction = ( - - - resetTour()} - data-test-subj="onboarding--securityTourSkipButton" - > - - - - - incrementStep()} - color="success" - data-test-subj="onboarding--securityTourNextStepButton" - > - - - - - ); - const lastStepFooter = ( - resetTour()} - data-test-subj="onboarding--securityTourEndButton" - > - - - ); - return tourConfig.map((stepConfig: StepConfig) => { - const { content, imageConfig, dataTestSubj, ...rest } = stepConfig; - return ( - resetTour()} - panelProps={{ - 'data-test-subj': dataTestSubj, - }} - content={ - <> - -

{content}

-
- {imageConfig && ( - <> - - - - )} - - } - footerAction={activeStep === tourConfig.length ? lastStepFooter : footerAction} - /> - ); - }); -}; - -export interface TourContextValue { - isTourShown: boolean; - endTour: () => void; -} - -const TourContext = createContext({ - isTourShown: false, - endTour: () => {}, -} as TourContextValue); - -export const TourContextProvider = ({ children }: { children: ReactChild }) => { - const [isTourActive, _setIsTourActive] = useState(getIsTourActiveFromLocalStorage()); - const setIsTourActive = useCallback((value: boolean) => { - _setIsTourActive(value); - saveIsTourActiveToLocalStorage(value); - }, []); - - const [activeStep, _setActiveStep] = useState(getTourStepFromLocalStorage()); - - const incrementStep = useCallback(() => { - _setActiveStep((prevState) => { - const nextStep = (prevState >= tourConfig.length ? 0 : prevState) + 1; - saveTourStepToLocalStorage(nextStep); - return nextStep; - }); - }, []); - - const resetStep = useCallback(() => { - _setActiveStep(1); - saveTourStepToLocalStorage(1); - }, []); - - const resetTour = useCallback(() => { - setIsTourActive(false); - resetStep(); - }, [setIsTourActive, resetStep]); - - const isSmallScreen = useIsWithinBreakpoints(['xs', 's']); - const showTour = isTourActive && !isSmallScreen; - const context: TourContextValue = { isTourShown: showTour, endTour: resetTour }; - return ( - - <> - {children} - {showTour && <>{getSteps({ activeStep, incrementStep, resetTour })}} - - - ); -}; - -export const useTourContext = (): TourContextValue => { - const ctx = useContext(TourContext); - if (!ctx) { - throw new Error('useTourContext can only be called inside of TourContext!'); - } - return ctx; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour_config.ts deleted file mode 100644 index 5a0f6f30daadc..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour_config.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EuiTourStepProps } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import alertsGif from '../../images/onboarding_tour_step_alerts.gif'; -import casesGif from '../../images/onboarding_tour_step_cases.gif'; - -export type StepConfig = Pick & { - anchor: string; - dataTestSubj: string; - imageConfig?: { - altText: string; - src: string; - }; -}; - -type TourConfig = StepConfig[]; - -export const tourConfig: TourConfig = [ - { - step: 1, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle', { - defaultMessage: 'Welcome to Elastic Security', - }), - content: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent', - { - defaultMessage: - 'Take a quick tour to explore a unified workflow for investigating suspicious activity.', - } - ), - anchor: `[id^="SolutionNav"]`, - anchorPosition: 'rightUp', - dataTestSubj: 'welcomeStep', - }, - { - step: 2, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle', { - defaultMessage: 'Protect your ecosystem', - }), - content: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent', - { - defaultMessage: - 'Decide what matters to you and your environment and create rules to detect and prevent malicious activity. ', - } - ), - anchor: `[data-test-subj="groupedNavItemLink-administration"]`, - anchorPosition: 'rightUp', - dataTestSubj: 'manageStep', - }, - { - step: 3, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle', { - defaultMessage: 'Get notified when something changes', - }), - content: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent', - { - defaultMessage: - "Know when a rule's conditions are met, so you can start your investigation right away. Set up notifications with third-party platforms like Slack, PagerDuty, and ServiceNow.", - } - ), - anchor: `[data-test-subj="groupedNavItemLink-alerts"]`, - anchorPosition: 'rightUp', - imageConfig: { - src: alertsGif, - altText: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText', - { - defaultMessage: 'Alerts demonstration', - } - ), - }, - dataTestSubj: 'alertsStep', - }, - { - step: 4, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle', { - defaultMessage: 'Create a case to track your investigation', - }), - content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent', { - defaultMessage: - 'Collect evidence, add more collaborators, and even push case details to third-party case management systems.', - }), - anchor: `[data-test-subj="groupedNavItemLink-cases"]`, - anchorPosition: 'rightUp', - imageConfig: { - src: casesGif, - altText: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText', - { - defaultMessage: 'Cases demonstration', - } - ), - }, - dataTestSubj: 'casesStep', - }, - { - step: 5, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle', { - defaultMessage: `Start gathering your data!`, - }), - content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent', { - defaultMessage: `Collect data from your endpoints using the Elastic Agent and a variety of third-party integrations.`, - }), - anchor: `[data-test-subj="add-data"]`, - anchorPosition: 'rightUp', - dataTestSubj: 'dataStep', - }, -]; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md new file mode 100644 index 0000000000000..eb30e20f1318e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md @@ -0,0 +1,140 @@ +## Security Guided Onboarding Tour +This work required some creativity for reasons. Allow me to explain some weirdness + +The [`EuiTourStep`](https://elastic.github.io/eui/#/display/tour) component needs an **anchor** to attach on in the DOM. This can be defined in 2 ways: +``` +type EuiTourStepAnchorProps = ExclusiveUnion<{ + //Element to which the tour step popover attaches when open + children: ReactElement; + // Selector or reference to the element to which the tour step popover attaches when open + anchor?: never; +}, { + children?: never; + anchor: ElementTarget; +}>; +``` + +It was important that the `EuiTourStep` **anchor** is in the DOM when the tour step becomes active. Additionally, when the **anchor** leaves the DOM, we need `EuiTourStep` to leave the DOM as well. + +## How to use components (for OLM/D&R step engineers) + +- Define your steps in [`./tour_config.ts`](https://github.com/elastic/kibana/pull/143598/files#diff-2c0372fc996eadbff00dddb92101432bf38cc1613895cb9a208abd8eb2e12930R136) in the `securityTourConfig` const +- For each step, implement the `GuidedOnboardingTourStep` component at the location of the **anchor**. As stated in the previous section, there are two ways to define the **anchor**. I will explain examples of both methods: + +1. **Method 1 - as children.** Looking at step 1 of the `SecurityStepId.alertsCases` tour. In the `alertsCasesConfig` you can see the config for this step looks like: + + ``` + { + ...defaultConfig, + step: 1, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourTitle', { + defaultMessage: 'Test alert for practice', + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourContent', + { + defaultMessage: + 'To help you practice triaging alerts, we enabled a rule to create your first alert.', + } + ), + anchorPosition: 'downCenter', + dataTestSubj: getTourAnchor(1, SecurityStepId.alertsCases), + } + ``` + + Notice that **no anchor prop is defined** in the step 1 config. + As you can see pictured below, the tour step anchor is the Rule name of the first alert. + + 1 + + The component for this anchor is `RenderCellValue` which returns `DefaultCellRenderer`. We wrap `DefaultCellRenderer` with `GuidedOnboardingTourStep`, passing `step={1} stepId={SecurityStepId.alertsCases}` to indicate the step. Since there are many other iterations of this component on the page, we also need to pass the `isTourAnchor` property to determine which of these components should be the anchor. In the code, this looks something like: + + ``` + export const RenderCellValue = (props) => { + const { columnId, rowIndex, scopeId } = props; + const isTourAnchor = useMemo( + () => + columnId === SIGNAL_RULE_NAME_FIELD_NAME && + isDetectionsAlertsTable(scopeId) && + rowIndex === 0, + [columnId, rowIndex, scopeId] + ); + + return ( + + + + ); + }; + ``` + +2. **Method 2 - as anchor props.** Looking at step 5 of the `SecurityStepId.alertsCases` tour. In the `alertsCasesConfig` you can see the config for this step looks like: + + ``` + { + ...defaultConfig, + step: 5, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.createCase.tourTitle', { + defaultMessage: `Add details`, + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.createCase.tourContent', + { + defaultMessage: `In addition to the alert, you can add any relevant information you need to the case.`, + } + ), + anchor: `[data-test-subj="create-case-flyout"]`, + anchorPosition: 'leftUp', + dataTestSubj: getTourAnchor(5, SecurityStepId.alertsCases), + hideNextButton: true, + } + ``` + + Notice that the **anchor prop is defined** as `[data-test-subj="create-case-flyout"]` in the step 5 config. There is also a `hideNextButton` boolean utilized here. + As you can see pictured below, the tour step anchor is the create case flyout and the next button is hidden. + + 5 + + + Since cases is its own plugin and we are using a method to generate the flyout, we cannot wrap the flyout as children of the `GuidedOnboardingTourStep`. We do however need the `EuiTourStep` component to mount in the same location as the anchor. Therefore, I had to pass a new optional property to the case component called `headerContent` that simply accepts and renders ` React.ReactNode` at the top of the flyout. In the code, this looks something like: + + ``` + createCaseFlyout.open({ + attachments: caseAttachments, + ...(isTourShown(SecurityStepId.alertsCases) && activeStep === 4 + ? { + headerContent: ( + // isTourAnchor=true no matter what in order to + // force active guide step outside of security solution (cases) + + ), + } + : {}), + }); + ``` + +- The **`useTourContext`** is used within anchor components, returning the state of the security tour + ``` + export interface TourContextValue { + activeStep: number; + endTourStep: (stepId: SecurityStepId) => void; + incrementStep: (stepId: SecurityStepId, step?: number) => void; + isTourShown: (stepId: SecurityStepId) => boolean; + } + ``` + When the tour step does not have a next button, the anchor component will need to call `incrementStep` after an action is taken. For example, in `SecurityStepId.alertsCases` step 4, the user needs to click the "Add to case" button to advance the tour. + + 4 + + So we utilize the `useTourContext` to do the following check and increment the step in `handleAddToNewCaseClick`: + ``` + if (isTourShown(SecurityStepId.alertsCases) && activeStep === 4) { + incrementStep(SecurityStepId.alertsCases); + } + ``` + + In `SecurityStepId.alertsCases` step 5, the user needs to fill out the form and hit the "Create case" button in order to end the `alertsCases` portion the tour, so with the `afterCaseCreated` method we call `endTourStep(SecurityStepId.alertsCases)`. \ No newline at end of file diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/index.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/index.ts similarity index 67% rename from x-pack/plugins/security_solution/public/common/components/guided_onboarding/index.ts rename to x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/index.ts index ed0dfa6c76339..2bb68dff4646f 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/index.ts @@ -5,9 +5,4 @@ * 2.0. */ -export { - useTourContext, - TourContextProvider, - SECURITY_TOUR_ACTIVE_KEY, - SECURITY_TOUR_STEP_KEY, -} from './tour'; +export { useTourContext, TourContextProvider } from './tour'; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx new file mode 100644 index 0000000000000..faea94a1c37ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { of } from 'rxjs'; +import { TourContextProvider, useTourContext } from './tour'; +import { SecurityStepId, securityTourConfig } from './tour_config'; +import { useKibana } from '../../lib/kibana'; + +jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: () => true, +})); + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + + return { + ...original, + useLocation: jest.fn().mockReturnValue({ pathname: '/alerts' }), + }; +}); + +describe('useTourContext', () => { + const mockCompleteGuideStep = jest.fn(); + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + guidedOnboarding: { + guidedOnboardingApi: { + isGuideStepActive$: () => of(true), + completeGuideStep: mockCompleteGuideStep, + }, + }, + }, + }); + jest.clearAllMocks(); + }); + // @ts-ignore + const stepIds = Object.values(SecurityStepId); + describe.each(stepIds)('%s', (stepId) => { + it('if guidedOnboardingApi?.isGuideStepActive$ is false, isTourShown should be false', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + guidedOnboarding: { + guidedOnboardingApi: { + isGuideStepActive$: () => of(false), + }, + }, + }, + }); + const { result } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + expect(result.current.isTourShown(stepId)).toBe(false); + }); + it('if guidedOnboardingApi?.isGuideStepActive$ is true, isTourShown should be true', () => { + const { result } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + expect(result.current.isTourShown(stepId)).toBe(true); + }); + it('endTourStep calls completeGuideStep with correct stepId', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + await waitForNextUpdate(); + result.current.endTourStep(stepId); + expect(mockCompleteGuideStep).toHaveBeenCalledWith('security', stepId); + }); + }); + it('activeStep is initially 1', () => { + const { result } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + expect(result.current.activeStep).toBe(1); + }); + it('increment step properly increments for each stepId, and if attempted to increment beyond length of tour config steps resets activeStep to 1', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + await waitForNextUpdate(); + const stepCount = securityTourConfig[stepId].length; + for (let i = 0; i < stepCount - 1; i++) { + result.current.incrementStep(stepId); + } + const lastStep = stepCount ? stepCount : 1; + expect(result.current.activeStep).toBe(lastStep); + result.current.incrementStep(stepId); + expect(result.current.activeStep).toBe(1); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx new file mode 100644 index 0000000000000..43f6ca15b33cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ReactChild } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import useObservable from 'react-use/lib/useObservable'; +import { catchError, of, timeout } from 'rxjs'; +import { useLocation } from 'react-router-dom'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; +import { isDetectionsPath } from '../../../helpers'; +import { useKibana } from '../../lib/kibana'; +import { securityTourConfig, SecurityStepId } from './tour_config'; + +export interface TourContextValue { + activeStep: number; + endTourStep: (stepId: SecurityStepId) => void; + incrementStep: (stepId: SecurityStepId, step?: number) => void; + isTourShown: (stepId: SecurityStepId) => boolean; +} + +const initialState: TourContextValue = { + activeStep: 0, + endTourStep: () => {}, + incrementStep: () => {}, + isTourShown: () => false, +}; + +const TourContext = createContext(initialState); + +export const RealTourContextProvider = ({ children }: { children: ReactChild }) => { + const { guidedOnboardingApi } = useKibana().services.guidedOnboarding; + + const isRulesTourActive = useObservable( + guidedOnboardingApi?.isGuideStepActive$('security', SecurityStepId.rules).pipe( + // if no result after 30s the observable will error, but the error handler will just emit false + timeout(30000), + catchError((error) => of(false)) + ) ?? of(false), + false + ); + const isAlertsCasesTourActive = useObservable( + guidedOnboardingApi?.isGuideStepActive$('security', SecurityStepId.alertsCases).pipe( + // if no result after 30s the observable will error, but the error handler will just emit false + timeout(30000), + catchError((error) => of(false)) + ) ?? of(false), + false + ); + + const tourStatus = useMemo( + () => ({ + [SecurityStepId.rules]: isRulesTourActive, + [SecurityStepId.alertsCases]: isAlertsCasesTourActive, + }), + [isRulesTourActive, isAlertsCasesTourActive] + ); + + const isTourShown = useCallback((stepId: SecurityStepId) => tourStatus[stepId], [tourStatus]); + const [activeStep, _setActiveStep] = useState(1); + + const incrementStep = useCallback((stepId: SecurityStepId) => { + _setActiveStep( + (prevState) => (prevState >= securityTourConfig[stepId].length ? 0 : prevState) + 1 + ); + }, []); + + // TODO: @Steph figure out if we're allowing user to skip tour or not, implement this if so + // const onSkipTour = useCallback((stepId: SecurityStepId) => { + // // active state means the user is on this step but has not yet begun. so when the user hits skip, + // // the tour will go back to this step until they "re-start it" + // // guidedOnboardingApi.idkSetStepTo(stepId, 'active') + // }, []); + + const [completeStep, setCompleteStep] = useState(null); + + useEffect(() => { + if (!completeStep || !guidedOnboardingApi) { + return; + } + let ignore = false; + const complete = async () => { + await guidedOnboardingApi.completeGuideStep('security', completeStep); + if (!ignore) { + setCompleteStep(null); + _setActiveStep(1); + } + }; + complete(); + return () => { + ignore = true; + }; + }, [completeStep, guidedOnboardingApi]); + + const endTourStep = useCallback((stepId: SecurityStepId) => { + setCompleteStep(stepId); + }, []); + + const context = { + activeStep, + endTourStep, + incrementStep, + isTourShown, + }; + + return {children}; +}; + +export const TourContextProvider = ({ children }: { children: ReactChild }) => { + const { pathname } = useLocation(); + const isTourEnabled = useIsExperimentalFeatureEnabled('guidedOnboarding'); + + if (isDetectionsPath(pathname) && isTourEnabled) { + return {children}; + } + + return {children}; +}; + +export const useTourContext = (): TourContextValue => { + const ctx = useContext(TourContext); + if (!ctx) { + throw new Error('useTourContext can only be called inside of TourContext!'); + } + return ctx; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts new file mode 100644 index 0000000000000..f7ed05be4c418 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiTourStepProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ElementTarget } from '@elastic/eui/src/services/findElement'; + +export const enum SecurityStepId { + rules = 'rules', + alertsCases = 'alertsCases', +} + +export type StepConfig = Pick< + EuiTourStepProps, + 'step' | 'content' | 'anchorPosition' | 'title' | 'initialFocus' | 'anchor' +> & { + anchor?: ElementTarget; + dataTestSubj: string; + hideNextButton?: boolean; + imageConfig?: { + altText: string; + src: string; + }; +}; + +const defaultConfig = { + minWidth: 360, + maxWidth: 360, + offset: 10, + repositionOnScroll: true, +}; + +export const getTourAnchor = (step: number, stepId: SecurityStepId) => + `tourStepAnchor-${stepId}-${step}`; + +const alertsCasesConfig: StepConfig[] = [ + { + ...defaultConfig, + step: 1, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourTitle', { + defaultMessage: 'Test alert for practice', + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourContent', + { + defaultMessage: + 'To help you practice triaging alerts, we enabled a rule to create your first alert.', + } + ), + anchorPosition: 'downCenter', + dataTestSubj: getTourAnchor(1, SecurityStepId.alertsCases), + initialFocus: `button[tour-step="nextButton"]`, + }, + { + ...defaultConfig, + step: 2, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.openFlyout.tourTitle', { + defaultMessage: 'Review the alert details', + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.openFlyout.tourContent', + { + defaultMessage: + "Some information is provided at-a-glance in the table, but for full details, you'll want to open the alert.", + } + ), + anchorPosition: 'rightUp', + dataTestSubj: getTourAnchor(2, SecurityStepId.alertsCases), + hideNextButton: true, + }, + { + ...defaultConfig, + step: 3, + title: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.flyoutOverview.tourTitle', + { + defaultMessage: 'Explore alert details', + } + ), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.flyoutOverview.tourContent', + { + defaultMessage: + 'Learn more about alerts by checking out all the information available on each tab.', + } + ), + // needs to use anchor to properly place tour step + anchor: `[tour-step="${getTourAnchor(3, SecurityStepId.alertsCases)}"] .euiTabs`, + anchorPosition: 'leftUp', + dataTestSubj: getTourAnchor(3, SecurityStepId.alertsCases), + }, + { + ...defaultConfig, + step: 4, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.addToCase.tourTitle', { + defaultMessage: 'Create a case', + }), + content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.addToCase.tourContent', { + defaultMessage: 'From the Take action menu, add the alert to a new case.', + }), + anchorPosition: 'upRight', + dataTestSubj: getTourAnchor(4, SecurityStepId.alertsCases), + hideNextButton: true, + }, + { + ...defaultConfig, + step: 5, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.createCase.tourTitle', { + defaultMessage: `Add details`, + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.createCase.tourContent', + { + defaultMessage: `In addition to the alert, you can add any relevant information you need to the case.`, + } + ), + anchor: `[data-test-subj="create-case-flyout"]`, + anchorPosition: 'leftUp', + dataTestSubj: getTourAnchor(5, SecurityStepId.alertsCases), + hideNextButton: true, + }, +]; + +interface SecurityTourConfig { + [SecurityStepId.rules]: StepConfig[]; + [SecurityStepId.alertsCases]: StepConfig[]; +} + +export const securityTourConfig: SecurityTourConfig = { + /** + * D&R team implement your tour config here + */ + [SecurityStepId.rules]: [], + [SecurityStepId.alertsCases]: alertsCasesConfig, +}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx new file mode 100644 index 0000000000000..04f2cfd6a4311 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { GuidedOnboardingTourStep, SecurityTourStep } from './tour_step'; +import { SecurityStepId } from './tour_config'; +import { useTourContext } from './tour'; + +jest.mock('./tour'); +const mockTourStep = jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( + {children} + )); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + EuiTourStep: (props: any) => mockTourStep(props), + }; +}); +const defaultProps = { + isTourAnchor: true, + step: 1, + stepId: SecurityStepId.alertsCases, +}; + +const mockChildren =

{'random child element'}

; + +describe('GuidedOnboardingTourStep', () => { + beforeEach(() => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 1, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + jest.clearAllMocks(); + }); + it('renders as a tour step', () => { + const { getByTestId } = render( + {mockChildren} + ); + const tourStep = getByTestId('tourStepMock'); + const header = getByTestId('h1'); + expect(tourStep).toBeInTheDocument(); + expect(header).toBeInTheDocument(); + }); + it('isTourAnchor={false}, just render children', () => { + const { getByTestId, queryByTestId } = render( + + {mockChildren} + + ); + const tourStep = queryByTestId('tourStepMock'); + const header = getByTestId('h1'); + expect(tourStep).not.toBeInTheDocument(); + expect(header).toBeInTheDocument(); + }); +}); + +describe('SecurityTourStep', () => { + const { isTourAnchor: _, ...securityTourStepDefaultProps } = defaultProps; + beforeEach(() => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 1, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + jest.clearAllMocks(); + }); + + it('does not render if tour step does not exist', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 99, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + render( + + {mockChildren} + + ); + expect(mockTourStep).not.toHaveBeenCalled(); + }); + + it('does not render if tour step does not equal active step', () => { + render( + + {mockChildren} + + ); + expect(mockTourStep).not.toHaveBeenCalled(); + }); + + it('does not render if security tour step is not shown', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 1, + incrementStep: jest.fn(), + isTourShown: () => false, + }); + render({mockChildren}); + expect(mockTourStep).not.toHaveBeenCalled(); + }); + + it('renders tour step with correct number of steppers', () => { + render({mockChildren}); + const mockCall = { ...mockTourStep.mock.calls[0][0] }; + expect(mockCall.step).toEqual(1); + expect(mockCall.stepsTotal).toEqual(5); + }); + + it('forces the render for step 5 of the SecurityStepId.alertsCases tour step', () => { + render( + + {mockChildren} + + ); + const mockCall = { ...mockTourStep.mock.calls[0][0] }; + expect(mockCall.step).toEqual(5); + expect(mockCall.stepsTotal).toEqual(5); + }); + + it('does render next button if step hideNextButton=false ', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 3, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + render( + + {mockChildren} + + ); + const mockCall = { ...mockTourStep.mock.calls[0][0] }; + expect(mockCall.footerAction).toMatchInlineSnapshot(` + + + + `); + }); + + it('if a step has an anchor declared, the tour step should be a sibling of the mockChildren', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 3, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + const { container } = render( + + {mockChildren} + + ); + const selectParent = container.querySelector( + `[data-test-subj="tourStepMock"] [data-test-subj="h1"]` + ); + const selectSibling = container.querySelector( + `[data-test-subj="tourStepMock"]+[data-test-subj="h1"]` + ); + expect(selectSibling).toBeInTheDocument(); + expect(selectParent).not.toBeInTheDocument(); + }); + + it('if a step does not an anchor declared, the tour step should be the parent of the mockChildren', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 2, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + const { container } = render( + + {mockChildren} + + ); + const selectParent = container.querySelector( + `[data-test-subj="tourStepMock"] [data-test-subj="h1"]` + ); + const selectSibling = container.querySelector( + `[data-test-subj="tourStepMock"]+[data-test-subj="h1"]` + ); + expect(selectParent).toBeInTheDocument(); + expect(selectSibling).not.toBeInTheDocument(); + }); + + it('if a tour step does not have children and has anchor, only render tour step', () => { + const { getByTestId } = render(); + expect(getByTestId('tourStepMock')).toBeInTheDocument(); + }); + + it('if a tour step does not have children and does not have anchor, render nothing', () => { + const { queryByTestId } = render( + + ); + expect(queryByTestId('tourStepMock')).not.toBeInTheDocument(); + }); + + it('does not render next button if step hideNextButton=true ', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 4, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + render( + + {mockChildren} + + ); + const mockCall = { ...mockTourStep.mock.calls[0][0] }; + expect(mockCall.footerAction).toMatchInlineSnapshot(``); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx new file mode 100644 index 0000000000000..ef07c5ce44a42 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; + +import type { EuiTourStepProps } from '@elastic/eui'; +import { EuiButton, EuiImage, EuiSpacer, EuiText, EuiTourStep } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useTourContext } from './tour'; +import { securityTourConfig, SecurityStepId } from './tour_config'; +interface SecurityTourStep { + children?: React.ReactElement; + step: number; + stepId: SecurityStepId; +} + +export const SecurityTourStep = ({ children, step, stepId }: SecurityTourStep) => { + const { activeStep, incrementStep, isTourShown } = useTourContext(); + const tourStep = useMemo( + () => securityTourConfig[stepId].find((config) => config.step === step), + [step, stepId] + ); + const onClick = useCallback(() => incrementStep(stepId), [incrementStep, stepId]); + // step === 5 && stepId === SecurityStepId.alertsCases is in Cases app and out of context. + // If we mount this step, we know we need to render it + // we are also managing the context on the siem end in the background + const overrideContext = step === 5 && stepId === SecurityStepId.alertsCases; + if (tourStep == null || ((step !== activeStep || !isTourShown(stepId)) && !overrideContext)) { + return children ? children : null; + } + + const { anchor, content, imageConfig, dataTestSubj, hideNextButton = false, ...rest } = tourStep; + + const footerAction: EuiTourStepProps['footerAction'] = !hideNextButton ? ( + + + + ) : ( + <> + {/* Passing empty element instead of undefined. If undefined "Skip tour" button is shown, we do not want that*/} + + ); + + const commonProps = { + ...rest, + content: ( + <> + +

{content}

+
+ {imageConfig && ( + <> + + + + )} + + ), + footerAction, + // we would not have mounted this component if it was not open + isStepOpen: true, + // guided onboarding does not allow skipping tour through the steps + onFinish: () => null, + stepsTotal: securityTourConfig[stepId].length, + // TODO: re-add panelProps + // EUI has a bug https://github.com/elastic/eui/issues/6297 + // where any panelProps overwrite their panelProps, + // so we lose cool things like the EuiBeacon + // panelProps: { + // 'data-test-subj': dataTestSubj, + // } + }; + + // tour step either needs children or an anchor element + // see type EuiTourStepAnchorProps + return anchor != null ? ( + <> + + <>{children} + + ) : children != null ? ( + {children} + ) : null; +}; + +interface GuidedOnboardingTourStep extends SecurityTourStep { + // can be false if the anchor is an iterative element + // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway + isTourAnchor?: boolean; +} + +// wraps tour anchor component +// and gives the tour step itself a place to mount once it is active +// mounts the tour step with a delay to ensure the anchor will render first +export const GuidedOnboardingTourStep = ({ + children, + // can be false if the anchor is an iterative element + // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway + isTourAnchor = true, + ...props +}: GuidedOnboardingTourStep) => + isTourAnchor ? {children} : <>{children}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index 5a99df01e5328..1d13d100b4d88 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -16,7 +16,7 @@ import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental import { TestProviders } from '../../../mock'; import { CASES_FEATURE_ID } from '../../../../../common/constants'; import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks'; -import { useTourContext } from '../../guided_onboarding'; +import { useTourContext } from '../../guided_onboarding_tour'; import { useUserPrivileges } from '../../user_privileges'; import { noCasesPermissions, @@ -38,7 +38,7 @@ jest.mock('../../../hooks/use_selector'); jest.mock('../../../hooks/use_experimental_features'); jest.mock('../../../utils/route/use_route_spy'); jest.mock('../../../../management/pages/host_isolation_exceptions/view/hooks'); -jest.mock('../../guided_onboarding'); +jest.mock('../../guided_onboarding_tour'); jest.mock('../../user_privileges'); const mockUseUserPrivileges = useUserPrivileges as jest.Mock; @@ -187,25 +187,4 @@ describe('useSecuritySolutionNavigation', () => { }); }); }); - - describe('Guided onboarding tour', () => { - it('nav can be collapsed if tour is not shown', () => { - const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>( - () => useSecuritySolutionNavigation(), - { wrapper: TestProviders } - ); - - expect(result.current?.canBeCollapsed).toBe(true); - }); - it(`nav can't be collapsed if tour is shown`, () => { - (useTourContext as jest.Mock).mockReturnValue({ isTourShown: true }); - - const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>( - () => useSecuritySolutionNavigation(), - { wrapper: TestProviders } - ); - - expect(result.current?.canBeCollapsed).toBe(false); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx index 9e83ae9339dcd..647193357b66b 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -13,7 +13,6 @@ import type { PrimaryNavigationProps } from './types'; import { usePrimaryNavigationItems } from './use_navigation_items'; import { useIsGroupedNavigationEnabled } from '../helpers'; import { SecuritySideNav } from '../security_side_nav'; -import { useTourContext } from '../../guided_onboarding'; const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { defaultMessage: 'Security', @@ -31,8 +30,6 @@ export const usePrimaryNavigation = ({ const [selectedTabId, setSelectedTabId] = useState(mapLocationToTab()); - const { isTourShown } = useTourContext(); - useEffect(() => { const currentTabSelected = mapLocationToTab(); @@ -49,7 +46,7 @@ export const usePrimaryNavigation = ({ }); return { - canBeCollapsed: !isTourShown, + canBeCollapsed: true, name: translatedNavTitle, icon: 'logoSecurity', ...(isGroupedNavigationEnabled diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 5d2fed9fc6241..efa9ce4831be7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -44,6 +44,7 @@ import { noCasesPermissions } from '../../../cases_test_utils'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; import { mockApm } from '../apm/service.mock'; import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; +import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; const mockUiSettings: Record = { [DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' }, @@ -106,6 +107,7 @@ export const createStartServicesMock = ( cases.helpers.getUICapabilities.mockReturnValue(noCasesPermissions()); const triggersActionsUi = triggersActionsUiMock.createStart(); const cloudExperiments = cloudExperimentsMock.createStartMock(); + const guidedOnboarding = guidedOnboardingMock.createStart(); return { ...core, @@ -173,6 +175,7 @@ export const createStartServicesMock = ( }, triggersActionsUi, cloudExperiments, + guidedOnboarding, } as unknown as StartServices; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 538ae5b4ba211..70455fa342ab5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -9,6 +9,9 @@ import React, { useCallback, useMemo } from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; import { CommentType } from '@kbn/cases-plugin/common'; import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public'; +import { GuidedOnboardingTourStep } from '../../../../common/components/guided_onboarding_tour/tour_step'; +import { SecurityStepId } from '../../../../common/components/guided_onboarding_tour/tour_config'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; import type { Ecs } from '../../../../../common/ecs'; @@ -53,9 +56,18 @@ export const useAddToCaseActions = ({ : []; }, [casesUi.helpers, ecsData, nonEcsData]); + const { activeStep, endTourStep, incrementStep, isTourShown } = useTourContext(); + + const afterCaseCreated = useCallback(async () => { + if (isTourShown(SecurityStepId.alertsCases)) { + endTourStep(SecurityStepId.alertsCases); + } + }, [endTourStep, isTourShown]); + const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({ onClose: onMenuItemClick, onSuccess, + afterCaseCreated, }); const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({ @@ -66,8 +78,22 @@ export const useAddToCaseActions = ({ const handleAddToNewCaseClick = useCallback(() => { // TODO rename this, this is really `closePopover()` onMenuItemClick(); - createCaseFlyout.open({ attachments: caseAttachments }); - }, [onMenuItemClick, createCaseFlyout, caseAttachments]); + createCaseFlyout.open({ + attachments: caseAttachments, + ...(isTourShown(SecurityStepId.alertsCases) && activeStep === 4 + ? { + headerContent: ( + // isTourAnchor=true no matter what in order to + // force active guide step outside of security solution (cases) + + ), + } + : {}), + }); + if (isTourShown(SecurityStepId.alertsCases) && activeStep === 4) { + incrementStep(SecurityStepId.alertsCases); + } + }, [onMenuItemClick, createCaseFlyout, caseAttachments, isTourShown, activeStep, incrementStep]); const handleAddToExistingCaseClick = useCallback(() => { // TODO rename this, this is really `closePopover()` diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index f4364443a6dfa..f9d5a5bc998c0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -8,6 +8,8 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step'; +import { SecurityStepId } from '../../../common/components/guided_onboarding_tour/tour_config'; import { isActiveTimeline } from '../../../helpers'; import { TableId } from '../../../../common/types'; import { useResponderActionItem } from '../endpoint_responder'; @@ -252,19 +254,24 @@ export const TakeActionDropdown = React.memo( ] ); - const takeActionButton = useMemo(() => { - return ( - - {TAKE_ACTION} - - ); - }, [togglePopoverHandler]); + const takeActionButton = useMemo( + () => ( + + + {TAKE_ACTION} + + + ), + + [togglePopoverHandler] + ); + return items.length && !loadingEventDetails && ecsData ? ( = ({ - browserFields, - columnId, - data, - ecsData, - eventId, - globalFilters, - header, - isDetails, - isDraggable, - isExpandable, - isExpanded, - linkValues, - rowIndex, - colIndex, - rowRenderers, - setCellProps, - scopeId, - truncate, -}) => ( - -); +export const RenderCellValue: React.FC = ( + props +) => { + const { columnId, rowIndex, scopeId } = props; + const isTourAnchor = useMemo( + () => + columnId === SIGNAL_RULE_NAME_FIELD_NAME && + isDetectionsAlertsTable(scopeId) && + rowIndex === 0, + [columnId, rowIndex, scopeId] + ); + + return ( + + + + ); +}; export const useRenderCellValue = ({ setFlyoutAlert, diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts index abcaa079d3b20..e63fff5009152 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts @@ -18,6 +18,14 @@ export const mockAPMIndexPatternIds: IndexPatternMapping[] = [ { title: 'traces-apm*,logs-apm*,metrics-apm*,apm-*', id: '8c7323ac-97ad-4b53-ac0a-40f8f691a918' }, ]; +export const mockLayerGroup = { + id: 'uuid.v4()', + label: 'filebeat-*', + sourceDescriptor: null, + type: LAYER_TYPE.LAYER_GROUP, + visible: true, +}; + export const mockSourceLayer = { sourceDescriptor: { id: 'uuid.v4()', @@ -64,6 +72,7 @@ export const mockSourceLayer = { }, }, id: 'uuid.v4()', + parent: 'uuid.v4()', label: `filebeat-* | Source Point`, minZoom: 0, maxZoom: 24, @@ -121,6 +130,7 @@ export const mockDestinationLayer = { }, }, id: 'uuid.v4()', + parent: 'uuid.v4()', label: `filebeat-* | Destination Point`, minZoom: 0, maxZoom: 24, @@ -176,6 +186,7 @@ export const mockClientLayer = { }, }, id: 'uuid.v4()', + parent: 'uuid.v4()', label: `apm-* | Client Point`, minZoom: 0, maxZoom: 24, @@ -238,6 +249,7 @@ export const mockServerLayer = { }, }, id: 'uuid.v4()', + parent: 'uuid.v4()', label: `apm-* | Server Point`, minZoom: 0, maxZoom: 24, @@ -307,6 +319,7 @@ export const mockLineLayer = { }, }, id: 'uuid.v4()', + parent: 'uuid.v4()', label: `filebeat-* | Line`, minZoom: 0, maxZoom: 24, @@ -371,6 +384,7 @@ export const mockClientServerLineLayer = { }, }, id: 'uuid.v4()', + parent: 'uuid.v4()', label: `apm-* | Line`, minZoom: 0, maxZoom: 24, @@ -399,6 +413,7 @@ export const mockLayerList = [ mockLineLayer, mockDestinationLayer, mockSourceLayer, + mockLayerGroup, ]; export const mockLayerListDouble = [ @@ -416,9 +431,11 @@ export const mockLayerListDouble = [ mockLineLayer, mockDestinationLayer, mockSourceLayer, + mockLayerGroup, mockLineLayer, mockDestinationLayer, mockSourceLayer, + mockLayerGroup, ]; export const mockLayerListMixed = [ @@ -436,12 +453,21 @@ export const mockLayerListMixed = [ mockLineLayer, mockDestinationLayer, mockSourceLayer, + mockLayerGroup, mockClientServerLineLayer, mockServerLayer, mockClientLayer, + { + ...mockLayerGroup, + label: 'apm-*', + }, mockApmDataStreamClientServerLineLayer, mockApmDataStreamServerLayer, mockApmDataStreamClientLayer, + { + ...mockLayerGroup, + label: 'traces-apm*,logs-apm*,metrics-apm*,apm-*', + }, ]; export const mockAPMIndexPattern: IndexPatternSavedObject = { diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.test.ts b/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.test.ts index f122d0a93ce90..d476840e91063 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.test.ts +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.test.ts @@ -15,6 +15,7 @@ import { mockLayerList, mockLayerListDouble, mockLayerListMixed, + mockLayerGroup, mockLineLayer, mockServerLayer, mockSourceLayer, @@ -50,6 +51,7 @@ describe('map_config', () => { const layerList = getSourceLayer( mockIndexPatternIds[0].title, mockIndexPatternIds[0].id, + mockLayerGroup.id, lmc.default.source ); expect(layerList).toStrictEqual(mockSourceLayer); @@ -59,6 +61,7 @@ describe('map_config', () => { const layerList = getSourceLayer( mockAPMIndexPatternIds[0].title, mockAPMIndexPatternIds[0].id, + mockLayerGroup.id, lmc[mockAPMIndexPatternIds[0].title].source ); expect(layerList).toStrictEqual(mockClientLayer); @@ -70,6 +73,7 @@ describe('map_config', () => { const layerList = getDestinationLayer( mockIndexPatternIds[0].title, mockIndexPatternIds[0].id, + mockLayerGroup.id, lmc.default.destination ); expect(layerList).toStrictEqual(mockDestinationLayer); @@ -79,6 +83,7 @@ describe('map_config', () => { const layerList = getDestinationLayer( mockAPMIndexPatternIds[0].title, mockAPMIndexPatternIds[0].id, + mockLayerGroup.id, lmc[mockAPMIndexPatternIds[0].title].destination ); expect(layerList).toStrictEqual(mockServerLayer); @@ -90,6 +95,7 @@ describe('map_config', () => { const layerList = getLineLayer( mockIndexPatternIds[0].title, mockIndexPatternIds[0].id, + mockLayerGroup.id, lmc.default ); expect(layerList).toStrictEqual(mockLineLayer); @@ -99,6 +105,7 @@ describe('map_config', () => { const layerList = getLineLayer( mockAPMIndexPatternIds[0].title, mockAPMIndexPatternIds[0].id, + mockLayerGroup.id, lmc[mockAPMIndexPatternIds[0].title] ); expect(layerList).toStrictEqual(mockClientServerLineLayer); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.ts b/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.ts index 0a0e926840035..701631d585169 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.ts +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_config.ts @@ -117,11 +117,29 @@ export const getLayerList = (indexPatternIds: IndexPatternMapping[]) => { type: LAYER_TYPE.EMS_VECTOR_TILE, }, ...indexPatternIds.reduce((acc: object[], { title, id }) => { + const layerGroupDescriptor = { + id: uuid.v4(), + label: title, + sourceDescriptor: null, + type: LAYER_TYPE.LAYER_GROUP, + visible: true, + }; return [ ...acc, - getLineLayer(title, id, lmc[title] ?? lmc.default), - getDestinationLayer(title, id, lmc[title]?.destination ?? lmc.default.destination), - getSourceLayer(title, id, lmc[title]?.source ?? lmc.default.source), + getLineLayer(title, id, layerGroupDescriptor.id, lmc[title] ?? lmc.default), + getDestinationLayer( + title, + id, + layerGroupDescriptor.id, + lmc[title]?.destination ?? lmc.default.destination + ), + getSourceLayer( + title, + id, + layerGroupDescriptor.id, + lmc[title]?.source ?? lmc.default.source + ), + layerGroupDescriptor, ]; }, []), ]; @@ -133,11 +151,13 @@ export const getLayerList = (indexPatternIds: IndexPatternMapping[]) => { * * @param indexPatternTitle used as layer name in LayerToC UI: "${indexPatternTitle} | Source point" * @param indexPatternId used as layer's indexPattern to query for data + * @param parentId * @param layerDetails layer-specific field details */ export const getSourceLayer = ( indexPatternTitle: string, indexPatternId: string, + parentId: string, layerDetails: LayerMappingDetails ) => ({ sourceDescriptor: { @@ -179,6 +199,7 @@ export const getSourceLayer = ( }, }, id: uuid.v4(), + parent: parentId, label: `${indexPatternTitle} | ${layerDetails.label}`, minZoom: 0, maxZoom: 24, @@ -195,12 +216,14 @@ export const getSourceLayer = ( * * @param indexPatternTitle used as layer name in LayerToC UI: "${indexPatternTitle} | Destination point" * @param indexPatternId used as layer's indexPattern to query for data + * @param parentId used as layer's indexPattern to query for data * @param layerDetails layer-specific field details * */ export const getDestinationLayer = ( indexPatternTitle: string, indexPatternId: string, + parentId: string, layerDetails: LayerMappingDetails ) => ({ sourceDescriptor: { @@ -243,6 +266,7 @@ export const getDestinationLayer = ( }, }, id: uuid.v4(), + parent: parentId, label: `${indexPatternTitle} | ${layerDetails.label}`, minZoom: 0, maxZoom: 24, @@ -258,11 +282,13 @@ export const getDestinationLayer = ( * * @param indexPatternTitle used as layer name in LayerToC UI: "${indexPatternTitle} | Line" * @param indexPatternId used as layer's indexPattern to query for data + * @param parentId * @param layerDetails layer-specific field details */ export const getLineLayer = ( indexPatternTitle: string, indexPatternId: string, + parentId: string, layerDetails: LayerMapping ) => ({ sourceDescriptor: { @@ -327,6 +353,7 @@ export const getLineLayer = ( }, }, id: uuid.v4(), + parent: parentId, label: `${indexPatternTitle} | ${i18n.LINE_LAYER}`, minZoom: 0, maxZoom: 24, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 2c1b5b06a284e..a0384d7707534 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -12,6 +12,10 @@ import { noop } from 'lodash/fp'; import styled from 'styled-components'; import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public'; +import { GuidedOnboardingTourStep } from '../../../../../common/components/guided_onboarding_tour/tour_step'; +import { isDetectionsAlertsTable } from '../../../../../common/components/top_n/helpers'; +import { useTourContext } from '../../../../../common/components/guided_onboarding_tour'; +import { SecurityStepId } from '../../../../../common/components/guided_onboarding_tour/tour_config'; import { getScopedActions, isTimelineScope } from '../../../../../helpers'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { eventHasNotes, getEventType, getPinOnClick } from '../helpers'; @@ -201,6 +205,24 @@ const ActionsComponent: React.FC = ({ scopedActions, ]); + const { isTourShown, incrementStep } = useTourContext(); + + const isTourAnchor = useMemo( + () => + isTourShown(SecurityStepId.alertsCases) && + eventType === 'signal' && + isDetectionsAlertsTable(timelineId) && + ariaRowindex === 1, + [isTourShown, ariaRowindex, eventType, timelineId] + ); + + const onExpandEvent = useCallback(() => { + if (isTourAnchor) { + incrementStep(SecurityStepId.alertsCases); + } + onEventDetailsPanelOpened(); + }, [incrementStep, isTourAnchor, onEventDetailsPanelOpened]); + return ( {showCheckboxes && !tGridEnabled && ( @@ -220,19 +242,25 @@ const ActionsComponent: React.FC = ({
)} -
- - - - - -
+ +
+ + + + + +
+
<> {timelineId !== TimelineId.active && ( Promise>(asyncNoop); const abortCtrl = useRef(new AbortController()); const searchSubscription$ = useRef(new Subscription()); - const [loading, setLoading] = useState(false); + + // loading = false initial state causes flashes of empty tables + const [loading, setLoading] = useState(true); const [timelineDetailsRequest, setTimelineDetailsRequest] = useState(null); const { addError, addWarning } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts index eaad3e6fb09f8..0a7c29bb67c2b 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts @@ -213,10 +213,18 @@ export class BlocklistValidator extends BaseValidator { return item.listId === ENDPOINT_BLOCKLISTS_LIST_ID; } + protected async validateHasWritePrivilege(): Promise { + return super.validateHasPrivilege('canWriteBlocklist'); + } + + protected async validateHasReadPrivilege(): Promise { + return super.validateHasPrivilege('canReadBlocklist'); + } + async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasWritePrivilege(); item.entries = removeDuplicateEntryValues(item.entries as BlocklistConditionEntry[]); @@ -228,27 +236,27 @@ export class BlocklistValidator extends BaseValidator { } async validatePreDeleteItem(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasWritePrivilege(); } async validatePreGetOneItem(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreMultiListFind(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreExport(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreSingleListFind(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreGetListSummary(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreUpdateItem( @@ -257,7 +265,7 @@ export class BlocklistValidator extends BaseValidator { ): Promise { const updatedItem = _updatedItem as ExceptionItemLikeOptions; - await this.validateCanManageEndpointArtifacts(); + await this.validateHasWritePrivilege(); _updatedItem.entries = removeDuplicateEntryValues( _updatedItem.entries as BlocklistConditionEntry[] diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts index 7759caa20e1f9..d448ee0fb1f75 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts @@ -48,8 +48,16 @@ export class EventFilterValidator extends BaseValidator { return item.listId === ENDPOINT_EVENT_FILTERS_LIST_ID; } + protected async validateHasWritePrivilege(): Promise { + return super.validateHasPrivilege('canWriteEventFilters'); + } + + protected async validateHasReadPrivilege(): Promise { + return super.validateHasPrivilege('canReadEventFilters'); + } + async validatePreCreateItem(item: CreateExceptionListItemOptions) { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasWritePrivilege(); await this.validateEventFilterData(item); // user can always create a global entry so additional checks not needed @@ -67,7 +75,7 @@ export class EventFilterValidator extends BaseValidator { ): Promise { const updatedItem = _updatedItem as ExceptionItemLikeOptions; - await this.validateCanManageEndpointArtifacts(); + await this.validateHasWritePrivilege(); await this.validateEventFilterData(updatedItem); try { @@ -96,27 +104,27 @@ export class EventFilterValidator extends BaseValidator { } async validatePreGetOneItem(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreSummary(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreDeleteItem(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasWritePrivilege(); } async validatePreExport(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreSingleListFind(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreMultiListFind(): Promise { - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreImport(): Promise { diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts index 01809c2c28f68..b20a6db4c046c 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts @@ -105,7 +105,7 @@ export class HostIsolationExceptionsValidator extends BaseValidator { } async validatePreExport(): Promise { - await this.validateHasWritePrivilege(); + await this.validateHasReadPrivilege(); } async validatePreSingleListFind(): Promise { diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index 86b11249af9bd..38dd3442f3b4f 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -207,7 +207,7 @@ export class TrustedAppValidator extends BaseValidator { } async validatePreExport(): Promise { - await this.validateHasWritePrivilege(); + await this.validateHasReadPrivilege(); } async validatePreSingleListFind(): Promise { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 7beb42271c73f..283ffe2b84854 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -7164,7 +7164,6 @@ "xpack.apm.dependencyOperationDetailTraceListOutcomeColumn": "Résultat", "xpack.apm.dependencyOperationDetailTraceListServiceNameColumn": "Service d'origine", "xpack.apm.dependencyOperationDetailTraceListTimestampColumn": "Horodatage", - "xpack.apm.dependencyOperationDetailTraceListTraceIdColumn": "Trace", "xpack.apm.dependencyOperationDetailTraceListTransactionNameColumn": "Nom de la transaction", "xpack.apm.dependencyOperationDistributionChart.allSpansLegendLabel": "Tous les intervalles", "xpack.apm.dependencyOperationDistributionChart.failedSpansLegendLabel": "Intervalles ayant échoué", @@ -15595,15 +15594,15 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "Voir les instructions de configuration", "xpack.infra.homePage.settingsTabTitle": "Paramètres", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "Rechercher des données d'infrastructure… (par exemple host.name:host-1)", + "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "Total de la mémoire (moy.)", + "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "Utilisation de la mémoire (moy.)", + "xpack.infra.hostsTable.averageRxColumnHeader": "", + "xpack.infra.hostsTable.averageTxColumnHeader": "", + "xpack.infra.hostsTable.diskLatencyColumnHeader": "", "xpack.infra.hostsTable.nameColumnHeader": "Nom", - "xpack.infra.hostsTable.operatingSystemColumnHeader": "Système d'exploitation", "xpack.infra.hostsTable.numberOfCpusColumnHeader": "Nombre de processeurs", - "xpack.infra.hostsTable.diskLatencyColumnHeader": "", - "xpack.infra.hostsTable.averageTxColumnHeader": "", - "xpack.infra.hostsTable.averageRxColumnHeader": "", - "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "Total de la mémoire (moy.)", + "xpack.infra.hostsTable.operatingSystemColumnHeader": "Système d'exploitation", "xpack.infra.hostsTable.servicesOnHostColumnHeader": "", - "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "Utilisation de la mémoire (moy.)", "xpack.infra.infra.nodeDetails.apmTabLabel": "APM", "xpack.infra.infra.nodeDetails.createAlertLink": "Créer une règle d'inventaire", "xpack.infra.infra.nodeDetails.openAsPage": "Ouvrir en tant que page", @@ -28235,21 +28234,7 @@ "xpack.securitySolution.getCurrentUser.unknownUser": "Inconnu", "xpack.securitySolution.globalHeader.buttonAddData": "Ajouter des intégrations", "xpack.securitySolution.goToDocumentationButton": "Afficher la documentation", - "xpack.securitySolution.guided_onboarding.endTour.buttonLabel": "Terminer la visite", "xpack.securitySolution.guided_onboarding.nextStep.buttonLabel": "Suivant", - "xpack.securitySolution.guided_onboarding.skipTour.buttonLabel": "Ignorer la visite", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText": "Démonstration des alertes", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent": "Sachez quand les conditions d'une règle sont remplies, afin de pouvoir commencer votre investigation immédiatement. Configurez des notifications avec des plateformes tierces telles que Slack, PagerDuty et ServiceNow.", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle": "Soyez informé en cas de modification", - "xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText": "Démonstration des cas", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent": "Recueillez des éléments probants, ajoutez des collaborateurs et transmettez même les détails de l'affaire à des systèmes tiers de gestion des cas.", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle": "Créez un cas pour suivre votre investigation", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent": "Recueillez des données à partir de vos points de terminaison en utilisant l'agent Elastic et une variété d'intégrations tierces.", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle": "Commencez à collecter vos données !", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent": "Décidez de ce qui est important pour vous et votre environnement, et créez des règles pour détecter et prévenir les activités malveillantes. ", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle": "Protégez votre écosystème", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent": "Faites une visite rapide pour découvrir un flux de travail unifié pour enquêter sur les activités suspectes.", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle": "Bienvenue dans Elastic Security", "xpack.securitySolution.handleInputAreaState.inputPlaceholderText": "Soumettre l’action de réponse", "xpack.securitySolution.header.editableTitle.cancel": "Annuler", "xpack.securitySolution.header.editableTitle.save": "Enregistrer", @@ -33705,4 +33690,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fcc2c1c49cdf8..671be94fced60 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7152,7 +7152,6 @@ "xpack.apm.dependencyOperationDetailTraceListOutcomeColumn": "成果", "xpack.apm.dependencyOperationDetailTraceListServiceNameColumn": "発生元サービス", "xpack.apm.dependencyOperationDetailTraceListTimestampColumn": "タイムスタンプ", - "xpack.apm.dependencyOperationDetailTraceListTraceIdColumn": "トレース", "xpack.apm.dependencyOperationDetailTraceListTransactionNameColumn": "トランザクション名", "xpack.apm.dependencyOperationDistributionChart.allSpansLegendLabel": "すべてのスパン", "xpack.apm.dependencyOperationDistributionChart.failedSpansLegendLabel": "失敗したスパン", @@ -15580,15 +15579,15 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "セットアップの手順を表示", "xpack.infra.homePage.settingsTabTitle": "設定", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "インフラストラクチャデータを検索…(例:host.name:host-1)", - "xpack.infra.hostsTable.nameColumnHeader": "名前", - "xpack.infra.hostsTable.operatingSystemColumnHeader": "オペレーティングシステム", - "xpack.infra.hostsTable.numberOfCpusColumnHeader": "CPU数", - "xpack.infra.hostsTable.diskLatencyColumnHeader": "", + "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "メモリ合計 (平均) ", + "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "メモリー使用状況(平均)", "xpack.infra.hostsTable.averageTxColumnHeader": "", "xpack.infra.hostsTable.averageRxColumnHeader": "", - "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "メモリ合計 (平均) ", + "xpack.infra.hostsTable.diskLatencyColumnHeader": "", + "xpack.infra.hostsTable.nameColumnHeader": "名前", + "xpack.infra.hostsTable.numberOfCpusColumnHeader": "CPU数", + "xpack.infra.hostsTable.operatingSystemColumnHeader": "オペレーティングシステム", "xpack.infra.hostsTable.servicesOnHostColumnHeader": "", - "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "メモリー使用状況(平均)", "xpack.infra.infra.nodeDetails.apmTabLabel": "APM", "xpack.infra.infra.nodeDetails.createAlertLink": "インベントリルールの作成", "xpack.infra.infra.nodeDetails.openAsPage": "ページとして開く", @@ -28210,21 +28209,7 @@ "xpack.securitySolution.getCurrentUser.unknownUser": "不明", "xpack.securitySolution.globalHeader.buttonAddData": "統合の追加", "xpack.securitySolution.goToDocumentationButton": "ドキュメンテーションを表示", - "xpack.securitySolution.guided_onboarding.endTour.buttonLabel": "ツアーを終了", "xpack.securitySolution.guided_onboarding.nextStep.buttonLabel": "次へ", - "xpack.securitySolution.guided_onboarding.skipTour.buttonLabel": "ツアーをスキップ", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText": "アラートデモ", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent": "ルールの条件が満たされているときを把握し、調査をすぐに開始できるようにします。Slack、PagerDuty、ServiceNowなどのサードパーティプラットフォームで通知を設定します。", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle": "変更が発生したときに通知", - "xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText": "ケースのデモ", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent": "エビデンスを収集し、その他のコラボレーターを追加し、さらにケース詳細をサードパーティケース管理システムにプッシュします。", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle": "調査を追跡するには、ケースを作成します", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent": "Elasticエージェントとさまざまなサードパーティ統合を使用して、エンドポイントからデータを収集します。", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle": "データの収集を開始してください。", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent": "重要な項目と環境を決定し、悪意のあるアクティビティを検出および防御するルールを作成します。", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle": "エコシステムを保護", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent": "不審なアクティビティの調査については、統合ワークフローを説明するクイックガイドを表示してください。", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle": "Elastic Securityへようこそ", "xpack.securitySolution.handleInputAreaState.inputPlaceholderText": "対応アクションを送信", "xpack.securitySolution.header.editableTitle.cancel": "キャンセル", "xpack.securitySolution.header.editableTitle.save": "保存", @@ -33679,4 +33664,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ff49a2cd73d6c..a88ad972ae72e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7168,7 +7168,6 @@ "xpack.apm.dependencyOperationDetailTraceListOutcomeColumn": "结果", "xpack.apm.dependencyOperationDetailTraceListServiceNameColumn": "发起服务", "xpack.apm.dependencyOperationDetailTraceListTimestampColumn": "时间戳", - "xpack.apm.dependencyOperationDetailTraceListTraceIdColumn": "跟踪", "xpack.apm.dependencyOperationDetailTraceListTransactionNameColumn": "事务名称", "xpack.apm.dependencyOperationDistributionChart.allSpansLegendLabel": "所有跨度", "xpack.apm.dependencyOperationDistributionChart.failedSpansLegendLabel": "失败的跨度", @@ -15601,15 +15600,15 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "查看设置说明", "xpack.infra.homePage.settingsTabTitle": "设置", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "搜索基础设施数据……(例如 host.name:host-1)", + "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "内存合计 (平均值)", + "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "内存使用率(平均值)", + "xpack.infra.hostsTable.averageRxColumnHeader": "", + "xpack.infra.hostsTable.averageTxColumnHeader": "", + "xpack.infra.hostsTable.diskLatencyColumnHeader": "", "xpack.infra.hostsTable.nameColumnHeader": "名称", - "xpack.infra.hostsTable.operatingSystemColumnHeader": "操作系统", "xpack.infra.hostsTable.numberOfCpusColumnHeader": "# 个 CPU", - "xpack.infra.hostsTable.diskLatencyColumnHeader": "", - "xpack.infra.hostsTable.averageTxColumnHeader": "", - "xpack.infra.hostsTable.averageRxColumnHeader": "", - "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "内存合计 (平均值)", + "xpack.infra.hostsTable.operatingSystemColumnHeader": "操作系统", "xpack.infra.hostsTable.servicesOnHostColumnHeader": "", - "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "内存使用率(平均值)", "xpack.infra.infra.nodeDetails.apmTabLabel": "APM", "xpack.infra.infra.nodeDetails.createAlertLink": "创建库存规则", "xpack.infra.infra.nodeDetails.openAsPage": "以页面形式打开", @@ -28244,21 +28243,7 @@ "xpack.securitySolution.getCurrentUser.unknownUser": "未知", "xpack.securitySolution.globalHeader.buttonAddData": "添加集成", "xpack.securitySolution.goToDocumentationButton": "查看文档", - "xpack.securitySolution.guided_onboarding.endTour.buttonLabel": "结束教程", "xpack.securitySolution.guided_onboarding.nextStep.buttonLabel": "下一步", - "xpack.securitySolution.guided_onboarding.skipTour.buttonLabel": "跳过教程", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText": "告警演示", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent": "知道何时满足规则条件,以便您立即开始调查。通过 Slack、PagerDuty 和 ServiceNow 等第三方平台设置通知。", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle": "发生更改时接收通知", - "xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText": "案例演示", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent": "收集证据,添加更多协作者,甚至将案例详情推送到第三方案例管理系统。", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle": "创建案例以跟踪您的调查", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent": "使用 Elastic 代理和一系列第三方集成从您的终端收集数据。", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle": "开始收集您的数据!", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent": "确定对您和您的环境至关重要的事项,并创建规则来检测并防止恶意活动。", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle": "保护您的生态系统", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent": "学习快速教程以浏览调查可疑活动的统一工作流。", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle": "欢迎使用 Elastic Security", "xpack.securitySolution.handleInputAreaState.inputPlaceholderText": "提交响应操作", "xpack.securitySolution.header.editableTitle.cancel": "取消", "xpack.securitySolution.header.editableTitle.save": "保存", @@ -33716,4 +33701,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} +} \ No newline at end of file diff --git a/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts b/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts index e93af3051d451..fe244297f5d3c 100644 --- a/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts +++ b/x-pack/test/apm_api_integration/tests/dependencies/top_spans.spec.ts @@ -155,7 +155,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(javaSpans.length + goSpans.length).to.eql(spans.length); - expect(omit(javaSpans[0], 'traceId', 'transactionId')).to.eql({ + expect(omit(javaSpans[0], 'spanId', 'traceId', 'transactionId')).to.eql({ '@timestamp': 1609459200000, agentName: 'java', duration: 100000, @@ -166,7 +166,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { outcome: 'success', }); - expect(omit(goSpans[0], 'traceId', 'transactionId')).to.eql({ + expect(omit(goSpans[0], 'spanId', 'traceId', 'transactionId')).to.eql({ '@timestamp': 1609459200000, agentName: 'go', duration: 50000, @@ -223,34 +223,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); - describe('when requesting spans without a transaction', () => { - it('should return the spans without transaction metadata', async () => { - const response = await callApi({ - dependencyName: 'elasticsearch', - spanName: 'without transaction', - }); - - const { spans } = response.body; - - const spanNames = uniq(spans.map((span) => span.spanName)); - - expect(spanNames).to.eql(['without transaction']); - - expect(omit(spans[0], 'traceId')).to.eql({ - '@timestamp': 1609459200000, - agentName: 'java', - duration: 200000, - serviceName: 'java', - spanName: 'without transaction', - outcome: 'unknown', - }); - - expect(spans[0].transactionType).not.to.be.ok(); - expect(spans[0].transactionId).not.to.be.ok(); - expect(spans[0].transactionName).not.to.be.ok(); - }); - }); - describe('when requesting spans within a specific sample range', () => { it('returns only spans whose duration falls into the requested range', async () => { const response = await callApi({ diff --git a/x-pack/test/functional/apps/lens/group1/fields_list.ts b/x-pack/test/functional/apps/lens/group1/fields_list.ts new file mode 100644 index 0000000000000..3d571483bf9ac --- /dev/null +++ b/x-pack/test/functional/apps/lens/group1/fields_list.ts @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const find = getService('find'); + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const filterBar = getService('filterBar'); + const fieldEditor = getService('fieldEditor'); + const retry = getService('retry'); + + describe('lens fields list tests', () => { + for (const datasourceType of ['form-based', 'ad-hoc', 'ad-hoc-no-timefield']) { + describe(`${datasourceType} datasource`, () => { + before(async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + + if (datasourceType !== 'form-based') { + await PageObjects.lens.createAdHocDataView( + '*stash*', + datasourceType !== 'ad-hoc-no-timefield' + ); + retry.try(async () => { + const selectedPattern = await PageObjects.lens.getDataPanelIndexPattern(); + expect(selectedPattern).to.eql('*stash*'); + }); + } + + if (datasourceType !== 'ad-hoc-no-timefield') { + await PageObjects.lens.goToTimeRange(); + } + + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtime_string'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit('abc')"); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + }); + + it('should show all fields as available', async () => { + expect( + await (await testSubjects.find('lnsIndexPatternAvailableFields-count')).getVisibleText() + ).to.eql(53); + }); + + it('should show a histogram and top values popover for numeric field', async () => { + const [fieldId] = await PageObjects.lens.findFieldIdsByType('number'); + await log.debug(`Opening field stats for ${fieldId}`); + await testSubjects.click(fieldId); + // check for popover + await testSubjects.exists('lnsFieldListPanel-title'); + // check for top values chart + await testSubjects.existOrFail('lnsFieldListPanel-topValues'); + const topValuesRows = await testSubjects.findAll('lnsFieldListPanel-topValues-bucket'); + expect(topValuesRows.length).to.eql(11); + // check for the Other entry + expect(await topValuesRows[10].getVisibleText()).to.eql('Other\n96.7%'); + // switch to date histogram + await testSubjects.click('lnsFieldListPanel-buttonGroup-distributionButton'); + // check for date histogram chart + expect( + await find.existsByCssSelector( + '[data-test-subj="lnsFieldListPanelFieldContent"] .echChart' + ) + ).to.eql(true); + }); + + it('should show a top values popover for a keyword field', async () => { + const [fieldId] = await PageObjects.lens.findFieldIdsByType('string'); + await log.debug(`Opening field stats for ${fieldId}`); + await testSubjects.click(fieldId); + // check for popover + await testSubjects.exists('lnsFieldListPanel-title'); + // check for top values chart + await testSubjects.existOrFail('lnsFieldListPanel-topValues'); + const topValuesRows = await testSubjects.findAll('lnsFieldListPanel-topValues-bucket'); + expect(topValuesRows.length).to.eql(11); + // check for the Other entry + expect(await topValuesRows[10].getVisibleText()).to.eql('Other\n99.9%'); + // check no date histogram + expect( + await find.existsByCssSelector( + '[data-test-subj="lnsFieldListPanelFieldContent"] .echChart' + ) + ).to.eql(false); + }); + + it('should show a date histogram popover for a date field', async () => { + const [fieldId] = await PageObjects.lens.findFieldIdsByType('date'); + await log.debug(`Opening field stats for ${fieldId}`); + await testSubjects.click(fieldId); + // check for popover + await testSubjects.exists('lnsFieldListPanel-title'); + // check for date histogram chart + expect( + await find.existsByCssSelector( + '[data-test-subj="lnsFieldListPanelFieldContent"] .echChart' + ) + ).to.eql(true); + // check no top values chart + await testSubjects.missingOrFail('lnsFieldListPanel-buttonGroup-topValuesButton'); + }); + + it('should show a placeholder message about geo points field', async () => { + const [fieldId] = await PageObjects.lens.findFieldIdsByType('geo_point'); + await log.debug(`Opening field stats for ${fieldId}`); + await testSubjects.click(fieldId); + const message = await testSubjects.getVisibleText('lnsFieldListPanel-missingFieldStats'); + expect(message).to.eql('Analysis is not available for this field.'); + }); + + it('should show stats for a numeric runtime field', async () => { + await PageObjects.lens.searchField('runtime'); + await PageObjects.lens.waitForField('runtime_number'); + const [fieldId] = await PageObjects.lens.findFieldIdsByType('number'); + await log.debug(`Opening field stats for ${fieldId}`); + await testSubjects.click(fieldId); + // check for popover + await testSubjects.exists('lnsFieldListPanel-title'); + // check for top values chart + await testSubjects.existOrFail('lnsFieldListPanel-topValues'); + // check values + const topValuesRows = await testSubjects.findAll('lnsFieldListPanel-topValues-bucket'); + expect(topValuesRows.length).to.eql(11); + // check for the Other entry + expect(await topValuesRows[10].getVisibleText()).to.eql('Other\n96.7%'); + // switch to date histogram + await testSubjects.click('lnsFieldListPanel-buttonGroup-distributionButton'); + // check for date histogram chart + expect( + await find.existsByCssSelector( + '[data-test-subj="lnsFieldListPanelFieldContent"] .echChart' + ) + ).to.eql(true); + }); + + it('should show stats for a keyword runtime field', async () => { + await PageObjects.lens.searchField('runtime'); + await PageObjects.lens.waitForField('runtime_string'); + const [fieldId] = await PageObjects.lens.findFieldIdsByType('string'); + await log.debug(`Opening field stats for ${fieldId}`); + await testSubjects.click(fieldId); + // check for popover + await testSubjects.exists('lnsFieldListPanel-title'); + // check for top values chart + await testSubjects.existOrFail('lnsFieldListPanel-topValues'); + // check no date histogram + expect( + await find.existsByCssSelector( + '[data-test-subj="lnsFieldListPanelFieldContent"] .echChart' + ) + ).to.eql(false); + await PageObjects.lens.searchField(''); + }); + + it('should change popover content if user defines a filter that affects field values', async () => { + // check the current records count for stats + const [fieldId] = await PageObjects.lens.findFieldIdsByType('string'); + await log.debug(`Opening field stats for ${fieldId}`); + await testSubjects.click(fieldId); + const valuesCount = parseInt( + (await testSubjects.getVisibleText('lnsFieldListPanel-statsFooter')) + .replaceAll(/(Calculated from | records\.)/g, '') + .replace(',', ''), + 10 + ); + // define a filter + await filterBar.addFilter('geo.src', 'is', 'CN'); + await retry.waitFor('Wait for the filter to take effect', async () => { + await testSubjects.click(fieldId); + // check for top values chart has changed compared to the previous test + const newValuesCount = parseInt( + (await testSubjects.getVisibleText('lnsFieldListPanel-statsFooter')) + .replaceAll(/(Calculated from | records\.)/g, '') + .replace(',', ''), + 10 + ); + return newValuesCount < valuesCount; + }); + }); + + // One Fields cap's limitation is to not know when an index has no fields based on filters + it('should detect fields have no data in popup if filter excludes them', async () => { + await filterBar.removeAllFilters(); + await filterBar.addFilter('bytes', 'is', '-1'); + // check via popup fields have no data + const [fieldId] = await PageObjects.lens.findFieldIdsByType('string'); + await log.debug(`Opening field stats for ${fieldId}`); + await retry.try(async () => { + await testSubjects.click(fieldId); + expect(await testSubjects.find('lnsFieldListPanel-missingFieldStats')).to.be.ok(); + // close the popover + await testSubjects.click(fieldId); + }); + }); + + if (datasourceType !== 'ad-hoc-no-timefield') { + it('should move some fields as empty when the time range excludes them', async () => { + // remove the filter + await filterBar.removeAllFilters(); + // tweak the time range to 17 Sept 2015 to 18 Sept 2015 + await PageObjects.lens.goToTimeRange( + 'Sep 17, 2015 @ 06:31:44.000', + 'Sep 18, 2015 @ 06:31:44.000' + ); + // check all fields are empty now + expect( + await (await testSubjects.find('lnsIndexPatternEmptyFields-count')).getVisibleText() + ).to.eql(52); + // check avaialble count is 0 + expect( + await ( + await testSubjects.find('lnsIndexPatternAvailableFields-count') + ).getVisibleText() + ).to.eql(1); + }); + } + }); + } + }); +} diff --git a/x-pack/test/functional/apps/lens/group1/index.ts b/x-pack/test/functional/apps/lens/group1/index.ts index 47f08a59e7341..302289319adbf 100644 --- a/x-pack/test/functional/apps/lens/group1/index.ts +++ b/x-pack/test/functional/apps/lens/group1/index.ts @@ -79,6 +79,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./table_dashboard')); loadTestFile(require.resolve('./table')); loadTestFile(require.resolve('./text_based_languages')); + loadTestFile(require.resolve('./fields_list')); loadTestFile(require.resolve('./layer_actions')); } }); diff --git a/x-pack/test/functional/apps/lens/open_in_lens/agg_based/heatmap.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/heatmap.ts new file mode 100644 index 0000000000000..8556ae601daf9 --- /dev/null +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/heatmap.ts @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const { visualize, lens, visChart, timePicker, visEditor } = getPageObjects([ + 'visualize', + 'lens', + 'visChart', + 'timePicker', + 'visEditor', + ]); + + describe('Heatmap', function describeIndexTests() { + const isNewChartsLibraryEnabled = true; + + before(async () => { + await visualize.initTests(isNewChartsLibraryEnabled); + }); + + beforeEach(async () => { + await visualize.navigateToNewAggBasedVisualization(); + await visualize.clickHeatmapChart(); + await visualize.clickNewSearch(); + await timePicker.setDefaultAbsoluteRange(); + }); + + it('should show the "Edit Visualization in Lens" menu item if no X-axis was specified', async () => { + await visChart.waitForVisualizationRenderingStabilized(); + + expect(await visualize.hasNavigateToLensButton()).to.eql(true); + }); + + it('should show the "Edit Visualization in Lens" menu item', async () => { + await visEditor.clickBucket('X-axis'); + await visEditor.selectAggregation('Terms'); + await visEditor.selectField('machine.os.raw'); + await visEditor.clickGo(); + + expect(await visualize.hasNavigateToLensButton()).to.eql(true); + }); + + it('should convert to Lens', async () => { + await visEditor.clickBucket('X-axis'); + await visEditor.selectAggregation('Terms'); + await visEditor.selectField('machine.os.raw'); + await visEditor.clickGo(); + + await visualize.navigateToLensFromAnotherVisulization(); + await lens.waitForVisualization('heatmapChart'); + const debugState = await lens.getCurrentChartDebugState('heatmapChart'); + + if (!debugState) { + throw new Error('Debug state is not available'); + } + + // assert axes + expect(debugState.axes!.x[0].labels).to.eql(['win 8', 'win xp', 'win 7', 'ios', 'osx']); + expect(debugState.axes!.y[0].labels).to.eql(['']); + expect(debugState.heatmap!.cells.length).to.eql(5); + expect(debugState.legend!.items).to.eql([ + { + color: '#006837', + key: '0 - 25', + name: '0 - 25', + }, + { color: '#86CB66', key: '25 - 50', name: '25 - 50' }, + { + color: '#FEFEBD', + key: '50 - 75', + name: '50 - 75', + }, + { + color: '#F88D52', + key: '75 - 100', + name: '75 - 100', + }, + ]); + }); + + it('should convert to Lens if Y-axis is defined, but X-axis is not', async () => { + await visEditor.clickBucket('Y-axis'); + await visEditor.selectAggregation('Terms'); + await visEditor.selectField('machine.os.raw'); + await visEditor.clickGo(); + + await visualize.navigateToLensFromAnotherVisulization(); + await lens.waitForVisualization('heatmapChart'); + const debugState = await lens.getCurrentChartDebugState('heatmapChart'); + + if (!debugState) { + throw new Error('Debug state is not available'); + } + + expect(debugState.axes!.x[0].labels).to.eql(['*']); + expect(debugState.axes!.y[0].labels).to.eql(['win 8', 'win xp', 'win 7', 'ios', 'osx']); + expect(debugState.heatmap!.cells.length).to.eql(5); + }); + + it('should respect heatmap colors number', async () => { + await visEditor.clickBucket('X-axis'); + await visEditor.selectAggregation('Terms'); + await visEditor.selectField('machine.os.raw'); + await visEditor.clickGo(); + + await visEditor.clickOptionsTab(); + await visEditor.changeHeatmapColorNumbers(6); + await visEditor.clickGo(); + await visChart.waitForVisualizationRenderingStabilized(); + + await visualize.navigateToLensFromAnotherVisulization(); + await lens.waitForVisualization('heatmapChart'); + const debugState = await lens.getCurrentChartDebugState('heatmapChart'); + + if (!debugState) { + throw new Error('Debug state is not available'); + } + + expect(debugState.legend!.items).to.eql([ + { + color: '#006837', + key: '0 - 16.67', + name: '0 - 16.67', + }, + { + color: '#4CB15D', + key: '16.67 - 33.33', + name: '16.67 - 33.33', + }, + { + color: '#B7E075', + key: '33.33 - 50', + name: '33.33 - 50', + }, + { + color: '#FEFEBD', + key: '50 - 66.67', + name: '50 - 66.67', + }, + { + color: '#FDBF6F', + key: '66.67 - 83.33', + name: '66.67 - 83.33', + }, + { + color: '#EA5839', + key: '83.33 - 100', + name: '83.33 - 100', + }, + ]); + }); + + it('should show respect heatmap custom color ranges', async () => { + await visEditor.clickBucket('X-axis'); + await visEditor.selectAggregation('Terms'); + await visEditor.selectField('machine.os.raw'); + await visEditor.clickGo(); + + await visEditor.clickOptionsTab(); + await visEditor.clickOptionsTab(); + await visEditor.clickEnableCustomRanges(); + await visEditor.clickAddRange(); + await visEditor.clickAddRange(); + await visEditor.clickAddRange(); + await visEditor.clickAddRange(); + await visEditor.clickAddRange(); + + await visEditor.clickGo(); + await visChart.waitForVisualizationRenderingStabilized(); + + await visualize.navigateToLensFromAnotherVisulization(); + await lens.waitForVisualization('heatmapChart'); + const debugState = await lens.getCurrentChartDebugState('heatmapChart'); + + if (!debugState) { + throw new Error('Debug state is not available'); + } + + expect(debugState.legend!.items).to.eql([ + { + color: '#006837', + key: '0 - 100', + name: '0 - 100', + }, + { + color: '#65BC62', + key: '100 - 200', + name: '100 - 200', + }, + { + color: '#D8EF8C', + key: '200 - 300', + name: '200 - 300', + }, + { + color: '#FEDF8B', + key: '300 - 400', + name: '300 - 400', + }, + { + color: '#F36D43', + key: '400 - 500', + name: '400 - 500', + }, + { + color: '#A50026', + key: '500 - 600', + name: '500 - 600', + }, + ]); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts index 87c9d025893a1..52ef856d53ef6 100644 --- a/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./gauge')); loadTestFile(require.resolve('./goal')); loadTestFile(require.resolve('./table')); + loadTestFile(require.resolve('./heatmap')); }); } diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/custom_urls.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/custom_urls.ts index 5661a30362641..e5f181ea8414c 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/custom_urls.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/custom_urls.ts @@ -62,7 +62,8 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); const browser = getService('browser'); - describe('custom urls', function () { + // Failing: See https://github.com/elastic/kibana/issues/143933 + describe.skip('custom urls', function () { this.tags(['ml']); let testDashboardId: string | null = null; diff --git a/x-pack/test/functional/es_archives/reporting/big_int_id_field/data.json.gz b/x-pack/test/functional/es_archives/reporting/big_int_id_field/data.json.gz new file mode 100644 index 0000000000000..c42d21903c912 Binary files /dev/null and b/x-pack/test/functional/es_archives/reporting/big_int_id_field/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/reporting/big_int_id_field/mappings.json b/x-pack/test/functional/es_archives/reporting/big_int_id_field/mappings.json new file mode 100644 index 0000000000000..d2ee24696e0f1 --- /dev/null +++ b/x-pack/test/functional/es_archives/reporting/big_int_id_field/mappings.json @@ -0,0 +1,25 @@ +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "test_elastic", + "mappings": { + "properties": { + "timestamp": { + "format": "yyyyMMddHHmmss||yyyyMMddHHmmssZ||strict_date_optional_time||epoch_millis", + "type": "date" + }, + "message_type": { + "type": "keyword" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/fixtures/kbn_archiver/reporting/big_int_id_field.json b/x-pack/test/functional/fixtures/kbn_archiver/reporting/big_int_id_field.json new file mode 100644 index 0000000000000..770758f52d0d3 --- /dev/null +++ b/x-pack/test/functional/fixtures/kbn_archiver/reporting/big_int_id_field.json @@ -0,0 +1,96 @@ +{ + "attributes": { + "fieldAttrs": "{}", + "fieldFormatMap": "{}", + "fields": "[]", + "name": "test_elastic*", + "runtimeFieldMap": "{}", + "sourceFilters": "[]", + "timeFieldName": "timestamp", + "title": "test_elastic*", + "typeMeta": "{}" + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-25T20:55:46.970Z", + "id": "c424ce04-f440-4f48-aa0c-534da84d06f6", + "migrationVersion": { + "index-pattern": "8.0.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2022-10-25T20:55:46.970Z", + "version": "WzIxOCwxXQ==" +} + +{ + "attributes": { + "columns": [], + "description": "", + "grid": {}, + "hideChart": false, + "isTextBasedQuery": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "timestamp", + "desc" + ] + ], + "timeRestore": false, + "title": "testsearch" + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-25T20:57:39.872Z", + "id": "a984aeb0-54a7-11ed-b3f3-41d5096a3cfd", + "migrationVersion": { + "search": "8.0.0" + }, + "references": [ + { + "id": "c424ce04-f440-4f48-aa0c-534da84d06f6", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "search", + "updated_at": "2022-10-25T20:57:39.872Z", + "version": "WzI2MCwxXQ==" +} + +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"version\":\"8.6.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":18,\"i\":\"7307be50-603d-4091-b4b9-e76a96c6a33a\"},\"panelIndex\":\"7307be50-603d-4091-b4b9-e76a96c6a33a\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7307be50-603d-4091-b4b9-e76a96c6a33a\"}]", + "refreshInterval": { + "pause": true, + "value": 0 + }, + "timeFrom": "now-15y", + "timeRestore": true, + "timeTo": "2022-10-30T00:00:00.000Z", + "title": "rbbaf", + "version": 1 + }, + "coreMigrationVersion": "8.6.0", + "created_at": "2022-10-25T21:01:17.780Z", + "id": "b78b1350-54a7-11ed-b3f3-41d5096a3cfd", + "migrationVersion": { + "dashboard": "8.6.0" + }, + "references": [ + { + "id": "a984aeb0-54a7-11ed-b3f3-41d5096a3cfd", + "name": "7307be50-603d-4091-b4b9-e76a96c6a33a:panel_7307be50-603d-4091-b4b9-e76a96c6a33a", + "type": "search" + } + ], + "type": "dashboard", + "updated_at": "2022-10-25T21:01:17.780Z", + "version": "WzMzNiwxXQ==" +} \ No newline at end of file diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 8dd95aa107929..c814b5b161fcd 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -1335,9 +1335,9 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await testSubjects.click('indexPattern-add-field'); }, - async createAdHocDataView(name: string) { + async createAdHocDataView(name: string, hasTimeField?: boolean) { await testSubjects.click('lns-dataView-switch-link'); - await PageObjects.unifiedSearch.createNewDataView(name, true); + await PageObjects.unifiedSearch.createNewDataView(name, true, hasTimeField); }, async switchToTextBasedLanguage(language: string) { @@ -1638,5 +1638,17 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }) ); }, + + async findFieldIdsByType( + type: 'string' | 'number' | 'date' | 'geo_point' | 'ip_range', + group: 'available' | 'empty' | 'meta' = 'available' + ) { + const groupCapitalized = `${group[0].toUpperCase()}${group.slice(1).toLowerCase()}`; + const allFieldsForType = await find.allByCssSelector( + `[data-test-subj="lnsIndexPattern${groupCapitalized}Fields"] .lnsFieldItem--${type}` + ); + // map to testSubjId + return Promise.all(allFieldsForType.map((el) => el.getAttribute('data-test-subj'))); + }, }); } diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap b/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap index 43649d0ed7552..73c7a6ef4b542 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap +++ b/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap @@ -1,5 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Reporting APIs CSV Generation from SearchSource _id field is a big integer passes through the value without mutation 1`] = ` +"\\"_id\\",\\"_index\\",\\"_score\\",\\"message_type\\",timestamp +202209071000000604,\\"test_elastic\\",\\"-\\",OP,\\"Jan 1, 2020 @ 11:00:00.000\\" +202209071000000605,\\"test_elastic\\",\\"-\\",OP,\\"Jan 1, 2020 @ 11:00:00.000\\" +202209071000000606,\\"test_elastic\\",\\"-\\",OP,\\"Jan 1, 2020 @ 11:00:00.000\\" +202209071000000607,\\"test_elastic\\",\\"-\\",OP,\\"Jan 1, 2020 @ 11:00:00.000\\" +" +`; + exports[`Reporting APIs CSV Generation from SearchSource date formatting With filters and timebased data, default to UTC 1`] = ` "\\"@timestamp\\",clientip,extension \\"Sep 20, 2015 @ 10:26:48.725\\",\\"74.214.76.90\\",jpg diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts b/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts index 922ff565b4e29..3941037733c70 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts @@ -394,6 +394,85 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('_id field is a big integer', () => { + before(async () => { + await Promise.all([ + esArchiver.load('x-pack/test/functional/es_archives/reporting/big_int_id_field'), + kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/reporting/big_int_id_field' + ), + ]); + }); + + after(async () => { + await Promise.all([ + esArchiver.unload('x-pack/test/functional/es_archives/reporting/big_int_id_field'), + kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/reporting/big_int_id_field' + ), + ]); + }); + it('passes through the value without mutation', async () => { + const { text } = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + browserTimezone: 'UTC', + version: '8.6.0', + searchSource: { + query: { query: '', language: 'kuery' }, + fields: [{ field: '*', include_unmapped: 'true' }], + index: 'c424ce04-f440-4f48-aa0c-534da84d06f6', + sort: [{ timestamp: 'desc' }], + filter: [ + { + meta: { + index: 'c424ce04-f440-4f48-aa0c-534da84d06f6', + params: {}, + field: 'timestamp', + }, + query: { + range: { + timestamp: { + format: 'strict_date_optional_time', + gte: '2007-10-25T21:18:23.905Z', + lte: '2022-10-30T00:00:00.000Z', + }, + }, + }, + }, + ], + parent: { + query: { query: '', language: 'kuery' }, + filter: [], + parent: { + filter: [ + { + meta: { + index: 'c424ce04-f440-4f48-aa0c-534da84d06f6', + params: {}, + field: 'timestamp', + }, + query: { + range: { + timestamp: { + format: 'strict_date_optional_time', + gte: '2007-10-25T21:18:23.905Z', + lte: '2022-10-30T00:00:00.000Z', + }, + }, + }, + }, + ], + }, + }, + }, + columns: [], + title: 'testsearch', + }) + )) as supertest.Response; + expectSnapshot(text).toMatch(); + }); + }); + describe('validation', () => { it('Return a 404', async () => { const { body } = (await generateAPI.getCSVFromSearchSource( diff --git a/yarn.lock b/yarn.lock index 9ed18bf2180b0..a0b5043df251d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11677,13 +11677,14 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^105.0.1: - version "105.0.1" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-105.0.1.tgz#325cf05aca200328176438991d236ddb6c61711b" - integrity sha512-QqylH9mvl4Ybq3mmHsym7jeq/LhEi2sPtD8ffd9ixiDFdPRlh2F4vzrzK+myj1MiXb0TYJK7+OCcMEmsB3Sm/Q== +chromedriver@^107.0.0: + version "107.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-107.0.0.tgz#9443ceb6020190f1a0f96ae6b5fad5453c0cd582" + integrity sha512-/VpGc83szXYUu9gBhCl6tg6XvtVwj2RQjOZ4wDA5TPSqudTMgWcMbkjeZbCfHwReJ9Qqo0hJ1jipG1IXWDxg3g== dependencies: "@testim/chrome-version" "^1.1.3" axios "^0.27.2" + compare-versions "^5.0.1" del "^6.1.1" extract-zip "^2.0.1" https-proxy-agent "^5.0.1" @@ -12098,6 +12099,11 @@ compare-versions@3.5.1: resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.5.1.tgz#26e1f5cf0d48a77eced5046b9f67b6b61075a393" integrity sha512-9fGPIB7C6AyM18CJJBHt5EnCZDG3oiTJYy0NjfIAGjKpzv0tkxWko7TNQHF5ymqm7IH03tqmeuBxtvD+Izh6mg== +compare-versions@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-5.0.1.tgz#14c6008436d994c3787aba38d4087fabe858555e" + integrity sha512-v8Au3l0b+Nwkp4G142JcgJFh1/TUhdxut7wzD1Nq1dyp5oa3tXaqb03EXOAB6jS4gMlalkjAUPZBMiAfKUixHQ== + component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" @@ -12811,20 +12817,20 @@ cypress-react-selector@^3.0.0: dependencies: resq "1.10.2" -cypress-real-events@^1.7.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.1.tgz#8f430d67c29ea4f05b9c5b0311780120cbc9b935" - integrity sha512-/Bg15RgJ0SYsuXc6lPqH08x19z6j2vmhWN4wXfJqm3z8BTAFiK2MvipZPzxT8Z0jJP0q7kuniWrLIvz/i/8lCQ== +cypress-real-events@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.2.tgz#d04e6d3f15117ef485eb49f9c9b361f3a3413002" + integrity sha512-tOANHbFRlqVL5Lu8OozvxTsrYgHwCnWmXAULGc1kdBF+k1gxrrvT/42uez3AhGoT+HcytyxieXAVt0jNP4yrvA== cypress-recurse@^1.23.0: version "1.23.0" resolved "https://registry.yarnpkg.com/cypress-recurse/-/cypress-recurse-1.23.0.tgz#f87334747516de6737bc4708754e8f429057bc6d" integrity sha512-CAsdvynhuR3SUEXVJRO2jBEnZRJ6nJp7nMXHwzV4UQq9Lap3Bj72AwcJK0cl51fJXcTaGDXYTQQ9zvGe3TyaQA== -cypress@^10.9.0: - version "10.9.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-10.9.0.tgz#273a61a6304766f9d6423e5ac8d4a9a11ed8b485" - integrity sha512-MjIWrRpc+bQM9U4kSSdATZWZ2hUqHGFEQTF7dfeZRa4MnalMtc88FIE49USWP2ZVtfy5WPBcgfBX+YorFqGElA== +cypress@^10.10.0: + version "10.11.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-10.11.0.tgz#e9fbdd7638bae3d8fb7619fd75a6330d11ebb4e8" + integrity sha512-lsaE7dprw5DoXM00skni6W5ElVVLGAdRUUdZjX2dYsGjbY/QnpzWZ95Zom1mkGg0hAaO/QVTZoFVS7Jgr/GUPA== dependencies: "@cypress/request" "^2.88.10" "@cypress/xvfb" "^1.2.4" @@ -21462,9 +21468,9 @@ numeral@^2.0.6: integrity sha1-StCAk21EPCVhrtnyGX7//iX05QY= nwsapi@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" - integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== + version "2.2.2" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0" + integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw== nyc@15.1.0, nyc@^15.1.0: version "15.1.0"